분산 추적은 어떻게 서비스 경계를 넘는가
TraceContext 전파부터 Baggage, MDC 자동 주입, Zipkin 시각화까지 — Spring Cloud 분산 추적의 전체 흐름을 하나의 구조로 추적한다.
MSA에서 장애가 발생하면 어떤 서비스가 범인인지 바로 알기 어렵다. 각 서비스의 로그는 분산돼 있고, “결제가 안 됩니다”라는 신고 하나를 추적하려면 Gateway, Order, Payment, External API를 모두 뒤져야 한다. 분산 추적은 이 흩어진 조각들을 하나의 식별자로 꿰는 기술이다. Spring Cloud Sleuth에서 Micrometer Tracing으로 이어지는 이 구조는 어떻게 서비스 경계를 넘어 컨텍스트를 전달하는가?
TraceContext — 분산 추적의 기본 단위
분산 추적의 핵심은 TraceContext라는 작은 구조체다. 세 가지 ID가 전부다.
- traceId: 전체 요청 체인을 식별하는 ID. Client에서 시작해 모든 서비스를 거치는 동안 동일한 값을 유지한다.
- spanId: 한 서비스 내의 작업 단위 ID. 서비스마다 새로 생성된다.
- parentSpanId: 부모 서비스의 spanId. 이 값으로 트리 구조가 만들어진다.
Gateway: traceId=abc123, spanId=111, parentId=null
Order Service: traceId=abc123, spanId=222, parentId=111
Payment: traceId=abc123, spanId=333, parentId=222
traceId는 변하지 않는다. spanId만 서비스마다 새로 태어난다. 이 단순한 규칙이 Zipkin UI에서 전체 호출 체인을 렌더링할 수 있는 이유다.
Spring Boot 3.x에서는 spring-cloud-starter-sleuth 대신 micrometer-tracing-bridge-brave를 사용한다. Sleuth가 Spring Cloud 전용이었다면, Micrometer Tracing은 범용 추상화 레이어다. Brave(Zipkin 호환)와 OpenTelemetry 구현체 중 하나를 선택할 수 있다.
Propagator — 컨텍스트가 HTTP 헤더를 타는 방법
TraceContext는 HTTP 헤더에 실려 서비스 경계를 넘는다. 이를 담당하는 것이 Propagator다.
발신 요청(inject)에서는 현재 TraceContext를 HTTP 헤더로 직렬화한다. 수신 요청(extract)에서는 헤더에서 컨텍스트를 복원해 새 SERVER Span의 부모로 삼는다.
발신 헤더 (B3 형식):
X-B3-TraceId: abc123...
X-B3-SpanId: 222...
X-B3-ParentSpanId: 111...
X-B3-Sampled: 1
B3는 Zipkin 기본 형식이고, W3C TraceContext(traceparent 헤더)는 OpenTelemetry와 Jaeger가 기본으로 사용하는 IETF 표준이다. 기존 Zipkin 환경이라면 B3, 신규 OTel 환경이라면 W3C를 선택한다. 두 형식 모두 지원하도록 설정할 수도 있다.
RestTemplate과 WebClient에는 인터셉터가 자동으로 등록돼 inject/extract를 처리한다. 직접 호출 코드를 건드릴 필요가 없다.
MDC — traceId가 로그에 찍히는 경로
Span이 시작될 때 MDCScopeDecorator가 MDC.put("traceId", ...) 와 MDC.put("spanId", ...)를 호출한다. Logback의 %X{traceId}는 이 MDC에서 값을 읽는다. Span이 종료되면 Scope가 닫히면서 MDC가 이전 값으로 복원된다.
<!-- logback-spring.xml -->
<pattern>
%d{HH:mm:ss.SSS} %5p [${spring.application.name},%X{traceId},%X{spanId}] %logger{36} - %msg%n
</pattern>
이 설정 하나로 모든 동기 요청의 로그에 traceId가 자동으로 붙는다.
문제는 스레드가 바뀔 때다. @Async, CompletableFuture.runAsync(), Reactor 체인은 새 스레드에서 실행되므로 ThreadLocal 기반의 MDC가 자동으로 전파되지 않는다.
// @Async: TaskDecorator로 MDC 복사
executor.setTaskDecorator(runnable -> {
Map<String, String> contextMap = MDC.getCopyOfContextMap();
return () -> {
try {
if (contextMap != null) MDC.setContextMap(contextMap);
runnable.run();
} finally {
MDC.clear();
}
};
});
Reactor(WebFlux)는 Hooks.enableAutomaticContextPropagation()을 활성화하면 context-propagation 라이브러리가 스레드 전환 시점마다 ThreadLocal을 자동으로 복원한다. Spring Boot 3.x + WebFlux 조합에서는 이 설정이 자동으로 적용된다.
Baggage — 비즈니스 컨텍스트를 코드 변경 없이 전파하기
userId나 tenantId를 모든 서비스에 전달해야 할 때, 메서드 파라미터로 계속 끌고 다니거나 매 서비스마다 헤더를 수동으로 추출/삽입하는 대신 Baggage를 쓸 수 있다.
Baggage는 TraceContext에 추가 데이터를 붙여 자동으로 전파하는 메커니즘이다.
management:
tracing:
baggage:
remote-fields: [user-id, tenant-id] # 서비스 간 전파
correlation-fields: [user-id, tenant-id] # MDC 자동 연동
remote-fields에 선언된 필드는 HTTP 헤더(X-B3-Bag-user-id 등)로 자동 전파된다. correlation-fields에 선언하면 CorrelationScopeDecorator가 Baggage 값을 MDC에 자동으로 복사한다. Gateway에서 BaggageField.create("user-id").updateValue(userId)를 한 번 호출하면, 이후 모든 하위 서비스 로그에 user-id가 자동으로 포함된다.
remote 필드는 HTTP 헤더로 전파되므로 외부에서 위조할 수 있다. Gateway에서 외부 입력 Baggage 헤더를 제거하고 JWT 검증 후 신뢰할 수 있는 값으로 재설정해야 한다. 값은 짧은 ID 정도로 제한하고, 민감 정보(이메일, 비밀번호)는 절대 넣지 않는다.
Baggage와 Span 태그의 차이를 혼동하기 쉽다. Baggage는 다른 서비스로 전파되지만 Zipkin에서 검색할 수 없다. Span 태그는 Zipkin에 저장돼 검색 가능하지만 다른 서비스로 전파되지 않는다. userId를 Zipkin에서 검색하려면 태그로, 다른 서비스에 전달하려면 Baggage로, 둘 다 필요하면 동시에 사용한다.
트레이드오프
자동 계측만 사용하면 오버헤드는 최소지만 HTTP 레벨 정보만 얻는다. @NewSpan으로 메서드 단위 Span을 추가하면 Zipkin 타임라인이 세밀해지지만 Span 수가 늘고 Zipkin 전송 부하도 증가한다. @ContinueSpan + @SpanTag는 새 Span 없이 현재 Span에 태그만 추가해 중간 지점이다. 운영에서는 샘플링 확률도 핵심 변수다. 100 RPS 미만이면 1.0(전수 추적), 1000 RPS 이상이면 0.01~0.05가 적절하다. 오류 발생 요청은 항상 100% 샘플링하는 커스텀 Sampler를 조합하면 장애 분석 데이터를 보장하면서 일반 오버헤드를 최소화할 수 있다.
Zipkin 서버가 다운되면 AsyncReporter의 로컬 큐에 Span이 쌓이다가 큐가 넘치면 드롭된다. 서비스 자체는 영향을 받지 않는다. 이것이 “Fail Open” 설계다 — 추적 인프라 장애가 서비스 장애로 전파되지 않도록 설계된 의도적 선택이다.
정리
- traceId는 서비스를 넘어도 변하지 않는다. spanId만 서비스마다 새로 생성된다.
- Propagator가 inject/extract로 HTTP 헤더를 통해 TraceContext를 전파한다.
- MDC 자동 주입은 동기 코드에서만 공짜다.
@Async와 Reactor에서는 명시적 설정이 필요하다. - Baggage는 코드 변경 없이 비즈니스 컨텍스트를 전파하지만 Zipkin에서 검색되지 않는다. 검색이 필요하면 Span 태그를 함께 사용한다.
- Zipkin 장애가 서비스 장애로 이어지지 않는 것은 설계 결정이지 버그가 아니다.
다음 글에서는 Zipkin의 parentId 기반 트리 재구성과 Elasticsearch 백엔드 구성, 그리고 운영 환경에서 샘플링 전략을 동적으로 조정하는 방법을 다룬다.