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: 고유 IDtrace_id: 속한 traceparent_span_id: 부모 span (없으면 root)name: 작업 이름 (예: "HTTP GET /users")start_time,end_timestatus: OK / ERRORattributes: 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 비교표
| Jaeger | Tempo | Zipkin | |
|---|---|---|---|
| 출신 | Uber | Grafana | |
| 언어 | Go | Go | Java |
| 스토리지 | Cassandra/ES | Object Storage | MySQL/ES |
| 비용 | 비쌈 | 저렴 | 보통 |
| 운영 | 복잡 | 단순 | 단순 |
| UI | 자체 + Grafana | Grafana | 자체 |
| 검색 | 강력 | 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
- 데이터 양: 초당 트레이스 수 × 평균 span 수
- 저장 기간: 7일 vs 30일 vs 90일
- 인덱스: 검색 가능한 필드
- 네트워크 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.callspan이 매우 느림 (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_query
✅ SELECT users by id
❌ http_request
✅ GET /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_codedb.system,db.statementmessaging.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 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) 성능 최적화 우선순위. 분산 트레이싱 없이는 디버깅에 며칠 걸리는 문제가 분 단위로 해결됩니다.
참고 자료
- OpenTelemetry
- W3C Trace Context
- Jaeger
- Grafana Tempo
- Zipkin
- OTel Semantic Conventions
- Distributed Systems Observability — Cindy Sridharan
- Mastering Distributed Tracing — Yuri Shkuro
- Honeycomb Observability — Charity Majors blog
- SigNoz — 오픈소스 OTel 백엔드
- Beyla — eBPF 기반 auto-instrumentation
현재 단락 (1/419)
- **분산 트레이싱 = 마이크로서비스 디버깅의 필수**: 한 요청의 전체 흐름을 시각화