NestJS + OpenTelemetry로 마이크로서비스 분산 추적 구현하기 — Jaeger·Grafana Tempo 연동 실전 가이드
마이크로서비스로 전환하고 나서 처음 맞닥뜨리는 장벽이 뭔지 아시나요? 로그는 있는데 어떤 서비스에서 느려졌는지 모르겠고, 에러 메시지는 있는데 어디서 시작된 건지 추적이 안 되는 그 답답함입니다. 저도 처음 마이크로서비스 환경을 운영했을 때 장애 하나 잡으려고 여섯 개 서비스 로그를 Kibana에서 시간 맞춰 비교하며 반나절을 날린 적이 있어요.
OpenTelemetry(OTel)는 그 상황에서 근본 원인을 빠르게 특정할 수 있게 해줍니다. CNCF가 관리하는 오픈소스 관찰 가능성 프레임워크로, 분산 시스템에서 요청이 거치는 모든 경로를 하나의 Trace로 시각화해줍니다. 여러 서비스의 로그를 시간 맞춰 교차 비교하는 대신, 한 화면에서 요청이 어떤 서비스를 거쳤고 어디서 얼마나 걸렸는지를 바로 볼 수 있게 되는 거예요. 벤더 중립적인 표준이라 Jaeger, Datadog, Grafana Tempo 어디서든 백엔드를 교체할 수 있다는 점도 매력적이고요.
이 글을 끝까지 읽으시면 NestJS 서비스에 OTel을 붙여 로컬에서 Jaeger로 Trace를 직접 확인하는 것까지 완료할 수 있습니다. OTel 개념을 이미 아시는 분이라면 실전 적용 섹션으로 바로 이동하셔도 됩니다.
핵심 개념
Trace, Span, 그리고 Context Propagation
분산 추적의 작동 방식을 한 문장으로 요약하면 이렇습니다. 최초 서비스에서 요청이 들어올 때 고유한 trace_id가 생성되고, 다운스트림 서비스를 호출할 때마다 이 ID가 HTTP 헤더를 통해 함께 전달됩니다. 각 서비스는 자신이 처리한 범위를 Span으로 기록하고, 모든 Span이 하나의 Trace로 재조합되어 전체 경로가 보이는 구조입니다.
| 개념 | 설명 |
|---|---|
| Trace | 하나의 요청이 여러 서비스를 거치는 전체 여정. 고유한 trace_id로 식별 |
| Span | Trace를 구성하는 개별 작업 단위. 시작·종료 시간, 메타데이터(attributes), 상태(status) 포함 |
| Context Propagation | HTTP 헤더(traceparent, tracestate) 등을 통해 서비스 간 Trace ID와 Span ID를 전달하는 메커니즘 |
| OTel Collector | 앱에서 텔레메트리를 수신·변환·라우팅하는 중간 게이트웨이. 앱과 백엔드를 디커플링하는 허브 역할 |
| OTLP | OpenTelemetry Line Protocol. gRPC(고성능 바이너리 RPC 프레임워크) 또는 HTTP 기반의 표준 전송 포맷 |
Context Propagation이란? 서비스 A가 서비스 B를 HTTP로 호출할 때, 요청 헤더에
traceparent: 00-{trace_id}-{span_id}-01형태로 현재 컨텍스트를 심어 보내는 것을 말합니다. 서비스 B는 이 헤더를 읽어 같은 Trace에 자신의 Span을 이어붙입니다.
OTel Collector — 앱과 백엔드를 분리하는 허브
Collector가 없으면 앱이 직접 Jaeger나 Tempo에 데이터를 보내야 합니다. 백엔드를 교체하면 앱 코드도 바꿔야 하죠. Collector를 중간에 두면 앱은 항상 OTLP로만 내보내고, 어디로 라우팅할지는 Collector 설정 파일에서만 관리할 수 있습니다.
아래 예시는 YAML 설정 파일로 Collector 파이프라인을 구성하는 예시입니다.
# otel-config.yaml — Collector 파이프라인 구성
receivers:
otlp:
protocols:
grpc: {}
http: {}
processors:
batch: {}
# tail_sampling은 기본 otelcol 배포판에 없습니다
# otel/opentelemetry-collector-contrib 이미지를 사용해야 합니다
tail_sampling:
policies:
- name: errors-policy
type: status_code
status_code: { status_codes: [ERROR] }
exporters:
# jaeger exporter는 Jaeger 1.35 이후 레거시로, otlp 방식 권장
otlp/jaeger:
endpoint: "jaeger:4317"
tls:
insecure: true
otlp/tempo:
endpoint: "tempo:4317"
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch, tail_sampling]
exporters: [otlp/jaeger, otlp/tempo]Tail Sampling이란? Head-based 샘플링은 요청 진입 시점에 바로 수집 여부를 결정하지만, Tail-based는 모든 Span을 일단 수집한 뒤 전체를 보고 결정합니다. 에러나 슬로우 쿼리가 발생한 Trace를 놓치지 않으려면 Tail Sampling이 필요합니다. 단, 이 프로세서는 기본 배포판(
otelcol)이 아닌otelcontribcol(contrib 빌드)에만 포함되어 있으니 이미지 선택에 주의하세요.
개념을 살펴봤으니, 이제 실제 코드로 옮겨볼게요.
실전 적용
예시 1: NestJS 서비스에 OTel SDK 붙이기
솔직히 처음 셋업할 때 가장 많이 실수하는 부분이 초기화 순서입니다. OTel SDK는 반드시 NestJS 앱이 모듈을 로드하기 전에 초기화되어야 합니다. main.ts의 첫 줄에 import './tracing'을 넣어야 하는 이유가 바로 이것입니다.
먼저 패키지를 설치합니다. 버전을 고정해두는 게 나중에 API 변경으로 당황하는 일을 줄여줍니다.
pnpm add \
@opentelemetry/sdk-node@0.57.0 \
@opentelemetry/auto-instrumentations-node@0.57.0 \
@opentelemetry/exporter-trace-otlp-http@0.57.0 \
@opentelemetry/resources@1.30.0 \
@opentelemetry/semantic-conventions@1.30.0 \
@opentelemetry/api@1.9.0// tracing.ts — 이 파일은 main.ts보다 먼저 실행되어야 합니다
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { Resource } from '@opentelemetry/resources';
import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';
// SEMRESATTRS_SERVICE_NAME은 SDK 1.x에서 deprecated 예정입니다
// semantic-conventions 1.27+ 기준 ATTR_SERVICE_NAME으로 마이그레이션을 권장합니다
const sdk = new NodeSDK({
resource: new Resource({
[ATTR_SERVICE_NAME]: 'order-service',
}),
traceExporter: new OTLPTraceExporter({
url: 'http://otel-collector:4318/v1/traces',
}),
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
// 프로세스 종료 시 미전송 Span을 flush하고 SDK를 정상 종료합니다
// 이 처리가 없으면 마지막 요청의 Trace가 유실될 수 있습니다
process.on('SIGTERM', () => sdk.shutdown());
process.on('SIGINT', () => sdk.shutdown());// main.ts — import './tracing'이 반드시 첫 번째 줄이어야 합니다
import './tracing';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();| 코드 포인트 | 설명 |
|---|---|
getNodeAutoInstrumentations() |
Express, TypeORM, Prisma, Redis, HTTP 클라이언트 등을 코드 변경 없이 자동 계측 |
ATTR_SERVICE_NAME |
Jaeger·Tempo에서 서비스를 구분하는 이름. 반드시 고유하게 설정 |
OTLPTraceExporter |
OTLP/HTTP로 Collector에 Trace를 전송. gRPC를 사용하려면 exporter-trace-otlp-grpc 패키지를 사용하면 됩니다 |
sdk.shutdown() |
SIGTERM·SIGINT 수신 시 버퍼에 남은 Span을 flush. 누락 시 마지막 요청 Trace가 유실됩니다 |
예시 2: 커스텀 Span으로 비즈니스 로직 추적하기
자동 계측만으로는 부족할 때가 있습니다. 예를 들어 결제 처리 로직처럼 내부 비즈니스 흐름을 상세히 추적하고 싶은 경우, 커스텀 Span을 추가하면 됩니다. 실무에서 자주 쓰는 패턴을 소개할게요.
// order.service.ts
import { Injectable } from '@nestjs/common';
import { trace, SpanStatusCode } from '@opentelemetry/api';
@Injectable()
export class OrderService {
async processOrder(orderId: string) {
const tracer = trace.getTracer('order-service');
return tracer.startActiveSpan('processOrder', async (span) => {
span.setAttribute('order.id', orderId);
try {
const result = await this.orderRepository.process(orderId);
span.setStatus({ code: SpanStatusCode.OK });
return result;
} catch (err) {
span.recordException(err);
span.setStatus({ code: SpanStatusCode.ERROR });
throw err;
} finally {
span.end(); // finally에서 반드시 호출해야 Span이 닫힙니다
}
});
}
}
span.end()를 빠뜨리면? Span이 닫히지 않아 Collector 측에서 메모리 누수가 발생하고, Trace 뷰어에서 해당 Span이 아예 표시되지 않을 수 있습니다.finally블록에 두는 습관을 들이시는 것을 권장합니다.
RabbitMQ 비동기 경계 넘어서도 Trace 이어가기
HTTP 호출은 헤더로 컨텍스트를 자동 전파하지만, 메시지 큐를 쓰면 어떨까요? 다행히 getNodeAutoInstrumentations()에는 amqplib 계측도 포함되어 있습니다. 메시지를 발행할 때 현재 Trace 컨텍스트를 메시지 헤더에 자동으로 주입하고, 소비 시 자동으로 추출해 비동기 경계를 넘어서도 Trace가 끊기지 않습니다.
Order Service → RabbitMQ → Notification Service 흐름이 하나의 Trace로 연결되는 걸 처음 Jaeger에서 봤을 때, 저도 꽤 감탄했던 기억이 납니다.
단, "자동으로 다 된다"고 낙관하기엔 이른 경우도 있습니다. amqplib의 direct-reply-to 패턴처럼 비표준 응답 방식을 사용하거나, amqplib이 아닌 사내 래퍼 라이브러리를 경유하는 경우엔 자동 계측이 동작하지 않을 수 있습니다. 이럴 땐 수동으로 컨텍스트를 메시지 헤더에 주입하고 추출하는 코드를 직접 작성해야 합니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 벤더 중립성 | 한 번 계측하면 Jaeger, Datadog, New Relic 등 어느 백엔드로도 교체 가능 |
| 자동 계측 | Express, TypeORM, Redis, HTTP 클라이언트 등 주요 프레임워크의 80%를 코드 변경 없이 계측 |
| 신호 상관관계 | Trace ID로 Traces·Metrics·Logs를 연결해 근본 원인 분석 시간을 크게 단축 |
| CNCF 표준 | Kubernetes 다음으로 두 번째로 큰 CNCF 프로젝트. 커뮤니티와 생태계가 탄탄 |
단점 및 주의사항
아래 수치는 실제 서비스 운영에서 측정한 참고값입니다. 요청 패턴, 인스턴스 사양, 샘플링 비율에 따라 편차가 크므로 직접 스테이징 환경에서 측정해보시는 것을 권장합니다.
| 항목 | 영향 | 대응 방안 |
|---|---|---|
| CPU 오버헤드 | 환경에 따라 기준 대비 수십 % 증가 가능 | 샘플링 비율 조정(1~10%), Batch Processor 적극 활용 |
| 메모리 RSS | 수 MB 추가 | 사이드카 Collector 분리로 앱 메모리 부담 완화 |
| P99 레이턴시 | 지속 부하 시 소폭 증가 가능 | 비동기·버퍼링 Exporter 설정으로 앱 블로킹 방지 |
| 네트워크 비용 | 트래픽 규모에 따라 Collector 이그레스 비용 발생 | Tail Sampling으로 의미 있는 Trace만 전송 |
Batch Processor란? Span을 하나씩 즉시 내보내지 않고 일정 수량 또는 시간 단위로 모아서 일괄 전송하는 방식입니다. 네트워크 호출 횟수를 줄여 성능 오버헤드를 크게 낮출 수 있습니다.
실무에서 가장 흔한 실수
tracing.ts초기화 순서를 놓치는 것 —main.ts에서import './tracing'이 첫 줄이 아니면 NestJS 모듈 로딩 시점에 이미 Express가 패치되지 않은 상태가 되어 HTTP 자동 계측이 동작하지 않습니다.- Span 이름을 동적 값으로 만드는 것 —
processOrder-${orderId}같이 고유한 Span 이름이 1,000개를 넘어가면 백엔드(Jaeger, Tempo)의 인덱스 성능이 급격히 저하됩니다. Span 이름은processOrder처럼 고정하고, 동적 값은 attribute로 넣는 것을 권장합니다. - 프로덕션에서 Exporter 실패 시 앱이 멈추는 것 — Collector가 잠시 다운되면 Exporter 큐가 가득 차 앱 응답이 블로킹될 수 있습니다.
maxQueueSize,scheduledDelayMillis등 비동기·버퍼링 옵션을 미리 확인해두시면 좋습니다.
마치며
OpenTelemetry는 마이크로서비스 환경에서 '어디서 터졌는가'를 추측 대신 데이터로 보여주는 가장 실용적인 선택입니다. 오늘 몇 줄의 코드에 투자하면, 팀 전체의 온콜 부담이 눈에 띄게 줄어드는 경험을 하실 수 있습니다.
지금 바로 시작해볼 수 있는 3단계:
- 패키지 설치: 위의
pnpm add명령을 실행하고,tracing.ts를 프로젝트에 붙여넣으면 기본 설정이 완료됩니다. - 로컬 Jaeger 실행:
docker run -d --name jaeger -p 16686:16686 -p 4318:4318 jaegertracing/all-in-one:latest로 Jaeger를 띄우고http://localhost:16686에서 Trace가 쌓이는 걸 직접 눈으로 확인해보세요. - 커스텀 Span 추가: 자동 계측이 잡지 못하는 핵심 비즈니스 로직에
tracer.startActiveSpan()을 하나씩 붙여가면, 점진적으로 가시성을 높일 수 있습니다.
다음 글: OTel Collector의 샘플링 전략을 프로덕션 트래픽 패턴에 맞게 튜닝하는 방법 — Head-based vs Tail-based 정책 설계, 비용 최적화, 그리고 에러 Trace 100% 보존 전략까지
참고 자료
개념 이해용
- OpenTelemetry 공식 문서 — Traces
- OpenTelemetry 공식 문서 — Context Propagation
- OpenTelemetry 공식 문서 — Sampling
실전 설정용
- OpenTelemetry NestJS 완전 가이드 2026 | SigNoz
- Kubernetes Advanced Observability with OTel, Jaeger, and Tempo | johal.in
심화 튜닝용