- Published on
OpenTelemetry 분산 트레이싱 실전 가이드 — 계측부터 Jaeger 시각화까지
- Authors
- Name

들어가며
마이크로서비스 환경에서 하나의 사용자 요청은 수십 개의 서비스를 거칩니다. 어디서 병목이 발생하는지, 어떤 서비스가 느린지 파악하려면 분산 트레이싱이 필수입니다.
**OpenTelemetry(OTel)**는 CNCF의 Observability 표준으로, Traces, Metrics, Logs를 통합하는 벤더 중립적 프레임워크입니다.
핵심 개념
Trace, Span, Context
Trace (전체 요청의 여정):
├── Span A: API Gateway (50ms)
│ ├── Span B: Auth Service (10ms)
│ ├── Span C: Order Service (35ms)
│ │ ├── Span D: Database Query (15ms)
│ │ └── Span E: Payment Service (18ms)
│ │ └── Span F: Bank API Call (12ms)
│ └── Span G: Notification Service (5ms, async)
# Span의 구조
{
"traceId": "abc123def456...", # 전체 Trace 고유 ID (128-bit)
"spanId": "span789...", # 이 Span 고유 ID (64-bit)
"parentSpanId": "parent456...", # 부모 Span ID
"name": "POST /api/orders", # Span 이름
"kind": "SERVER", # CLIENT, SERVER, PRODUCER, CONSUMER, INTERNAL
"startTime": "2026-03-03T12:00:00Z",
"endTime": "2026-03-03T12:00:00.050Z",
"status": {"code": "OK"},
"attributes": { # 메타데이터
"http.method": "POST",
"http.url": "/api/orders",
"http.status_code": 201,
"service.name": "order-service"
},
"events": [ # Span 내 이벤트
{
"name": "order.validated",
"timestamp": "2026-03-03T12:00:00.010Z",
"attributes": {"order_id": "ORD-123"}
}
]
}
Context Propagation
서비스 간 컨텍스트 전파:
[Service A] [Service B]
│ │
│ traceparent: 00-abc123-span1-01
│ ─────────────────────────> │
│ │ (같은 traceId로 자식 Span 생성)
│ │
│ │ traceparent: 00-abc123-span2-01
│ │ ──────────> [Service C]
HTTP Header:
traceparent: 00-{traceId}-{spanId}-{flags}
예: traceparent: 00-abc123def456789-span12345678-01
Python 계측
자동 계측 (Zero-Code)
# 의존성 설치
pip install opentelemetry-distro opentelemetry-exporter-otlp
opentelemetry-bootstrap -a install # 자동 계측 패키지 설치
# 자동 계측으로 앱 실행
opentelemetry-instrument \
--service_name order-service \
--traces_exporter otlp \
--metrics_exporter otlp \
--exporter_otlp_endpoint http://otel-collector:4317 \
python app.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.semconv.resource import ResourceAttributes
# 1. TracerProvider 설정
resource = Resource.create({
ResourceAttributes.SERVICE_NAME: "order-service",
ResourceAttributes.SERVICE_VERSION: "1.2.0",
ResourceAttributes.DEPLOYMENT_ENVIRONMENT: "production",
})
provider = TracerProvider(resource=resource)
processor = BatchSpanProcessor(
OTLPSpanExporter(endpoint="http://otel-collector:4317")
)
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)
# 2. Tracer 생성
tracer = trace.get_tracer("order-service", "1.2.0")
# 3. Span 생성 및 사용
@tracer.start_as_current_span("create_order")
def create_order(order_data: dict) -> dict:
span = trace.get_current_span()
# 속성 추가
span.set_attribute("order.customer_id", order_data["customer_id"])
span.set_attribute("order.total_amount", order_data["total"])
span.set_attribute("order.item_count", len(order_data["items"]))
# 이벤트 추가
span.add_event("order.validation_started")
# 검증
validate_order(order_data)
span.add_event("order.validation_completed")
# 결제 처리 (자식 Span 자동 생성)
payment_result = process_payment(order_data)
# 결과 기록
span.set_attribute("order.id", payment_result["order_id"])
span.set_status(trace.StatusCode.OK)
return payment_result
@tracer.start_as_current_span("process_payment")
def process_payment(order_data: dict) -> dict:
span = trace.get_current_span()
span.set_attribute("payment.method", order_data.get("payment_method", "card"))
try:
result = payment_client.charge(order_data)
span.set_attribute("payment.transaction_id", result["txn_id"])
return result
except Exception as e:
span.set_status(trace.StatusCode.ERROR, str(e))
span.record_exception(e)
raise
FastAPI 통합
from fastapi import FastAPI, Request
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
app = FastAPI()
# 자동 계측 적용
FastAPIInstrumentor.instrument_app(app)
HTTPXClientInstrumentor().instrument() # 외부 HTTP 호출
SQLAlchemyInstrumentor().instrument(engine=db_engine) # DB 쿼리
@app.post("/api/orders")
async def create_order(request: Request, order: OrderRequest):
# 현재 Span에 비즈니스 컨텍스트 추가
span = trace.get_current_span()
span.set_attribute("order.customer_id", order.customer_id)
span.set_attribute("order.region", order.shipping_region)
result = await order_service.create(order)
return result
Java/Spring Boot 계측
Spring Boot 자동 설정
<dependency>
<groupId>io.opentelemetry.instrumentation</groupId>
<artifactId>opentelemetry-spring-boot-starter</artifactId>
<version>2.11.0</version>
</dependency>
# application.yml
otel:
service:
name: order-service
exporter:
otlp:
endpoint: http://otel-collector:4317
traces:
sampler: parentbased_traceidratio
sampler.arg: '0.1' # 10% 샘플링 (프로덕션)
@RestController
@RequiredArgsConstructor
public class OrderController {
private final Tracer tracer;
private final OrderService orderService;
@PostMapping("/api/orders")
public ResponseEntity<OrderResponse> createOrder(@RequestBody OrderRequest request) {
Span span = Span.current();
span.setAttribute("order.customer_id", request.getCustomerId());
span.setAttribute("order.total", request.getTotal().doubleValue());
OrderResponse response = orderService.create(request);
span.setAttribute("order.id", response.getOrderId());
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
}
// 수동 Span 생성
@Service
public class PaymentService {
@Autowired
private Tracer tracer;
public PaymentResult processPayment(Order order) {
Span span = tracer.spanBuilder("process_payment")
.setAttribute("payment.amount", order.getTotal().doubleValue())
.startSpan();
try (Scope scope = span.makeCurrent()) {
PaymentResult result = paymentGateway.charge(order);
span.setAttribute("payment.txn_id", result.getTransactionId());
return result;
} catch (Exception e) {
span.setStatus(StatusCode.ERROR, e.getMessage());
span.recordException(e);
throw e;
} finally {
span.end();
}
}
}
OTel Collector 구성
# otel-collector-config.yaml
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 5s
send_batch_size: 1024
send_batch_max_size: 2048
memory_limiter:
check_interval: 1s
limit_mib: 512
spike_limit_mib: 128
attributes:
actions:
- key: environment
value: production
action: upsert
tail_sampling:
decision_wait: 10s
policies:
- name: errors
type: status_code
status_code: { status_codes: [ERROR] }
- name: slow-traces
type: latency
latency: { threshold_ms: 1000 }
- name: probabilistic
type: probabilistic
probabilistic: { sampling_percentage: 10 }
exporters:
otlp/jaeger:
endpoint: jaeger:4317
tls:
insecure: true
otlp/tempo:
endpoint: tempo:4317
tls:
insecure: true
prometheus:
endpoint: 0.0.0.0:8889
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch, attributes, tail_sampling]
exporters: [otlp/jaeger]
metrics:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [prometheus]
Kubernetes 배포
apiVersion: apps/v1
kind: Deployment
metadata:
name: otel-collector
spec:
replicas: 2
selector:
matchLabels:
app: otel-collector
template:
spec:
containers:
- name: collector
image: otel/opentelemetry-collector-contrib:0.96.0
args: ['--config=/conf/config.yaml']
ports:
- containerPort: 4317 # gRPC
- containerPort: 4318 # HTTP
- containerPort: 8889 # Prometheus metrics
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: 1000m
memory: 512Mi
volumeMounts:
- name: config
mountPath: /conf
volumes:
- name: config
configMap:
name: otel-collector-config
---
apiVersion: v1
kind: Service
metadata:
name: otel-collector
spec:
selector:
app: otel-collector
ports:
- name: grpc
port: 4317
- name: http
port: 4318
샘플링 전략
from opentelemetry.sdk.trace.sampling import (
TraceIdRatioBased,
ParentBasedTraceIdRatio,
ALWAYS_ON,
ALWAYS_OFF,
)
# 개발 환경: 전부 수집
dev_sampler = ALWAYS_ON
# 프로덕션: 10% 샘플링 (부모 Span 결정 따름)
prod_sampler = ParentBasedTraceIdRatio(0.1)
# 커스텀 샘플러: 에러는 100%, 정상은 5%
class SmartSampler:
def should_sample(self, context, trace_id, name, kind, attributes, links):
# 에러 Span은 무조건 수집
if attributes and attributes.get("error") == True:
return SamplingResult(Decision.RECORD_AND_SAMPLE)
# 정상은 5%
if trace_id % 100 < 5:
return SamplingResult(Decision.RECORD_AND_SAMPLE)
return SamplingResult(Decision.DROP)
Jaeger UI 활용
# Docker로 Jaeger 실행
docker run -d --name jaeger \
-p 16686:16686 \
-p 4317:4317 \
-p 4318:4318 \
jaegertracing/all-in-one:1.62
# 브라우저에서 http://localhost:16686 접속
주요 기능:
1. Trace 검색
- Service 선택 → Operation 필터 → Duration 범위
- Tags 검색: http.status_code=500
2. Trace 타임라인 (Waterfall)
- 각 Span의 시작/종료 시간 시각화
- 병렬 처리와 순차 처리 구분
- 각 Span의 속성/이벤트/로그 상세 확인
3. Service Dependency Graph
- 서비스 간 호출 관계 시각화
- 호출 빈도와 에러율 표시
4. Compare
- 두 Trace 비교하여 성능 차이 분석
베스트 프랙티스
1. Semantic Conventions 사용
from opentelemetry.semconv.trace import SpanAttributes
# 표준 속성 사용 (벤더 간 호환)
span.set_attribute(SpanAttributes.HTTP_METHOD, "POST")
span.set_attribute(SpanAttributes.HTTP_URL, "/api/orders")
span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, 201)
span.set_attribute(SpanAttributes.DB_SYSTEM, "postgresql")
span.set_attribute(SpanAttributes.DB_STATEMENT, "SELECT * FROM orders")
2. 민감 정보 제거
# Collector에서 PII 제거
processors:
attributes:
actions:
- key: http.request.header.authorization
action: delete
- key: db.statement
action: hash # SQL을 해시로 대체
- key: user.email
action: delete
3. 에러 기록
try:
result = external_api.call()
except Exception as e:
span.set_status(trace.StatusCode.ERROR, str(e))
span.record_exception(e)
# record_exception은 자동으로 스택 트레이스 포함
raise
퀴즈
Q1. Trace, Span, Context의 관계는?
Trace는 하나의 요청 전체 경로, Span은 그 중 하나의 작업 단위, Context는 Trace/Span 정보를 서비스 간에 전파하는 메커니즘입니다.
Q2. W3C Trace Context의 traceparent 형식은?
{version}-{traceId}-{spanId}-{flags} 형식입니다. 예: 00-abc123...-span123...-01
Q3. OpenTelemetry Collector의 역할은?
Receive(수신) → Process(처리/필터링/샘플링) → Export(전송)의 파이프라인을 담당합니다. 애플리케이션과 백엔드(Jaeger, Tempo 등) 사이의 중간 계층입니다.
Q4. Tail Sampling vs Head Sampling의 차이는?
Head Sampling은 Trace 시작 시 수집 여부를 결정하고, Tail Sampling은 Trace 완료 후 전체 정보를 보고 결정합니다. Tail Sampling이 에러 Trace를 100% 수집하는 등 더 정밀한 제어가 가능합니다.
Q5. 프로덕션에서 샘플링률을 100%로 설정하면 안 되는 이유는?
Trace 데이터 볼륨이 매우 커서 스토리지 비용 증가, 네트워크 부하, 그리고 Collector 과부하가 발생합니다. 보통 5~10%가 적절합니다.
Q6. record_exception()과 set_status(ERROR)의 차이는?
record_exception()은 Span에 예외 이벤트(스택 트레이스 포함)를 기록하고, set_status(ERROR)는 Span의 상태를 에러로 표시합니다. 보통 둘 다 함께 사용합니다.
Q7. Semantic Conventions를 사용하는 이유는?
표준화된 속성 이름을 사용하면 다른 벤더의 백엔드(Jaeger, Datadog, Grafana 등)에서도 일관되게 Trace를 분석할 수 있습니다.
마무리
OpenTelemetry는 분산 시스템의 Observability 표준으로 자리잡았습니다. 자동 계측으로 빠르게 시작하고, 필요에 따라 수동 계측으로 비즈니스 컨텍스트를 추가하세요. OTel Collector를 중간 계층으로 두면 샘플링, 필터링, 다중 백엔드 전송 등 유연한 운영이 가능합니다.