콘텐츠로 이동

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

Ktor, Exposed, R2DBC 블록 사이에서 로봇 작업자들이 X-TENANT-ID와 route key를 정리하는 3D 작업대 일러스트
멀티테넌시에서 header는 출발점일 뿐입니다. 검증 없이 schema까지 데려가면 나중에 뚜껑이 열립니다.

멀티테넌트 예제는 겉으로 보면 단순합니다. 요청에 X-TENANT-ID가 있고, 그 값으로 korean schema를 쓰거나 english schema를 쓰면 됩니다. 여기까지만 보면 “헤더 하나 읽어서 SET SCHEMA 하면 되겠네” 싶습니다.

그런데 이 코드는 조금만 방심해도 이상한 방향으로 자랍니다. raw header 문자열이 repository까지 흘러가고, 요청 범위 상태가 전역 변수처럼 변하고, pool이 connection을 재사용하는 순간 이전 tenant schema가 남아 있으면 사고가 납니다. 멀티테넌시에서 제일 무서운 장애는 터지자마자 죽는 장애가 아닙니다. 남의 데이터를 조용히 읽는 장애입니다. 겉으로는 정상처럼 보이기 때문에 더 위험합니다.

이번 글은 exposed-r2dbc-workshop의 두 Ktor 예제를 같이 봅니다.

모듈다루는 문제
10-multi-tenant/07-multitenant-ktor하나의 R2DBC database에서 tenant별 schema를 바꾼다
11-high-performance/06-routing-datasource-ktor-r2dbctenant + read/write 조합으로 R2DBC target을 고른다

둘은 구현 모양이 다르지만 원칙은 같습니다. header는 plugin에서 검증하고, request handler와 repository에는 검증된 값만 넘깁니다.

Ktor 예제의 핵심은 request-scoped 상태를 ApplicationCall.attributes에 둔다는 점입니다. WebFlux 예제는 ReactorContext를 보여주고, Spring MVC/JDBC 예제는 ThreadLocal이나 AOP 경계를 보여줍니다. Ktor 예제는 그 구조를 그대로 복사하지 않습니다. Ktor에는 Ktor답게 call 단위 상태가 있습니다.

Ktor plugin이 X-TENANT-ID를 검증하고 call attributes에 tenant enum을 저장한 뒤 tenant transaction helper가 R2DBC schema를 전환하는 sequence diagram
raw header는 plugin에서 끝납니다. schema 선택에는 검증된 enum만 넘어갑니다.

실제 plugin도 그 의도를 그대로 드러냅니다.

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이라는 문자열은 plugin 바깥으로 나가지 않습니다. repository나 transaction helper가 "korean" 같은 raw string을 다시 해석하기 시작하면, 검증 경계가 여러 군데로 흩어집니다. 그때부터 버그가 여러 곳에서 발호합니다. 한 군데만 고쳐서는 잡히지 않습니다.

중복 header도 그냥 넘기면 안 됩니다.

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()
}

같은 tenant가 공백만 다르게 반복되는 것은 허용하지만, koreanenglish가 같이 오면 400입니다. workshop 예제라도 이런 입력 경계를 넣어두면 운영 코드로 옮길 때 추가 작업이 줄고 완성도가 높아집니다.

Schema 전환은 transaction 안에서 한다

섹션 제목: “Schema 전환은 transaction 안에서 한다”

tenant가 정해졌으면 다음 질문은 schema를 언제 바꾸느냐입니다. 예제에서는 transaction helper가 이 일을 맡습니다.

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()
}

이 작은 helper가 중요한 이유는 schema가 connection state이기 때문입니다. R2DBC pool이 connection을 재사용하면, 이전 요청에서 쓰던 schema가 남아 있을 수 있습니다. 그래서 “처음에 한 번 schema를 바꿨으니 됐다”가 아닙니다. tenant 작업을 시작하는 transaction 경계에서 다시 schema를 맞춥니다.

테스트도 이 위험을 검증합니다.

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
}

pool size를 1로 줄이고 tenant를 빠르게 교차 호출합니다. connection을 재사용하는 조건에서 브래드Brad가 서로 섞이지 않아야 합니다. 이 테스트가 없다면 schema 누수는 꽤 오래 숨어 있을 수 있습니다. 화면에는 그냥 actor가 나오니까요. 하지만 그 actor가 남의 tenant에서 온 순간부터는 단순 버그가 아니라 데이터 사고입니다.

Routing datasource는 target을 값으로 고른다

섹션 제목: “Routing datasource는 target을 값으로 고른다”

chapter 11의 Ktor R2DBC 예제는 schema 전환이 아니라 target 선택을 보여줍니다. X-Tenant-IdX-Read-Only, 그리고 /readonly path를 보고 default:rw, default:ro, acme:rw, acme:ro 중 하나를 고릅니다.

여기서도 같은 원칙을 씁니다. plugin에서 한 번 해석하고, repository에는 검증된 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는 작은 값 객체지만, 이 값이 routing의 계약입니다.

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)
}

repository는 header를 모릅니다. path도 모릅니다. route.key로 database를 고릅니다.

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)
}

이렇게 하면 route 해석은 Ktor plugin에 있고, DB 선택은 registry에 있고, repository는 명시적인 route 값만 받습니다. 관심사가 나뉘면 테스트도 좋아집니다.

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도 일부러 엄격하게 처리합니다. caller가 X-Read-Only: true를 보냈는데 write로 조용히 바꿔버리면 디버깅이 어려워집니다. 예제는 400 INVALID_ROUTING_REQUEST로 거부합니다. 이런 자동 보정은 운영에서 오히려 문제를 숨깁니다. 애매한 입력은 빨리 실패하는 편이 낫습니다.

Spring/JDBC와 Ktor/R2DBC는 carrier가 다르다

섹션 제목: “Spring/JDBC와 Ktor/R2DBC는 carrier가 다르다”

Spring/JDBC 예제를 보다가 Ktor/R2DBC로 넘어오면 carrier를 그대로 가져오려는 실수를 하기 쉽습니다. ThreadLocal, ReactorContext, Ktor call attributes는 같은 역할을 할 수 있지만 같은 물건은 아닙니다. 복사해야 할 것은 도구가 아니라 불변식입니다.

Spring JDBC workshop과 Ktor Exposed R2DBC workshop이 tenant routing 문제를 서로 다른 runtime boundary로 푸는 전략 비교 map
carrier는 runtime에 맞게 바뀝니다. 불변식은 같습니다. 검증하고, 명시적으로 넘기고, 격리를 테스트합니다.

정리하면 이렇게 볼 수 있습니다.

질문Spring/JDBC 쪽Ktor/R2DBC 쪽
요청 상태를 어디에 두나ThreadLocal, ScopedValue, ReactorContextApplicationCall.attributes
header는 어디서 해석하나filter, interceptor, AOP 경계Ktor plugin
DB 선택은 무엇을 받나tenant context 또는 routing keyTenants.Tenant, RoutingRequest
transaction 경계JDBC transaction / Spring transaction operatorsuspendTransaction, suspendTransactionWithTenant
꼭 필요한 테스트tenant 격리, cleanup, read/write 분리call attribute 격리, pool 재사용, target marker 검증

즉 “Ktor에서도 ThreadLocal을 쓰면 되지 않나?”가 출발점이 되면 방향이 틀어집니다. Ktor에서는 요청이 coroutine으로 흘러가고, 예제도 coroutine-safe request routing을 보여줍니다. 그래서 call attributes에 검증된 값을 넣고, handler와 repository에는 그 값을 명시적으로 넘깁니다.

직접 호출해 보면 차이가 보인다

섹션 제목: “직접 호출해 보면 차이가 보인다”

schema-per-tenant 예제는 actor 데이터가 tenant별로 갈라집니다.

Terminal window
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",...}

routing datasource 예제는 target marker가 달라집니다.

Terminal window
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"}

이 예제의 실행 결과 값들은 실제 수행한 결과입니다. 테스트가 실제로 이 marker를 확인합니다. response field만 맞고 DB target이 틀린 경우를 막으려고 target마다 다른 marker row를 심어두었습니다. 이런 검증은 조금 귀찮습니다만, routing 코드는 꼼꼼히 테스트하지 않으면 나중에 더 큰 비용으로 돌아옵니다.

댓글

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