📑 목차
들어가며
엔터프라이즈 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+ |
배운 점
- 멀티테넌시 전략과 ORM은 함께 결정해야 한다 — 별개의 의사결정처럼 보이지만 실제로는 깊이 연결되어 있다
- Exposed는 Kotlin 프로젝트에서 과소평가되어 있다 — JPA의 관성에서 벗어나면 더 나은 선택이 될 수 있다
- ThreadLocal 기반 아키텍처는 Coroutine/Async와 본질적으로 충돌한다 — 이 갭을 인지하고 설계 초기에 대응해야 한다
'백엔드 트러블슈팅' 카테고리의 다른 글
| Windows 환경 Blue-Green 무중단 배포 구축기: 50커밋 삽질 완전 정리 (0) | 2026.03.29 |
|---|---|
| Reactor 파이프라인 설계기: External Task 200건 동시 처리 · ConnectableFlux 삽질 (0) | 2026.03.29 |
| Kafka 동기화 삽질기 4편: 멀티테넌시 + @Async에서 ThreadLocal이 사라지는 문제 (0) | 2026.03.29 |
| Kafka 동기화 삽질기 3편: Reconciliation — "없는 것"을 처리하는 기술 (0) | 2026.03.29 |
| BPMN 자동화 엔진 리팩토링: 이벤트 디스패치 · 상태 업데이트 역할 분리 5단계 정리 (0) | 2026.03.28 |
