Skip to Content
Infra & DevOps왜 우리 서비스는 B3와 W3C TraceContext를 함께 써야 했나
☁️ Infra & DevOps2023년 6월 14일

왜 우리 서비스는 B3와 W3C TraceContext를 함께 써야 했나

#infra-devops#observability#distributed-tracing#opentelemetry#w3c-trace-context#zipkin

운영 중인 마이크로서비스에서 분산 추적을 설정하다 보면 가끔 이런 설정을 만나게 된다.

bash
sample/jvm-options.txt
-Dotel.propagators=b3,b3multi,tracecontext

위 예시는 Java에서 JVM 시스템 프로퍼티로 넣는 방식이고, 설정의 핵심 키는 otel.propagators다. 즉 개념 자체는 OpenTelemetry 전반에서 공통으로 쓰이지만, Java에서는 흔히 -Dotel.propagators=... 형태로 보게 된다.

겉으로 보면 단순한 옵션 한 줄이지만, 이 설정에는 꽤 긴 생태계의 역사가 들어 있다. 이상적으로는 모든 서비스가 하나의 표준만 쓰면 된다. 하지만 현실의 서비스 환경은 그렇게 한 번에 정리되지 않았다. 어떤 서비스는 Zipkin 계열의 B3를 쓰고, 어떤 서비스는 OpenTelemetry와 W3C TraceContext를 쓰고, 어떤 서비스는 더 오래된 벤더 방식의 흔적을 아직도 갖고 있다.

나 역시 초기에 Zipkin을 걷어내던 시절부터 이 문제를 계속 겪어 왔다. OpenTracing과 OpenCensus가 각자 표준이 되려 하던 시기에도 분산 추적을 써 왔고, 여러 형식이 섞인 환경에서 trace가 끊기지 않게 유지하는 일을 반복해서 봤다. 그 시절에는 단순히 사용자 입장만이 아니라, 모니터링 플랫폼을 만들던 입장에서 이 파편화 자체를 계속 마주해야 했다. 그래서 시간이 꽤 지난 지금, 생태계가 결국 OpenTelemetry 중심으로 많이 정리된 것이 더 반갑게 느껴진다.

그래서 이 글은 새로운 개념을 처음 소개하는 글이라기보다, 오랫동안 써 온 분산 추적 생태계가 어떻게 여기까지 왔는지 다시 한 번 정리하는 글에 가깝다. 왜 이런 공존 상태가 생겼는지, 그리고 왜 실무에서는 아직도 여러 propagation format을 함께 지원해야 하는지를 지금 시점에서 다시 정리해 보려 한다.

분산 추적은 Dapper에서 출발했다

오늘날 분산 추적의 출발점으로 가장 자주 언급되는 것은 Google의 Dapper  다. Dapper는 대규모 분산 시스템에서 요청 하나가 여러 서비스를 거칠 때, 그 경로 전체를 하나의 trace로 묶어 관찰하는 방식을 정리했다.

이 아이디어는 이후 업계 전반으로 퍼졌고, 오픈소스와 상용 제품이 각자 구현을 내놓기 시작했다. 문제는 이 시기에 모두가 같은 헤더 규격을 쓰지 않았다는 점이다. 분산 추적이라는 개념은 빠르게 퍼졌지만, trace context를 HTTP 헤더로 어떻게 전달할지는 한동안 제품과 프레임워크마다 달랐다.

분산 추적에서 중요한 것은 단순히 trace를 저장하는 기능이 아니라, 서비스 A에서 시작된 요청이 서비스 B, C, D로 이어질 때도 같은 trace ID가 계속 전파되는 것이다.

표준이 하나로 정리되지 않았던 시기

초기 분산 추적 생태계는 대략 세 갈래로 흘렀다.

계열대표 형식특징
Zipkin 계열B3, B3 MultiHTTP 헤더 기반 전파가 널리 퍼짐
Jaeger 계열uber-trace-idJaeger 클라이언트와 강하게 결합된 전파 형식
벤더/에이전트 계열제품별 고유 형식Datadog, New Relic 같은 제품도 자체 방식과 호환 레이어를 가짐

특히 Zipkin의 B3는 오랫동안 사실상의 업계 표준처럼 널리 쓰였다. 반면 Jaeger는 자신의 생태계에서 uber-trace-id를 사용했고, 여러 상용 제품들도 각자 에이전트와 헤더 규칙을 제공했다.

즉, 이 시기의 문제는 “분산 추적이 없었다”가 아니라, 분산 추적은 있었지만 서로 다른 언어를 쓰고 있었다는 데 있었다.

OpenTracing과 OpenCensus는 왜 OpenTelemetry로 합쳐졌는가

생태계가 복잡해지면서 “계측 표준을 하나로 모아야 한다”는 요구가 커졌다. 이 흐름에서 등장한 것이 OpenTracing과 OpenCensus였다.

  • OpenTracing은 주로 추적 API 표준화에 집중했다.
  • OpenCensus는 추적과 메트릭을 함께 다루는 계측 라이브러리 쪽에 가까웠다.

둘 다 의미 있는 시도였지만, 동시에 두 프로젝트가 병행되면서 오히려 혼란이 커졌다. 결국 두 프로젝트는 OpenTelemetry로 합쳐졌고, 계측 표준은 점차 OpenTelemetry 중심으로 모이기 시작했다.

이 합병이 중요한 이유는 단순히 프로젝트가 하나로 줄어들었다는 점이 아니다. OpenTelemetry 이후로는 “어떤 백엔드를 쓰든 일단 같은 계측 모델과 propagation 전략으로 갈 수 있다”는 기대가 생겼기 때문이다.

산업 표준은 왜 W3C TraceContext 쪽으로 수렴했는가

OpenTelemetry가 계측 축을 정리했다면, 전파 형식 쪽의 표준 축은 W3C TraceContext가 맡게 됐다. W3C TraceContext는 traceparent, tracestate라는 헤더 규격을 정의했고, 벤더와 프레임워크가 공통으로 따를 수 있는 기준이 됐다.

예를 들면 이런 식이다.

text
sample/traceparent.txt
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

이 표준의 장점은 명확했다.

  • 특정 벤더에 묶이지 않는다.
  • OpenTelemetry와 자연스럽게 연결된다.
  • 새로운 서비스가 어떤 백엔드를 쓰든 공통 기반 위에서 시작할 수 있다.

그래서 최근에 새로 만드는 서비스라면 W3C TraceContext를 기본 축으로 잡는 경우가 많다.

그런데 왜 아직도 B3를 같이 켜야 하는가

문제는 서비스 생태계가 표준보다 느리게 움직인다는 점이다. 현실의 시스템에서는 다음이 동시에 존재한다.

  • 오래전에 Zipkin/B3 기반으로 계측된 서비스
  • 중간 시기에 B3와 TraceContext를 함께 받도록 설정된 서비스
  • 최근에 OpenTelemetry와 W3C TraceContext로 시작한 서비스

이 상태에서 어떤 서비스가 tracecontext만 읽고, 다른 서비스가 b3만 보낸다면 trace는 중간에서 끊긴다. 요청은 정상 처리돼도, 호출 체인을 하나의 trace로 이어서 보는 것이 어려워진다.

그래서 실무에서는 이런 식의 설정이 나온다.

bash
sample/otel-propagators.txt
otel.propagators=b3,b3multi,tracecontext

Java 애플리케이션이라면 이 값을 -Dotel.propagators=... 같은 JVM 옵션으로 넣을 수 있고, 다른 런타임에서는 환경 변수나 설정 파일 형태로 줄 수도 있다.

이 설정의 의미는 단순하다.

  • 들어오는 요청에서 B3, B3 Multi, W3C TraceContext 중 무엇이 와도 읽는다.
  • 나가는 요청에도 여러 형식을 함께 실어 보낼 수 있게 한다.

이 방식은 완벽하게 우아하지는 않다. 하지만 점진적으로 표준을 옮겨 가는 현실적인 운영 전략으로는 꽤 합리적이다.

B3, B3 Multi, TraceContext는 무엇이 다른가

세 형식은 결국 “trace context를 다음 서비스로 전달한다”는 목표는 같다. 다만 전달 방식이 다르다.

형식예시특징
B3 MultiX-B3-TraceId, X-B3-SpanId여러 헤더로 나눠 보냄
B3 Singleb3: traceid-spanid-sampled하나의 헤더에 압축
W3C TraceContexttraceparent, tracestate현재 가장 널리 받아들여지는 표준

운영 관점에서는 기능 차이보다 호환성 차이가 더 중요하다. 어떤 서비스가 어떤 형식을 읽고 쓰는지를 알아야 trace 단절을 막을 수 있기 때문이다.

trace는 실제로 어떻게 이어지는가

분산 추적의 핵심 원리는 생각보다 단순하다. 요청이 처음 들어올 때 trace ID와 span ID를 만들고, 다음 서비스로 호출을 보낼 때 그 값을 헤더에 심어서 넘긴다. 다음 서비스는 그 헤더를 파싱해서 “나는 기존 trace의 다음 span이구나”를 이해하고, 자기 작업 구간을 새로운 child span으로 기록한다.

즉 trace는 서비스들이 중앙 저장소를 공유해서 이어지는 것이 아니라, 요청 헤더에 담긴 context를 각 서비스가 읽고 다시 써 주면서 이어진다.

예를 들어 W3C TraceContext라면 이런 식이다.

text
sample/incoming-traceparent.txt
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

여기서 중요한 것은 trace-id는 그대로 유지되고, 각 서비스가 자기 구간을 표현하는 새로운 span-id를 만든다는 점이다. 그래서 하나의 요청 안에서 여러 서비스가 같은 trace에 속하면서도, 각각의 처리 구간은 다른 span으로 구분된다.

다이어그램 로딩 중...

수신 측은 헤더를 파싱해서 parent context를 복원한다

서비스 입장에서 가장 먼저 하는 일은 들어온 요청 헤더를 읽는 것이다. OpenTelemetry에서는 configured propagator가 이 헤더를 해석해 현재 요청의 parent context를 복원한다.

개념적으로는 이런 흐름이다.

java
sample/incoming-context.java
Context parent = propagators.getTextMapPropagator() .extract(Context.current(), request, headerGetter); Span span = tracer.spanBuilder("GET /users/me") .setParent(parent) .startSpan();

이 코드는 실제 구현체와 다를 수 있지만 의미는 같다.

  • 헤더에서 trace context를 꺼낸다.
  • 현재 요청 span의 parent로 연결한다.
  • 이 서비스 구간을 새로운 span으로 기록한다.

만약 여기서 파싱에 실패하거나, 서비스가 특정 형식만 읽도록 설정돼 있으면 trace는 이 지점에서 끊긴다.

송신 측은 같은 trace를 다음 서비스로 다시 심어 보낸다

현재 서비스가 다음 서비스로 HTTP 호출을 보낼 때는, 방금 만든 현재 span context를 다시 헤더에 써 넣는다. 이것이 propagation이다.

java
sample/outgoing-context.java
propagators.getTextMapPropagator().inject( Context.current(), outgoingRequest, headerSetter );

이 단계가 있기 때문에 다음 서비스도 같은 trace를 이어받을 수 있다. 결국 분산 추적은:

  1. 들어온 헤더를 읽고
  2. 현재 span을 만들고
  3. 나갈 때 다시 헤더에 써 주는

이 반복으로 이어진다.

왜 워터폴처럼 보이는가

분산 추적 UI에서 가장 익숙한 화면은 워터폴이다. 그 이유는 각 서비스가 “언제 시작했고 언제 끝났는지”를 span 단위로 기록하기 때문이다. 백엔드가 span의 시작 시각, 종료 시각, parent-child 관계를 모두 모으면, UI에서는 그것을 시간축 위에 쌓아서 보여줄 수 있다.

예를 들어 하나의 요청이 아래처럼 흘렀다고 해보자.

다이어그램 로딩 중...

이런 화면이 있으면 “느린 서비스가 어디인지”를 감으로 보지 않고 바로 찾을 수 있다. 예를 들어 외부 인증 호출이 220ms를 쓰고 있는지, BFF에서 fan-out을 잘못해서 전체 응답 시간이 늘어났는지, 특정 서비스 내부에서 DB 조회가 병목인지가 훨씬 빨리 드러난다.

그래서 분산 추적은 단순히 trace ID를 남기는 기능이 아니라, 서비스 경계마다 시간이 어디에 쓰였는지를 시각적으로 복원하는 장치에 가깝다.

로그와 연결되면 더 강해진다

trace만 있으면 요청의 경로를 볼 수 있고, 로그만 있으면 구체적인 에러 메시지를 볼 수 있다. 실무에서 강력한 건 둘이 연결될 때다.

예를 들어 각 로그 라인에 traceparent나 trace ID를 함께 남기면:

  • trace 화면에서 느린 span을 찾고
  • 그 trace ID로 로그를 다시 검색하고
  • 같은 요청의 예외, 조건 분기, 외부 응답 값을 더 자세히 확인할 수 있다.

그래서 많은 팀이 구조화 로그에 trace 필드를 함께 남긴다. 분산 추적의 품질은 tracing backend만이 아니라, 로그와 얼마나 잘 연결되어 있는가에도 크게 좌우된다.

trace context는 프런트에서 시작해 큐까지 이어져야 한다

실무에서는 서비스 간 HTTP 호출만 추적한다고 끝나지 않는다. 많은 요청은 브라우저나 모바일 앱에서 시작해서, BFF를 거쳐 백엔드 서비스로 들어가고, 그 뒤에 이벤트 발행이나 메시지 큐 처리까지 이어진다. 이 흐름 전체가 하나의 사용자 행동에서 시작됐다면, 가능하면 같은 trace로 묶여야 원인을 따라가기 쉽다.

예를 들면 이런 식이다.

다이어그램 로딩 중...

즉 trace context는 동기 HTTP 호출에서만 의미가 있는 값이 아니다. 프런트엔드에서 시작한 요청이 백엔드 로직을 거쳐 메시지 브로커로 넘어가고, 나중에 워커가 그 메시지를 처리하는 흐름까지 이어져야 “이 사용자 요청이 결국 어떤 비동기 작업으로 이어졌는가”를 한 눈에 이해할 수 있다.

SQS 같은 비동기 경계에서는 HTTP 헤더 대신 메시지 attribute나 metadata에 trace context를 함께 실어 보내는 방식이 자주 쓰인다. 핵심은 transport가 무엇이든 context를 꺼내서 다음 처리 단계의 parent로 복원할 수 있어야 한다는 점이다.

수동 주입과 자동 계측은 무엇이 다른가

trace를 이어 붙이는 기본 원리는 같지만, 실제 구현 방식은 크게 둘로 나뉜다.

방식설명장점단점
수동 주입코드에서 직접 span 생성, inject/extract 수행동작을 세밀하게 제어 가능빠뜨리기 쉽고 반복 코드가 많음
자동 계측에이전트/라이브러리가 HTTP, DB, 메시징 경계를 자동 계측빠르고 일관됨세부 제어가 제한적일 수 있음

수동 주입은 예를 들어 HTTP 요청을 보내기 전에 직접 헤더를 넣거나, 메시지를 발행할 때 trace context를 attribute로 복사하는 식이다.

java
sample/manual-inject-java.java
Span span = tracer.spanBuilder("publish-order-event").startSpan(); try (Scope ignored = span.makeCurrent()) { propagators.getTextMapPropagator().inject( Context.current(), messageAttributes, (carrier, key, value) -> carrier.put(key, value) ); queueClient.send(messageAttributes, payload); } finally { span.end(); }

이 방식은 명확하지만, 서비스가 많아질수록 같은 보일러플레이트가 반복된다. 그래서 많은 팀이 가능한 구간은 자동 계측에 맡기고, HTTP가 아닌 특수한 메시징 경계나 비즈니스 이벤트 구간만 수동 계측으로 보강한다.

OpenTelemetry 에이전트를 쓰면 많은 부분이 자동으로 된다

OpenTelemetry 생태계에서는 언어별로 자동 계측을 위한 에이전트나 auto-instrumentation 패키지를 제공한다. 이 도구들은 웹 프레임워크 미들웨어만 건드리는 것이 아니라, HTTP 서버/클라이언트, DB 드라이버, ORM, 메시징 클라이언트, 클라우드 SDK 같은 경계 라이브러리에 훅을 걸어서 span 생성과 context propagation을 자동으로 수행한다.

즉 “헤더를 어떻게 심고 파싱할까”를 모든 코드에서 직접 구현하지 않아도, 공통적인 구간은 에이전트가 대신 처리해준다. 실무에서는 보통:

  • HTTP 서버 진입점
  • HTTP 클라이언트 호출
  • JDBC/ORM/DB 드라이버
  • Kafka producer/consumer 같은 메시징 클라이언트
  • AWS SDK처럼 SQS 호출을 감싸는 클라이언트 라이브러리

같은 지점을 자동 계측에 맡기고, 필요한 비즈니스 span만 수동으로 추가한다. 그래서 Kafka나 SQS 같은 메시징 경계도 지원되는 경우가 있다. 다만 여기서 주의할 점도 있다. “자동 계측이 존재한다”와 “내 서비스의 모든 비동기 전파가 자동으로 완벽하게 이어진다”는 같은 말이 아니다. 언어, 프레임워크, 클라이언트 라이브러리, 사용 방식에 따라 자동 전파 수준이 다를 수 있고, 특히 메시지 attribute에 context를 어떻게 담고 꺼낼지는 세부 설정이나 추가 계측이 필요한 경우도 있다.

OpenTelemetry 공식 GitHub:

관련 참고:

언어별 자동 계측 예시는 어떻게 생겼는가

Java

Java는 에이전트 방식이 가장 대표적이다. 애플리케이션 시작 시 Java agent를 붙이면 많은 프레임워크와 라이브러리를 zero-code에 가깝게 계측할 수 있다.

bash
sample/java-agent.sh
java \ -javaagent:/path/opentelemetry-javaagent.jar \ -Dotel.service.name=identity-service \ -Dotel.exporter.otlp.endpoint=http://otel-collector:4317 \ -Dotel.propagators=b3,b3multi,tracecontext \ -jar app.jar

공식 문서:

Java 쪽은 특히 지원 범위가 넓어서, Spring 같은 웹 프레임워크뿐 아니라 kafka-clients, AWS SDK 같은 라이브러리 계측도 함께 기대할 수 있다. 그래서 Kafka producer/consumer span이나 SQS 호출 span이 자동으로 잡히는 경우가 많다. 다만 메시지 context propagation은 사용 중인 라이브러리와 설정에 따라 세부 동작이 달라질 수 있다.

Node.js

Node.js는 auto-instrumentation 패키지와 preload 방식을 자주 쓴다.

bash
sample/node-agent.sh
npm install @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node NODE_OPTIONS="--require ./instrumentation.js" node app.js
js
sample/instrumentation.js
const { NodeSDK } = require('@opentelemetry/sdk-node') const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node') const sdk = new NodeSDK({ instrumentations: [getNodeAutoInstrumentations()] }) sdk.start()

공식 문서:

Python

Python은 opentelemetry-instrument 커맨드를 사용한 자동 계측이 비교적 단순하다.

bash
sample/python-agent.sh
pip install opentelemetry-distro opentelemetry-bootstrap -a install OTEL_SERVICE_NAME=recommendation-service \ opentelemetry-instrument python app.py

공식 문서:

언어별 도구 형태는 다르지만 공통 개념은 같다. 자동 계측 도구가 프레임워크 진입점과 주요 라이브러리를 감싸고, span 생성과 context propagation을 기본으로 처리해 준다.

제품 생태계는 어떻게 나뉘었는가

분산 추적 제품은 크게 오픈소스 계열과 상용 SaaS 계열로 나눠 볼 수 있다.

오픈소스 계열

제품성격메모
Zipkin초기 분산 추적 대표 주자B3 확산에 큰 영향
JaegerCNCF 계열 tracing backend자체 전파 형식도 있었음
PinpointNAVER가 만든 오픈소스 APM한국에서도 익숙한 도구
Grafana Tempo대규모 tracing backendOpenTelemetry, Jaeger, Zipkin 계열과 잘 연결됨

상용 제품 계열

제품강점메모
Datadogtrace, log, metric 통합 경험자체 에이전트 생태계가 강함
New RelicAPM과 분산 추적 통합여러 언어 에이전트 지원
Honeycomb고카디널리티 분석과 탐색 경험이벤트 중심 관측성으로 유명

이 제품들이 중요한 이유는 단순히 UI가 다르기 때문이 아니다. 각 제품은 각자의 에이전트, 헤더, ingestion 방식, query UX를 밀어 왔기 때문에, 실제 서비스 환경에서는 표준보다 특정 제품 도입 시점의 흔적이 더 오래 남기도 한다.

실무에서 더 중요한 것은 역사보다 호환 전략이었다

역사를 아는 것은 도움이 된다. 하지만 운영 환경에서 더 중요한 질문은 따로 있다.

  • 지금 우리 서비스는 어떤 propagation format을 읽는가
  • 우리와 연결된 레거시 서비스는 무엇을 보내는가
  • 신규 서비스는 무엇을 기본값으로 삼아야 하는가
  • 로그와 trace를 연결할 때 어떤 필드를 남겨야 하는가

이 질문에 답하지 못하면, 분산 추적은 “설정은 되어 있는데 막상 장애가 나면 안 보이는 기능”이 되기 쉽다.

실제로는 다음 순서로 정리하는 것이 가장 현실적이다.

현재 서비스가 읽는 propagation format을 확인한다

애플리케이션 설정, 에이전트 설정, 프레임워크 기본값을 먼저 확인한다.

연결된 서비스의 형식을 조사한다

레거시 서비스가 아직 B3인지, 신규 서비스가 tracecontext인지 파악한다.

다중 지원이 필요한 구간을 정한다

전체를 한 번에 바꾸기보다, trace가 자주 끊기는 경계부터 우선 정리한다.

신규 서비스는 TraceContext 중심으로 시작한다

새로 만드는 서비스는 W3C TraceContext를 기본으로 삼고, 필요한 경우에만 호환 레이어를 둔다.

결론

분산 추적은 Dapper에서 출발해 Zipkin, Jaeger, OpenTracing, OpenCensus, OpenTelemetry를 거치며 발전해 왔다. 이 과정에서 표준은 점차 W3C TraceContext 쪽으로 수렴했지만, 실제 서비스 환경은 그보다 훨씬 느리게 이동했다.

그래서 오늘날 b3,b3multi,tracecontext 같은 설정이 남아 있는 것은 이상한 일이 아니다. 오히려 그 설정은 레거시와 신규 서비스가 공존하는 환경에서 trace를 끊기지 않게 유지하기 위한 타협의 결과에 가깝다.

중요한 것은 “왜 표준이 하나가 아닌가”를 비난하는 것이 아니라, 우리 환경에서 어떤 형식이 어디서 오고 어디서 끊기는지 이해하는 것이다. 분산 추적의 품질은 도구 이름보다 그 연결 지점을 얼마나 잘 파악하고 있느냐에 더 크게 좌우된다.

참고 자료

Last updated on