분산 추적은 어떻게 서비스 경계를 넘는가
Trace와 Span의 데이터 모델부터 W3C TraceContext 전파, 비동기 Context 손실까지 — 분산 추적이 마이크로서비스를 꿰뚫는 원리를 추적한다.
- 01 Observability는 왜 Monitoring과 다른가
- 02 Java Agent는 코드 한 줄 없이 어떻게 계측하는가
- 03 분산 추적은 어떻게 서비스 경계를 넘는가
- 04 Prometheus는 왜 Pull 방식인가
- 05 로그는 왜 구조여야 하는가 — Observability 로깅의 설계 철학
- 06 관찰 가능성 스택은 어떻게 하나로 연결되는가
- 07 Observability는 왜 세 신호가 함께여야 하는가
“주문 API가 가끔 3초가 걸린다 — 어디서 막히는지 모르겠다.” 로그를 뒤져 각 서비스의 타임스탬프를 손으로 더하는 대신, traceId 하나로 Grafana Tempo를 열면 어떤 Span에서 2.7초가 소모됐는지 한 눈에 보인다. 어떻게 가능한가? 그리고 왜 CompletableFuture 한 줄이 이 연결을 조용히 끊어버리는가?
Trace와 Span — 나무 구조로 요청을 추적한다
Trace는 하나의 분산 요청 전체다. Span은 그 안의 작업 단위다. 둘의 관계는 parentSpanId로 만들어진다.
Span 1 (SERVER): POST /orders [0~234ms]
└─ Span 2 (CLIENT): POST /order-service [1~233ms]
└─ Span 3 (SERVER): POST /orders [2~232ms]
└─ Span 4 (CLIENT): POST /payment-service [50~230ms]
└─ Span 5 (SERVER): POST /payments [51~229ms]
└─ Span 6 (CLIENT): INSERT payments [52~120ms]
API Gateway가 요청을 받으면 traceId를 새로 생성한다. 이후 모든 서비스가 이 ID를 공유한다. 각 서비스는 자신의 spanId를 생성하고, 자신을 호출한 Span의 spanId를 parentSpanId로 기록한다. Tempo가 이 트리를 재구성할 때 쓰는 것이 바로 이 parentSpanId다.
Span Kind는 이 나무에서 각 노드의 역할을 구분한다. SERVER는 요청을 받는 쪽, CLIENT는 요청을 보내는 쪽이다. 서비스 A가 B를 호출하면 Span이 두 개 생긴다 — A의 CLIENT Span이 부모, B의 SERVER Span이 자식. 타임라인에서 CLIENT 안에 SERVER가 포함된 것처럼 보이는 이유다.
span.setName("GET /orders/99437/items")처럼 주문 ID를 Span 이름에 넣으면 Tempo에서 수십만 개의 고유 이름이 만들어져 집계가 불가능해진다. 이름은 "GET /orders/{orderId}/items"로 템플릿화하고, 실제 ID는 span.setAttribute("order.id", "99437")로 분리해야 한다.
traceparent — 서비스 간 Context 전파의 표준
서비스 경계를 넘을 때 traceId와 spanId는 HTTP 헤더로 전달된다. W3C TraceContext 표준(RFC 9532)이 정의한 형식이다.
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
▲ ▲ ▲ ▲
version traceId(128비트) parentId(64비트) flags
parentId 자리에 들어가는 것은 보내는 쪽의 spanId다. 받는 서비스는 이 값을 자신의 parentSpanId로 설정하고, 새 spanId를 생성한다. 이름이 헷갈리는 이유는 W3C 표준이 수신자 관점에서 필드를 명명했기 때문이다.
flags의 마지막 비트가 샘플링 결정이다. 01이면 이 Trace의 모든 Span을 수집하라는 신호, 00이면 Span 생성 자체를 생략해도 된다. 이 결정은 Root 서비스에서 한 번 내려지고, 모든 하위 서비스가 그대로 따른다. 중간에 바꾸면 일부만 수집된 불완전 Trace가 만들어진다.
W3C 표준 이전에는 Zipkin이 X-B3-TraceId, Jaeger가 uber-trace-id를 각자 사용했다. 서로 다른 벤더 간 Trace 연결은 불가능했다. 레거시 시스템과 OTel을 함께 운영할 때는 OTEL_PROPAGATORS=tracecontext,baggage,b3multi 설정으로 두 형식을 동시에 주입하면 마이그레이션 기간 동안 Trace를 이을 수 있다.
Context 전파 — Carrier 패턴이 분리하는 것
OTel이 traceparent를 HTTP 헤더에 넣고 빼는 방법은 TextMapPropagator + TextMapSetter/Getter 패턴이다. 전파 로직(W3C 규칙)과 전송 매체(HTTP 헤더, Kafka 헤더, gRPC Metadata)를 분리한 설계다.
// 나가는 요청에 헤더 삽입
propagator.inject(Context.current(), request,
(carrier, key, value) -> carrier.setHeader(key, value));
// 들어오는 요청에서 Context 복원
Context extracted = propagator.extract(Context.current(), request,
(carrier, key) -> carrier.getHeader(key));
try (Scope scope = extracted.makeCurrent()) {
// 이 블록 안의 Span은 추출된 Context의 자식이 된다
}
OTel Agent가 설치되어 있으면 Spring MVC와 RestTemplate에서 이 패턴이 자동으로 적용된다. 단, new RestTemplate()처럼 Spring Bean이 아닌 방식으로 생성하면 Agent가 Interceptor를 주입하지 못해 Trace가 끊긴다.
비동기의 함정 — ThreadLocal은 스레드를 따라간다
OTel Context는 ThreadLocal에 저장된다. 스레드가 바뀌면 Context가 없다.
// 나쁜 코드 — 다른 스레드에서 Span.current()는 빈 NOOP Span이다
CompletableFuture.supplyAsync(() -> {
callExternalService(); // traceparent 헤더 없이 나간다 → Trace 끊김
});
// 올바른 코드 — 제출 시점의 Context를 캡처해서 실행 시점에 복원한다
Context context = Context.current();
CompletableFuture.supplyAsync(context.wrap(() -> {
callExternalService(); // traceparent 헤더가 정상적으로 삽입된다
}));
@Async 전체에 적용하려면 Executor를 교체하면 된다.
@Bean
public ExecutorService asyncExecutor() {
return ContextExecutorService.wrap(Executors.newFixedThreadPool(10));
}
ContextExecutorService는 submit() 호출 시점의 Context를 캡처하고, 실제 실행 스레드에서 makeCurrent()로 복원한다. 모든 @Async 작업에 자동으로 Context가 전파된다.
Java 21 Virtual Thread는 이 문제를 부분적으로 완화한다. Virtual Thread도 ThreadLocal을 가지며, I/O 블로킹으로 Carrier Thread가 교체될 때도 Virtual Thread의 ThreadLocal은 유지된다. spring.threads.virtual.enabled=true 설정 후 OTel Agent와 함께 사용하면 대부분의 경우 추가 설정 없이 Context가 전파된다. 단, Virtual Thread 안에서 명시적으로 새 스레드를 생성할 때는 여전히 Context.wrap()이 필요하다.
Span을 풍부하게 — Attribute와 Event의 설계 원칙
Trace가 “3초 걸렸다”는 사실만 알려주면 절반의 가치다. “어떤 결제 수단으로 처리했는지”, “재시도가 몇 번 발생했는지”가 Span에 있어야 Trace가 진단 도구가 된다.
Attribute 설계의 핵심은 카디널리티다.
- 낮은 카디널리티 (수십~수백 종류): Span Attribute로 기록한다. TraceQL에서 검색 가능하고, Micrometer Observation의
lowCardinalityKeyValues로 설정하면 메트릭 태그로도 사용된다. - 높은 카디널리티 (order.id, user.id 등): Span Attribute로 넣으면 Tempo 인덱스가 폭발한다.
span.addEvent()의 Attributes에 넣거나 로그에 남긴다. Event의 Attribute는 인덱싱되지 않아 카디널리티 제한이 없다.
예외는 span.setAttribute("error.message", e.getMessage())가 아니라 span.recordException(e)로 기록해야 한다. OTel이 exception.type, exception.message, exception.stacktrace를 표준 형식의 Span Event로 자동 생성한다.
OTel Agent는 MDC에 traceId와 spanId를 자동으로 주입한다. Logback 패턴에 %X{traceId}를 추가하면 모든 로그에 Trace 정보가 포함된다. Loki에서 traceId로 검색하면 해당 요청의 모든 로그를 꺼낼 수 있고, Grafana에서 메트릭 → Trace → 로그로 드릴다운하는 경로가 완성된다.
정리
- Trace는
traceId로 묶인 Span의 나무다.parentSpanId가 트리를 만들고, Tempo가 이를 조회 시에 재구성한다. - W3C
traceparent헤더가 서비스 간 Context를 전달한다.parentId자리에는 보내는 쪽의spanId가 들어간다. - ThreadLocal 기반 Context는 스레드 경계를 자동으로 넘지 못한다.
Context.wrap()또는ContextExecutorService로 명시적으로 전달해야 한다. - Span Attribute는 낮은 카디널리티만, 높은 카디널리티 값은
addEvent()또는 로그로 분리한다. span.recordException(e)+ MDC의 traceId로 Trace와 로그를 연결하면 분산 추적이 완성된다.
다음 글에서는 Prometheus가 메트릭을 수집하는 Pull 모델의 내부 구조와, TSDB가 시계열을 어떻게 압축하는지 추적한다.