
들어가며
마이크로서비스 환경에서 하나의 사용자 요청은 수십 개의 서비스를 거칩니다. 어디서 병목이 발생하는지, 어떤 서비스가 느린지 파악하려면 분산 트레이싱이 필수입니다.
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를 중간 계층으로 두면 샘플링, 필터링, 다중 백엔드 전송 등 유연한 운영이 가능합니다.
참고 자료
현재 단락 (1/321)
마이크로서비스 환경에서 하나의 사용자 요청은 수십 개의 서비스를 거칩니다. 어디서 병목이 발생하는지, 어떤 서비스가 느린지 파악하려면 **분산 트레이싱**이 필수입니다.