멀티 테넌 시
Lumie는 shared-schema PostgreSQL 멀티 테넌시를 사용합니다. tenant 격리는
학원별 schema가 아니라 public schema의 Row Level Security로 강제됩니다.
이 페이지는 RLS 모델, 이를 바인딩하는 코드, 그리고 데이터 접근을 추가하거나
변경할 때 중요한 failure mode를 다루는 reference 문서입니다.
소스 경로
| Path | 역할 |
|---|---|
app/src/main/resources/db/migration/public/V18__rls_baseline.sql | shared-schema RLS baseline과 schema-per-tenant 모델 폐기 |
app/src/main/resources/db/migration/public/V65__repair_read_receipt_and_file_download_rls.sql | tenant-safe 제약이나 정책이 빠진 table의 실제 forward repair 예시 |
libs/common/src/main/java/com/lumie/common/tenant/RlsTenantContextAspect.java | 각 트랜잭션에 app.tenant_id를 바인딩 |
libs/common/src/main/java/com/lumie/common/tenant/TenantContextHolder.java | 현재 thread에 tenant slug와 tenant ID를 보관 |
libs/common/src/main/java/com/lumie/common/domain/TenantScopedEntity.java | 대부분의 tenant 범위 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-tenant 격리에 대한 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를 생성합니다. 이것은 tenant 데이터 저장 모델이 아니라
기술적인 worker-state 예외입니다.
핵심을 지탱하는 조건식
migration은 다음과 같은 표준 policy 형태를 사용합니다.
using (tenant_id = nullif(current_setting('app.tenant_id', true), '')::bigint)
with check (tenant_id = nullif(current_setting('app.tenant_id', true), '')::bigint)
Java 쪽은 트랜잭션 진입 시 이 값을 바인딩합니다.
SELECT set_config('app.tenant_id', ?, true)
is_local=true이므로 PostgreSQL은 commit 또는 rollback 시 이 설정을
자동으로 지웁니다.
런타임 흐름
Slug와 ID가 분리된 이유
tenantSlug는 URL, JWT claim, 로그, object key, worker header에 쓰이는 제품 측면 식별자입니다.tenantId는tenant_idcolumn과 RLS predicate에 쓰이는 데이터베이스 측면 식별자입니다.
slug만 설정하는 것으로는 데이터 접근에 충분하지 않습니다. 그래서 tenant context에 다시 진입하는 코드는 다음을 사용해야 합니다.
TenantContextHolder.withinContext(slug, tenantId, action)
단순히 setTenant(slug)만 호출해서는 안 됩니다.
컨텍스트를 다시 설정해야 하는 위치
현재 코드는 다음 경계 사례에서 tenant context를 명시적으로 복원합니다.
examRabbitMQ callback listenercustomId -> tenant해석 이후의homepage공개 조회- HMAC 인증을 거치는
/internal/**요청 - 원래 요청 thread가 사라진 뒤 실행되는 Spring Modulith listener
TenantService.listActiveTenants()를 순회하는 scheduler loop- AI worker callback과 tool 실행 경로
엔티티 및 테이블 패턴
대부분의 tenant 범위 aggregate는 TenantScopedEntity를 상속하고,
@PrePersist에서 tenant_id를 씁니다.
일부 table은 base class 대신 tenant_id를 직접 관리합니다. 현재 예시로는
modules/staff/domain/entity/StaffPermission.java가 있으며, 이 클래스는
자신의 @PrePersist에서 tenant_id를 명시적으로 설정합니다.
안전 가드와 알려진 실패 모드
TenantContextHolder에 tenant ID가 없으면 tenant 범위 row는 보이지 않습니다.- Spring transaction ordering이 바뀌어
RlsTenantContextAspect가 활성 트랜잭션 밖에서 실행되면, GUC가 한 statement 후 사라지고 RLS는 의도한 tenant를 더 이상 볼 수 없습니다. - 새 tenant 범위 table에
tenant_id, RLS 활성화,FORCE ROW LEVEL SECURITY,tenant_isolationpolicy가 빠지면 그 table은 cross-tenant 누수가 됩니다. - 런타임 DB role에
SUPERUSER또는BYPASSRLS가 있으면 RLS는 조용히 우회됩니다.RuntimeDbRoleGuard가 startup을 막아 이를 방지합니다. V65__repair_read_receipt_and_file_download_rls.sql은 tenant-safe 제약이나 정책 없이 들어간 table을 어떻게 복구하는지 보여주는 실제 예시입니다.
읽기 전용 레플리카는 테넌시 모델을 바꾸지 않습니다
백엔드 의 read/write split은 어떤 Hikari pool을 쓰는지만 바꾸며, 테넌시 모델 자체를 바꾸지는 않습니다.
- write 및 비트랜잭션 코드 -> primary pool
@Transactional(readOnly = true)-> readonly pool- 두 pool 모두 동일한
app.tenant_id바인딩과 런타임 role에 의존
AI 모듈의 전용 readOnlyJdbcTemplate도 같은 규칙을 따르며, raw SQL 읽기를
위해 set_config('app.tenant_id', ?, true)를 명시적으로 호출합니다.
검증 명령어
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/RuntimeDbRoleGuardTest.javalibs/common/src/test/java/com/lumie/common/tenant/RlsTenantContextAspectIntegrationTest.java