본문으로 건너뛰기

RLS Tenant Context 누락

backend 경로가 HTTP 또는 message boundary에서는 올바른 tenant slug를 가지고 있는데도, tenant-scoped database row가 보이지 않거나 insert가 Postgres RLS에서 실패할 때 이 가이드를 사용합니다.

이 문서는 RLS migration, chatbot cutover, report-job, SMS 회고에서 더 넓게 다룬 실패의 운영 runbook입니다.

증상

  • tenant row가 실제로 존재하지만 backend read가 404 또는 empty result를 반환합니다.
  • worker callback, internal endpoint, scheduler, export, MVC async response에서 tenant state가 사라집니다.
  • 요청에 tenant slug가 있었는데도 write가 RLS WITH CHECK policy에 실패합니다.
  • 일반 controller request에서는 동작하지만 @Async, StreamingResponseBody, TransactionTemplate, /internal/**에서 실패합니다.

알려진 실패 이력

Commit실패 모드지속되는 수정
03d11fb5OMR presign read가 transaction에 들어가지 않아 RLS가 app.tenant_id를 받지 못했습니다.ResultCommandService.generatePresignedUrls(...)@Transactional(readOnly = true)입니다.
64d7dc2cbackend OMR worker 호출에서 X-Tenant-Slug가 누락됐습니다.OmrServiceClient가 tenant slug를 grading-svc로 전달합니다.
7917d743MVC async export에서 tenant/user/MDC/security context가 사라지고 async-dispatch auth 실패가 날 수 있었습니다.TenantAwareTaskDecoratorConfig, AsyncMvcConfig, async dispatcher permission이 context를 보존합니다.
8c03ca33report job polling이 tenant-scoped job row를 보지 못했습니다.ReportJobService가 job read를 tenant-bound transaction으로 감쌉니다.
3d11f768internal chatbot persistence가 transactional service boundary 없이 직접 write했습니다.ChatService가 모든 conversation/message write를 @Transactional 아래에서 소유합니다.
db393adcread receipt와 file download tracking에 RLS gap이 있었습니다.V65__repair_read_receipt_and_file_download_rls.sql가 tenant policy를 보수하고 service가 tenant read를 다시 바인딩합니다.

현재 런타임 계약

활성 계약은 세 계층입니다.

계층소스 경로필요한 동작
Thread contextlibs/common/src/main/java/com/lumie/common/tenant/TenantContextHolder.java현재 작업 단위의 tenant slug와 tenant id를 보관합니다.
Transaction bindinglibs/common/src/main/java/com/lumie/common/tenant/RlsTenantContextAspect.javaSpring transaction advice 안에서 tenant id를 RLS용으로 바인딩합니다.
Direct transaction bindinglibs/common/src/main/java/com/lumie/common/tenant/RlsTenantTransactionBinder.javaprogrammatic TransactionTemplate 흐름에서 tenant id를 수동으로 바인딩합니다.

binding은 다음 SQL을 사용합니다.

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

마지막 true는 값을 transaction-local로 만듭니다. PgBouncer transaction pool과 호환되지만, active transaction 밖의 repository call은 RLS state를 안정적으로 갖지 못한다는 뜻이기도 합니다.

진단

  1. 실행 경계를 식별합니다. 실패 경로가 HTTP controller, MVC async, @Async, scheduler, RabbitMQ callback, /internal/**, TransactionTemplate 중 무엇인지 구분합니다.
  2. tenant slug와 tenant id가 모두 있는지 확인합니다. slug만으로는 tenant_idcurrent_setting('app.tenant_id', true)를 비교하는 RLS policy를 통과할 수 없습니다.
  3. database access가 @Transactional 안이나 RlsTenantTransactionBinder를 호출하는 TransactionTemplate 안에서 일어나는지 확인합니다.
  4. async 경로라면 TenantAwareTaskDecoratorConfig를 통하거나 context를 명시적으로 다시 설정하는지 확인하고, Spring Security가 DispatcherType.ASYNC를 허용하는지도 확인합니다.
  5. /internal/** 경로라면 request body 또는 신뢰된 header가 tenant slug와 tenant id를 들고 있고, controller가 service call을 TenantContextHolder.withinContext(...)로 감싸는지 확인합니다.
  6. RESULT_NOT_FOUND, Tenant context not set, Tenant ID is not set in context, RLS policy violation 같은 false negative log를 확인합니다.

유용한 소스 확인:

cd lumie-backend
rg -n "withinContext|bindRequiredTenantToCurrentTransaction|@Transactional|X-Tenant-Slug" \
app/src/main/java modules libs

수정

  • 일반 service method에서는 tenant-scoped repository access 주위에 올바른 @Transactional boundary를 추가합니다.
  • programmatic TransactionTemplate 흐름에서는 repository access 전에 transaction 내부에서 RlsTenantTransactionBinder.bindRequiredTenantToCurrentTransaction()을 호출합니다.
  • async execution에서는 decorated executor를 사용하거나 TenantContextHolderUserContextHolder를 명시적으로 set/clear합니다.
  • backend-to-worker 호출에서는 X-Tenant-Slug를 전달하고, callback이 RLS context를 복원해야 하면 payload에 tenant id도 포함합니다.
  • internal chatbot 같은 callback surface에서는 controller가 직접 write하지 말고 transactional application service에 persistence를 둡니다.

검증 증거

source와 runtime 두 수준에서 증거를 모읍니다.

  • 실패 경로가 database access 전에 tenant slug와 tenant id를 가지고 있습니다.
  • repository call이 active transaction 안에서 실행됩니다.
  • RlsTenantTransactionBinderIntegrationTest가 transaction-local cleanup을 계속 증명합니다.
  • 영향을 받은 endpoint 또는 callback이 기존 tenant row를 읽을 수 있습니다.
  • tenant id가 바뀌거나 없으면 cross-tenant read는 계속 보이지 않습니다.

예방

  • tenant slug는 routing context, tenant id는 RLS enforcement value로 취급합니다. 대부분의 runtime path에는 둘 다 필요합니다.
  • request-thread context가 async execution까지 살아남는다고 가정하지 않습니다.
  • TransactionTemplate이 RLS aspect를 자동으로 받는다고 가정하지 않습니다.
  • external I/O는 database transaction 밖에 두되, 모든 database mutation은 짧은 tenant-bound transaction으로 다시 들어갑니다.
  • tenant row가 "사라지면" repository query를 바꾸기 전에 transaction/context boundary를 먼저 확인합니다.

관련 페이지