본문으로 건너뛰기

멀티테넌시

Lumie는 Postgres Row-Level Security(RLS) 기반 멀티테넌시를 사용합니다. 모든 테넌트 데이터는 단일 public 스키마에 저장되며, 각 테넌트 스코프 테이블의 tenant_id BIGINT NOT NULL 컬럼과 RLS 정책이 행 단위 격리를 강제합니다.

전체 아키텍처 개요


Postgres 역할 분리

역할속성용도
lumie_appNOSUPERUSER, NOBYPASSRLS앱 런타임 커넥션 (Spring spring.datasource.*)
postgressuperuserFlyway 마이그레이션 전용 (spring.flyway.user/password)

lumie_app는 BYPASSRLS가 없으므로 RLS 정책이 항상 적용됩니다. postgres 롤은 마이그레이션 시에만 사용하며 앱 런타임 코드에서 직접 사용하지 않습니다.


RLS 정책 구조

모든 테넌트 스코프 테이블에 다음 패턴이 적용됩니다.

-- V18__rls_baseline.sql (모든 테넌트 스코프 테이블 적용)
ALTER TABLE students ENABLE ROW LEVEL SECURITY;
ALTER TABLE students FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON students
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::bigint)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::bigint);
  • FORCE ROW LEVEL SECURITY: 테이블 소유자(superuser 포함)에게도 정책이 적용됩니다.
  • 플랫폼 테이블(tenants, plans, paddle_webhook_events)은 tenant_id 없음, RLS 적용 대상이 아닙니다.

GUC 바인딩 방식

트랜잭션 시작
→ RlsTenantContextAspect (HIGHEST_PRECEDENCE+1)
→ SELECT set_config('app.tenant_id', '18', true) ← tx-local
→ 이하 모든 SQL은 RLS 필터 적용됨
트랜잭션 종료 → GUC 자동 해제

set_config(..., true)의 세 번째 파라미터 true는 트랜잭션 종료 시 자동 해제를 의미합니다. PgBouncer 트랜잭션 풀 모드에서도 세션 상태가 유출되지 않습니다.


HTTP 요청 라이프사이클

HTTP 요청
→ JwtAuthenticationFilter
→ JWT 파싱 (HMAC-SHA256)
→ Redis 블랙리스트 확인
→ TenantContextHolder.setTenant("inst-c704d223") ← slug (MDC/로그용)
→ TenantContextHolder.setTenantId(18L) ← RLS 바인딩의 핵심
→ UserContextHolder.setUserId / setUserRole / setUserName

@Transactional 메서드 진입
→ RlsTenantContextAspect.around()
→ set_config('app.tenant_id', '18', true)

JPA 쿼리 실행 — RLS 자동 필터

트랜잭션 종료 → GUC 해제

finally: TenantContextHolder.clear() / MDC.clear()

X-헤더 전파

HTTP 경계를 넘어가는 모든 요청(lumie-backend → lumie-worker 등)에는 X-Tenant-Slug 헤더를 포함해야 합니다.

헤더값 예시설명
X-Tenant-Sluginst-c704d223테넌트 슬러그 (MDC, 로그)

비동기 라이프사이클

RabbitListener 패턴

@RabbitListener 스레드는 JWT 필터를 거치지 않으므로 메시지에서 직접 테넌트 컨텍스트를 복원해야 합니다. setTenant만 호출하면 행이 보이지 않습니다 — setTenantId도 반드시 호출하세요.

@RabbitListener(queues = "${mq.grading.callback-queue}")
public void handleCallback(GradingCallbackMessage message) {
TenantContextHolder.setTenant(message.getTenantSlug());
TenantContextHolder.setTenantId(message.getTenantId());
try {
// 이하 @Transactional 메서드 호출 → RlsTenantContextAspect 자동 실행
gradingResultService.persist(message);
} finally {
TenantContextHolder.clear(); // 반드시 정리
}
}

Scheduler 패턴

@Scheduled(cron = "0 0 2 * * *")
@SchedulerLock(name = "nightly-report-job")
public void runNightlyReports() {
List<TenantService.TenantData> tenants = tenantService.listActiveTenants();
for (TenantService.TenantData tenant : tenants) {
TenantContextHolder.withinContext(tenant.slug(), tenant.id(), () -> {
reportService.generateNightlyReport(); // @Transactional 내부
});
}
}

TenantContextHolder.withinContext(slug, tenantId, action) 은 slug + tenantId 모두 설정하고 finally에서 자동 정리합니다. 어댑터에서 테넌트 전환 시 이 메서드를 사용합니다.


TenantContextHolder

// 읽기
String slug = TenantContextHolder.getRequiredTenant(); // "inst-c704d223"
Long tenantId = TenantContextHolder.getRequiredTenantId(); // 18

// 설정 (setTenantId가 RLS 바인딩의 핵심 — 누락 시 모든 행이 보이지 않음)
TenantContextHolder.setTenant("inst-c704d223");
TenantContextHolder.setTenantId(18L);

// 또는 안전한 withinContext 사용 (finally 자동 정리)
TenantContextHolder.withinContext("inst-c704d223", 18L, () -> {
// ...
});

// 정리 (수동 set 사용 시 finally 블록 필수)
TenantContextHolder.clear();

어드민 크로스 테넌트 쿼리

FORCE ROW LEVEL SECURITY가 테이블 소유자에게도 적용되므로, 어드민 전용 쿼리는 명시적으로 RLS를 비활성화해야 합니다.

// @Transactional 내부에서만 사용 가능
@Modifying
@Query(value = "SET LOCAL row_security = off", nativeQuery = true)
void disableRlsForCurrentTransaction();

이 패턴은 전용 어드민 레포지토리에서만 허용됩니다. 일반 모듈에서 row_security = off를 실행하는 것은 크로스 테넌트 데이터 유출 버그입니다.


엔티티 계층

BaseEntity ← 플랫폼 테이블 (tenants, plans, ...)
└── TenantScopedEntity ← 테넌트 스코프 테이블
├── @Column tenant_id BIGINT
└── @PrePersist auto-fill from TenantContextHolder.getRequiredTenantId()

새 테넌트 스코프 엔티티는 반드시 TenantScopedEntity를 상속해야 합니다. @PrePersisttenant_id를 자동 설정합니다.


역사적 맥락

2026-05-12 이전 Lumie 백엔드는 Schema-per-Tenant 방식(테넌트별 tenant_{slug} PostgreSQL 스키마)을 사용했습니다. @RabbitListener@Scheduled 경로에서 search_path tx-local 트릭이 HTTP 타이밍 의존성 때문에 올바르게 작동하지 않는 문제가 확인되어 RLS로 전환했습니다. TenantConnectionProvider, TenantIdentifierResolver, 기존 db/migration/tenant/ 파일이 모두 삭제되고 V18__rls_baseline.sql로 통합되었습니다.


관련 문서