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

X-TENANT-ID가 올바른 형식이라고 해서 곧바로 DB를 고르면 안 됩니다. 그 테넌트에 접근할 권한이 없는 사용자가
값을 보냈을 수도 있고, 막 생성한 테넌트가 아직 DB와 연결 풀을 준비하는 중일 수도 있습니다. 멀티테넌시는
헤더를 읽는 문제보다, 검증된 테넌트가 언제 요청 경로와 운영 목록에 들어와도 되는지를 정하는 문제에 가깝습니다.
이 글은 exposed-r2dbc-workshop의 Spring WebFlux 예제를 따라 두 흐름을 하나로 묶습니다.
- 요청마다 테넌트를 식별하고 권한을 검증한 뒤, Reactor Context와 Kotlin 코루틴을 거쳐 올바른 DB 연결을 선택한다.
- 새로운 테넌트의 준비 소유권과 상태를 영속화하고, 준비를 검증한 뒤 실제 요청이 선택할 수 있는 목록에 안전하게 공개한다.
이전 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 요청에 별다른 알림 없이
기본 테넌트를 적용하는 것은 안전한 대체 동작이 아닙니다. 누락은 요청 경계에서 실패시켜야 합니다.
연결 팩터리를 테넌트별로 나누는 예제에는 연결 지점이 한 번 더 있습니다. TenantTransactionExecutor는
Mono.deferContextual로 현재 Reactor Context를 얻고, 그것을 mono(bridgedContext)에 다시 결합한 뒤
suspendTransaction을 실행합니다. 트랜잭션을 시작하는 위치도 코루틴이고, 그 위치가 테넌트를 잃지 않도록
재연결하는 것입니다.

테넌트 식별과 접근 권한은 같은 문제가 아니다
섹션 제목: “테넌트 식별과 접근 권한은 같은 문제가 아니다”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는 영속 자원이 준비됐다는 기록이고, 런타임 레지스트리는 현재 프로세스가 준비 상태 검사를 통과한
자원을 실제로 라우팅할 수 있다는 기록입니다. 준비와 공개를 분리하면, 재시작 후에도 이 둘을 다시 맞출 수 있습니다.

온보딩 실패를 운영 가능한 상태로 남긴다
섹션 제목: “온보딩 실패를 운영 가능한 상태로 남긴다”실패 처리에는 서로 다른 두 경로가 있습니다. 이를 하나의 “실패 상태 저장”으로 뭉뚱그리면 실제 코드와 운영 판단이 어긋납니다. 이 예제는 원시 예외 메시지나 연결 정보를 저장하지 않고, 안정적인 실패 범주만 메타데이터에 남깁니다.
| 시점 | Workshop의 처리 | 운영상 의미 |
|---|---|---|
| 같은 프로세스 안에서 온보딩이 실패·취소 | FAILED와 실패 범주를 기록하고 레지스트리를 해제한 뒤 확보한 자원을 닫는다 | 불완전한 테넌트를 요청 경로에 남기지 않고 재시도 근거를 보존한다 |
| 프로세스가 재시작된 뒤 | 유효 시간이 지난 PROVISIONING은 FAILED(RECOVERY)로 바꾸고, ACTIVE는 복원·준비 상태 검사 뒤에만 다시 게시한다 | 메모리 기반 런타임 레지스트리가 사라진 상태를 영속 메타데이터와 다시 맞춘다 |
| 복구 검사가 실패 | ACTIVE 행을 FAILED(RECOVERY)로 바꾸고 레지스트리에 게시하지 않는다 | ACTIVE라는 과거 기록만으로 요청을 라우팅하지 않는다 |
| 이후 다시 온보딩 | FAILED 행을 새 PROVISIONING 시도로 갱신 | 재시도 횟수와 소유권을 새 시도에 묶는다 |
PostgreSQL 프로필에서는 생명주기 메타데이터를 public.tenant_lifecycle에, 테넌트 업무 자원과 준비 표식을
tenant_<tenant-id> 스키마에 둡니다. 스키마 생성 뒤 준비 상태 검사가 실패해도 스키마를 자동으로 지우지 않습니다. 장애 조사와
멱등 재시도를 위해 보존하되, 생명주기 행은 FAILED로 바꿔 일반 요청이 그 자원을 선택하지 못하게 합니다. 운영 서비스는
이 구분을 바탕으로 감사 로그, 알림, 재시도 정책을 별도로 연결할 수 있습니다.
테스트는 정상 응답보다 경계를 검증해야 한다
섹션 제목: “테스트는 정상 응답보다 경계를 검증해야 한다”멀티테넌시 테스트는 “행위자가 조회된다”로는 부족합니다. 다른 테넌트의 값이 섞이지 않았고, 권한 없는 요청과 준비되지 않은 테넌트가 데이터 계층까지 가지 않았다는 사실을 증명해야 합니다.
| 검증할 경계 | 확인할 결과 |
|---|---|
| 헤더와 테넌트 해석 | 누락·공백·알 수 없는 테넌트가 요청 초기에 거부된다 |
| 인증 테넌트와 요청 테넌트 | 불일치 요청이 컨텍스트 공개 전에 거부된다 |
| Reactor → 코루틴 전파 | suspend 서비스와 트랜잭션에서도 같은 테넌트를 읽는다 |
| 연결 팩터리 선택 | 테넌트 A의 요청이 테넌트 B의 풀·데이터베이스로 가지 않는다 |
| 온보딩 공개 시점 | ACTIVE이면서 현재 런타임 레지스트리에 게시된 테넌트만 라우팅 대상이 된다 |
| 실패·취소 | FAILED 기록, 레지스트리 해제, 자원 정리가 NonCancellable 정리 구문에서 끝난다 |
| 재시작 복구 | 만료된 PROVISIONING과 준비 상태 검사에 실패한 ACTIVE가 FAILED(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가 그 전달 수단 역할을 합니다. 하지만 지켜야 할 원칙은 같습니다.
| 원칙 | Ktor | Spring WebFlux |
|---|---|---|
| 입력 검증 | 플러그인에서 헤더를 해석한다 | 필터에서 테넌트를 해석한다 |
| 요청 범위 전달 | ApplicationCall.attributes | Reactor Context → ReactorContext |
| 권한 경계 | 경로·플러그인 앞단에 둔다 | 컨텍스트 공개 전에 둔다 |
| DB 선택 | 명시적 경로 또는 테넌트 값 | 컨텍스트에서 읽은 검증 테넌트 |
| 신규 테넌트 공개 | 레지스트리가 준비된 뒤 선택 가능해야 한다 | 레지스트리가 준비된 뒤 선택 가능해야 한다 |
전달 수단을 복사하는 것이 아니라, 검증된 값만 전달하고, 권한 뒤에 공개하며, 준비되지 않은 자원은 선택하지 않는다는 불변식을 런타임에 맞게 옮기는 것이 핵심입니다.

- Project Reactor Context 문서: 구독자별 컨텍스트가 왜 스레드 저장소와 다른지 확인할 수 있습니다.
- kotlinx-coroutines-reactor의 ReactorContext API: Reactor 컨텍스트를 코루틴에서 읽는 연결 지점을 설명합니다.
- Spring WebFlux 테넌트 컨텍스트 워크숍: 헤더에서
ReactorContext까지의 기본 경로를 볼 수 있습니다. - 테넌트별 ConnectionFactory 워크숍: 트랜잭션에서 컨텍스트 연결과 연결 팩터리 선택을 확인할 수 있습니다.
- Spring Security 테넌트 권한 검증 워크숍: 권한 검증 뒤에 테넌트 컨텍스트를 공개하는 필터를 볼 수 있습니다.
- 장애 복구형 테넌트 온보딩 워크숍: 영속 소유권, 리스,
FAILED재시도, PostgreSQL 스키마 격리와 재시작 복구를 확인할 수 있습니다. ResilientTenantProvisioner: 준비·검증·활성화·런타임 게시와 취소 정리의 실제 순서입니다.TenantLifecycleReconciler: 재시작 시 메타데이터를 다시 읽고 정상 테넌트만 레지스트리에 게시하는 경계입니다.
마무리
섹션 제목: “마무리”WebFlux 멀티테넌시의 어려움은 비동기 API를 하나 더 쓰는 데 있지 않습니다. 테넌트를 입력값에서 신뢰 가능한 요청 컨텍스트로 바꾸고, 그 컨텍스트가 코루틴과 트랜잭션을 지나 DB 선택까지 유지되게 하며, 새 테넌트가 준비되기 전에는 요청 경로에 나타나지 않게 만드는 데 있습니다.
요청 경로와 온보딩 경로를 같은 계약으로 보면 판단이 단순해집니다. 권한을 통과한 테넌트만 컨텍스트에 넣고, 영속 자원을 검증해 현재 프로세스에 게시한 테넌트만 레지스트리에 넣습니다. 그 두 공개 지점을 분명히 하면, 테넌트가 많아져도 데이터 격리와 재시작 복구를 함께 설계할 수 있습니다.
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.