이코에코(Eco²) Knowledge Base/Applied
Distributed Tracing: Trace, Span으로 분산 시스템 추적하기
mango_fr
2025. 12. 25. 22:22
들어가며
분산 시스템에서 하나의 요청이 여러 서비스를 거쳐 처리된다.
문제가 발생했을 때 "어디서 느려졌는지", "어디서 에러가 났는지" 찾기가 매우 어렵다.
┌─────────────────────────────────────────────────────────────┐
│ 분산 시스템 디버깅의 어려움 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 사용자 요청: "스캔 결과가 안 와요" │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ API GW │──▶│ Scan API │──▶│ RabbitMQ │ │
│ └──────────┘ └──────────┘ └────┬─────┘ │
│ │ │
│ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Kafka │◀──│ Celery │──▶│Vision API│ │
│ └────┬─────┘ │ Worker │ └──────────┘ │
│ │ └──────────┘ │
│ ▼ │
│ ┌──────────┐ ┌──────────┐ │
│ │Character │──▶│ My │ │
│ │ Consumer │ │ Consumer │ │
│ └──────────┘ └──────────┘ │
│ │
│ 문제: │
│ • 로그가 각 서비스에 흩어져 있음 │
│ • 어떤 요청이 어디까지 갔는지 모름 │
│ • 병목이 어디인지 찾기 어려움 │
│ • "이 에러가 어떤 요청 때문인지?" 연결 불가 │
│ │
└─────────────────────────────────────────────────────────────┘
Distributed Tracing은 이 문제를 해결한다. 요청에 고유 ID를 부여하고, 모든 서비스가 이 ID를 전파하여 전체 흐름을 하나의 타임라인으로 시각화한다.
핵심 개념
Trace, Span, Context
┌─────────────────────────────────────────────────────────────┐
│ Trace, Span, Context │
├─────────────────────────────────────────────────────────────┤
│ │
│ Trace: 하나의 요청 전체 흐름 │
│ ───────────────────────────── │
│ trace_id: abc-123 │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Span A: API Gateway (parent=none) │ │
│ │ ├─────────────────────────────────────────────┤ │ │
│ │ │ 0ms 100ms│ │ │
│ │ │ │
│ │ Span B: Scan API (parent=A) │ │
│ │ ├───────────────────────────────────┤ │ │
│ │ │ 10ms 90ms│ │ │
│ │ │ │
│ │ Span C: RabbitMQ Publish (parent=B) │ │
│ │ ├─────────┤ │ │
│ │ │20ms 30ms│ │ │
│ │ │ │
│ │ Span D: Celery Task (parent=B) │ │
│ │ ├─────────────────────────────┤ │ │
│ │ │ 30ms 80ms│ │ │
│ │ │ │
│ │ Span E: Vision API (parent=D) │ │
│ │ ├───────────────┤ │ │
│ │ │ 35ms 60ms│ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Span: 하나의 작업 단위 │
│ • span_id: 고유 식별자 │
│ • parent_span_id: 부모 Span │
│ • operation_name: 작업 이름 │
│ • start_time, end_time: 시작/종료 시각 │
│ • attributes: 추가 정보 (user_id, task_id 등) │
│ • events: Span 내 이벤트 (로그) │
│ • status: OK, ERROR │
│ │
│ Context: trace_id + span_id + 전파 정보 │
│ • 서비스 간 전달되는 메타데이터 │
│ • HTTP Header, Kafka Header, Task Header로 전파 │
│ │
└─────────────────────────────────────────────────────────────┘
W3C Trace Context 표준
┌─────────────────────────────────────────────────────────────┐
│ W3C Trace Context │
├─────────────────────────────────────────────────────────────┤
│ │
│ HTTP Header로 Context 전파: │
│ │
│ traceparent: 00-{trace_id}-{span_id}-{flags} │
│ │ │ │ │ │
│ │ │ │ └─ 01=sampled │
│ │ │ └─ 현재 span_id (16자리) │
│ │ └─ trace_id (32자리) │
│ └─ version │
│ │
│ 예시: │
│ traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736- │
│ 00f067aa0ba902b7-01 │
│ │
│ tracestate: vendor1=value1,vendor2=value2 │
│ → 벤더별 추가 정보 (optional) │
│ │
│ 장점: │
│ • 업계 표준 (모든 벤더 지원) │
│ • 언어/프레임워크 독립적 │
│ • 자동 전파 라이브러리 풍부 │
│ │
└─────────────────────────────────────────────────────────────┘
OpenTelemetry 아키텍처
구성 요소
┌─────────────────────────────────────────────────────────────┐
│ OpenTelemetry Architecture │
├─────────────────────────────────────────────────────────────┤
│ │
│ Application │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐│ │
│ │ │ Traces │ │ Metrics │ │ Logs ││ │
│ │ │ (추적) │ │ (지표) │ │ (로그) ││ │
│ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘│ │
│ │ │ │ │ │ │
│ │ └────────────────┼────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ OpenTelemetry SDK │ │ │
│ │ │ │ │ │
│ │ │ • Auto-instrumentation (자동 계측) │ │ │
│ │ │ • Manual instrumentation (수동 계측) │ │ │
│ │ │ • Context Propagation (컨텍스트 전파) │ │ │
│ │ └──────────────────────┬──────────────────────┘ │ │
│ │ │ │ │
│ └──────────────────────────┼─────────────────────────┘ │
│ │ OTLP │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ OpenTelemetry Collector │ │
│ │ │ │
│ │ Receivers ──▶ Processors ──▶ Exporters │ │
│ │ (수신) (가공) (내보내기) │ │
│ └────────────────────┬────────────────────────────────┘ │
│ │ │
│ ┌───────────────┼───────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Jaeger │ │ Tempo │ │Prometheus│ │
│ │ (Trace) │ │ (Trace) │ │(Metrics) │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
SDK vs Collector
| 구성 요소 | 역할 | 위치 |
|---|---|---|
| SDK | 애플리케이션에서 데이터 수집 | 앱 내부 |
| Collector | 데이터 수신, 가공, 내보내기 | 별도 프로세스/사이드카 |
Auto-Instrumentation
# 자동 계측: 라이브러리 패치로 코드 수정 없이 추적
# 설치
# pip install opentelemetry-instrumentation-fastapi
# pip install opentelemetry-instrumentation-httpx
# pip install opentelemetry-instrumentation-celery
# pip install opentelemetry-instrumentation-kafka-python
# 자동 계측 활성화
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.celery import CeleryInstrumentor
FastAPIInstrumentor.instrument()
CeleryInstrumentor.instrument()
# 이후 모든 FastAPI 요청, Celery Task가 자동으로 추적됨
컨텍스트 전파 패턴
HTTP → HTTP
┌─────────────────────────────────────────────────────────────┐
│ HTTP → HTTP 전파 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Service A Service B │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ │ │ │ │
│ │ # 자동 전파 │ │ # 자동 추출 │ │
│ │ response = │ │ @app.get("/") │ │
│ │ httpx.get( │──Headers──▶│ def handler(): │ │
│ │ "http://b", │ │ # 자동으로 │ │
│ │ ) │ │ # trace 연결 │ │
│ │ │ │ │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
│ Headers: │
│ traceparent: 00-abc123...-def456...-01 │
│ │
│ OpenTelemetry SDK가 자동으로: │
│ • 요청 시: Header에 Context 주입 │
│ • 수신 시: Header에서 Context 추출 │
│ │
└─────────────────────────────────────────────────────────────┘
구조화 로깅과 통합
trace_id를 로그에 주입
┌─────────────────────────────────────────────────────────────┐
│ Trace-Log 상관관계 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 문제: 로그와 Trace가 분리되어 있음 │
│ ───── │
│ Trace: task-123이 느림 │
│ → 해당 task의 상세 로그를 찾으려면? │
│ → trace_id로 검색해야 함 │
│ │
│ 해결: 모든 로그에 trace_id 포함 │
│ ───── │
│ │
│ import structlog │
│ from opentelemetry import trace │
│ │
│ def add_trace_context(logger, method, event_dict): │
│ """로그에 trace_id, span_id 자동 추가""" │
│ span = trace.get_current_span() │
│ if span: │
│ ctx = span.get_span_context() │
│ event_dict["trace_id"] = format(ctx.trace_id, "032x")
│ event_dict["span_id"] = format(ctx.span_id, "016x")│
│ return event_dict │
│ │
│ structlog.configure( │
│ processors=[ │
│ add_trace_context, # ← 추가 │
│ structlog.processors.JSONRenderer(), │
│ ] │
│ ) │
│ │
│ 결과 로그: │
│ { │
│ "event": "Processing scan", │
│ "task_id": "abc-123", │
│ "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", │
│ "span_id": "00f067aa0ba902b7", │
│ "timestamp": "2025-12-19T10:00:00Z" │
│ } │
│ │
│ → Grafana에서 trace_id로 로그 검색 가능 │
│ → Trace 뷰에서 바로 관련 로그 확인 가능 │
│ │
└─────────────────────────────────────────────────────────────┘
EFK + Trace 연동

┌─────────────────────────────────────────────────────────────┐
│ EFK + Grafana Tempo 연동 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ │
│ │ Application │ │
│ │ │ │
│ │ Logs ───────┼───▶ Fluentd ───▶ Elasticsearch │
│ │ (trace_id) │ │ │
│ │ │ │ │
│ │ Traces ─────┼───▶ OTel Collector ───▶ Grafana Tempo │
│ │ │ │ │
│ └─────────────┘ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Grafana │ │
│ │ │ │
│ │ ┌─────────────────┐│ │
│ │ │ Trace View ││ │
│ │ │ ││ │
│ │ │ Span A ─────────││ │
│ │ │ Span B ───────││ │
│ │ │ Span C ─────││ │
│ │ │ ││ │
│ │ │ [View Logs] ────┼┼───┐ │
│ │ └─────────────────┘│ │ │
│ │ │ │ │
│ │ ┌─────────────────┐│ │ │
│ │ │ Logs (ES) │◀───┘ │
│ │ │ ││ │
│ │ │ trace_id로 필터 ││ │
│ │ └─────────────────┘│ │
│ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
시각화 도구
Jaeger

┌─────────────────────────────────────────────────────────────┐
│ Jaeger │
├─────────────────────────────────────────────────────────────┤
│ │
│ 특징: │
│ • CNCF 프로젝트 (졸업) │
│ • 독립 실행형 │
│ • 자체 스토리지 (Cassandra, Elasticsearch) │
│ • 분산 컨텍스트 전파 │
│ │
│ Kubernetes 배포: │
│ # Jaeger Operator 사용 │
│ apiVersion: jaegertracing.io/v1 │
│ kind: Jaeger │
│ metadata: │
│ name: eco2-jaeger │
│ spec: │
│ strategy: production │
│ storage: │
│ type: elasticsearch │
│ options: │
│ es: │
│ server-urls: http://elasticsearch:9200 │
│ │
└─────────────────────────────────────────────────────────────┘
비교
| 기준 | Jaeger | Grafana Tempo |
|---|---|---|
| 스토리지 | Cassandra/ES | Object Storage |
| 인덱싱 | 있음 | 없음 |
| 비용 | 높음 (인덱싱) | 낮음 |
| 검색 | 다양한 필터 | trace_id만 |
| Grafana 통합 | 플러그인 | 네이티브 |
| 권장 | 소규모, 독립 실행 | 대규모, Grafana 사용 중 |
참고 자료
부록: Eco² 적용 포인트
AI 파이프라인 전체 추적

┌─────────────────────────────────────────────────────────────┐
│ Eco² AI Pipeline Tracing │
├─────────────────────────────────────────────────────────────┤
│ │
│ Trace: scan-request-abc │
│ ───────────────────────────────────────────────────────── │
│ │
│ │ POST /scan (50ms) │
│ ├─ validate_request (5ms) │
│ ├─ create_task (10ms) │
│ ├─ publish_to_rabbitmq (5ms) │
│ │ │
│ │ celery.process_image (5000ms) │
│ ├─ vision_scan (2000ms) │
│ │ └─ call_vision_api (1900ms) │
│ ├─ rule_match (500ms) │
│ ├─ answer_gen (2500ms) │
│ │ └─ call_llm_api (2400ms) │
│ │ │
│ │ save_to_event_store (20ms) │
│ │ │
│ │ kafka.publish (10ms) │
│ │ │
│ │ character.consumer (100ms) │
│ ├─ handle_scan_completed (50ms) │
│ │ └─ grant_reward (30ms) │
│ └─ publish_character_granted (10ms) │
│ │
│ Total: 5180ms │
│ Bottleneck: vision_api (39%), llm_api (46%) │
│ │
└─────────────────────────────────────────────────────────────┘
OpenTelemetry 설정
# domains/_shared/observability/tracing.py
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.celery import CeleryInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
def setup_tracing(service_name: str):
"""OpenTelemetry 초기화"""
# 1. Resource 설정 (서비스 식별)
resource = Resource.create({
"service.name": service_name,
"service.namespace": "eco2",
"deployment.environment": settings.ENVIRONMENT,
})
# 2. TracerProvider 설정
provider = TracerProvider(resource=resource)
# 3. Exporter 설정 (OTLP → Collector)
exporter = OTLPSpanExporter(
endpoint=settings.OTEL_COLLECTOR_ENDPOINT,
insecure=True,
)
provider.add_span_processor(BatchSpanProcessor(exporter))
# 4. 전역 TracerProvider 설정
trace.set_tracer_provider(provider)
# 5. Auto-instrumentation
FastAPIInstrumentor.instrument()
CeleryInstrumentor.instrument()
HTTPXClientInstrumentor.instrument()
return trace.get_tracer(service_name)
# 사용
tracer = setup_tracing("scan-api")
Celery Task Tracing
# domains/scan/tasks/ai_pipeline.py
from opentelemetry import trace
from opentelemetry.propagate import inject, extract
tracer = trace.get_tracer(__name__)
@celery_app.task(bind=True, max_retries=3)
def process_image(self, task_id: str, image_url: str):
"""AI 파이프라인 (추적 포함)"""
# Celery headers에서 Context 추출 (CeleryInstrumentor가 자동 처리)
# 수동으로 하려면:
# context = extract(self.request.headers or {})
with tracer.start_as_current_span("vision_scan") as span:
span.set_attribute("task.id", task_id)
span.set_attribute("task.step", "vision")
result = vision_api.analyze(image_url)
span.set_attribute("vision.category", result["category"])
with tracer.start_as_current_span("rule_match") as span:
span.set_attribute("task.step", "rule")
rules = rule_engine.match(result)
with tracer.start_as_current_span("answer_gen") as span:
span.set_attribute("task.step", "answer")
answer = llm_api.generate({"vision": result, "rules": rules})
return {"classification": result, "answer": answer}
scan_id ↔ trace_id 매핑
# API 응답에 trace_id 포함
from opentelemetry import trace
@app.post("/scan")
async def create_scan(request: ScanRequest):
span = trace.get_current_span()
trace_id = format(span.get_span_context().trace_id, "032x")
task = await scan_service.create(request)
return {
"task_id": task.id,
"status": "processing",
"trace_id": trace_id, # ← 디버깅용
}
# 클라이언트가 trace_id를 받아 Grafana에서 조회 가능
AS-IS vs TO-BE
| 원칙 | AS-IS (gRPC) | TO-BE (Command-Event Separation) |
|---|---|---|
| 추적 범위 | gRPC 호출 단위 | 전체 요청 흐름 |
| Context 전파 | gRPC Metadata | W3C Trace Context |
| 로그 연결 | 수동 (request_id) | 자동 (trace_id) |
| 비동기 추적 | 불가 | Kafka/Celery 전파 |
| 시각화 | 없음 | Grafana Tempo |
| 병목 분석 | 로그 분석 | Span 타임라인 |