마이크로서비스가 열 개를 넘어가는 순간, “이 요청이 왜 느린가”라는 질문에 답하기가 급격히 어려워집니다. 게이트웨이에서 결제·재고·알림 서비스를 거쳐 데이터베이스까지 이어지는 호출 경로를 로그만으로 재구성하는 것은 사실상 불가능합니다. 분산 트레이싱은 하나의 요청에 트레이스 ID를 부여하고, 각 구간을 스팬(span)으로 쪼개어 전체 경로를 나무 구조로 이어 붙입니다.

이 파이프라인이 작동하려면 트레이스 ID와 스팬 컨텍스트가 서비스 경계를 넘어 정확히 전파되어야 합니다. 여기서 대부분의 트레이싱 도입이 무너집니다. 컨텍스트가 한 번 끊기면 그 지점부터 트레이스는 조각나고, “고아 스팬(orphan span)”이 흩어집니다. 이 글은 OpenTelemetry(OTel)를 기준으로 컨텍스트 전파가 어떻게 동작하는지, 어디서 끊기는지, 이를 견고하게 설계하는 방법을 실무 관점에서 다룹니다.

트레이스·스팬·컨텍스트의 관계

OTel의 데이터 모델은 세 층입니다. 트레이스는 하나의 논리적 요청 전체이고, 그 안의 각 작업 단위가 스팬입니다. 스팬은 트레이스 ID, 스팬 ID(자기 자신), 부모 스팬 ID를 가지며, 이 부모-자식 관계가 나무를 구성합니다.

스팬 컨텍스트(SpanContext)는 이 나무를 이어 붙이는 최소 식별 정보입니다. 트레이스 ID(16바이트), 스팬 ID(8바이트), 트레이스 플래그(1바이트), 벤더 확장용 trace state로 구성됩니다. 핵심은 스팬 자체가 아니라 스팬 컨텍스트만 전파된다는 점입니다. 스팬의 이름·속성·이벤트는 로컬에 남고 익스포터를 통해 백엔드로 흘러갈 뿐, 네트워크 호출에 실려 가는 것은 이 작은 식별자뿐입니다.

  • 트레이스 ID: 요청 하나에 대해 전 구간 동일. 서로 다른 서비스의 스팬을 한 트레이스로 묶는 열쇠
  • 스팬 ID: 각 스팬 고유. 다음 홉의 부모 스팬 ID가 됨
  • 트레이스 플래그: sampled 비트가 실려 전파되어 샘플링 결정이 전 구간에서 일관되게 유지됨

W3C Trace Context: 전파의 표준

컨텍스트를 어떤 형식으로 헤더에 실을지가 전파 포맷(propagator)의 문제입니다. 현재 사실상의 표준은 W3C Trace Context이며, HTTP 헤더 두 개를 사용합니다.

# traceparent 헤더 구조 (하이픈으로 4개 필드 구분)
# 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
#  │            │                                │              │
# 버전    트레이스 ID(32 hex)          부모 스팬 ID(16 hex)   플래그(01=sampled)

curl -v http://order-service/api/orders 
  -H "traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" 
  -H "tracestate: vendorA=t61rcWkgMzE,vendorB=00f067aa0ba902b7"

traceparent는 필수 컨텍스트를, tracestate는 벤더별 추가 정보를 담습니다. 과거에는 각 도구가 X-B3-TraceId 같은 자체 헤더(B3, Jaeger 등)를 썼기 때문에 도구가 섞인 환경에서 전파가 끊겼습니다. 이종 시스템 통합 시에는 composite propagator로 여러 포맷을 동시에 읽되 내보낼 때는 하나로 표준화하는 것이 안전합니다.

# composite propagator로 W3C + B3 동시 수용
from opentelemetry.propagate import set_global_textmap
from opentelemetry.propagators.composite import CompositePropagator
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
from opentelemetry.propagators.b3 import B3MultiFormat

# 수신 시 두 포맷 모두 파싱, 송신 시 등록 순서대로 주입
set_global_textmap(CompositePropagator([
    TraceContextTextMapPropagator(),  # W3C traceparent/tracestate
    B3MultiFormat(),                  # 레거시 B3 호환
]))

동기 HTTP 경계에서의 전파

가장 흔한 전파 경로는 HTTP 호출입니다. 계측 라이브러리가 자동으로 처리하지만 내부 동작을 이해해야 문제를 진단할 수 있습니다. 송신 측은 현재 컨텍스트를 헤더로 주입(inject)하고, 수신 측은 헤더에서 컨텍스트를 추출(extract)합니다.

# 수동 주입/추출 (자동 계측이 없는 커스텀 클라이언트 예시)
from opentelemetry import trace
from opentelemetry.propagate import inject, extract

tracer = trace.get_tracer(__name__)

# 송신 측: 현재 스팬 컨텍스트를 헤더에 주입
def call_downstream(url, payload):
    with tracer.start_as_current_span("http.client order->payment"):
        headers = {}
        inject(headers)                 # 활성 컨텍스트를 traceparent로 직렬화
        return http_post(url, payload, headers=headers)

# 수신 측: 헤더에서 컨텍스트 복원 후 자식 스팬 생성
def handle_request(request):
    ctx = extract(request.headers)      # traceparent → SpanContext
    with tracer.start_as_current_span("http.server /payment", context=ctx):
        process_payment(request)        # 부모는 상류 서비스의 스팬

핵심은 extract가 반환한 컨텍스트를 start_as_current_span의 context 인자로 명시적으로 넘겨야 한다는 점입니다. 이 연결을 빠뜨리면 서버 스팬은 부모 없는 새 트레이스의 루트가 되어 상류와 끊깁니다. 특정 프레임워크에서만 트레이스가 갈라진다면 미들웨어 순서나 헤더 대소문자 처리를 먼저 의심하세요.

비동기 경계: 메시지 큐에서 컨텍스트 잇기

HTTP는 계측이 성숙해 자동으로 잘 되지만, 메시지 큐를 넘는 순간 전파가 자주 끊깁니다. Kafka·RabbitMQ는 헤더를 지원하므로 프로듀서가 컨텍스트를 메시지 헤더에 실어 보내고 컨슈머가 복원하면 됩니다. 문제는 큐 구간의 스팬 관계입니다.

동기 호출과 달리 큐는 시간적으로 분리됩니다. 프로듀서 스팬은 발행 순간 끝나고 컨슈머 스팬은 한참 뒤에 시작됩니다. 이때 컨슈머 스팬을 프로듀서의 자식으로 만들면 대기 시간까지 부모 스팬 지속시간에 포함되어 왜곡됩니다. OTel은 이를 위해 스팬 링크(span link)를 제공합니다. 인과 관계는 표현하되 지속시간 종속은 만들지 않는 느슨한 연결입니다.

# Kafka 프로듀서: 컨텍스트를 메시지 헤더에 주입
def produce(topic, value):
    with tracer.start_as_current_span("kafka.produce", kind=trace.SpanKind.PRODUCER):
        carrier = {}
        inject(carrier)  # 헤더용 dict에 traceparent 직렬화
        kafka_headers = [(k, v.encode()) for k, v in carrier.items()]
        producer.send(topic, value=value, headers=kafka_headers)

# 컨슈머: 헤더에서 복원하되 링크로 연결 (자식 아님)
def consume(msg):
    carrier = {k: v.decode() for k, v in msg.headers}
    ctx = extract(carrier)
    link = trace.Link(trace.get_current_span(ctx).get_span_context())
    with tracer.start_as_current_span(
        "kafka.process", kind=trace.SpanKind.CONSUMER,
        links=[link],   # 대기 시간이 부모에 포함되지 않도록 링크 사용
    ):
        handle_message(msg.value)

배치 처리 컨슈머라면 하나의 처리 스팬이 여러 메시지에서 유래하므로, 각 메시지의 컨텍스트를 여러 개의 링크로 붙이는 것이 정확합니다. 단일 부모로는 표현할 수 없는 다대일 인과 관계를 링크가 담아냅니다.

스레드·비동기 런타임 안의 컨텍스트 전파

서비스 경계를 넘는 전파만큼 자주 놓치는 것이 프로세스 내부의 전파입니다. OTel은 활성 컨텍스트를 스레드 로컬에 저장하므로, 작업을 다른 스레드나 코루틴으로 넘기면 컨텍스트가 따라가지 않아 스팬이 끊깁니다.

# 스레드풀로 작업을 넘길 때 컨텍스트를 명시적으로 전달
from opentelemetry import context as otel_context

def run_in_pool(pool, fn, *args):
    captured = otel_context.get_current()      # 현재 활성 컨텍스트 캡처
    def wrapper():
        token = otel_context.attach(captured)  # 워커 스레드에 복원
        try:
            return fn(*args)
        finally:
            otel_context.detach(token)         # 반드시 원복 (누수 방지)
    return pool.submit(wrapper)

Go는 context.Context를 명시적으로 전달하는 관례 덕분에 덜하지만, 고루틴에 부모 ctx를 넘기지 않으면 동일하게 끊깁니다. 증상이 미묘한 것이 함정으로, 전체 트레이스는 멀쩡한데 백그라운드 작업 스팬만 새 트레이스로 튀어나갑니다. “왜 이 배치 스팬만 별도 트레이스로 잡히지”라는 의문이 들면 스레드 경계부터 점검하세요.

샘플링 결정과 컨텍스트의 일관성

모든 요청을 100% 저장하면 비용을 감당할 수 없으므로 샘플링이 필요합니다. 서비스마다 독립적으로 샘플링을 결정하면 어떤 서비스는 저장하고 어떤 서비스는 버려서 구멍 난 트레이스가 생깁니다.

해결책은 ParentBased 샘플러입니다. 루트에서 한 번 결정하고 그 결과(sampled 플래그)를 traceparent에 실어 전파하면 하류 서비스는 부모의 결정을 그대로 따릅니다. 트레이스가 전부 저장되거나 전부 버려지는 완결한 상태가 됩니다.

# 루트에서만 확률 결정, 하류는 부모 결정 상속
from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased

# 루트 스팬은 10% 확률, 부모가 있으면 부모의 sampled 플래그를 따름
sampler = ParentBased(root=TraceIdRatioBased(0.1))
# → "절반만 있는 트레이스" 문제 방지

더 정교한 요구(에러 난 트레이스는 무조건 저장, 나머지는 확률)에는 tail-based sampling이 필요합니다. 이는 SDK가 아니라 OTel Collector 단에서 모든 스팬이 도착한 뒤 트레이스 전체를 보고 결정합니다. 단 한 트레이스의 모든 스팬이 같은 Collector 인스턴스로 모여야 하므로, Collector를 여러 대로 확장한다면 트레이스 ID 기준 로드밸런싱을 앞에 두어야 합니다.

실무에서 컨텍스트가 끊기는 지점 점검표

트레이스가 조각나는 원인은 거의 정해져 있습니다. 새 서비스를 붙이거나 트레이스가 깨졌을 때 아래 순서로 점검하면 대부분 빠르게 해결됩니다.

  • propagator 불일치: 한쪽 W3C, 다른 쪽 B3만 이해. composite propagator로 통일
  • 프록시의 헤더 스트립: 게이트웨이가 traceparent를 제거. 헤더 allowlist 확인
  • 비동기 경계 미계측: 큐·스레드풀·스케줄러를 넘을 때 수동 주입/추출 누락
  • 미들웨어 순서: extract가 라우팅보다 먼저 실행되어야 서버 스팬이 부모 인식
  • time skew: 서버 간 시계 오차로 화면상 인과가 어긋남. NTP 동기화 필수
  • 샘플러 불일치: 하류가 독립 샘플링해 트레이스에 구멍. ParentBased로 통일
# 프록시가 트레이스 헤더를 전달하는지 즉석 검증 (반사 엔드포인트 사용)
curl -s http://gateway/echo 
  -H "traceparent: 00-$(openssl rand -hex 16)-$(openssl rand -hex 8)-01" 
  | grep -i traceparent
# 출력이 비어 있으면 게이트웨이가 헤더를 스트립하고 있다는 신호

마무리

분산 트레이싱의 가치는 계측 자체가 아니라 컨텍스트가 끊김 없이 이어질 때 비로소 실현됩니다. W3C Trace Context로 전파 포맷을 표준화하고, HTTP·메시지 큐·스레드 경계마다 주입과 추출을 빠짐없이 배치하며, ParentBased 샘플링으로 완결성을 지키는 것 — 이 세 가지가 견고한 트레이싱의 뼈대입니다. 도입 초기에는 “모든 곳에 스팬을 만드는” 것보다 “컨텍스트가 절대 끊기지 않는” 최소 경로를 먼저 완성하고 점진적으로 넓혀 가는 접근이 훨씬 빠르게 실효를 냅니다.

자주 묻는 질문

Q. 자동 계측을 켰는데도 트레이스가 서비스 경계에서 갈라집니다. 무엇을 먼저 봐야 하나요?

A. propagator 설정과 헤더 통과 여부를 순서대로 확인하세요. 두 서비스가 서로 다른 전파 포맷(한쪽 W3C, 한쪽 B3)을 쓰면 헤더는 오가도 파싱이 실패해 컨텍스트가 유실됩니다. 그다음 사이에 낀 게이트웨이가 traceparent를 스트립하는지 반사(echo) 엔드포인트로 검증하면 프록시 문제인지 애플리케이션 문제인지 구분됩니다.