멀티 테넌시
Lumie는 shared-schema PostgreSQL 멀티 테넌시를 사용합니다. 테넌트 격리는
학원별 schema가 아니라 public schema의 Row Level Security로 강제됩니다.
이 페이지는 RLS 모델, 이를 바인딩하는 코드, 그리고 데이터 접근을 추가하거나 변경할 때 중요한 failure mode를 다루는 레퍼런스 문서입니다.
소스 경로
| 경로 | 역할 |
|---|---|
app/src/main/resources/db/migration/public/V18__rls_baseline.sql | shared-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.java | migration chain 상태와 hostile-테넌트 격리에 대한 integration test |
저장소 모델
테넌트 범위 테이블
public에 위치tenant_id BIGINT NOT NULL보유ROW LEVEL SECURITY활성화FORCE ROW LEVEL SECURITY강제tenant_id를current_setting('app.tenant_id', true)와 비교하는tenant_isolationpolicy 정의
플랫폼 범위 테이블
이 table들은 설계상 cross-tenant이며, 런타임 읽기에 RLS를 사용하지 않습니다.
tenantsplansevent_publicationshedlockV28__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에 쓰이는 제품 측면 식별자입니다.tenantId는tenant_idcolumn과 RLS predicate에 쓰이는 데이터베이스 측면 식별자입니다.
slug만 설정하는 것으로는 데이터 접근에 충분하지 않습니다. Source anchor:
libs/common/src/main/java/com/lumie/common/tenant/TenantContextHolder.java.
그래서 테넌트 컨텍스트에 다시 진입하는 코드는 다음을 사용해야 합니다.
TenantContextHolder.withinContext(slug, tenantId, action)
단순히 setTenant(slug)만 호출해서는 안 됩니다.
컨텍스트를 다시 설정해야 하는 위치
현재 코드는 다음 경계 사례에서 테넌트 컨텍스트를 명시적으로 복원합니다.
examRabbitMQ 콜백 listenercustomId -> 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_isolationpolicy가 빠지면 그 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.javaapp/src/test/java/com/lumie/app/config/RlsTenantTransactionBinderIntegrationTest.javaapp/src/test/java/com/lumie/app/config/RuntimeDbRoleGuardTest.javaapp/src/test/java/com/lumie/app/SmsDispatchServiceWiringTest.javalibs/common/src/test/java/com/lumie/common/tenant/RlsTenantContextAspectIntegrationTest.java