마이크로서비스 구조에서는 사용자의 요청 하나가 여러 서비스를 거친다. 주문 요청이 게이트웨이를 지나 주문 서비스로, 거기서 다시 결제 서비스와 재고 서비스로 퍼져나가는 식이다. 이때 어딘가에서 응답이 느려지거나 오류가 났다면, 그 요청이 어느 서비스에서 막혔는지 추적해야 한다. 그런데 각 서비스는 자기 앞의 일만 알 뿐, 그게 어떤 사용자 요청에서 비롯됐는지는 모른다.

이를 해결하는 것이 분산 추적(distributed tracing)이다. 요청마다 고유한 식별자를 붙이고, 그 요청이 거치는 모든 서비스가 같은 식별자를 공유하게 하면, 흩어진 로그를 하나의 흐름으로 꿰어볼 수 있다. 문제는 이 식별자를 서비스 사이에 어떻게 주고받느냐다. W3C Trace Context는 바로 그 전달 방식을 표준으로 못 박은 명세다.

왜 표준이 필요했나

분산 추적 자체는 새롭지 않다. Zipkin은 B3라는 헤더를, Jaeger는 또 다른 헤더를 써서 추적 정보를 전달했다. 문제는 저마다 형식이 달랐다는 것이다. Zipkin으로 추적하는 서비스와 다른 도구로 추적하는 서비스가 한 요청 안에서 만나면, 서로의 헤더를 못 알아보고 추적의 사슬이 거기서 끊겼다. 서비스마다, 도구마다 방언을 쓰니 대화가 안 됐던 셈이다.

W3C Trace Context는 이 방언들을 하나의 공용어로 통일했다. HTTP 헤더 두 개의 이름과 형식을 표준으로 정해, 어떤 추적 도구를 쓰든 서로의 추적 정보를 이해하고 이어받을 수 있게 했다. 두 헤더는 traceparenttracestate다.

traceparent — 요청의 신분증

traceparent는 이 요청이 어떤 추적에 속하고, 직전에 누가 호출했는지를 담는다. 하이픈으로 나뉜 네 부분으로 이뤄진다.

traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
             └┬┘ └──────────────┬──────────────┘ └───────┬──────┘ └┬┘
           version          trace-id              parent-id      flags

version은 형식의 버전으로 현재는 00이다. trace-id는 16바이트(32자리 16진수) 값으로, 하나의 요청 흐름 전체를 식별한다. 그 요청이 몇 개의 서비스를 거치든 이 값은 처음부터 끝까지 바뀌지 않는다. parent-id는 8바이트(16자리) 값으로, 직전 호출 구간을 식별한다. flags는 추적 옵션을 담는데, 대표적으로 마지막 비트가 이 요청을 실제로 기록할지(샘플링 여부)를 나타낸다. 01이면 기록, 00이면 기록하지 않는다.

여기서 두 식별자의 역할이 갈린다. trace-id는 “이 요청 전체"를 가리키는 변하지 않는 번호이고, parent-id는 “바로 직전 단계"를 가리키는, 서비스를 거칠 때마다 바뀌는 번호다.

스팬과 전파

추적에서 각 서비스가 처리한 한 구간을 스팬(span)이라 부른다. 하나의 추적(trace)은 여러 스팬이 부모-자식으로 이어진 나무 구조다. trace-id가 나무 전체의 이름이라면, 각 스팬은 그 나무의 가지 하나다.

전파(propagation)는 이 추적 정보를 다음 서비스로 넘기는 과정이다. 서비스가 요청을 받으면 들어온 traceparent를 읽어, trace-id는 그대로 유지하고 자신의 처리 구간에 새 스팬 ID를 부여한다. 그리고 다음 서비스를 호출할 때는 parent-id 자리에 자신의 스팬 ID를 넣은 traceparent를 실어 보낸다. 받는 쪽 입장에서는 그 값이 “나를 부른 직전 구간"이 된다. 이렇게 각 서비스가 trace-id는 보존하고 parent-id만 자기 것으로 바꿔 넘기면, 흩어진 스팬들이 부모-자식 관계로 자연스럽게 연결된다.

[게이트웨이]  trace=abc, span=11
     │ traceparent: 00-abc-11-01
[주문 서비스]  trace=abc, span=22   (parent=11)
     │ traceparent: 00-abc-22-01
[결제 서비스]  trace=abc, span=33   (parent=22)

trace-id abc는 끝까지 같고, parent-id만 단계마다 바뀐다. 추적 도구는 이 관계를 모아 하나의 폭포수 그래프로 재구성한다.

tracestate — 도구별 부가 정보

tracestate는 추적 도구들이 자기만의 부가 정보를 실어 나르는 칸이다. 쉼표로 구분된 키-값 쌍들로 이뤄진다.

tracestate: congo=t61rcWkgMzE,rojo=00f067aa0ba902b7

traceparent가 모든 도구가 공유하는 공통 정보라면, tracestate는 각 도구가 추가로 챙기고 싶은 정보를 표준을 깨지 않고 끼워 넣는 공간이다. 표준 형식(traceparent)으로 상호운용성을 보장하면서도, 도구별 확장(tracestate)의 여지를 남겨둔 설계다.

OpenTelemetry가 이 표준을 어떻게 쓰나

Trace Context는 어디까지나 “헤더 두 개의 형식"을 정한 명세일 뿐이다. 실제로 스팬을 만들고, 헤더를 읽고 쓰고, 추적 데이터를 저장소로 보내는 일은 별도의 구현이 맡는다. 오늘날 그 구현의 사실상 표준이 OpenTelemetry(OTel)다. 둘의 관계는 한 문장으로 정리된다. Trace Context는 “무엇을 어떤 형식으로 전파할지"를 정하는 규약이고, OpenTelemetry는 그 규약대로 전파를 실제로 수행하는 라이브러리다. 층이 다르므로 경쟁하지 않고 포개진다.

구체적으로 OpenTelemetry 안에서 이 표준을 다루는 부품을 전파기(propagator)라 한다. 서비스가 외부 요청을 받으면 OTel의 전파기가 traceparent/tracestate 헤더를 읽어(extract) 현재 실행 맥락(context)에 추적 정보를 복원한다. 반대로 이 서비스가 다른 서비스를 호출할 때는 전파기가 그 맥락을 다시 헤더로 직렬화해(inject) 요청에 실어 보낸다. 앞 절에서 “trace-id는 보존하고 parent-id만 자기 것으로 바꿔 넘긴다"고 한 그 작업을, 엔지니어가 손으로 짜는 대신 OTel 전파기가 자동으로 해주는 것이다.

더 나아가 OpenTelemetry의 자동 계측(auto-instrumentation)을 붙이면, HTTP 클라이언트·서버 라이브러리 호출에 이 추출과 주입이 자동으로 끼어든다. 애플리케이션 코드를 거의 건드리지 않아도 모든 서비스가 표준 헤더를 일관되게 주고받게 되는 셈이다. 또한 OTel은 W3C Trace Context와 짝을 이루는 또 다른 명세인 Baggage(요청을 따라 흐르는 사용자 정의 키-값)도 같은 전파 메커니즘으로 다룬다. 정리하면, 표준이 약속한 헤더를 실제 운영에서 일관되게 구현하기 위한 가장 흔한 답이 OpenTelemetry이고, 그래서 둘은 거의 한 묶음으로 등장한다.

트레이드오프 — 무엇을 얻고 무엇을 감수하나

얻는 것은 분명하다.

  • 상호운용성. 서비스마다 언어와 추적 도구가 달라도, 표준 헤더 두 개만 지키면 한 요청의 흐름이 경계를 넘어 하나로 이어진다. 특정 벤더에 묶이지 않으므로 추적 백엔드(Jaeger, Tempo, 상용 APM 등)를 바꿔도 계측 코드는 그대로다.
  • 얹기 쉬움. 추가 채널이 아니라 기존 HTTP 헤더에 얹히므로, 통신 구조를 바꾸지 않고도 도입된다. OpenTelemetry 자동 계측과 결합하면 코드 변경도 거의 없다.

감수하는 것은 그 대가로 따라온다.

  • 전 구간 일관성이 전제다. 추적은 사슬이라, 중간에 한 서비스라도 헤더를 읽어 다음으로 넘기지 않으면 그 지점에서 끊긴다. 그러면 끊긴 뒤의 스팬들은 부모를 잃고 추적이 두 동강 난다. 모든 서비스가 빠짐없이 전파를 구현해야 하며, 이것이 OpenTelemetry 같은 공통 라이브러리를 표준으로 도입하는 실질적 이유다.
  • HTTP 경계를 넘는 곳엔 손이 더 간다. 헤더 전파는 HTTP·gRPC처럼 헤더가 있는 통신에서 자연스럽지만, 메시지 큐나 비동기 이벤트처럼 헤더 개념이 다른 경로에서는 추적 정보를 메시지 속성에 직접 실어 나르도록 따로 맞춰야 한다.
  • 기록 비용과 샘플링의 타협. 모든 요청의 모든 스팬을 다 저장하면 데이터양과 비용이 폭증한다. 그래서 flags의 샘플링으로 일부만 기록하는데, 적게 잡으면 정작 문제가 터진 요청이 기록에서 빠지고, 많이 잡으면 비용이 커진다. “무엇을 얼마나 남길지"가 운영에서 끊임없이 조율해야 하는 지점이다.
  • 약간의 오버헤드. 헤더 파싱, 스팬 생성, 데이터 전송에 자원이 든다. 보통은 무시할 수준이지만, 극단적으로 지연에 민감한 경로에서는 고려 대상이 된다.

요약하면, W3C Trace Context는 “표준 헤더로 추적을 잇는다"는 이점을 주는 대신 “모든 서비스가 전파를 빠짐없이 구현하고, 무엇을 얼마나 기록할지 끊임없이 조율해야 한다"는 운영 부담을 요구한다. 마이크로서비스가 보편화된 지금은 그 부담을 감수하더라도 요청의 전체 경로를 들여다보는 편이 이득인 경우가 대부분이고, OpenTelemetry가 그 부담의 상당 부분을 표준화된 구현으로 덜어준다.