Skip to content
Published on

분산 트레이싱 완전 가이드 2025: OpenTelemetry, Jaeger, Tempo, Span 분석, 샘플링 전략

Authors

TL;DR

  • 분산 트레이싱 = 마이크로서비스 디버깅의 필수: 한 요청의 전체 흐름을 시각화
  • OpenTelemetry가 표준: CNCF 졸업, 모든 언어 지원, 벤더 중립
  • 3대 백엔드: Jaeger (Uber), Tempo (Grafana), Zipkin (Twitter)
  • Span 구조: trace_id + span_id + parent_id + 속성 + 이벤트
  • 샘플링이 핵심: 100% 저장은 비용 폭증 → Head/Tail/Adaptive 샘플링
  • W3C Trace Context: 서비스 간 trace 전달 표준 헤더

1. 분산 트레이싱이 필요한 이유

1.1 모놀리스 디버깅

요청 → [모놀리스 앱]
        ├─ Auth check
        ├─ DB query
        ├─ Cache lookup
        └─ Response

스택 트레이스 하나로 충분합니다. 모든 코드가 한 프로세스에.

1.2 마이크로서비스 디버깅의 악몽

요청 → [API Gateway]
       [Auth Service]
       [User Service]
        ↓ ↘
       [DB]  [Cache]
       [Email Service]
       [SMS Service]

문제:

  • 어떤 서비스가 느린가?
  • 에러는 어디서 시작되었나?
  • 네트워크 지연인가, 코드 문제인가?
  • 한 요청에 관련된 로그를 어떻게 모으나?

분산 트레이싱이 답입니다.

1.3 분산 트레이싱의 약속

Total: 1245ms
├─ API Gateway (5ms)
├─ Auth Service (50ms)
│   └─ JWT verify (45ms)
├─ User Service (1180ms) ⚠️
│   ├─ DB query (1100ms) 🔴 ← 병목!
│   └─ Cache lookup (5ms)
└─ Response (5ms)

즉시 보입니다: DB 쿼리가 1100ms. 인덱스 누락.


2. 트레이싱 핵심 개념

2.1 Trace

Trace = 한 요청의 전체 흐름. 고유 ID(trace_id)로 식별.

trace_id: abc123...
├─ Span A (root)
├─ Span B (child of A)
├─ Span C (child of B)
└─ Span D (child of A)

2.2 Span

Span = 트레이스의 단위 작업.

필수 필드:

  • span_id: 고유 ID
  • trace_id: 속한 trace
  • parent_span_id: 부모 span (없으면 root)
  • name: 작업 이름 (예: "HTTP GET /users")
  • start_time, end_time
  • status: OK / ERROR
  • attributes: key-value 메타데이터

선택 필드:

  • events: 시점 이벤트 (예: "cache miss")
  • links: 다른 span과의 관계
  • kind: SERVER, CLIENT, PRODUCER, CONSUMER, INTERNAL

2.3 Span 예시

{
  "trace_id": "abc123def456...",
  "span_id": "789xyz...",
  "parent_span_id": "456abc...",
  "name": "GET /api/users/123",
  "start_time": "2025-04-15T10:00:00.000Z",
  "end_time": "2025-04-15T10:00:00.150Z",
  "duration_ms": 150,
  "status": { "code": "OK" },
  "attributes": {
    "http.method": "GET",
    "http.url": "/api/users/123",
    "http.status_code": 200,
    "user.id": "123",
    "db.query.count": 3
  },
  "events": [
    {
      "name": "cache_miss",
      "timestamp": "2025-04-15T10:00:00.020Z"
    }
  ]
}

2.4 Context Propagation

서비스 간 trace를 어떻게 전달?

W3C Trace Context 표준 헤더:

GET /api/users/123 HTTP/1.1
traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
tracestate: rojo=00f067aa0ba902b7,congo=t61rcWkgMzE

traceparent 형식: version-trace_id-parent_span_id-trace_flags

다음 서비스가 이 헤더를 받아 새 span을 만들고, 같은 trace_id로 자식 관계 설정.


3. OpenTelemetry — 표준화의 승리

3.1 OpenTelemetry란?

OTel = CNCF의 observability 프로젝트. 트레이스, 메트릭, 로그를 통합.

역사:

  • 2015: Google이 OpenCensus 출시
  • 2016: Uber/Lightstep이 OpenTracing 출시
  • 2019: 두 프로젝트 합병 → OpenTelemetry
  • 2021: CNCF Incubating
  • 2024: Trace/Metric GA (Stable)

3.2 왜 OTel이 표준이 되었나?

1. 벤더 중립

  • 코드는 OTel API만 사용
  • 백엔드는 Jaeger, Tempo, Datadog, New Relic 등 어디든 가능

2. 모든 언어 지원

  • Go, Java, Python, JS, C#, Ruby, PHP, Rust, Swift, ...

3. Auto-instrumentation

  • Java agent, Python decorators 등으로 코드 변경 없이 트레이싱

4. 단일 표준

  • 이전: 각 벤더마다 다른 SDK
  • 지금: OTel 하나로 모두 호환

3.3 OTel 아키텍처

[Application]
    (OTel SDK)
[Spans/Metrics/Logs]
    (OTLP)
[OpenTelemetry Collector]
    (export)
[Jaeger / Tempo / Datadog / ...]

OTel Collector: 데이터를 받고, 변환하고, 여러 백엔드로 전송.

3.4 코드 예시 (Node.js)

const { NodeSDK } = require('@opentelemetry/sdk-node')
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http')
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node')

const sdk = new NodeSDK({
  serviceName: 'my-service',
  traceExporter: new OTLPTraceExporter({
    url: 'http://localhost:4318/v1/traces'
  }),
  instrumentations: [getNodeAutoInstrumentations()]
})

sdk.start()

// 이제 HTTP, Express, MongoDB 등 모두 자동 트레이싱

3.5 수동 span 추가

const { trace } = require('@opentelemetry/api')

const tracer = trace.getTracer('my-service')

async function processOrder(orderId) {
  const span = tracer.startSpan('process_order')
  span.setAttribute('order.id', orderId)
  
  try {
    await chargePayment(orderId)
    span.addEvent('payment_charged')
    
    await updateInventory(orderId)
    span.addEvent('inventory_updated')
    
    span.setStatus({ code: SpanStatusCode.OK })
  } catch (error) {
    span.recordException(error)
    span.setStatus({ code: SpanStatusCode.ERROR, message: error.message })
    throw error
  } finally {
    span.end()
  }
}

4. 백엔드 비교 — Jaeger vs Tempo vs Zipkin

4.1 Jaeger — Uber 출신

특징:

  • Uber에서 시작 (2017), CNCF 졸업
  • Go로 작성
  • Cassandra/Elasticsearch 백엔드
  • 강력한 UI (검색, 비교, 종속성 그래프)

장점:

  • 성숙
  • 넓은 채택
  • OTel 호환

단점:

  • 스토리지 비용 (Elasticsearch)
  • 복잡한 설치

4.2 Tempo — Grafana 작품

특징:

  • Grafana Labs (2020)
  • Object storage 사용 (S3, GCS) → 매우 저렴
  • 인덱스 없이 trace_id로 직접 조회
  • Grafana 통합

장점:

  • 저렴 (S3 사용)
  • 단순 운영
  • Grafana Loki/Mimir와 자연스러운 통합

단점:

  • 풍부한 검색 어려움 (TraceQL로 개선 중)
  • Jaeger보다 신생

TraceQL 예시:

{ resource.service.name = "checkout" && duration > 1s }

4.3 Zipkin — 가장 오래된

특징:

  • Twitter (2012)
  • Java
  • MySQL/Cassandra/Elasticsearch

장점:

  • 가장 오래된, 안정
  • 단순

단점:

  • 새 기능 느림
  • 많은 도구가 Jaeger/Tempo로 이동 중

4.4 비교표

JaegerTempoZipkin
출신UberGrafanaTwitter
언어GoGoJava
스토리지Cassandra/ESObject StorageMySQL/ES
비용비쌈저렴보통
운영복잡단순단순
UI자체 + GrafanaGrafana자체
검색강력TraceQL (개선중)보통
OTel

4.5 클라우드 관리형

가격특징
Datadog APM비쌈강력, 상용 표준
New Relic비쌈풀스택
Honeycomb비쌈고차원 데이터 분석
Lightstep비쌈변경 분석
Grafana Cloud합리적Tempo 매니지드
AWS X-Ray보통AWS 통합

5. 샘플링 전략

5.1 왜 샘플링이 필요한가?

100% 저장의 비용:

  • 초당 10,000 요청 × 30일 = 26억 트레이스
  • 트레이스당 평균 5KB = 130 TB/월
  • 스토리지 + 처리 비용 폭증

해결: 샘플링 — 일부만 저장.

5.2 Head Sampling

요청 시작 시 결정.

# 10% 샘플링
if random.random() < 0.1:
    span = tracer.start_span(...)

장점:

  • 매우 단순
  • 결정 빠름
  • 컴퓨팅 비용 낮음

단점:

  • 에러 트레이스도 90% 버림
  • 흥미로운 트레이스를 놓침

5.3 Probabilistic Sampling

특정 비율로 일관되게 샘플링.

# OTel Collector
processors:
  probabilistic_sampler:
    sampling_percentage: 10

Trace ID 기반 hash: 같은 trace_id면 같은 결정 → 분산 환경에서도 일관성.

5.4 Tail Sampling

요청 완료 후 결정.

processors:
  tail_sampling:
    decision_wait: 10s
    policies:
      - name: error-traces
        type: status_code
        status_code: { status_codes: [ERROR] }
      - name: slow-traces
        type: latency
        latency: { threshold_ms: 1000 }
      - name: probabilistic
        type: probabilistic
        probabilistic: { sampling_percentage: 1 }

장점:

  • 에러는 100% 저장
  • 느린 트레이스는 100% 저장
  • 정상 트레이스는 1%만

단점:

  • 모든 span을 메모리에 임시 저장
  • 복잡

5.5 Adaptive Sampling

트래픽에 따라 자동 조정.

  • 트래픽 적음 → 높은 비율 (디버깅 정보 충분)
  • 트래픽 많음 → 낮은 비율 (비용 제어)

Datadog, Honeycomb이 자동 지원.

5.6 샘플링 베스트 프랙티스

정상 트래픽: 1%
느린 트레이스 (>1s): 100%
에러: 100%
새 endpoint: 100% (1주일)
중요 endpoint (/checkout): 10%

→ Tail sampling으로 위 정책 구현.


6. Auto-instrumentation

6.1 Java

java -javaagent:opentelemetry-javaagent.jar \
  -Dotel.service.name=my-app \
  -Dotel.exporter.otlp.endpoint=http://collector:4318 \
  -jar my-app.jar

자동 트레이싱:

  • HTTP 요청 (Servlet, Spring MVC)
  • DB (JDBC, Hibernate)
  • 메시징 (Kafka, RabbitMQ)
  • gRPC
  • 100+ 라이브러리

6.2 Python

pip install opentelemetry-distro opentelemetry-exporter-otlp
opentelemetry-bootstrap --action=install
opentelemetry-instrument python my_app.py

자동으로 Flask, Django, FastAPI, requests, SQLAlchemy 등 트레이싱.

6.3 Node.js

node --require @opentelemetry/auto-instrumentations-node/register my-app.js

또는 코드:

require('@opentelemetry/auto-instrumentations-node/register')
// 이후 모든 import는 자동 트레이싱됨

6.4 Go

Go는 Auto-instrumentation이 어려움 (컴파일 언어). 명시적 코드 필요:

import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/trace"
)

tracer := otel.Tracer("my-service")

func handleRequest(ctx context.Context) {
    ctx, span := tracer.Start(ctx, "handle_request")
    defer span.End()
    // ...
}

최신: eBPF 기반 auto-instrumentation 등장 (Beyla, Grafana).


7. 트레이스 분석

7.1 무엇을 봐야 하나?

1. Critical Path: 가장 긴 경로

  • 어떤 span이 시간을 가장 많이 잡아먹나?
  • 병렬화 가능한가?

2. Error Span: 빨간 span

  • 어디서 시작되었나?
  • 에러 메시지

3. 외부 호출: HTTP, DB, gRPC

  • 가장 느린 호출
  • 재시도 패턴

4. 시간 비교:

  • 평균 vs 이번 요청
  • 배포 전후

7.2 일반적 패턴

N+1 쿼리

parent_span (200ms)
├─ db_query (5ms)  ← user 정보
├─ db_query (5ms)  ← user 1의 posts
├─ db_query (5ms)  ← user 2의 posts
├─ db_query (5ms)  ← user 3의 posts
... (47 more)

→ JOIN 또는 batch query로 해결.

직렬 vs 병렬

parent_span (300ms)
├─ call_service_a (100ms)
├─ call_service_b (100ms)  ← 직렬!
└─ call_service_c (100ms)

Promise.all로 병렬화 → 100ms.

캐시 미스

parent_span (200ms)
├─ cache_check (5ms)        ← miss
└─ db_query (190ms)

→ 캐시 적중률 분석.

7.3 RED 메서드와 결합

Rate, Errors, Duration:

  • 트레이스 데이터를 메트릭으로 변환
  • "이 endpoint의 p99 latency" 계산

OTel Collector로 자동:

processors:
  spanmetrics:
    metrics_exporter: prometheus

→ Prometheus에 자동 메트릭 생성.


8. 비용 최적화

8.1 비용 driver

  1. 데이터 양: 초당 트레이스 수 × 평균 span 수
  2. 저장 기간: 7일 vs 30일 vs 90일
  3. 인덱스: 검색 가능한 필드
  4. 네트워크 egress: cloud → 다른 region

8.2 비용 절감 전략

1. 샘플링 강화

  • 정상: 0.1% → 매우 적은 비용
  • 에러/느림: 100% → 디버깅 가능

2. Cardinality 감소

  • 너무 많은 attribute 지양
  • user_id 같은 고유값을 attribute에 넣지 말 것

3. Object Storage (Tempo)

  • S3 사용 → Elasticsearch 대비 90% 절감

4. 로컬 처리

  • Collector에서 메트릭 추출
  • 트레이스 중 일부만 저장

5. 공급자 비교

  • 자체 호스팅 (Tempo/Jaeger) vs 매니지드 (Datadog)
  • 100억 span/월:
    • Datadog: $30,000+
    • Self-hosted Tempo on S3: $500

9. 실전 — 마이크로서비스 디버깅

9.1 시나리오

사용자 불만: "주문이 가끔 30초 걸려요."

9.2 트레이스 검색

{ resource.service.name = "checkout" && duration > 5s }

→ 100개 트레이스 발견.

9.3 패턴 분석

공통점:

  • 모두 결제 service에서 시간 소요
  • payment.gateway.call span이 매우 느림 (15-25s)

9.4 깊이 들어가기

checkout (28s)
├─ validate (10ms)
├─ inventory (50ms)
└─ payment (27s)
    ├─ db_save (10ms)
    └─ stripe_api_call (26.9s)!!
        └─ http_retry (3 attempts, 9s each)

발견: Stripe 호출이 매번 timeout, 3번 재시도.

9.5 추가 조사

OTel attributes:

"http.url": "https://api.stripe.com/v1/charges",
"http.status_code": 0,  ← timeout
"http.error": "EAI_AGAIN"

→ DNS 문제. checkout 서비스의 /etc/resolv.conf가 잘못됨.

9.6 해결

DNS 수정 후 재배포. 트레이스로 확인:

checkout (1.2s)└─ payment (800ms)
    └─ stripe_api_call (750ms)  ← 정상

시간 절약: 분산 트레이싱 없이는 며칠이 걸렸을 디버깅.


10. 베스트 프랙티스

10.1 좋은 Span 이름

db_querySELECT users by id

http_requestGET /api/users/{id}

카디널리티 제어: 변수는 attribute로, 이름은 패턴으로.

10.2 의미 있는 attributes

span.set_attribute("user.id", user_id)  # 개별 ID
span.set_attribute("user.tier", "premium")  # 분류
span.set_attribute("db.statement", query)  # 정보

OTel Semantic Conventions 따르기:

  • http.method, http.url, http.status_code
  • db.system, db.statement
  • messaging.system, messaging.destination

10.3 에러 기록

try:
    do_something()
except Exception as e:
    span.record_exception(e)
    span.set_status(Status(StatusCode.ERROR, str(e)))
    raise

10.4 너무 많은 span 지양

작은 함수마다 span 만들면 → 노이즈. 의미 있는 작업 단위로.

10.5 보안 — 민감 정보 제외

password, credit_card, api_key를 attribute에 ✅ 마스킹된 값 또는 ID만

10.6 트레이스 + 로그 + 메트릭

같은 trace_id로 연결:

import logging
from opentelemetry import trace

current_span = trace.get_current_span()
ctx = current_span.get_span_context()

logger.info("Order processed", extra={
    "trace_id": format(ctx.trace_id, "032x"),
    "span_id": format(ctx.span_id, "016x"),
    "order_id": order_id
})

→ 로그에서 trace_id 검색 → 전체 흐름 시각화.


퀴즈

1. OpenTelemetry가 표준이 된 이유는?

: (1) 벤더 중립 — 코드는 OTel API만 사용하고, 백엔드는 어떤 벤더든 가능, (2) 모든 언어 지원 — Go, Java, Python, JS, C# 등 10+ 언어, (3) Auto-instrumentation — Java agent, Python decorators로 코드 변경 없이 트레이싱, (4) CNCF 졸업 — 클라우드 네이티브 표준, (5) OpenCensus + OpenTracing 합병 — 두 주요 프로젝트가 통합. 결과: 모든 옵저버빌리티 도구(Jaeger, Tempo, Datadog, New Relic)가 OTel 호환.

2. Head Sampling vs Tail Sampling?

: Head: 요청 시작 시 결정 (예: 10% 확률). 단순하지만 에러 트레이스도 90% 버림. Tail: 요청 완료 후 결정. 정책 가능: "에러는 100%, 느린 것 100%, 정상은 1%". 디버깅에 훨씬 유용. 단점: 모든 span을 메모리에 임시 저장 → 비용. 대규모 시스템은 Tail Sampling이 표준이지만, 단순 환경은 Head로 충분합니다.

3. Tempo가 Jaeger보다 저렴한 이유는?

: Object storage 사용. Jaeger는 Cassandra 또는 Elasticsearch (검색 인덱스 필요 → 비쌈). Tempo는 S3/GCS에 trace를 직접 저장 — 인덱스 없음. trace_id로만 직접 조회 가능. 검색 약한 대신 저장 비용 90% 절감. TraceQL이 개선 중. 100억 span/월: Datadog 30,000+vsSelfhostedTempoonS330,000+ vs Self-hosted Tempo on S3 500.

4. W3C Trace Context란?

: 서비스 간 trace 정보를 전달하는 표준 HTTP 헤더. traceparent 헤더에 version-trace_id-parent_span_id-trace_flags 형식으로 전달. 예: traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01. 이전에는 벤더마다 다른 헤더(X-B3-TraceId, X-Datadog-Trace-Id)였지만, W3C 표준으로 통일. OTel, Jaeger, Tempo, Datadog 모두 지원.

5. 분산 트레이싱의 핵심 가치는?

: 마이크로서비스 환경에서 한 요청의 전체 흐름을 시각화합니다. 모놀리스에서는 스택 트레이스 하나로 충분하지만, 마이크로서비스에서는 어떤 서비스가 느린지, 어디서 에러가 시작되었는지 알기 어렵습니다. 트레이스를 통해: (1) 병목 즉시 발견 (DB 쿼리가 1100ms), (2) 에러 원인 추적, (3) 서비스 종속성 파악, (4) 성능 최적화 우선순위. 분산 트레이싱 없이는 디버깅에 며칠 걸리는 문제가 분 단위로 해결됩니다.


참고 자료