콘텐츠로 이동

Spring WebFlux 멀티테넌시: Reactor Context 전파에서 테넌트 온보딩까지

WebFlux 요청 토큰이 Reactor Context와 코루틴 작업대를 지나 테넌트별 데이터베이스와 온보딩 제어판으로 연결되는 어두운 3D 미니어처 장면
테넌트 컨텍스트는 헤더 문자열이 아니라 요청 권한과 데이터 자원, 운영 상태를 연결하는 계약입니다.

X-TENANT-ID가 올바른 형식이라고 해서 곧바로 DB를 고르면 안 됩니다. 그 테넌트에 접근할 권한이 없는 사용자가 값을 보냈을 수도 있고, 막 생성한 테넌트가 아직 DB와 연결 풀을 준비하는 중일 수도 있습니다. 멀티테넌시는 헤더를 읽는 문제보다, 검증된 테넌트가 언제 요청 경로와 운영 목록에 들어와도 되는지를 정하는 문제에 가깝습니다.

이 글은 exposed-r2dbc-workshop의 Spring WebFlux 예제를 따라 두 흐름을 하나로 묶습니다.

  1. 요청마다 테넌트를 식별하고 권한을 검증한 뒤, Reactor Context와 Kotlin 코루틴을 거쳐 올바른 DB 연결을 선택한다.
  2. 새로운 테넌트의 준비 소유권과 상태를 영속화하고, 준비를 검증한 뒤 실제 요청이 선택할 수 있는 목록에 안전하게 공개한다.

이전 Ktor 멀티테넌트 라우팅 글은 Ktor의 ApplicationCall.attributes와 명시적인 값 전달을 다뤘습니다. 이번 글은 같은 불변식을 WebFlux의 비동기 요청 경계에서 어떻게 지키는지, 그리고 테넌트가 그 경계에 들어오기 전의 생명주기까지 살펴봅니다.

멀티테넌시의 출발점은 헤더가 아니다

섹션 제목: “멀티테넌시의 출발점은 헤더가 아니다”

HTTP 헤더는 클라이언트가 보낸 입력입니다. 반면 테넌트는 데이터 격리, 접근 권한, DB 연결, 운영 상태를 묶는 도메인 값입니다. 이 둘을 같은 것으로 취급하면 X-TENANT-ID: acme라는 문자열이 컨트롤러와 서비스, 저장소까지 흘러갑니다. 그러면 각 계층이 제각각 검증하거나, 더 나쁘게는 검증을 잊습니다.

요청 경로에서 먼저 확정해야 할 것은 네 가지입니다.

확인할 질문아직 확정하지 않으면 생기는 일
헤더가 하나의 유효한 테넌트를 가리키는가빈 값, 중복 값, 알 수 없는 값이 뒤늦게 DB까지 간다
인증된 사용자는 그 테넌트에 접근할 수 있는가다른 고객의 테넌트를 지정해 데이터를 읽을 수 있다
현재 실행 흐름에서 테넌트를 꺼낼 수 있는가비동기 경계 뒤에 테넌트가 사라진다
테넌트의 DB 자원이 실제로 준비됐는가아직 준비 중인 연결을 라우팅 대상으로 선택한다

즉 헤더는 출발점일 뿐입니다. 이후에는 원본 문자열 대신 검증된 TenantId를 다루고, 권한을 확인한 뒤에만 요청 범위의 컨텍스트로 공개해야 합니다.

WebFlux에서는 요청과 스레드가 일치하지 않는다

섹션 제목: “WebFlux에서는 요청과 스레드가 일치하지 않는다”

전통적인 서버 코드에서는 요청 하나가 작업을 끝낼 때까지 특정 스레드에 붙어 있다고 생각하기 쉽습니다. WebFlux에서는 이 가정이 맞지 않습니다. 같은 요청도 비동기 작업을 거치면서 여러 실행 지점으로 이동할 수 있으므로, 스레드에 값을 숨기는 ThreadLocal은 테넌트의 전달 수단으로 적합하지 않습니다.

Project Reactor의 Context는 구독자에 붙는 불변 키/값 전달 수단입니다. WebFlux 요청에서 테넌트를 이 컨텍스트에 넣으면, 실행 스레드가 바뀌더라도 해당 요청의 반응형 체인 안에서 값을 이어 갈 수 있습니다. 다만 Reactor 체인 안에서 Kotlin의 suspend 함수로 들어갈 때는 이 사실을 코드가 명시적으로 받아들여야 합니다.

Reactor Context에서 코루틴까지 테넌트를 전달한다

섹션 제목: “Reactor Context에서 코루틴까지 테넌트를 전달한다”

워크숍의 기본 WebFlux 예제는 필터에서 헤더를 해석해 TenantId로 바꾸고, 다음 체인에 Reactor Context를 붙입니다. 아래 코드는 핵심만 남긴 축약본입니다.

override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> = mono {
val tenant = resolveTenant(exchange.request.headers)
chain.filter(exchange)
.contextWrite { context ->
context.put(TenantId.TENANT_ID_KEY, TenantId(tenant))
}
.awaitSingleOrNull()
}
suspend fun currentReactorTenant(): Tenants.Tenant =
coroutineContext[ReactorContext]
?.context
?.get<TenantId>(TenantId.TENANT_ID_KEY)
?.value
?: error("Tenant context is required")

ReactorContext는 Reactor의 컨텍스트를 CoroutineContext 요소로 노출합니다. 그래서 suspend 컨트롤러나 서비스는 coroutineContext[ReactorContext]에서 같은 테넌트를 읽습니다. 테넌트가 없는 HTTP 요청에 별다른 알림 없이 기본 테넌트를 적용하는 것은 안전한 대체 동작이 아닙니다. 누락은 요청 경계에서 실패시켜야 합니다.

연결 팩터리를 테넌트별로 나누는 예제에는 연결 지점이 한 번 더 있습니다. TenantTransactionExecutorMono.deferContextual로 현재 Reactor Context를 얻고, 그것을 mono(bridgedContext)에 다시 결합한 뒤 suspendTransaction을 실행합니다. 트랜잭션을 시작하는 위치도 코루틴이고, 그 위치가 테넌트를 잃지 않도록 재연결하는 것입니다.

X-TENANT-ID와 인증 정보가 권한 검증 필터, Reactor Context, 코루틴 서비스, 테넌트 트랜잭션 실행기와 저장소를 거치는 요청 상호작용 다이어그램
테넌트 헤더는 입력일 뿐입니다. 검증된 테넌트와 인증 주체의 권한이 일치한 뒤에만 Reactor Context를 공개하고 DB 연결을 선택합니다.

테넌트 식별과 접근 권한은 같은 문제가 아니다

섹션 제목: “테넌트 식별과 접근 권한은 같은 문제가 아니다”

tenant=acme를 해석할 수 있다는 사실과, 현재 사용자가 acme 데이터를 볼 수 있다는 사실은 다릅니다. 이 둘을 한 필터 안에서 분리해 생각하면 순서가 선명해집니다.

authenticate(request)
requestedTenant = resolveTenant(request)
authorize(principal, requestedTenant)
publishTenantToReactorContext(requestedTenant)
continueFilterChain()

AuthorizedTenantContextWebFilter는 인증 정보를 얻고, 요청 테넌트와 인증 주체가 허용받은 테넌트를 비교합니다. 일치하지 않으면 다음 필터나 컨트롤러로 넘기지 않습니다. 여기서 지켜야 할 순서는 권한 확인이 Reactor Context 공개보다 앞선다는 것입니다. 아직 인증되지 않았거나 권한이 없는 값이 요청 컨텍스트에 들어가면, 이후 코드가 그 값을 신뢰할 여지가 생깁니다.

권한 모델은 서비스마다 다릅니다. 한 사용자가 테넌트 하나에만 속할 수도, 관리자가 여러 테넌트를 관리할 수도 있습니다. 그러나 요청 경계의 순서는 바뀌지 않습니다. 식별 → 인증 → 권한 검증 → 컨텍스트 공개 순서를 하나의 계약으로 두면, 데이터 계층은 이미 검증된 테넌트만 받는다는 단순한 전제를 가질 수 있습니다.

검증된 테넌트가 DB 연결을 선택한다

섹션 제목: “검증된 테넌트가 DB 연결을 선택한다”

테넌트 격리 방식은 하나뿐이 아닙니다. 워크숍은 스키마를 바꾸는 방식과 테넌트별 ConnectionFactory를 선택하는 방식을 모두 보여 줍니다. 선택은 격리 강도와 운영 비용의 균형입니다.

전략격리 단위장점운영 비용
공유 DB·테넌트별 스키마스키마하나의 DB 자원을 공유한다연결 재사용 시 스키마 설정과 누수 검증이 필요하다
테넌트별 ConnectionFactory풀·데이터베이스연결과 장애 범위를 더 강하게 격리한다테넌트 수만큼 풀과 생명주기를 관리해야 한다

후자에서는 TenantRoutingConnectionFactory와 레지스트리가 함께 움직입니다. 트랜잭션 실행기가 컨텍스트에서 읽은 테넌트로 레지스트리를 조회하고, 그 테넌트에 등록된 연결 팩터리만 선택합니다. 다른 테넌트를 기본값으로 대신 고르는 동작은 복구 전략이 아닙니다. 데이터 격리를 깨뜨릴 수 있으므로, 등록되지 않았거나 준비 중인 테넌트는 명시적으로 거부하거나 준비 상태를 알려야 합니다.

신규 테넌트는 라우팅 목록에 어떻게 들어오는가

섹션 제목: “신규 테넌트는 라우팅 목록에 어떻게 들어오는가”

요청 라우팅만 구현하면 기존 테넌트에는 동작합니다. 하지만 SaaS에서는 새 고객이 가입하는 순간에도 같은 경계를 지켜야 합니다. 메타데이터 테이블에 행이 생기는 것과, 실제 요청이 사용할 연결 팩터리가 준비되는 것은 다른 사건입니다.

이 차이를 무시하면 두 종류의 불완전한 테넌트가 생깁니다.

  • 메타데이터에는 있지만 풀 또는 스키마가 준비되지 않아 요청을 처리할 수 없는 테넌트
  • 풀은 만들었지만 레지스트리에 공개되지 않아 요청이 찾을 수 없는 테넌트

온보딩은 이 둘 사이를 건너는 절차입니다. 테넌트가 아직 준비 중인지, 요청을 받을 수 있는지, 복구가 필요한지를 운영자가 판단할 수 있어야 합니다.

온보딩은 준비와 공개를 분리한다

섹션 제목: “온보딩은 준비와 공개를 분리한다”

장애 복구형 예제의 ResilientTenantProvisioner는 먼저 메타데이터의 준비 소유권을 PROVISIONING으로 예약하고, 리스 유효 시간을 갱신하면서 실제 자원을 준비합니다. 준비 표식을 검사해 ACTIVE로 바꾼 다음에만 런타임 레지스트리에 게시합니다. 다음은 이름과 순서를 이해하기 위한 구조화한 의사코드입니다.

suspend fun onboard(command: TenantOnboardingCommand): TenantOnboardingResult {
val owner = lifecycleRepository.claim(...) // PROVISIONING + 리스
val renewed = lifecycleRepository.renewLease(owner, ...)
val resources = resourceFactory.create(renewed.metadata) // 스키마 + 준비 표식
resourceFactory.probe(resources)
val active = lifecycleRepository.markActive(renewed, ...) // ACTIVE
runtimeRegistry.publish(resources) // 라우팅 공개
return Created(active)
}

여기서 runtimeRegistry.publish()가 프로세스 안의 공개 경계입니다. 그 전에는 테넌트가 메타데이터에 존재해도 일반 요청이 선택할 수 없습니다. ACTIVE는 영속 자원이 준비됐다는 기록이고, 런타임 레지스트리는 현재 프로세스가 준비 상태 검사를 통과한 자원을 실제로 라우팅할 수 있다는 기록입니다. 준비와 공개를 분리하면, 재시작 후에도 이 둘을 다시 맞출 수 있습니다.

운영자 요청이 테넌트 생명주기의 PROVISIONING 예약과 리스 갱신, PostgreSQL 스키마와 준비 표식 검사, ACTIVE 기록, 런타임 레지스트리 게시로 이어지고 실패 기록과 재시작 복구로 분기하는 상호작용 다이어그램
일반 요청은 현재 프로세스에 게시된 런타임 레지스트리만 바라봅니다. 온보딩은 준비를 검증하고 ACTIVE를 기록한 뒤에 테넌트를 라우팅 목록에 올립니다.

온보딩 실패를 운영 가능한 상태로 남긴다

섹션 제목: “온보딩 실패를 운영 가능한 상태로 남긴다”

실패 처리에는 서로 다른 두 경로가 있습니다. 이를 하나의 “실패 상태 저장”으로 뭉뚱그리면 실제 코드와 운영 판단이 어긋납니다. 이 예제는 원시 예외 메시지나 연결 정보를 저장하지 않고, 안정적인 실패 범주만 메타데이터에 남깁니다.

시점Workshop의 처리운영상 의미
같은 프로세스 안에서 온보딩이 실패·취소FAILED와 실패 범주를 기록하고 레지스트리를 해제한 뒤 확보한 자원을 닫는다불완전한 테넌트를 요청 경로에 남기지 않고 재시도 근거를 보존한다
프로세스가 재시작된 뒤유효 시간이 지난 PROVISIONINGFAILED(RECOVERY)로 바꾸고, ACTIVE는 복원·준비 상태 검사 뒤에만 다시 게시한다메모리 기반 런타임 레지스트리가 사라진 상태를 영속 메타데이터와 다시 맞춘다
복구 검사가 실패ACTIVE 행을 FAILED(RECOVERY)로 바꾸고 레지스트리에 게시하지 않는다ACTIVE라는 과거 기록만으로 요청을 라우팅하지 않는다
이후 다시 온보딩FAILED 행을 새 PROVISIONING 시도로 갱신재시도 횟수와 소유권을 새 시도에 묶는다

PostgreSQL 프로필에서는 생명주기 메타데이터를 public.tenant_lifecycle에, 테넌트 업무 자원과 준비 표식을 tenant_<tenant-id> 스키마에 둡니다. 스키마 생성 뒤 준비 상태 검사가 실패해도 스키마를 자동으로 지우지 않습니다. 장애 조사와 멱등 재시도를 위해 보존하되, 생명주기 행은 FAILED로 바꿔 일반 요청이 그 자원을 선택하지 못하게 합니다. 운영 서비스는 이 구분을 바탕으로 감사 로그, 알림, 재시도 정책을 별도로 연결할 수 있습니다.

테스트는 정상 응답보다 경계를 검증해야 한다

섹션 제목: “테스트는 정상 응답보다 경계를 검증해야 한다”

멀티테넌시 테스트는 “행위자가 조회된다”로는 부족합니다. 다른 테넌트의 값이 섞이지 않았고, 권한 없는 요청과 준비되지 않은 테넌트가 데이터 계층까지 가지 않았다는 사실을 증명해야 합니다.

검증할 경계확인할 결과
헤더와 테넌트 해석누락·공백·알 수 없는 테넌트가 요청 초기에 거부된다
인증 테넌트와 요청 테넌트불일치 요청이 컨텍스트 공개 전에 거부된다
Reactor → 코루틴 전파suspend 서비스와 트랜잭션에서도 같은 테넌트를 읽는다
연결 팩터리 선택테넌트 A의 요청이 테넌트 B의 풀·데이터베이스로 가지 않는다
온보딩 공개 시점ACTIVE이면서 현재 런타임 레지스트리에 게시된 테넌트만 라우팅 대상이 된다
실패·취소FAILED 기록, 레지스트리 해제, 자원 정리가 NonCancellable 정리 구문에서 끝난다
재시작 복구만료된 PROVISIONING과 준비 상태 검사에 실패한 ACTIVEFAILED(RECOVERY)가 되고, 정상 ACTIVE만 다시 게시된다

경계 1: 권한 없는 테넌트는 컨텍스트에 공개되지 않는다

섹션 제목: “경계 1: 권한 없는 테넌트는 컨텍스트에 공개되지 않는다”

403 하나만 확인하면 요청이 어느 지점에서 멈췄는지 알 수 없습니다. 이 경계의 핵심은 권한 검증보다 뒤의 컴포넌트가 테넌트를 볼 수 없어야 한다는 것입니다. 실제 워크숍도 AuthorizedTenantContextWebFilter만 Reactor Context에 테넌트를 기록하도록 구조 검증을 둡니다.

준비: 인증 주체는 tenant-a에 접근할 권한이 없다
실행: GET /actors with X-TENANT-ID = tenant-a
검증: 응답은 FORBIDDEN이다
그리고 Reactor Context에는 TENANT_ID가 없다
그리고 TenantTransactionExecutor는 호출되지 않는다
그리고 tenant-a의 ConnectionFactory는 선택되지 않는다

이 의사코드는 HTTP 상태 코드와 내부 경계를 함께 확인합니다. 필터가 원본 헤더를 먼저 컨텍스트에 넣고 나중에 권한을 검사하는 구현은 첫 줄의 FORBIDDEN만 보면 통과할 수 있지만, 여기서는 실패합니다.

경계 2: 동시에 흐르는 요청은 DB 선택에서도 섞이지 않는다

섹션 제목: “경계 2: 동시에 흐르는 요청은 DB 선택에서도 섞이지 않는다”

테넌트 격리는 단일 요청에서만 확인해서는 부족합니다. 연결 재사용이 가장 잦은 작은 풀 조건에서 테넌트 A와 B를 교차시키면 스키마 또는 연결 팩터리 누수가 훨씬 잘 드러납니다.

준비: 연결 풀 크기 = 1
그리고 tenant-a와 tenant-b는 서로 다른 행위자 식별 표식을 가진다
실행: A와 B 요청을 동시에 여러 번 보낸다
검증: 모든 A 응답에는 A 식별 표식만 있다
그리고 모든 B 응답에는 B 식별 표식만 있다
그리고 각 요청은 자신의 테넌트 자원을 선택한다
그리고 어떤 요청도 다른 테넌트의 기본 자원으로 대체되지 않는다

여기서 검증 대상은 단순히 두 요청이 200을 돌려주는지가 아닙니다. 각 응답의 식별 표식과 실제 선택된 자원이 요청의 검증된 테넌트와 끝까지 같은지를 확인해야 합니다. TenantRoutingConnectionFactory가 알 수 없는 테넌트에 실패하고 관대한 대체 동작을 비활성화한 이유도 같습니다.

경계 3: 준비 중이거나 복구에 실패한 테넌트는 라우팅 대상이 아니다

섹션 제목: “경계 3: 준비 중이거나 복구에 실패한 테넌트는 라우팅 대상이 아니다”

온보딩 테스트는 ACTIVE 상태 문자열만 검증하면 충분하지 않습니다. 런타임 레지스트리에 게시되는 순간이 실제 요청 경로의 공개 시점이므로, 준비 상태 검사와 레지스트리를 함께 확인해야 합니다.

준비: tenant-x는 PROVISIONING이다
또는 tenant-x는 ACTIVE이지만 재시작 후 준비 상태 검사에 실패한다
실행: 애플리케이션이 영속 생명주기 기록을 조정한다
검증: tenant-x는 런타임 레지스트리에 없다
그리고 tenant-x의 ConnectionFactory는 선택되지 않는다
그리고 생명주기는 유지되거나 FAILED(RECOVERY)로 바뀐다
실행: tenant-y는 ACTIVE이고 준비 상태 검사를 통과한다
검증: tenant-y만 런타임 레지스트리에 게시된다

이렇게 쓰면 “재시작 뒤에도 테넌트가 보인다”는 정상 경로뿐 아니라, 불완전한 자원이 일반 요청으로 새지 않는다는 운영 경계까지 증명할 수 있습니다. TenantLifecycleReconciler의 준비 상태 검사 뒤 게시와 PostgreSQL 재시작 통합 테스트가 바로 이 계약을 고정합니다.

풀 크기를 작게 만든 뒤 테넌트 요청을 번갈아 보내는 테스트도 유용합니다. 연결 재사용이 가장 잦은 조건에서 스키마나 연결 팩터리 선택이 섞이지 않는지 확인할 수 있기 때문입니다.

Ktor와 비교하면 전달 수단만 다르다

섹션 제목: “Ktor와 비교하면 전달 수단만 다르다”

Ktor 글에서 요청 범위 상태는 ApplicationCall.attributes에 두고, 이후에는 검증된 값을 명시적으로 넘겼습니다. WebFlux에서는 Reactor Context와 CoroutineContext가 그 전달 수단 역할을 합니다. 하지만 지켜야 할 원칙은 같습니다.

원칙KtorSpring WebFlux
입력 검증플러그인에서 헤더를 해석한다필터에서 테넌트를 해석한다
요청 범위 전달ApplicationCall.attributesReactor Context → ReactorContext
권한 경계경로·플러그인 앞단에 둔다컨텍스트 공개 전에 둔다
DB 선택명시적 경로 또는 테넌트 값컨텍스트에서 읽은 검증 테넌트
신규 테넌트 공개레지스트리가 준비된 뒤 선택 가능해야 한다레지스트리가 준비된 뒤 선택 가능해야 한다

전달 수단을 복사하는 것이 아니라, 검증된 값만 전달하고, 권한 뒤에 공개하며, 준비되지 않은 자원은 선택하지 않는다는 불변식을 런타임에 맞게 옮기는 것이 핵심입니다.

Ktor의 Tenant Plugin과 ApplicationCall attributes 경로, Spring WebFlux의 WebFilter와 Reactor Context, 코루틴 연결 경로가 모두 검증된 테넌트를 트랜잭션과 DB 자원 선택으로 전달하는 비교 다이어그램
런타임에 맞는 전달 수단을 선택하되, 원본 헤더를 도메인 테넌트로 바꾸는 지점과 DB 선택 경계를 한 번씩만 둡니다.

WebFlux 멀티테넌시의 어려움은 비동기 API를 하나 더 쓰는 데 있지 않습니다. 테넌트를 입력값에서 신뢰 가능한 요청 컨텍스트로 바꾸고, 그 컨텍스트가 코루틴과 트랜잭션을 지나 DB 선택까지 유지되게 하며, 새 테넌트가 준비되기 전에는 요청 경로에 나타나지 않게 만드는 데 있습니다.

요청 경로와 온보딩 경로를 같은 계약으로 보면 판단이 단순해집니다. 권한을 통과한 테넌트만 컨텍스트에 넣고, 영속 자원을 검증해 현재 프로세스에 게시한 테넌트만 레지스트리에 넣습니다. 그 두 공개 지점을 분명히 하면, 테넌트가 많아져도 데이터 격리와 재시작 복구를 함께 설계할 수 있습니다.

댓글

GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.