ClOr

ClOr

백엔드 실무 트러블슈팅과 AI 에이전트 구조 분석을 기록합니다.

Claude Code 해부학 (완결)

51만 줄 소스코드를 19편에 걸쳐 분석한 완결 시리즈

전체 시리즈 보기 →

백엔드 트러블슈팅

실무에서 겪은 장애와 해결 과정 기록

전체 시리즈 보기 →

최신 글

article thumbnail

들어가며

엔터프라이즈 SaaS 플랫폼을 설계할 때 멀티테넌시 전략은 아키텍처의 근간을 결정한다. 사내 업무 자동화 프로젝트에서 Kotlin MSA로 재설계하면서, 멀티테넌시 전략과 ORM 선택이라는 두 가지 의사결정을 동시에 해야 했다.

이 글에서는 왜 Row-Level이 아닌 Schema-per-Tenant을, JPA가 아닌 Exposed를 선택했는지, 그리고 실제 구현에서 어떤 문제를 만났는지를 공유한다.


멀티테넌시 전략: Row-Level vs Schema-per-Tenant

항목 Row-Level (tenant_id) Schema-per-Tenant
데이터 격리 논리적 (쿼리 조건) 물리적 (스키마 분리)
관리 복잡도 낮음 높음
보안 리스크 tenant_id 누락 시 데이터 노출 스키마 자체가 격리
커스터마이징 어려움 테넌트별 스키마 변경 가능
조인 쉬움 크로스 스키마 조인 필요

선택: Schema-per-Tenant

고객사의 데이터 격리 요구 수준이 높았고, tenant_id 누락 리스크는 엔터프라이즈 환경에서 감수하기 어려웠다.


ORM 선택: JPA vs Exposed

Schema-per-Tenant을 선택한 순간, 런타임 DataSource 전환이 핵심 요구사항이 됐다.

항목 JPA (Hibernate) Exposed
DataSource 전환 AbstractRoutingDataSource + EntityManagerFactory 관리 transaction(db) 블록으로 명시적 지정
캐시 이슈 1차 캐시가 테넌트 전환 시점과 충돌 가능 캐시 없음, 매번 명시적
Kotlin 친화 보통 타입 안전 DSL, 컴파일 타임 검증
코드 가독성 어떤 DB인지 암묵적 어떤 DB인지 코드에서 보임
// Exposed — transaction(db) 블록으로 DataSource를 명시적 지정
transaction(tenantDatabase) {
    Users.selectAll().where { Users.active eq true }
}

transaction(sharedDatabase) {
    CommonSettings.selectAll()
}

구현

테넌트 식별 (3가지 소스)

소스 용도
JWT companyId API 호출 시
포털 쿠키 업무 자동화 포털 연동 시
Query Parameter 관리자 도구에서

커넥션 풀 분리

커넥션 수 용도
Tenant Pool 40 테넌트별 전용
Shared Pool 15 공통 데이터
Quartz Pool 5 스케줄러 전용

Quartz 풀을 분리한 이유는, 스케줄러가 커넥션을 오래 물고 있으면 API 요청의 커넥션이 부족해지기 때문이다.

커스텀 트랜잭션 어노테이션

@TenantTransactional   // → TenantContext에서 식별한 테넌트 DB로 연결
@SharedTransactional   // → 공통 shared DB로 연결

Flyway 이중 마이그레이션

db/migration/
├── shared/    ← 공통 스키마 (V001~V103)
└── tenant/    ← 테넌트 전용 (V001~V090)

신규 테넌트를 온보딩하면 Flyway가 자동으로 tenant 마이그레이션을 적용한다. 현재 210+ 버전의 마이그레이션이 운영 중이다.


실제로 만난 문제들

문제 원인 해결 관련 글
Shared/Tenant 트랜잭션 혼합 하나의 메서드에서 양쪽 DB 접근 트랜잭션 분리 Kafka 동기화 삽질기 1편
suspend 함수와의 충돌 ThreadLocal ↔ Coroutine 스레드 전환 적용 범위 조정 Coroutine 전환기
@Async에서 TenantContext 소실 비동기 스레드에 ThreadLocal 전파 안 됨 TaskDecorator Kafka 동기화 삽질기 4편

결과

지표 Before After
테넌트 온보딩 수동 SQL 10+개, 수시간 API 1회 호출, 수분
운영 테넌트 3개 안정 운영
Flyway 버전 210+

배운 점

  1. 멀티테넌시 전략과 ORM은 함께 결정해야 한다 — 별개의 의사결정처럼 보이지만 실제로는 깊이 연결되어 있다
  2. Exposed는 Kotlin 프로젝트에서 과소평가되어 있다 — JPA의 관성에서 벗어나면 더 나은 선택이 될 수 있다
  3. ThreadLocal 기반 아키텍처는 Coroutine/Async와 본질적으로 충돌한다 — 이 갭을 인지하고 설계 초기에 대응해야 한다
profile

ClOr

@ClOr

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!

ClOr · 백엔드 트러블슈팅과 AI 에이전트 구조 분석을 기록합니다.