본문으로 건너뛰기

멀티 테넌시

Lumie는 shared-schema PostgreSQL 멀티 테넌시를 사용합니다. 테넌트 격리는 학원별 schema가 아니라 public schema의 Row Level Security로 강제됩니다.

이 페이지는 RLS 모델, 이를 바인딩하는 코드, 그리고 데이터 접근을 추가하거나 변경할 때 중요한 failure mode를 다루는 레퍼런스 문서입니다.

소스 경로

경로역할
app/src/main/resources/db/migration/public/V18__rls_baseline.sqlshared-schema RLS baseline과 테넌트별 스키마 모델 폐기
app/src/main/resources/db/migration/public/V65__repair_read_receipt_and_file_download_rls.sql테넌트-safe 제약이나 정책이 빠진 table의 실제 forward repair 예시
libs/common/src/main/java/com/lumie/common/tenant/RlsTenantContextAspect.java선언적 @Transactional 진입점에서 RLS 바인딩 호출
libs/common/src/main/java/com/lumie/common/tenant/RlsTenantTransactionBinder.java활성 트랜잭션에 app.tenant_id 바인딩
libs/common/src/main/java/com/lumie/common/tenant/TenantContextHolder.java현재 thread에 테넌트 slug와 테넌트 ID를 보관
libs/common/src/main/java/com/lumie/common/domain/TenantScopedEntity.java대부분의 테넌트 범위 aggregate에 tenant_id를 자동 채움
app/src/main/java/com/lumie/app/config/RuntimeDbRoleGuard.java런타임 DB role이 RLS를 우회할 수 있으면 startup 실패
app/src/test/java/com/lumie/app/migration/MigrationsRlsIntegrationTest.javamigration chain 상태와 hostile-테넌트 격리에 대한 integration test

저장소 모델

테넌트 범위 테이블

  • public에 위치
  • tenant_id BIGINT NOT NULL 보유
  • ROW LEVEL SECURITY 활성화
  • FORCE ROW LEVEL SECURITY 강제
  • tenant_idcurrent_setting('app.tenant_id', true)와 비교하는 tenant_isolation policy 정의

플랫폼 범위 테이블

이 table들은 설계상 cross-tenant이며, 런타임 읽기에 RLS를 사용하지 않습니다.

  • tenants
  • plans
  • event_publication
  • shedlock
  • V28__billing_platform_tables.sql에서 생성되는 billing platform table

특수 스키마

V27__langgraph_schema.sql은 chatbot checkpoint persistence를 위해 전용 langgraph schema를 생성합니다. 이것은 테넌트 데이터 저장 모델이 아니라 기술적인 워커-state 예외입니다.

핵심을 지탱하는 조건식

migration은 다음과 같은 표준 policy 형태를 사용합니다. Source anchor: app/src/main/resources/db/migration/public/V18__rls_baseline.sql.

using (tenant_id = nullif(current_setting('app.tenant_id', true), '')::bigint)
with check (tenant_id = nullif(current_setting('app.tenant_id', true), '')::bigint)

Java 쪽은 Spring transaction이 활성화된 뒤 이 값을 바인딩합니다. Source anchor: libs/common/src/main/java/com/lumie/common/tenant/RlsTenantTransactionBinder.java.

SELECT set_config('app.tenant_id', ?, true)

RlsTenantTransactionBinder가 이 statement를 소유합니다. binder는 활성 transaction이 없으면 실행을 거부하므로, is_local=true는 항상 PostgreSQL이 커밋 또는 롤백 시 설정을 자동으로 지운다는 의미를 유지합니다.

런타임 흐름

Slug와 ID가 분리된 이유

  • tenantSlug는 URL, JWT claim, 로그, object key, 워커 header에 쓰이는 제품 측면 식별자입니다.
  • tenantIdtenant_id column과 RLS predicate에 쓰이는 데이터베이스 측면 식별자입니다.

slug만 설정하는 것으로는 데이터 접근에 충분하지 않습니다. Source anchor: libs/common/src/main/java/com/lumie/common/tenant/TenantContextHolder.java. 그래서 테넌트 컨텍스트에 다시 진입하는 코드는 다음을 사용해야 합니다.

TenantContextHolder.withinContext(slug, tenantId, action)

단순히 setTenant(slug)만 호출해서는 안 됩니다.

컨텍스트를 다시 설정해야 하는 위치

현재 코드는 다음 경계 사례에서 테넌트 컨텍스트를 명시적으로 복원합니다.

  • exam RabbitMQ 콜백 listener
  • customId -> tenant 해석 이후의 homepage 공개 조회
  • HMAC 인증을 거치는 /internal/** 요청
  • 원래 요청 thread가 사라진 뒤 실행되는 Spring Modulith listener
  • TenantService.listActiveTenants()를 순회하는 scheduler loop
  • AI 워커 콜백과 tool 실행 경로

엔티티 및 테이블 패턴

대부분의 테넌트 범위 aggregate는 TenantScopedEntity를 상속하고, @PrePersist에서 tenant_id를 씁니다.

일부 table은 base 수업 대신 tenant_id를 직접 관리합니다. 현재 예시로는 modules/staff/domain/entity/StaffPermission.java가 있으며, 이 클래스는 자신의 @PrePersist에서 tenant_id를 명시적으로 설정합니다.

안전 가드와 알려진 실패 모드

  • TenantContextHolder에 테넌트 ID가 없으면 테넌트 범위 row는 보이지 않습니다.
  • 어떤 경로든 활성 transaction 없이 app.tenant_id를 바인딩하려 하면 RlsTenantTransactionBinder가 repository 접근 전에 즉시 실패합니다. 이 guard가 없다면 GUC가 한 statement 후 사라지고 RLS는 의도한 tenant를 더 이상 볼 수 없습니다.
  • 새 테넌트 범위 table에 tenant_id, RLS 활성화, FORCE ROW LEVEL SECURITY, tenant_isolation policy가 빠지면 그 table은 cross-테넌트 누수가 됩니다.
  • 런타임 DB role에 SUPERUSER 또는 BYPASSRLS가 있으면 RLS는 조용히 우회됩니다. RuntimeDbRoleGuard가 startup을 막아 이를 방지합니다.
  • V65__repair_read_receipt_and_file_download_rls.sql은 테넌트-safe 제약이나 정책 없이 들어간 table을 어떻게 복구하는지 보여주는 실제 예시입니다.

읽기 전용 레플리카는 테넌시 모델을 바꾸지 않습니다

백엔드의 read/write split은 어떤 Hikari pool을 쓰는지만 바꾸며, 테넌시 모델 자체를 바꾸지는 않습니다.

  • write 및 비트랜잭션 코드 -> primary pool
  • @Transactional(readOnly = true) -> readonly pool
  • 두 pool 모두 동일한 app.tenant_id 바인딩과 런타임 role에 의존

승인된 programmatic transaction 경로도 transaction 내부에서 테넌트 범위 query를 실행하기 전에 RlsTenantTransactionBinder를 호출해 같은 규칙을 따릅니다. AI 모듈의 전용 readOnlyJdbcTemplate은 raw SQL 읽기를 위해 아직 set_config('app.tenant_id', ?, true)를 명시적으로 호출합니다. 이것은 새 data access의 패턴이 아니라 legacy exception으로 취급합니다.

검증 명령어

cd /Users/bluemayne/Projects/Lumie/lumie-backend
./gradlew integrationTest
./gradlew :app:test
./gradlew :libs:common:test

가장 관련 있는 테스트:

  • app/src/test/java/com/lumie/app/migration/MigrationsRlsIntegrationTest.java
  • app/src/test/java/com/lumie/app/config/RlsTenantTransactionBinderIntegrationTest.java
  • app/src/test/java/com/lumie/app/config/RuntimeDbRoleGuardTest.java
  • app/src/test/java/com/lumie/app/SmsDispatchServiceWiringTest.java
  • libs/common/src/test/java/com/lumie/common/tenant/RlsTenantContextAspectIntegrationTest.java

관련 페이지