다중 스키마에서 RLS로의 마이그레이션
2026년 5월 12일, Lumie는 tenant별 schema routing을 중단하고 Postgres row-level security(RLS)가 보호하는 단일 public schema로 백엔드를 옮겼습니다. 이 마이그레이션은 외형 정리가 아니라 안전성 보정이었습니다.
기존 모델은 애플리케이션이 connection을 빌릴 때 tenant schema를 선택했습니다. 하지만 실제 작업이 RabbitMQ callback, scheduler, transaction pooling, 일반 HTTP request처럼 보이지 않는 경로로 이동하면서 이 방식은 취약해졌습니다.
배경
이전 tenancy 모델은 session-level schema selection에 의존했습니다.
tenant context -> connection borrow -> SET search_path -> ORM query
이 모델은 request 처리와 connection 사용이 단순할 때만 작동하기 쉬웠습니다. 다음 조건이 겹치면서 안전하지 않아졌습니다.
- callback과 scheduled job이 일반 request filter 밖에서 실행됩니다.
- PgBouncer transaction pooling이 session state 가정을 약하게 만듭니다.
- async worker는 원래 request가 사라진 뒤에도 tenant context가 필요합니다.
- schema fan-out이 migration과 운영 점검을 증가시킵니다.
현재 저장소의 대체 설계는 다음 소스 경로에 남아 있습니다.
lumie-backend/libs/common/src/main/java/com/lumie/common/tenant/RlsTenantContextAspect.javalumie-backend/libs/common/src/main/java/com/lumie/common/tenant/RlsTenantTransactionBinder.javalumie-backend/libs/common/src/main/java/com/lumie/common/domain/TenantScopedEntity.javalumie-backend/app/src/main/resources/db/migration/public/V18__rls_baseline.sql
영향
이는 단일 고객 장애로 기록된 사건은 아닙니다. 영향은 아키텍처 위험이었습니다. tenant isolation이 async 실행 경로에서 우회될 수 있는 connection lifecycle 세부사항에 의존하고 있었습니다.
운영 비용도 명확했습니다.
| 영역 | tenant별 schema의 비용 |
|---|---|
| Migration | 모든 tenant schema를 관리해야 했습니다 |
| Async callback | request 경계 밖에서도 tenant context를 유지해야 했습니다 |
| Debugging | 어떤 connection이 어떤 search_path를 갖고 있는지에 따라 실패가 달라졌습니다 |
| Pooling | transaction pooling과 session-scoped state가 서로 맞지 않았습니다 |
| Contributor model | 새 코드는 숨은 connection-binding 불변조건을 기억해야 했습니다 |
개념적으로 깨진 부분
핵심 불일치는 타이 밍이었습니다. Tenant 선택은 connection을 빌릴 때 일어났지만, 실제 격리 경계는 데이터 접근을 수행하는 transaction이었습니다.
그 결과 request boundary에서는 코드가 정상처럼 보이지만, 나중에 잘못된 schema나 의도한 tenant binding 없이 SQL을 실행할 수 있었습니다. RabbitMQ callback과 scheduled job은 HTTP request lifecycle을 자연스럽게 상속하지 않기 때문에 이 문제를 드러냈습니다.
진단
마이그레이션은 다음 식으로 범위를 좁혔습니다.
| 확인 | 결과 | 해석 |
|---|---|---|
| HTTP request path 확인 | request filter는 tenant context를 설정할 수 있었습니다 | HTTP만 보호해서는 충분하지 않았습니다 |
| Async callback 확인 | 원래 request connection lifecycle 밖에서 repository에 도달할 수 있었습니다 | schema routing은 완전한 tenancy boundary가 아니었습니다 |
| PgBouncer 동작 비교 | transaction pooling은 session state 가정을 약하게 만듭니다 | search_path는 이 runtime에 맞는 primitive가 아니었습니다 |
| RLS 평가 | tenant predicate가 table과 transaction-local setting에 붙습니다 | isolation을 데이터에 더 가깝게 옮길 수 있었습니다 |
지속 가능한 답은 transaction entry에서 tenant identity를 바인딩하고 Postgres가 row visibility를 강제하게 하는 것이었습니다.
해결
최종 설 계는 isolation을 데이터베이스로 옮겼습니다.
- tenant-owned table은
tenant_id를 가집니다. - tenant-scoped entity는
TenantScopedEntity를 확장합니다. - transaction은
RlsTenantTransactionBinder를 통해app.tenant_id를 바인딩합니다. V18__rls_baseline.sql의 policy는ENABLE ROW LEVEL SECURITY,FORCE ROW LEVEL SECURITY, tenant predicate를 적용합니다.- runtime app connection은 bypass 권한이 없는 role을 사용하고, migration은 migrator role을 사용합니다.
부하가 걸린 runtime sequence는 다음과 같습니다.
TenantContextHolder has slug and tenantId
-> @Transactional method begins
-> RlsTenantContextAspect runs inside transaction interceptor ordering
-> RlsTenantTransactionBinder sets app.tenant_id with transaction scope
-> repository query is filtered by Postgres RLS policy
이 순서는 중요합니다. Transaction management order나 RLS aspect order가 drift되면 binding이 active transaction 밖에서 일어나 policy가 의도한 값을 보지 못할 수 있습니다.
현재 상태
마이그레이션 결과는 현재 backend baseline입니다.
- modular monolith
- tenant-owned table의 단일
publicschema - row에 저장되는 tenant id
- Postgres에서 강제되는 RLS
BYPASSRLS가 없는 application runtime role- HTTP, backend-worker, worker-backend boundary를 지나는 명시적인 tenant propagation
과거 schema-routing class, tenant-schema migration runner, MultiTenantConnectionProvider 방식은 더 이상 steady-state 모델이 아닙니다. 이 문서는 그 모델이 왜 제거되었는지를 기록합니다.
검증
앞으로의 검증은 코드와 데이터베이스 불변조건을 함께 증명해야 합니다.
| 점검 | 기대 증거 |
|---|---|
| Migration baseline | V18__rls_baseline.sql이 tenant table에 RLS를 enable/force 합니다 |
| Runtime role | app datasource가 bypass 권한이 없는 runtime user를 사용합니다 |
| Transaction binding | transactional service method 안에서 tenant context가 적용되는 테스트가 있습니다 |
| Async path | callback과 scheduler가 data access 전에 tenant context를 설정합니다 |
| Negative isolation | tenant context가 맞지 않으면 cross-tenant read가 row를 반환하지 않습니다 |
원본 사건 기록 에는 마이그레이션 시점의 모든 command output이 남아 있지 않습니다. 따라서 이 문서는 현재 repo state와 migration file을 지속 증거로 봅니다.
잘된 점
- request filter를 더 추가하는 대신 isolation primitive 자체를 고쳤습니다.
- RLS가 최종 authorization boundary를 Postgres로 옮겨, 우발적인 repository access도 제한되게 했습니다.
- Contributor expectation이 명확해졌습니다. Tenant context는 controller 진입 전뿐 아니라 transactional work 전에 존재해야 합니다.
아쉬웠던 점
- 이전 tenant별 schema 모델은 connection lifecycle behavior에 너무 많은 것을 숨겼습니다.
- Async와 scheduler path가 충분히 일찍 1급 설계 입력이 되지 못했습니다.
- Schema fan-out은 migration 비용을 늘리면서도 가장 어려운 실행 경로를 보호하지 못했습니다.
액션 아이템
| 작업 | 소유 영역 | 상태 | 증거 |
|---|---|---|---|
| Runtime DB connection을 bypass 권한이 없는 app role로 유지합니다. | lumie-backend datasource config | 지속 불변조건 | Workspace hard rule이 BYPASSRLS 없는 lumie_app을 요구합니다. |
| RLS binding을 transaction boundary 안에 유지합니다. | RlsTenantContextAspect, transaction config | 지속 불변조건 | Aspect와 binder가 현재 source path로 남아 있습니다. |
| Callback path 변경 시 async tenant-context 테스트를 유지하거나 추가합니다. | backend tests | 향후 변경 필수 | 마이그레이션 동기는 async path risk였습니다. |
교훈
- Tenant isolation은 가장 request답지 않은 실행 경로에서도 유지되어야 합니다.
- Session-scoped schema switching은 transaction pooling과 async worker에 맞지 않습니다.
- RLS는 application tenant context 필요성을 없애지 않습니다. 대신 context 누락이 row leak이 아니라 fail-closed에 가깝게 나타나도록 만듭니다.
- Migration 성공은 데이터 배치 변경이 아니라 운영 계약 변경입니다.