스팬의 내부 구조와 종류, 부모-자식 관계, 샘플링 전략(Head/Tail/Rate)을 학습하고 Python으로 분산 추적을 직접 구현합니다.
**트레이스(Trace)**는 분산 시스템에서 하나의 요청이 여러 서비스를 거치는 전체 경로를 나타내는 방향성 비순환 그래프(DAG)입니다. 각 트레이스는 고유한 128비트 Trace ID로 식별되며, 이 ID가 서비스 간 전파되어 하나의 요청을 추적할 수 있게 합니다.
**스팬(Span)**은 트레이스를 구성하는 개별 작업 단위입니다. 하나의 스팬은 다음 필드를 포함합니다.
| 필드 | 설명 |
|---|---|
trace_id | 128비트 트레이스 식별자 |
span_id | 64비트 스팬 식별자 |
parent_span_id | 부모 스팬의 ID (루트 스팬은 비어 있음) |
name | 스팬 이름 (작업 설명) |
kind | 스팬 종류 (Client, Server, Internal 등) |
start_time | 시작 시각 (나노초 정밀도) |
end_time | 종료 시각 |
status | 상태 (Unset, Ok, Error) |
attributes | 키-값 쌍의 속성 |
events | 시간 기록이 있는 이벤트 (예외, 로그 등) |
links | 다른 스팬과의 인과 관계 |
from opentelemetry import trace
tracer = trace.get_tracer("order-service", "1.0.0")
with tracer.start_as_current_span("process-order") as span:
# 속성 추가
span.set_attribute("order.id", "ORD-2026-001")
span.set_attribute("order.total", 45000)
span.set_attribute("customer.tier", "premium")
# 이벤트 추가 (특정 시점의 기록)
span.add_event("payment-validated", {
"payment.method": "credit-card",
"payment.amount": 45000,
})
try:
process_payment()
except Exception as e:
# 에러 상태 기록
span.set_status(trace.StatusCode.ERROR, str(e))
span.record_exception(e)
raise스팬은 부모-자식 관계를 형성하여 트레이스의 계층 구조를 만듭니다. 최상위 스팬을 **루트 스팬(Root Span)**이라 하며, 전체 요청의 시작과 끝을 나타냅니다.
위 다이어그램에서 각 막대가 하나의 스팬이며, 들여쓰기가 부모-자식 관계를 나타냅니다. "GET /orders" 스팬이 루트이고, "process-order"가 자식, "validate-payment"과 "SELECT orders"가 손자 스팬입니다.
SpanContext는 스팬의 불변 식별 정보를 담는 객체로, 서비스 간 전파되는 핵심 데이터입니다.
from opentelemetry import trace
# 현재 활성 스팬의 컨텍스트 가져오기
current_span = trace.get_current_span()
ctx = current_span.get_span_context()
print(f"Trace ID: {format(ctx.trace_id, '032x')}")
print(f"Span ID: {format(ctx.span_id, '016x')}")
print(f"Trace Flags: {ctx.trace_flags}")
print(f"Is Remote: {ctx.is_remote}")일반적인 부모-자식 관계로 표현할 수 없는 인과 관계에는 **스팬 링크(Span Links)**를 사용합니다. 대표적인 예시는 메시지 큐를 통한 비동기 처리입니다.
from opentelemetry import trace
tracer = trace.get_tracer("consumer-service")
# 메시지 큐에서 받은 여러 메시지의 원본 스팬과 링크 생성
links = [
trace.Link(
context=producer_span_context_1,
attributes={"messaging.message.id": "msg-001"}
),
trace.Link(
context=producer_span_context_2,
attributes={"messaging.message.id": "msg-002"}
),
]
# 배치 처리 스팬에 링크 추가
with tracer.start_as_current_span("process-batch", links=links) as span:
span.set_attribute("messaging.batch.message_count", 2)
process_messages()OpenTelemetry는 다섯 가지 스팬 종류를 정의하여 스팬의 역할을 명확히 합니다.
| SpanKind | 설명 | 예시 |
|---|---|---|
CLIENT | 원격 서비스에 요청을 보내는 측 | HTTP 클라이언트, gRPC 클라이언트 |
SERVER | 원격 요청을 받아 처리하는 측 | HTTP 서버 핸들러, gRPC 서버 |
INTERNAL | 프로세스 내부 작업 | 비즈니스 로직, 데이터 변환 |
PRODUCER | 비동기 메시지를 보내는 측 | Kafka 프로듀서, RabbitMQ 퍼블리셔 |
CONSUMER | 비동기 메시지를 받는 측 | Kafka 컨슈머, 큐 워커 |
from opentelemetry.trace import SpanKind
# SERVER 스팬 -- 들어오는 HTTP 요청 처리
with tracer.start_as_current_span("GET /api/orders", kind=SpanKind.SERVER):
# INTERNAL 스팬 -- 비즈니스 로직
with tracer.start_as_current_span("validate-order", kind=SpanKind.INTERNAL):
validate()
# CLIENT 스팬 -- 외부 서비스 호출
with tracer.start_as_current_span("payment-api-call", kind=SpanKind.CLIENT):
call_payment_service()
# PRODUCER 스팬 -- 메시지 발행
with tracer.start_as_current_span("send-notification", kind=SpanKind.PRODUCER):
publish_to_queue()SpanKind는 백엔드 UI에서 트레이스를 시각화할 때 중요한 역할을 합니다. Jaeger나 Tempo는 CLIENT-SERVER 쌍을 인식하여 서비스 간 호출 관계를 자동으로 매핑합니다. 올바른 SpanKind 설정은 서비스 토폴로지 시각화의 정확성을 좌우합니다.
프로덕션 환경에서 모든 요청의 스팬을 100% 수집하면 저장 비용과 네트워크 부하가 급격히 증가합니다. **샘플링(Sampling)**은 수집할 트레이스를 선별하여 비용과 관측 가능성 사이의 균형을 맞추는 전략입니다.
Head Sampling은 트레이스가 시작되는 시점에 수집 여부를 결정합니다. 루트 스팬이 생성될 때 확률적으로 샘플링 여부가 정해지며, 이 결정이 모든 다운스트림 서비스로 전파됩니다.
from opentelemetry.sdk.trace.sampling import (
TraceIdRatioBased,
ParentBased,
)
# 10% 확률로 샘플링 (새로운 트레이스에만 적용)
sampler = ParentBased(
root=TraceIdRatioBased(0.1), # 루트 스팬: 10% 샘플링
# 부모가 샘플링된 경우 자식도 항상 샘플링
)장점: 구현이 간단하고 SDK 수준에서 동작하여 오버헤드가 최소입니다. 단점: 에러가 발생한 트레이스가 샘플링에서 제외될 수 있습니다. 중요한 트레이스를 놓칠 위험이 있습니다.
Tail Sampling은 트레이스가 완료된 후 전체 스팬 데이터를 분석하여 보존 여부를 결정합니다. 에러가 발생했거나 지연 시간이 긴 트레이스만 선별적으로 보존할 수 있습니다.
processors:
tail_sampling:
decision_wait: 10s
num_traces: 100000
policies:
# 에러가 있는 트레이스는 100% 보존
- name: errors-policy
type: status_code
status_code:
status_codes: [ERROR]
# 지연 시간이 500ms 이상인 트레이스 보존
- name: latency-policy
type: latency
latency:
threshold_ms: 500
# 나머지는 5% 확률 샘플링
- name: probabilistic-policy
type: probabilistic
probabilistic:
sampling_percentage: 5Tail Sampling은 반드시 OTel Collector에서 수행해야 하며, Gateway 모드로 배포된 단일 Collector가 특정 트레이스의 모든 스팬을 수신해야 합니다. 여러 Collector 인스턴스에 스팬이 분산되면 불완전한 트레이스로 잘못된 샘플링 결정이 내려질 수 있습니다. load_balancing 익스포터를 사용하여 Trace ID 기반 라우팅을 구성하세요.
장점: 중요한 트레이스를 확실히 보존합니다. 비용 효율적입니다. 단점: Collector에 메모리 부담이 큽니다. 모든 스팬을 일시적으로 버퍼링해야 합니다.
| 전략 | 결정 시점 | 실행 위치 | 비용 | 정확도 |
|---|---|---|---|---|
| Head Sampling | 트레이스 시작 시 | SDK | 낮음 | 낮음 |
| Tail Sampling | 트레이스 완료 후 | Collector | 높음 | 높음 |
| Rate Limiting | 초당 처리량 기준 | SDK/Collector | 낮음 | 중간 |
Flask 애플리케이션에 OpenTelemetry 분산 추적을 적용하는 실습을 진행합니다.
pip install opentelemetry-api \
opentelemetry-sdk \
opentelemetry-exporter-otlp-proto-grpc \
opentelemetry-instrumentation-flask \
opentelemetry-instrumentation-requests \
flask requestsfrom flask import Flask, jsonify
import requests
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.flask import FlaskInstrumentor
from opentelemetry.instrumentation.requests import RequestsInstrumentor
# SDK 초기화
resource = Resource.create({
"service.name": "order-service",
"service.version": "1.0.0",
"deployment.environment": "development",
})
provider = TracerProvider(resource=resource)
provider.add_span_processor(
BatchSpanProcessor(OTLPSpanExporter(endpoint="http://localhost:4317"))
)
trace.set_tracer_provider(provider)
# 자동 계측 활성화
app = Flask(__name__)
FlaskInstrumentor().instrument_app(app)
RequestsInstrumentor().instrument()
# 수동 계측을 위한 트레이서
tracer = trace.get_tracer("order-service.handlers", "1.0.0")
@app.route("/orders/<order_id>")
def get_order(order_id):
# 자동 계측: Flask가 SERVER 스팬을 생성
# 수동 계측: 비즈니스 로직에 커스텀 스팬 추가
with tracer.start_as_current_span("validate-order") as span:
span.set_attribute("order.id", order_id)
validate_order(order_id)
with tracer.start_as_current_span("fetch-order-details") as span:
span.set_attribute("order.id", order_id)
# 자동 계측: requests가 CLIENT 스팬을 생성
details = requests.get(
f"http://inventory-service:8081/items?order={order_id}"
)
span.set_attribute("inventory.item_count", len(details.json()))
return jsonify({"order_id": order_id, "status": "confirmed"})
def validate_order(order_id):
with tracer.start_as_current_span("check-inventory") as span:
span.set_attribute("order.id", order_id)
# 재고 확인 로직
span.add_event("inventory-checked", {"available": True})
if __name__ == "__main__":
app.run(port=8080)services:
order-service:
build: .
ports:
- "8080:8080"
environment:
- OTEL_SERVICE_NAME=order-service
- OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4317
depends_on:
- collector
collector:
image: otel/opentelemetry-collector-contrib:0.100.0
volumes:
- ./otel-collector-config.yaml:/etc/otelcol/config.yaml
ports:
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
depends_on:
- jaeger
jaeger:
image: jaegertracing/all-in-one:1.55
ports:
- "16686:16686" # Jaeger UI
- "4317" # OTLP gRPC (Jaeger 네이티브)receivers:
otlp:
protocols:
grpc:
endpoint: "0.0.0.0:4317"
processors:
batch:
timeout: 1s
send_batch_size: 1024
exporters:
otlp/jaeger:
endpoint: "jaeger:4317"
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlp/jaeger]트레이스를 효과적으로 활용하기 위한 실무 팁을 정리합니다.
스팬 이름은 카디널리티가 낮아야 합니다. 즉, 고유한 값(ID, 파라미터)을 이름에 포함하지 않습니다.
# 나쁜 예 -- 카디널리티가 높아 집계 불가
tracer.start_as_current_span(f"GET /users/{user_id}") # user_id마다 다른 스팬 이름
# 좋은 예 -- 패턴화된 이름 + 속성으로 상세 정보 분리
with tracer.start_as_current_span("GET /users/:id") as span:
span.set_attribute("user.id", user_id)with tracer.start_as_current_span("risky-operation") as span:
try:
result = perform_operation()
span.set_status(trace.StatusCode.OK)
except ValueError as e:
# 비즈니스 에러 -- 경고 수준
span.set_status(trace.StatusCode.ERROR, f"Validation failed: {e}")
span.record_exception(e)
return error_response(400, str(e))
except Exception as e:
# 시스템 에러 -- 심각
span.set_status(trace.StatusCode.ERROR, f"Internal error: {e}")
span.record_exception(e, attributes={
"exception.escaped": True,
})
raiserecord_exception 메서드는 스팬에 예외 이벤트를 자동 추가합니다. 예외 타입, 메시지, 스택 트레이스가 모두 기록되므로 별도의 로깅 없이도 충분한 디버깅 정보를 얻을 수 있습니다.
이번 장에서는 분산 추적의 핵심 구성 요소인 트레이스와 스팬을 심층적으로 살펴보았습니다. 스팬의 내부 구조, 부모-자식 관계, 다섯 가지 SpanKind의 역할을 학습했으며, Head Sampling과 Tail Sampling의 트레이드오프를 비교했습니다. Python Flask 애플리케이션에 자동 계측과 수동 계측을 적용하는 실습도 진행했습니다.
다음 장에서는 3대 신호의 두 번째인 메트릭(Metrics)을 다룹니다. Counter, Gauge, Histogram 등 메트릭 종류와 카디널리티 관리, 그리고 메트릭에서 트레이스로 연결하는 Exemplars를 학습합니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
OpenTelemetry 메트릭의 종류(Counter, Gauge, Histogram), 카디널리티 관리, Exemplars를 통한 메트릭-트레이스 연결, Prometheus 호환을 학습합니다.
OpenTelemetry의 API/SDK/Collector 3계층 구조, W3C TraceContext 기반 컨텍스트 전파, 리소스와 시맨틱 컨벤션, 배포 패턴을 심층적으로 분석합니다.
OpenTelemetry 로그 데이터 모델, 로그-트레이스 상관관계, 기존 로거 브릿지(Python logging, Go slog), 구조화 로그와 로그 레벨 전략을 학습합니다.