Exposed R2DBC Workshop: Ktor 멀티테넌트 라우팅 패턴

멀티테넌트 예제는 겉으로 보면 단순합니다. 요청의 X-TENANT-ID 값에 따라 korean 스키마 또는
english 스키마를 선택하면 되는 것처럼 보입니다.
그러나 검증하지 않은 헤더 문자열이 저장소까지 흐르고, 요청 범위 상태가 전역 상태처럼 변하며, 연결 풀이 이전 테넌트의 스키마가 남은 연결을 재사용하면 데이터 격리가 깨질 수 있습니다. 멀티테넌시에서 가장 위험한 장애는 요청이 즉시 실패하는 경우가 아니라 다른 테넌트의 데이터를 정상 응답처럼 반환하는 경우입니다.
이번 글은 exposed-r2dbc-workshop의 두 Ktor 예제를 같이 봅니다.
| 모듈 | 다루는 문제 |
|---|---|
10-multi-tenant/07-multitenant-ktor | 하나의 R2DBC 데이터베이스에서 테넌트별 스키마를 전환한다 |
11-high-performance/06-routing-datasource-ktor-r2dbc | 테넌트와 읽기·쓰기 조합으로 R2DBC 대상을 선택한다 |
둘은 구현 형태가 다르지만 원칙은 같습니다. HTTP 헤더는 플러그인에서 검증하고, 요청 처리기와 저장소에는 검증된 값만 전달합니다.
HTTP 헤더 해석은 플러그인에서 끝낸다
섹션 제목: “HTTP 헤더 해석은 플러그인에서 끝낸다”Ktor 예제의 핵심은 요청 범위 상태를 ApplicationCall.attributes에 둔다는 점입니다. WebFlux 예제는
Reactor Context를 사용하고, Spring MVC/JDBC 예제는 ThreadLocal이나 AOP 경계를 사용합니다. Ktor 예제는 그 구조를
그대로 복사하지 않고 호출 단위 상태를 사용합니다.

실제 플러그인도 그 의도를 그대로 드러냅니다.
val TenantPlugin = createApplicationPlugin(name = "TenantPlugin") { onCall { call -> val rawValues = call.request.headers.getAll(TenantHeader).orEmpty() val tenantId = normalizeTenantHeader(rawValues) val tenant = Tenants.findById(tenantId) ?: throw InvalidTenantException("Unknown tenant id: $tenantId") call.attributes.put(TenantAttributeKey, tenant) }}여기서 핵심은 TenantAttributeKey에 문자열이 아니라 Tenants.Tenant를 넣는다는 사실입니다.
X-TENANT-ID: korean이라는 문자열은 플러그인 바깥으로 나가지 않습니다. 저장소나 트랜잭션 도우미가
"korean" 같은 원본 문자열을 다시 해석하기 시작하면 검증 경계가 여러 곳으로 흩어집니다. 이 구조에서는
한 곳의 수정만으로 입력 해석 오류를 제거하기 어렵습니다.
중복 헤더도 그대로 허용해서는 안 됩니다.
private fun normalizeTenantHeader(values: List<String>): String { if (values.isEmpty()) { throw InvalidTenantException("Missing tenant id header: $TenantHeader") } val normalized = values .flatMap { it.split(',') } .map { it.trim() } if (normalized.any { it.isEmpty() }) { throw InvalidTenantException("Blank tenant id header: $TenantHeader") } val distinct = normalized.toSet() if (distinct.size != 1) { throw InvalidTenantException("Conflicting tenant id headers: $TenantHeader") } return distinct.single()}같은 테넌트가 공백만 다르게 반복되는 것은 허용하지만, korean과 english가 함께 전달되면 400으로 응답합니다.
워크숍 예제에도 이런 입력 경계를 두면 운영 코드로 확장할 때 필요한 추가 작업을 줄일 수 있습니다.
여기서 입력 검증과 권한 검증을 혼동하면 안 됩니다. 이 예제의 TenantPlugin은 헤더 형식과 알려진 테넌트인지를
확인하지만, 호출자가 해당 테넌트에 접근할 권한이 있는지까지 증명하지는 않습니다. 운영 서비스라면 인증 주체와
테넌트의 관계를 권한 계층에서 별도로 확인하고, 아직 준비되지 않았거나 중지된 테넌트는 데이터베이스 라우팅 전에
거부해야 합니다.
스키마 전환은 트랜잭션 안에서 수행한다
섹션 제목: “스키마 전환은 트랜잭션 안에서 수행한다”테넌트가 정해졌다면 다음 질문은 스키마를 언제 전환할 것인가입니다. 예제에서는 트랜잭션 도우미가 이 작업을 맡습니다.
suspend fun <T> suspendTransactionWithTenant( tenant: Tenants.Tenant, db: R2dbcDatabase, readOnly: Boolean = false, statement: suspend R2dbcTransaction.() -> T,): T = suspendTransaction(db = db, readOnly = readOnly) { SchemaUtils.setSchema(getSchemaDefinition(tenant)) statement() }이 작은 도우미가 중요한 이유는 스키마가 연결 상태에 속하기 때문입니다. R2DBC 연결 풀이 연결을 재사용하면 이전 요청에서 사용한 스키마가 남아 있을 수 있습니다. 따라서 애플리케이션 시작 시 한 번 설정하는 것으로는 충분하지 않습니다. 테넌트 작업을 시작하는 트랜잭션 경계에서 스키마를 다시 지정해야 합니다.
테스트도 이 위험을 검증합니다.
val observed = (1..20).map { index -> val tenant = if (index % 2 == 0) Tenant.ENGLISH else Tenant.KOREAN client.get("/actors/2") { header(TenantHeader, tenant.id) }.body<ActorRecord>().firstName}연결 풀 크기를 1로 줄이고 테넌트를 빠르게 교차 호출합니다. 연결을 재사용하는 조건에서도 브래드와 Brad가
서로 섞이지 않아야 합니다. 이 테스트가 없다면 스키마 누수를 발견하기 어렵습니다. 응답 형식은 정상이어도
다른 테넌트의 데이터가 반환되면 데이터 격리 사고가 됩니다.
라우팅 데이터소스는 값 객체로 대상을 선택한다
섹션 제목: “라우팅 데이터소스는 값 객체로 대상을 선택한다”11장의 Ktor R2DBC 예제는 스키마 전환이 아니라 대상 선택을 보여줍니다. X-Tenant-Id와 X-Read-Only,
그리고 /readonly 경로를 기준으로 default:rw, default:ro, acme:rw, acme:ro 중 하나를 선택합니다.
여기서도 같은 원칙을 적용합니다. 플러그인에서 한 번 해석하고, 저장소에는 검증된 RoutingRequest를 전달합니다.
private fun ApplicationCall.resolveRoutingRequest(): RoutingRequest { val tenant = request.headers[TENANT_HEADER] ?.trim() ?.takeUnless { it.isBlank() } ?.let { tenantId -> RoutingTenant.from(tenantId) ?: throw InvalidRoutingRequestException("Unknown tenant id: $tenantId") } ?: RoutingTenant.DEFAULT
val explicitReadOnly = request.headers[READ_ONLY_HEADER] ?.trim() ?.let { raw -> raw.toBooleanStrictOrNull() ?: throw InvalidRoutingRequestException("$READ_ONLY_HEADER must be true or false") } ?: false
return RoutingRequest( tenant = tenant, readOnly = explicitReadOnly || request.path().endsWith("/readonly"), )}RoutingRequest는 작은 값 객체지만 라우팅 계약을 명시적으로 표현합니다.
data class RoutingRequest( val tenant: RoutingTenant, val readOnly: Boolean,) { val mode: RoutingMode = if (readOnly) RoutingMode.READ_ONLY else RoutingMode.READ_WRITE val key: String = "${tenant.id}:${mode.suffix}" val expectedMarker: String = "${tenant.id}-${mode.suffix}"
fun asReadWrite(): RoutingRequest = copy(readOnly = false)}저장소는 헤더나 요청 경로를 해석하지 않고 route.key로 데이터베이스를 선택합니다.
suspend fun findMarker(route: RoutingRequest): RoutingMarkerResponse { awaitReady() return route.toResponse(findMarkerValue(route))}
private suspend fun findMarkerValue(route: RoutingRequest): String? = suspendTransaction(db = registry.database(route), readOnly = true) { RoutingMarkerTable .selectAll() .singleOrNull() ?.getOrNull(RoutingMarkerTable.marker) }이 구조에서는 라우팅 값 해석은 Ktor 플러그인, 데이터베이스 선택은 레지스트리, 데이터 조회는 저장소가 담당합니다. 각 경계가 명확해지므로 테스트도 역할별로 구성할 수 있습니다.
val pathResponse = client.get("/routing/marker/readonly") { header(TENANT_HEADER, "acme")}.body<RoutingMarkerResponse>()
val headerResponse = client.get("/routing/marker") { header(TENANT_HEADER, "acme") header(READ_ONLY_HEADER, "true")}.body<RoutingMarkerResponse>()
pathResponse.marker shouldBeEqualTo "acme-ro"headerResponse.marker shouldBeEqualTo "acme-ro"PATCH /routing/marker도 의도적으로 엄격하게 처리합니다. 호출자가 X-Read-Only: true를 보냈는데 쓰기 모드로
자동 전환하면 원인 파악이 어려워집니다. 예제는 400 INVALID_ROUTING_REQUEST로 거부합니다. 이런 자동 보정은
운영 환경에서 문제를 숨길 수 있으므로 모순된 입력은 즉시 실패 처리하는 편이 안전합니다.
Spring/JDBC와 Ktor/R2DBC는 상태 전달 방식이 다르다
섹션 제목: “Spring/JDBC와 Ktor/R2DBC는 상태 전달 방식이 다르다”Spring/JDBC 예제를 Ktor/R2DBC로 옮길 때 상태 전달 수단까지 그대로 적용하기 쉽습니다.
ThreadLocal, Reactor Context, Ktor 호출 속성은 같은 역할을 할 수 있지만 동작 경계는 다릅니다. 재사용해야 할 것은
특정 도구가 아니라 불변식입니다.

정리하면 이렇게 볼 수 있습니다.
| 질문 | Spring/JDBC 쪽 | Ktor/R2DBC 쪽 |
|---|---|---|
| 요청 상태를 어디에 두는가 | ThreadLocal, ScopedValue, Reactor Context | ApplicationCall.attributes |
| 헤더는 어디서 해석하는가 | 필터, 인터셉터, AOP 경계 | Ktor 플러그인 |
| DB 선택은 무엇을 받는가 | 테넌트 컨텍스트 또는 라우팅 키 | Tenants.Tenant, RoutingRequest |
| 트랜잭션 경계 | JDBC 트랜잭션 / Spring 트랜잭션 연산자 | suspendTransaction, suspendTransactionWithTenant |
| 필요한 테스트 | 테넌트 격리, 상태 정리, 읽기·쓰기 분리 | 호출 속성 격리, 연결 풀 재사용, 대상 표식 검증 |
Ktor에서도 ThreadLocal을 그대로 사용한다는 전제에서 시작하면 실행 모델의 차이를 놓치게 됩니다. Ktor에서는 요청이
코루틴을 따라 실행되므로, 예제도 코루틴 환경에서 안전한 요청 라우팅을 보여줍니다. 호출 속성에 검증된 값을
저장하고 요청 처리기와 저장소에는 그 값을 명시적으로 전달합니다.
직접 호출해 보면 차이가 보인다
섹션 제목: “직접 호출해 보면 차이가 보인다”테넌트별 스키마 예제는 배우 데이터를 테넌트별로 분리합니다.
curl -H 'X-TENANT-ID: korean' http://localhost:8080/actors/2# {"id":2,"firstName":"브래드","lastName":"피트",...}
curl -H 'X-TENANT-ID: english' http://localhost:8080/actors/2# {"id":2,"firstName":"Brad","lastName":"Pitt",...}라우팅 데이터소스 예제는 대상별 표식이 달라집니다.
curl -H 'X-Tenant-Id: acme' http://localhost:8080/routing/marker# {"tenant":"acme","readOnly":false,"routingKey":"acme:rw","marker":"acme-rw"}
curl -H 'X-Tenant-Id: acme' http://localhost:8080/routing/marker/readonly# {"tenant":"acme","readOnly":true,"routingKey":"acme:ro","marker":"acme-ro"}이 값들은 예제를 실제로 실행해 얻은 결과이며 테스트도 해당 표식을 확인합니다. 응답 필드만 일치하고 데이터베이스 대상은 잘못 선택되는 경우를 막기 위해 대상마다 서로 다른 표식 행을 저장했습니다. 라우팅 코드는 응답 형식과 실제 데이터 원본을 함께 검증해야 합니다.
- Ktor 테넌트별 스키마 안내서: 10-multi-tenant/07-multitenant-ktor/README.ko.md
TenantPlugin.kt: 소스TenantTransaction.kt: 소스- Ktor 라우팅 데이터소스 안내서: 11-high-performance/06-routing-datasource-ktor-r2dbc/README.ko.md
RoutingRequestPlugin.kt: 소스RoutingMarkerRepository.kt: 소스
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.