본문으로 건너뛰기

멀티 테넌시

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.sqlshared-schema RLS baseline과 schema-per-tenant 모델 폐기
app/src/main/resources/db/migration/public/V65__repair_read_receipt_and_file_download_rls.sqltenant-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.javamigration chain 상태와 hostile-tenant 격리에 대한 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를 생성합니다. 이것은 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에 쓰이는 제품 측면 식별자입니다.
  • tenantIdtenant_id column과 RLS predicate에 쓰이는 데이터베이스 측면 식별자입니다.

slug만 설정하는 것으로는 데이터 접근에 충분하지 않습니다. 그래서 tenant context에 다시 진입하는 코드는 다음을 사용해야 합니다.

TenantContextHolder.withinContext(slug, tenantId, action)

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

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

현재 코드는 다음 경계 사례에서 tenant context를 명시적으로 복원합니다.

  • exam RabbitMQ callback listener
  • customId -> 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_isolation policy가 빠지면 그 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.java
  • app/src/test/java/com/lumie/app/config/RuntimeDbRoleGuardTest.java
  • libs/common/src/test/java/com/lumie/common/tenant/RlsTenantContextAspectIntegrationTest.java

관련 페이지