콘텐츠로 이동

코루틴 관측성: 추적 문맥과 준비 상태가 지켜야 할 경계

작은 로봇 작업자들이 Spring과 Ktor 서비스, 코루틴 span 흐름, Zipkin 추적 트리, Prometheus와 Grafana 대시보드, 준비 상태 계기를 조립하는 3D 작업대 일러스트
추적 정보와 준비 상태는 시각적 장식이 아니라, 장애 시 어떤 신호를 신뢰할 수 있는지 정하는 운영 계약입니다.

코루틴 기반 WebFlux나 Ktor 서비스는 로컬에서 잘 동작합니다. 그러나 운영 로그에서는 추적 정보가 중간에서 끊긴 것처럼 보이고, 준비 상태 점검은 UP인데 실제 요청은 DB에서 막히는 경우가 있습니다. 이때 대시보드를 확장하는 것만으로 문제가 풀리지는 않습니다. 먼저 “어떤 신호가 어떤 코드를 증명하는가”를 정해야 합니다.

이번 글은 bluetape4k-workshop의 관측성 예제와 exposed-workshop의 준비 상태 예제를 기준으로 정리합니다. 핵심은 세 가지입니다.

  • 일시 중단 경계를 지나도 부모 span을 유지해야 합니다.
  • WebClient 같은 하위 시스템 호출에는 traceparent가 실제로 전파되어야 합니다.
  • 준비 상태는 단순한 프레임워크 엔드포인트가 아니라 DB 접근 가능성을 증명하는 운영 계약이어야 합니다.

먼저 업무 경로와 관찰 경로를 분리한다

섹션 제목: “먼저 업무 경로와 관찰 경로를 분리한다”

Prometheus, Grafana, Zipkin, Actuator는 업무를 시작하지 않습니다. 이미 실행된 요청 경로에서 나온 메트릭과 span을 읽을 뿐입니다. 이 구분이 흐려지면 다이어그램도 틀리고 운영 판단도 틀어집니다.

업무를 수행하는 코루틴 요청 경로와 메트릭 수집, 대시보드 조회, 추적 정보 전송 경로를 분리한 구조도
업무 요청은 핸들러, 관찰 범위, suspend 서비스, DB·HTTP 클라이언트로 흐릅니다. Prometheus, Grafana, Zipkin은 그 뒤에 남은 신호를 관찰합니다.

micrometer-tracing-coroutines 예제의 CoroutineServicewithObservationSuspending으로 suspend 블록을 감쌉니다. delay(...)WebClient.awaitBodyOrNull() 같은 일시 중단 지점을 지나도 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 자체는 얇은 래퍼입니다. 핵심은 withObservationContextSuspending이 만든 코루틴 문맥입니다. 현재 observation을 Reactor Context에도 넣고, 코루틴이 재개될 때마다 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)
}

여기서 확인할 것은 보조 함수의 이름이 아니라, suspend 함수 안의 중첩 span을 어떤 scope로 열고 닫는가입니다. 코루틴은 실행 스레드를 바꿀 수 있습니다. ThreadLocal에만 의존하면 span 트리의 부모·자식 관계가 끊길 수 있습니다. 같은 요청의 작업들이 서로 무관한 것처럼 보이기 시작하는 지점입니다.

테스트가 추적 문맥 전파를 검증한다

섹션 제목: “테스트가 추적 문맥 전파를 검증한다”

observability-basic 예제는 가장 작은 경로를 사용합니다. WebFlux suspend 엔드포인트가 HTTP 서버 span을 만들고, OrderServiceorder.service.fetch span을 하나 더 만들며, Spring Boot가 관리하는 WebClient.Builder가 하위 시스템 호출에 W3C traceparent를 넣습니다.

@AutoConfigureTracing
class 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 흐름까지 포함합니다. 캐시 미스에서는 user.service.get 아래에 user.cache.get, user.db.find, user.cache.put이 자식 span으로 붙어야 합니다. 캐시 적중이면 DB span이 없어야 합니다. 이 차이를 테스트가 고정합니다.

http.server.requests
└─ user.service.get
├─ user.cache.get
├─ user.db.find
└─ user.cache.put

UserServiceTesthasParentObservationContextSatisfying으로 부모·자식 관계를 확인합니다. 단순히 “span 이름이 있다”는 수준이 아닙니다. 캐시 미스 경로에서 세 자식 span이 같은 서비스 span 아래에 붙는지 확인합니다.

CancellationException은 오류 span이 아니다

섹션 제목: “CancellationException은 오류 span이 아니다”

코루틴 관측성에서 자주 하는 실수는 예외 처리를 runCatching {} 같은 넓은 범위로 감싸는 것입니다. 예제는 CancellationException을 먼저 다시 던지고, 취소가 아닌 예외만 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()
}

이 규칙은 관측성 코드가 구조화된 동시성을 훼손하지 않게 합니다. 요청 취소를 오류 span으로 기록하면 정상적인 제어 흐름을 장애로 오인하게 됩니다. 반대로 5xx나 Redis 실패 같은 실제 오류는 span 오류로 남겨야 합니다.

준비 상태는 “살아 있음”이 아니라 “요청을 받아도 됨”이다

섹션 제목: “준비 상태는 “살아 있음”이 아니라 “요청을 받아도 됨”이다”

생존 상태(liveness)와 준비 상태(readiness)는 실패했을 때 플랫폼이 취해야 할 행동부터 다릅니다. 생존 상태는 “이 프로세스를 재시작해야 하는가”를 판단합니다. 점검에 실패하면 Kubernetes 같은 오케스트레이터는 일반적으로 컨테이너를 재시작합니다. 준비 상태는 “지금 이 인스턴스로 트래픽을 보내도 되는가”를 판단합니다. 점검에 실패하면 프로세스를 종료하지 않고 로드 밸런서나 Service 엔드포인트에서 일시적으로 제외합니다.

DB가 중단된 경우뿐 아니라 연결 풀이 고갈되어 제한 시간 안에 DB를 사용할 수 없는 경우에도 HTTP 서버와 코루틴 디스패처는 정상일 수 있습니다. 프로세스가 살아 있으므로 생존 상태 점검은 성공할 수 있지만, 주문 처리에 DB가 필요하다면 준비 상태 점검은 실패해야 합니다. “프로세스가 살아 있음”과 “요청을 처리할 준비가 됨”을 분리해야 하는 이유입니다.

이 차이는 장애 상황을 설명할 때도 드러납니다. 백엔드 개발자는 연결 풀 고갈로 DB 요청이 시간 초과된 상황을 흔히 “DB가 죽었다”라고 표현하지만, 인프라 관점에서는 DB 프로세스가 정상일 수 있습니다. 두 관찰 결과는 모순이 아닙니다. 생존 상태와 준비 상태를 구분하지 않으면 “애플리케이션 프로세스가 살아 있음”, “DB 프로세스가 살아 있음”, “서비스가 DB를 사용할 수 있음”을 하나의 상태로 잘못 묶게 됩니다.

Spring Boot와 Ktor 예제는 이 운영 계약을 다른 프레임워크 모양으로 표현합니다.

준비 상태 점검이 내부 상태와 저장소 및 데이터베이스 접근 가능성을 확인하고 200 또는 503으로 응답하는 시퀀스
준비 상태 엔드포인트는 내부 상태와 DB 연결 확인을 함께 수행합니다. 하나라도 실패하면 200이 아닌 503을 반환해야 합니다.

Spring은 Actuator 준비 상태 그룹에 사용자 정의 health contributor를 연결합니다.

override fun health(): Health =
when {
!state.isDatabaseAvailable() ->
Health.down()
.withDetail("database", "degraded by example state")
.build()
else -> databaseHealth()
}

Ktor는 /readyz 경로를 직접 구현합니다.

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 규약을 사용하는 대신 엔드포인트 형태와 생명주기를 프레임워크에 맡깁니다. Ktor는 /readyz의 의미와 응답 형식을 코드로 직접 관리합니다. 둘 중 무엇이 더 낫다는 이야기가 아닙니다. 운영팀과 플랫폼이 어떤 계약을 기대하는지에 맞춰 선택해야 합니다.

아래 표는 예제를 보면서 운영에서 바로 확인할 수 있는 징후를 묶은 것입니다.

빠진 신호확인할 연결 지점예제 근거
하위 시스템 요청에 traceparent 없음추적 브리지 또는 관리형 WebClient.Builder를 우회TracePropagationTest
캐시 미스인데 DB span이 서비스 span의 자식이 아님코루틴 재개 뒤 현재 observation scope가 복원되지 않음ObservationScopeContextElement
취소가 오류 span으로 쌓임CancellationException을 넓은 catch에서 삼킴observed() 보조 함수
DB 장애에도 준비 상태가 200준비 상태와 저장소 연결 확인을 엔드포인트에 연결하지 않음Spring DatabaseReadinessHealthIndicator, Ktor /readyz
요청 ID가 로그와 응답에서 다름헤더 정제·반환 경계가 없음Spring RequestCorrelationFilter, Ktor CallId

관측성은 “무엇을 설치했는가”보다 “어떤 실패를 어떤 신호로 검증했는가”가 중요합니다. 추적 문맥이 이어지고, 준비 상태가 실제 처리 가능성을 반영하며, 요청 ID가 응답과 로그에 함께 남으면 장애 분석은 추측에서 확인으로 전환됩니다.

코루틴이라고 운영 계약이 달라지지는 않는다

섹션 제목: “코루틴이라고 운영 계약이 달라지지는 않는다”

코루틴 환경이라고 해서 관측성의 기본 방식이 완전히 바뀌지는 않습니다. HTTP 서버 span을 만들고, 서비스와 하위 시스템 span을 자식으로 연결하고, 오류를 기록하고, 준비 상태로 트래픽 수신 가능 여부를 표현하는 큰 구조는 기존 요청별 스레드 서비스와 같습니다.

가상 스레드도 이 관점에서는 비슷합니다. Spring MVC나 블로킹 클라이언트를 가상 스레드 위에서 실행하면 코드 형태는 기존 요청별 스레드 방식에 더 가깝고, ThreadLocal 기반 observation scope도 코루틴보다 직관적으로 전파됩니다. 그러나 운영 계약은 달라지지 않습니다. 가상 스레드가 많아져도 DB 연결 풀이 자동으로 늘지는 않습니다. 연결 풀 고갈, 하위 시스템 호출 시간 초과, 준비 상태 503 같은 판단은 그대로 필요합니다.

다만 코루틴에서는 “언제 같은 요청이라고 볼 수 있는가”를 더 명시적으로 다뤄야 합니다. 일시 중단과 재개 과정에서 실행 스레드가 바뀔 수 있고, ThreadLocal에만 의존하면 현재 observation이 끊길 수 있습니다. 따라서 ThreadContextElement로 scope를 재개 시점마다 복원하고, Reactor Context에도 observation을 넣어 둡니다.

조심할 지점은 세 가지입니다.

  1. 문맥 전파를 테스트로 고정합니다. 하위 시스템의 traceparent와 span 부모·자식 관계가 실제로 이어지는지 확인해야 합니다.
  2. 취소를 실패로 기록하지 않습니다. CancellationException은 먼저 다시 던지고, 실제 장애만 observation.error(e)로 남깁니다.
  3. 준비 상태는 코루틴 실행 여부가 아니라 요청 처리 가능 여부를 말해야 합니다. DB나 캐시 같은 필수 의존성이 중단되었다면 프로세스가 살아 있어도 503을 반환해야 합니다.

결국 코루틴 관측성은 새로운 개념을 많이 외우는 문제가 아닙니다. 기존 관측성 계약을 유지하되, 일시 중단 경계에서 문맥과 취소가 유실되지 않는지 확인하는 문제입니다.

댓글

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