Coroutine Observability: Micrometer tracing과 readiness가 실제로 지키는 경계

Coroutine 기반 WebFlux나 Ktor 서비스는 로컬에서 잘 돌아갑니다. 그런데 운영 로그를 보면 trace가 중간에서 끊긴 것처럼 보이고,
readiness probe는 UP인데 실제 요청은 DB에서 막히는 경우가 있습니다. 이때 대시보드를 더 크게 붙인다고 문제가 풀리지는
않습니다. 먼저 “어떤 신호가 어떤 코드를 증명하는가”를 정해야 합니다.
이번 글은 bluetape4k-workshop의 observability 예제와 exposed-workshop의 readiness 예제를 기준으로 정리합니다.
핵심은 세 가지입니다.
- suspend boundary를 지나도 parent span이 유지되어야 합니다.
- WebClient 같은 downstream 호출에는
traceparent가 실제로 나가야 합니다. - readiness는 프레임워크 endpoint가 아니라 DB reachability를 증명하는 운영 계약이어야 합니다.
먼저 work path와 observe path를 분리한다
섹션 제목: “먼저 work path와 observe path를 분리한다”Prometheus, Grafana, Zipkin, Actuator는 일을 시작하지 않습니다. 이미 실행된 request path에서 나온 meter와 span을 읽을 뿐입니다. 이 구분이 흐려지면 다이어그램도 틀리고 운영 판단도 틀어집니다.

micrometer-tracing-coroutines 예제의 CoroutineService는 withObservationSuspending으로 suspend block을 감쌉니다.
delay(...)와 WebClient.awaitBodyOrNull() 같은 suspension point를 지나도 span scope가 사라지지 않아야 하기 때문입니다.
private suspend fun getTodoById(id: Int): Todo? { return withObservationSuspending("get-todo-by-id", observationRegistry) { client.get() .uri("/todos/${id}") .retrieve() .awaitBodyOrNull<Todo>() }}withObservationSuspending 자체는 얇은 wrapper입니다. 핵심은 withObservationContextSuspending이 만든 coroutine
context입니다. 예제에서 기대하는 동작은 “현재 observation을 Reactor context에도 넣고, coroutine resume 시점마다
Micrometer scope를 다시 열어 주는 것”입니다.
suspend fun <T: Any> withObservationContextSuspending( name: String, observationRegistry: ObservationRegistry, block: suspend CoroutineScope.() -> T?,): T? { name.requireNotBlank("name") val observation = observationRegistry.start(name)
return try { withContext(observation.asCoroutineObservationContext()) { block() } } catch (e: CancellationException) { throw e } catch (e: Throwable) { observation.error(e) throw e } finally { observation.stop() }}그리고 asCoroutineObservationContext()가 실제 다리 역할을 합니다.
private class ObservationScopeContextElement( private val observation: Observation,): ThreadContextElement<Observation.Scope>, AbstractCoroutineContextElement(Key) { companion object Key: CoroutineContext.Key<ObservationScopeContextElement>
override fun updateThreadContext(context: CoroutineContext): Observation.Scope = observation.openScope()
override fun restoreThreadContext(context: CoroutineContext, oldState: Observation.Scope) { oldState.close() }}
private suspend fun Observation.asCoroutineObservationContext(): CoroutineContext { val reactorContext = (currentReactiveContext() ?: Context.empty()) .put(ObservationThreadLocalAccessor.KEY, this)
return reactorContext.asCoroutineContext() + ObservationScopeContextElement(this)}여기서 봐야 할 것은 helper 이름이 아닙니다. “suspend 함수 안의 nested span을 어떤 scope로 열고 닫는가”입니다. coroutine은 carrier thread를 바꿀 수 있습니다. ThreadLocal만 믿고 있으면 span tree가 갑자기 평평해집니다. 로그를 보던 사람 입장에서는 “분명 같은 요청인데 왜 남남처럼 보이지?”가 됩니다. 뚜껑 열리는 지점입니다.
테스트가 trace 전파를 고정한다
섹션 제목: “테스트가 trace 전파를 고정한다”observability-basic 예제는 가장 작은 경로를 씁니다. WebFlux suspend endpoint가 HTTP server span을 만들고,
OrderService가 order.service.fetch span을 하나 더 만들며, Spring Boot가 관리하는 WebClient.Builder가 downstream
호출에 W3C traceparent를 넣습니다.
@AutoConfigureTracingclass TracePropagationTest : AbstractBasicTest() { @Test fun `GET orders id - traceparent header propagated to downstream`() = runSuspendIO { webTestClient.get() .uri("/orders/1") .exchange() .expectStatus().isOk
val request = mockServer.takeRequest(2, TimeUnit.SECONDS) request.headers["traceparent"].shouldNotBeNull() }}observability-advanced 예제는 cache-aside 흐름까지 넣습니다. Cache miss에서는 user.service.get 아래에
user.cache.get, user.db.find, user.cache.put이 자식 span으로 붙어야 합니다. Cache hit이면 DB span이 없어야 합니다.
이 차이를 테스트가 고정합니다.
http.server.requests └─ user.service.get ├─ user.cache.get ├─ user.db.find └─ user.cache.putUserServiceTest는 hasParentObservationContextSatisfying으로 parent-child 관계를 확인합니다. 단순히 “span 이름이
있다”는 수준이 아닙니다. cache miss 경로에서 세 child span이 같은 service span 아래에 붙는지 확인합니다.
CancellationException은 error span이 아니다
섹션 제목: “CancellationException은 error span이 아니다”Coroutine observability에서 자주 하는 실수는 예외 처리를 runCatching {} 같은 넓은 그물로 덮는 것입니다.
예제들은 CancellationException을 먼저 다시 던지고, cancellation이 아닌 예외만 observation.error(e)로 기록합니다.
return try { withContext(ObservationScopeContextElement(observation)) { block() }} catch (e: CancellationException) { throw e} catch (e: Throwable) { observation.error(e) throw e} finally { observation.stop()}이 규칙은 관측성 코드가 structured concurrency를 망치지 않게 합니다. 요청이 취소된 것을 실패 span으로 쌓아두면 장애가 아닌 것을 장애처럼 보게 됩니다. 반대로 5xx나 Redis 실패처럼 실제 오류인 것은 span error로 남겨야 합니다.
Readiness는 “살아 있음”이 아니라 “받아도 됨”이다
섹션 제목: “Readiness는 “살아 있음”이 아니라 “받아도 됨”이다”Liveness와 readiness는 실패했을 때 플랫폼이 취해야 할 행동부터 다릅니다. Liveness는 “이 process를 계속 살려 둘 수 있는가”를 묻습니다. 여기서 실패하면 Kubernetes 같은 orchestrator는 보통 container를 재시작합니다. Readiness는 “지금 traffic을 보내도 되는가”를 묻습니다. 여기서 실패하면 process를 죽이는 대신 load balancer나 Service endpoint에서 잠시 빼야 합니다.
그래서 DB가 내려간 경우뿐 아니라 connection pool이 차서 timeout으로 DB를 사용할 수 없는 경우에도, HTTP server와 coroutine dispatcher는 정상일 수 있다는 점이 중요합니다. process는 살아 있으니 liveness는 성공할 수 있습니다. 하지만 주문을 처리하려면 DB가 필요하므로 readiness는 실패해야 합니다. “살아 있음”과 “받아도 됨”이 갈라지는 지점입니다.
여담이지만, 이 차이는 장애 대화에서도 자주 드러납니다. Backend 개발자는 connection pool 고갈로 DB 요청이 timeout 나는 상황을 “DB 사용 불가”보다 “DB가 죽었다”라고 표현하는 경우가 많습니다. 인프라 담당자는 “DB는 잘 살아 있는데?”라고 반박합니다. 둘 다 자기 관찰 범위에서는 맞는 말을 하고 있습니다. 문제는 liveness와 readiness를 구분하지 않으면 “process가 살아 있음”, “DB process가 살아 있음”, “서비스가 DB를 사용할 수 있음”을 한 문장으로 뭉개 버린다는 점입니다.
Spring Boot와 Ktor 예제는 이 운영 계약을 다른 프레임워크 모양으로 표현합니다.

Spring 쪽은 Actuator readiness group에 custom health contributor를 붙입니다.
override fun health(): Health = when { !state.isDatabaseAvailable() -> Health.down() .withDetail("database", "degraded by example state") .build() else -> databaseHealth() }Ktor 쪽은 /readyz route를 직접 구현합니다.
get("/readyz") { val databaseReady = readinessState.isDatabaseAvailable() && repository.ping() call.respond( status = if (databaseReady) HttpStatusCode.OK else HttpStatusCode.ServiceUnavailable, message = ReadinessResponse( status = if (databaseReady) "UP" else "DOWN", database = if (databaseReady) "reachable" else "degraded", requestId = call.callId.orEmpty(), ), )}Spring은 Actuator convention을 얻는 대신 endpoint shape와 lifecycle을 프레임워크에 맡깁니다. Ktor는 /readyz의 의미와
응답 모양을 코드로 직접 소유합니다. 둘 중 무엇이 더 낫다는 이야기가 아닙니다. 운영팀과 플랫폼이 어떤 contract를 기대하는지에
맞춰 선택해야 합니다.
빠진 신호는 대개 빠진 코드다
섹션 제목: “빠진 신호는 대개 빠진 코드다”아래 표는 예제를 보면서 운영에서 바로 확인할 수 있는 징후를 묶은 것입니다.
| 빠진 신호 | 의심할 wiring | 예제 근거 |
|---|---|---|
downstream 요청에 traceparent 없음 | tracing bridge 또는 managed WebClient.Builder를 우회 | TracePropagationTest |
| cache miss인데 DB span이 service span의 자식이 아님 | coroutine resume 뒤 current observation scope가 복원되지 않음 | ObservationScopeContextElement |
| cancellation이 error span으로 쌓임 | CancellationException을 넓은 catch에서 삼킴 | observed() helper |
| readiness가 DB 장애에도 200 | readiness state와 repository ping을 endpoint에 연결하지 않음 | Spring DatabaseReadinessHealthIndicator, Ktor /readyz |
| request-id가 로그와 response에서 다름 | header sanitize/echo 경계가 없음 | Spring RequestCorrelationFilter, Ktor CallId |
관측성은 “무엇을 설치했는가”보다 “어떤 실패를 어떤 신호로 고정했는가”가 중요합니다. Trace가 이어지고, readiness가 거짓말하지 않고, request-id가 응답과 로그에 같이 남으면 장애 분석은 추측에서 확인으로 넘어갑니다. 아직 삽질은 남지만, 최소한 삽질할 방향은 보입니다.
Coroutines라고 운영 계약이 달라지지는 않는다
섹션 제목: “Coroutines라고 운영 계약이 달라지지는 않는다”Coroutine 환경이라고 해서 observability의 기본 방식이 완전히 바뀌지는 않습니다. HTTP server span을 만들고, service와 downstream span을 자식으로 붙이고, error를 기록하고, readiness가 traffic 수신 가능 여부를 말하게 하는 큰 구조는 기존 thread-per-request 서비스와 같습니다.
Virtual Threads도 이 관점에서는 비슷합니다. Spring MVC나 blocking client를 virtual thread 위에서 돌리면 코드 모양은
기존 thread-per-request에 더 가깝고, ThreadLocal 기반 observation scope도 coroutine보다 직관적으로 따라옵니다. 그렇다고
운영 계약이 달라지는 것은 아닙니다. Virtual thread가 많아져도 DB connection pool이 자동으로 늘지는 않습니다. connection
pool 고갈, downstream timeout, readiness 503 같은 판단은 그대로 필요합니다.
다만 coroutine에서는 “언제 같은 요청이라고 볼 수 있는가”를 더 명시적으로 다뤄야 합니다. suspend/resume 때문에 실행 thread가
바뀔 수 있고, ThreadLocal에만 기대면 현재 observation이 끊길 수 있습니다. 그래서 ThreadContextElement로 scope를
resume 시점마다 복원하고, Reactor context에도 observation을 넣어 둡니다.
조심할 지점은 세 가지입니다.
- Context propagation을 테스트로 고정합니다. downstream
traceparent와 parent-child span 관계가 실제로 이어지는지 확인해야 합니다. - Cancellation을 실패로 기록하지 않습니다.
CancellationException은 먼저 다시 던지고, 실제 장애만observation.error(e)로 남깁니다. - Readiness는 coroutine이 도는지가 아니라 요청을 처리할 수 있는지를 말해야 합니다. DB나 cache 같은 필수 dependency가 내려갔으면 process가 살아 있어도 503이어야 합니다.
결국 coroutine observability는 새 개념을 많이 외우는 문제가 아닙니다. 기존 관측성 계약을 유지하되, suspend boundary에서 context와 cancellation이 새지 않도록 확인하는 문제입니다.
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.