OpenTelemetry 실전 가이드 — Node.js·Python 트레이싱 설정, Jaeger 연동, 프로덕션 관측가능성 구축
OpenTelemetry(오픈텔레메트리)로 Node.js Express·Python FastAPI 서비스에 분산 트레이싱을 도입하는 실전 가이드. @opentelemetry/sdk-node 설치부터 자동 계측, Jaeger 시각화, OTel Collector 배포, Prometheus 메트릭 연동까지 단계별로 정리했다. 커스텀 스팬·속성 추가 코드와 흔한 설정 오류 5가지 트러블슈팅을 포함해 프로덕션 관측가능성 파이프라인을 처음부터 구성할 수 있다.
관측가능성이 없으면 무슨 일이 벌어지나 — 도입 전 현실
오픈텔레메트리 핵심 개념 — 트레이스·메트릭·로그의 삼각형

OTel 구성 요소 완전 해부 — API, SDK, 계측 라이브러리, Collector
Node.js Express 트레이싱 — SDK 설치와 자동 계측 설정
npm 패키지 설치# 기본 SDK와 자동 계측 npm install @opentelemetry/sdk-node npm install @opentelemetry/auto-instrumentations-node # OTLP 익스포터 (gRPC 방식 — Jaeger·Collector 전송용) npm install @opentelemetry/exporter-trace-otlp-grpc npm install @opentelemetry/exporter-metrics-otlp-grpc npm install @grpc/grpc-js
instrumentation.js — 계측 초기화 파일// instrumentation.js 'use strict'; const { NodeSDK } = require('@opentelemetry/sdk-node'); const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node'); const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-grpc'); const { OTLPMetricExporter } = require('@opentelemetry/exporter-metrics-otlp-grpc'); const { PeriodicExportingMetricReader } = require('@opentelemetry/sdk-metrics'); const { Resource } = require('@opentelemetry/resources'); const { SEMRESATTRS_SERVICE_NAME } = require('@opentelemetry/semantic-conventions'); const sdk = new NodeSDK({ resource: new Resource({ [SEMRESATTRS_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || 'my-service', }), traceExporter: new OTLPTraceExporter({ url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4317', }), metricReader: new PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter({ url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4317', }), exportIntervalMillis: 10_000, // 10초마다 메트릭 전송 }), instrumentations: [ getNodeAutoInstrumentations({ '@opentelemetry/instrumentation-http': { enabled: true }, '@opentelemetry/instrumentation-express': { enabled: true }, '@opentelemetry/instrumentation-pg': { enabled: true }, }), ], }); sdk.start(); process.on('SIGTERM', () => { sdk.shutdown().then(() => process.exit(0)); });
package.json — 계측 파일을 먼저 로드하는 시작 스크립트{ "scripts": { "start": "node --require ./instrumentation.js src/index.js", "dev": "nodemon --require ./instrumentation.js src/index.js" } }
OTEL_SERVICE_NAME 환경변수로 주입하고, Collector 주소를 OTEL_EXPORTER_OTLP_ENDPOINT로 지정한다. 코드에 주소를 하드코딩하지 않으면 환경별(개발·스테이징·프로덕션)로 설정만 바꿔 사용할 수 있다.
Python FastAPI 트레이싱 — opentelemetry-sdk 자동 계측 설정
requirements.txt — Python 의존성opentelemetry-sdk==1.25.0 opentelemetry-instrumentation-fastapi==0.46b0 opentelemetry-instrumentation-httpx==0.46b0 opentelemetry-instrumentation-sqlalchemy==0.46b0 opentelemetry-exporter-otlp-proto-grpc==1.25.0
telemetry.py — FastAPI 계측 초기화# telemetry.py import os 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, SERVICE_NAME from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor def setup_telemetry(app): resource = Resource.create({ SERVICE_NAME: os.getenv("OTEL_SERVICE_NAME", "fastapi-service"), }) exporter = OTLPSpanExporter( endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"), ) provider = TracerProvider(resource=resource) provider.add_span_processor(BatchSpanProcessor(exporter)) trace.set_tracer_provider(provider) FastAPIInstrumentor.instrument_app(app) HTTPXClientInstrumentor().instrument()
main.py — FastAPI 앱 진입점# main.py from fastapi import FastAPI from telemetry import setup_telemetry app = FastAPI() setup_telemetry(app) @app.get("/items/{item_id}") async def get_item(item_id: int): return {"item_id": item_id, "name": "예제 아이템"}
opentelemetry-instrumentation-django 패키지를 대신 사용한다.Jaeger 로컬 설치와 트레이스 시각화
docker-compose.yml — 로컬 Jaeger 구성version: '3.8' services: jaeger: image: jaegertracing/all-in-one:1.56 environment: - COLLECTOR_OTLP_ENABLED=true ports: - "16686:16686" # Jaeger UI - "4317:4317" # OTLP gRPC - "4318:4318" # OTLP HTTP restart: unless-stopped api: build: . environment: - OTEL_SERVICE_NAME=my-express-api - OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4317 depends_on: - jaeger ports: - "3000:3000"
http://localhost:16686에서 Jaeger UI를 확인할 수 있다. 앱을 실행하고 API를 몇 번 호출한 뒤 Jaeger UI에서 서비스명을 선택하면 트레이스 목록이 표시된다.
각 트레이스를 클릭하면 스팬 타임라인이 펼쳐진다. 어느 DB 쿼리가 느렸는지, 어느 외부 API 호출에서 지연이 생겼는지 막대 그래프로 한눈에 볼 수 있다. 마이크로서비스 환경이라면 여러 서비스를 가로지르는 전체 경로가 하나의 트레이스로 연결되어 표시된다.
Jaeger 1.56 버전부터 OTLP를 기본 지원하므로 별도 Collector 없이 앱이 직접 전송할 수 있다. 개발 초기에 빠르게 설정을 확인할 때 유용하다.커스텀 스팬과 속성 추가 — 비즈니스 맥락을 트레이스에 심는 법
Node.js — 커스텀 스팬과 속성 추가// Node.js 커스텀 스팬 예제 const { trace, SpanStatusCode } = require('@opentelemetry/api'); const tracer = trace.getTracer('payment-service'); async function processPayment(userId, amount) { return tracer.startActiveSpan('payment.process', async (span) => { try { // 비즈니스 속성 추가 span.setAttribute('user.id', userId); span.setAttribute('payment.amount', amount); span.setAttribute('payment.currency', 'KRW'); const result = await chargeCard(userId, amount); span.setAttribute('payment.transaction_id', result.transactionId); span.setStatus({ code: SpanStatusCode.OK }); return result; } catch (err) { span.recordException(err); span.setStatus({ code: SpanStatusCode.ERROR, message: err.message }); throw err; } finally { span.end(); } }); }
Python — 커스텀 스팬과 속성# Python 커스텀 스팬 예제 from opentelemetry import trace from opentelemetry.trace import StatusCode tracer = trace.get_tracer("payment-service") async def process_payment(user_id: str, amount: int): with tracer.start_as_current_span("payment.process") as span: span.set_attribute("user.id", user_id) span.set_attribute("payment.amount", amount) span.set_attribute("payment.currency", "KRW") try: result = await charge_card(user_id, amount) span.set_attribute("payment.transaction_id", result["id"]) span.set_status(StatusCode.OK) return result except Exception as e: span.record_exception(e) span.set_status(StatusCode.ERROR, str(e)) raise
recordException()으로 스택 트레이스를 스팬에 포함시키면 Jaeger에서 에러 상세를 바로 볼 수 있다. 별도로 에러 로그를 찾아다니지 않아도 된다.OTel Collector 프로덕션 배포 — 백프레셔·배치·샘플링 설정
otel-collector-config.yaml — 기본 Collector 설정receivers: otlp: protocols: grpc: endpoint: 0.0.0.0:4317 http: endpoint: 0.0.0.0:4318 processors: batch: timeout: 1s send_batch_size: 1024 memory_limiter: check_interval: 1s limit_mib: 512 spike_limit_mib: 128 # 헬스체크 요청은 트레이스에서 제외 filter/drop-health: traces: span: - 'attributes["http.route"] == "/health"' exporters: otlp/jaeger: endpoint: jaeger:4317 tls: insecure: true prometheus: endpoint: "0.0.0.0:8889" service: pipelines: traces: receivers: [otlp] processors: [memory_limiter, filter/drop-health, batch] exporters: [otlp/jaeger] metrics: receivers: [otlp] processors: [memory_limiter, batch] exporters: [prometheus]

Prometheus·Grafana 메트릭 파이프라인 연동하기
Node.js — 커스텀 카운터와 히스토그램// 커스텀 메트릭 예제 (Node.js) const { metrics } = require('@opentelemetry/api'); const meter = metrics.getMeter('payment-service'); // 카운터 — 결제 시도 횟수 누적 const paymentCounter = meter.createCounter('payment.total', { description: '총 결제 시도 횟수', }); // 히스토그램 — P50/P95/P99 레이턴시 분포 const paymentDuration = meter.createHistogram('payment.duration', { description: '결제 처리 시간 (밀리초)', unit: 'ms', }); async function processPayment(userId, amount) { const startTime = Date.now(); try { const result = await chargeCard(userId, amount); paymentCounter.add(1, { 'payment.method': 'card', status: 'success' }); return result; } catch (err) { paymentCounter.add(1, { 'payment.method': 'card', status: 'error' }); throw err; } finally { paymentDuration.record(Date.now() - startTime, { 'payment.method': 'card', }); } }
흔한 설정 오류 5가지와 해결 방법
원인: 계측 파일이 앱보다 늦게 로드됨. instrumentation.js가 express 모듈보다 먼저 로드돼야 한다. Node.js는
--require ./instrumentation.js 플래그를 쓰는 것이 가장 안전하다. express를 가져온 뒤 SDK를 초기화하면 자동 계측이 적용되지 않는다.
2. OTLP 연결 오류 — "No connection established"원인: Collector나 Jaeger가 아직 준비되지 않았거나 포트가 다름. gRPC 포트는 4317, HTTP 포트는 4318이다.
docker compose ps로 상태를 확인한 뒤 재시도한다. 방화벽이나 네트워크 정책이 포트를 막고 있을 수도 있다.
3. Python에서 비동기 스팬이 부모 없이 나온다원인: 비동기 컨텍스트를 제대로 이어받지 못한 경우다. FastAPI의 비동기 핸들러 안에서도 동기
with문으로 감싸면 컨텍스트가 올바르게 전파된다. 별도 태스크로 분기하는 경우는 컨텍스트를 명시적으로 전달해야 한다.
4. 메트릭이 Prometheus에 안 들어간다원인: Collector의 Prometheus 익스포터 포트를 Prometheus 스크랩 설정에 추가해야 함. prometheus.yml의 스크랩 설정에 Collector 주소를 추가한다. Grafana 데이터소스도 Prometheus URL을 올바르게 가리키는지 확인한다. 5. 프로덕션에서 메모리 사용량이 급증한다
원인: 스팬 처리기 큐가 넘침. 큐 크기와 전송 주기를 환경에 맞게 조정한다. Collector의 메모리 제한도 함께 설정해야 앱에 백프레셔가 제대로 전달된다. 샘플링 비율을 낮추는 것도 효과적이다.
참고 자료
- OpenTelemetry 공식 문서 — opentelemetry.io/docs (SDK 설치·API·Collector 설정 전체 레퍼런스)
- Node.js 시작 가이드 — opentelemetry.io/docs/languages/js (공식 Node.js SDK 튜토리얼)
- Python 시작 가이드 — opentelemetry.io/docs/languages/python (공식 Python SDK 튜토리얼)
- OTel Collector 공식 문서 — opentelemetry.io/docs/collector (수신자·처리기·익스포터 전체 컴포넌트 레퍼런스)
- Jaeger 공식 문서 — jaegertracing.io (설치·배포·쿼리 레퍼런스)
FAQ — 자주 묻는 질문
오픈텔레메트리와 Jaeger, Zipkin은 어떤 관계인가?
Jaeger와 Zipkin은 분산 트레이싱 백엔드다. 트레이스 데이터를 저장하고 시각화하는 도구다. 오픈텔레메트리는 이 백엔드에 데이터를 보내는 SDK와 프로토콜이다. 예전에는 Jaeger를 쓰면 Jaeger 전용 클라이언트를 코드에 심었다. 이제는 오픈텔레메트리 SDK를 심고, Collector 설정으로 Jaeger·Zipkin·Datadog 어디든 보낼 수 있다. 코드를 바꾸지 않아도 되는 것이 핵심 장점이다.
Collector 없이 앱에서 Jaeger에 직접 보내도 되나?
개발 환경에서는 가능하다. Jaeger 1.56 이상은 OTLP를 직접 수신하므로 별도 Collector 없이 앱이 직접 전송할 수 있다. 그러나 프로덕션에서는 Collector를 두는 것이 좋다. 백엔드를 교체할 때 앱 재배포 없이 Collector 설정만 바꾸면 되고, 샘플링·필터링·변환을 Collector에서 처리할 수 있어 앱 코드가 단순해진다.
샘플링은 어떻게 설정하나? 모든 요청을 저장하면 비용이 크다.
오픈텔레메트리는 두 가지 샘플링을 지원한다. 헤드 샘플링은 요청 시작 시 비율로 결정한다. SDK 설정에서 비율 기반 샘플러를 적용하면 전체 트레이스의 일정 비율만 수집한다. 꼬리 샘플링은 요청이 끝난 뒤 에러 여부나 레이턴시 기준으로 결정한다. Collector에서 처리하며, 에러 요청 100% 저장, 정상 요청 10% 저장이 일반적인 패턴이다.
쿠버네티스 환경에서 OTel Collector를 어떻게 배포하나?
두 가지 방식이 있다. DaemonSet으로 노드마다 Collector를 두거나, Deployment로 클러스터 공유 Collector를 둔다. CNCF가 배포한 OpenTelemetry Operator를 쓰면 커스텀 리소스로 Collector를 선언적으로 관리할 수 있다. 사이드카 모드도 지원해 파드마다 Collector 컨테이너를 자동으로 주입한다. 처음 도입하거나 트래픽이 많지 않으면 Deployment 하나로 시작하는 것이 간단하다.
오픈텔레메트리 도입 후 성능 오버헤드가 걱정된다.
오픈텔레메트리 SDK는 비동기 방식으로 스팬을 처리한다. 스팬 생성과 전송이 별도 스레드에서 이루어져 요청 처리 경로에 영향이 거의 없다. 실제 운영 환경 측정 기준으로 CPU 사용률 증가가 1~3% 수준이다. 샘플링 비율을 낮추면 오버헤드도 함께 줄어든다. 자동 계측 라이브러리 중 불필요한 것은 비활성화하는 것도 방법이다.
로그와 트레이스를 연결하는 방법은?
활성 스팬의 트레이스 ID와 스팬 ID를 로그에 주입하면 된다. Node.js에서는 현재 활성 스팬에서 컨텍스트 정보를 가져와 로거 필드로 추가한다. Python은 현재 스팬 컨텍스트를 가져와 structlog나 logging 핸들러에 추가한다. Grafana Loki와 Tempo를 함께 쓰면 로그 화면에서 해당 트레이스로 직접 이동하는 연결이 지원된다.