본문으로 건너뛰기

다중 스키마에서 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.java
  • lumie-backend/libs/common/src/main/java/com/lumie/common/tenant/RlsTenantTransactionBinder.java
  • lumie-backend/libs/common/src/main/java/com/lumie/common/domain/TenantScopedEntity.java
  • lumie-backend/app/src/main/resources/db/migration/public/V18__rls_baseline.sql

영향

이는 단일 고객 장애로 기록된 사건은 아닙니다. 영향은 아키텍처 위험이었습니다. tenant isolation이 async 실행 경로에서 우회될 수 있는 connection lifecycle 세부사항에 의존하고 있었습니다.

운영 비용도 명확했습니다.

영역tenant별 schema의 비용
Migration모든 tenant schema를 관리해야 했습니다
Async callbackrequest 경계 밖에서도 tenant context를 유지해야 했습니다
Debugging어떤 connection이 어떤 search_path를 갖고 있는지에 따라 실패가 달라졌습니다
Poolingtransaction 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의 단일 public schema
  • 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 baselineV18__rls_baseline.sql이 tenant table에 RLS를 enable/force 합니다
Runtime roleapp datasource가 bypass 권한이 없는 runtime user를 사용합니다
Transaction bindingtransactional service method 안에서 tenant context가 적용되는 테스트가 있습니다
Async pathcallback과 scheduler가 data access 전에 tenant context를 설정합니다
Negative isolationtenant 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 성공은 데이터 배치 변경이 아니라 운영 계약 변경입니다.