NestJS와 OpenTelemetry
OpenTelemetry(OTel)는 분산 시스템에서 Trace, Metric, Log를 수집하는 벤더 중립 표준입니다. 마이크로서비스 환경에서 요청이 여러 서비스를 거칠 때, 어디서 병목이 발생하는지 추적하려면 분산 트레이싱이 필수입니다. 이 글에서는 NestJS에 OpenTelemetry SDK를 통합하고, 자동/수동 계측, Jaeger/Tempo 연동, 커스텀 Span 설계까지 실무에서 바로 쓸 수 있는 내용을 다룹니다.
OTel SDK 설정: tracing.ts
OpenTelemetry SDK는 애플리케이션 코드보다 먼저 초기화해야 합니다. NestJS의 main.ts보다 앞서 로드되는 별도 파일을 만듭니다.
// tracing.ts — main.ts보다 먼저 import
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
import { ExpressInstrumentation } from '@opentelemetry/instrumentation-express';
import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core';
import { PgInstrumentation } from '@opentelemetry/instrumentation-pg';
import { Resource } from '@opentelemetry/resources';
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from '@opentelemetry/semantic-conventions';
const sdk = new NodeSDK({
resource: new Resource({
[ATTR_SERVICE_NAME]: 'order-service',
[ATTR_SERVICE_VERSION]: '1.0.0',
'deployment.environment': process.env.NODE_ENV ?? 'development',
}),
traceExporter: new OTLPTraceExporter({
url: 'http://otel-collector:4318/v1/traces',
}),
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({
url: 'http://otel-collector:4318/v1/metrics',
}),
exportIntervalMillis: 30_000,
}),
instrumentations: [
new HttpInstrumentation(),
new ExpressInstrumentation(),
new NestInstrumentation(),
new PgInstrumentation(),
],
});
sdk.start();
// 종료 시 flush
process.on('SIGTERM', async () => {
await sdk.shutdown();
});
// main.ts
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();
NestInstrumentation이 핵심입니다. 컨트롤러, 가드, 파이프, 인터셉터의 실행을 자동으로 Span에 기록합니다.
자동 계측: 무엇이 추적되나?
위 설정만으로 다음이 자동 추적됩니다:
| Instrumentation | 추적 대상 | Span 예시 |
|---|---|---|
| HttpInstrumentation | 인바운드/아웃바운드 HTTP | GET /api/orders |
| ExpressInstrumentation | Express 미들웨어·라우터 | middleware – cors |
| NestInstrumentation | NestJS 핸들러·가드·파이프 | OrdersController.findAll |
| PgInstrumentation | PostgreSQL 쿼리 | pg.query SELECT * FROM orders |
수동 계측: 커스텀 Span
비즈니스 로직의 세부 구간을 추적하려면 수동으로 Span을 생성합니다:
import { trace, SpanStatusCode, SpanKind } from '@opentelemetry/api';
const tracer = trace.getTracer('order-service');
@Injectable()
export class OrderService {
async createOrder(dto: CreateOrderDto) {
return tracer.startActiveSpan('OrderService.createOrder', async (span) => {
try {
span.setAttribute('order.userId', dto.userId);
span.setAttribute('order.itemCount', dto.items.length);
// 재고 확인 Span
const stockResult = await tracer.startActiveSpan(
'checkStock',
{ kind: SpanKind.INTERNAL },
async (stockSpan) => {
const result = await this.stockService.checkAll(dto.items);
stockSpan.setAttribute('stock.available', result.available);
stockSpan.end();
return result;
},
);
if (!stockResult.available) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: '재고 부족',
});
throw new BadRequestException('재고 부족');
}
// 결제 처리 Span
const payment = await tracer.startActiveSpan(
'processPayment',
{ kind: SpanKind.CLIENT },
async (paySpan) => {
paySpan.setAttribute('payment.amount', dto.totalAmount);
const result = await this.paymentClient.charge(dto);
paySpan.setAttribute('payment.transactionId', result.txId);
paySpan.end();
return result;
},
);
span.setStatus({ code: SpanStatusCode.OK });
return payment;
} catch (error) {
span.recordException(error);
span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
throw error;
} finally {
span.end();
}
});
}
}
Span 데코레이터 패턴
매번 수동으로 Span을 만드는 것은 반복적입니다. 커스텀 데코레이터로 선언적으로 관리할 수 있습니다:
import { trace } from '@opentelemetry/api';
export function Span(name?: string): MethodDecorator {
return (target, propertyKey, descriptor: PropertyDescriptor) => {
const originalMethod = descriptor.value;
const spanName = name ?? `${target.constructor.name}.${String(propertyKey)}`;
descriptor.value = function (...args: any[]) {
const tracer = trace.getTracer('app');
return tracer.startActiveSpan(spanName, async (span) => {
try {
const result = await originalMethod.apply(this, args);
return result;
} catch (error) {
span.recordException(error);
span.setStatus({ code: 2, message: error.message });
throw error;
} finally {
span.end();
}
});
};
return descriptor;
};
}
// 사용
@Injectable()
export class PaymentService {
@Span() // → PaymentService.charge Span 자동 생성
async charge(orderId: string, amount: number) {
// ...
}
@Span('external.pg-api') // 커스텀 이름
async callPaymentGateway(payload: any) {
// ...
}
}
이 패턴은 NestJS Custom Decorator의 메서드 데코레이터 기법을 활용한 것입니다.
Context Propagation: 서비스 간 추적
마이크로서비스 간 트레이스를 연결하려면 Context Propagation이 필요합니다. HTTP 헤더에 trace-id를 전파합니다:
// HttpModule을 사용하는 경우 자동 전파됨
// axios/fetch를 직접 사용할 때는 수동 주입 필요
import { context, propagation } from '@opentelemetry/api';
@Injectable()
export class ExternalApiService {
constructor(private readonly httpService: HttpService) {}
async callUserService(userId: string) {
// W3C TraceContext 헤더 자동 주입
const headers: Record<string, string> = {};
propagation.inject(context.active(), headers);
return this.httpService.axiosRef.get(
`http://user-service:3001/users/${userId}`,
{ headers },
);
// user-service 측에서도 OTel SDK가 설정되어 있으면
// 같은 trace-id로 Span이 연결됨
}
}
OTel Collector 설정
OTel Collector는 텔레메트리 데이터를 수신하고 백엔드(Jaeger, Tempo, Prometheus)로 전달합니다:
# otel-collector-config.yaml
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
grpc:
endpoint: 0.0.0.0:4317
processors:
batch:
timeout: 5s
send_batch_size: 1024
memory_limiter:
check_interval: 1s
limit_mib: 512
exporters:
otlp/jaeger:
endpoint: jaeger:4317
tls:
insecure: true
prometheus:
endpoint: 0.0.0.0:8889
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [otlp/jaeger]
metrics:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [prometheus]
Interceptor로 트레이스 메타데이터 주입
NestJS Interceptor에서 현재 Span에 공통 메타데이터를 추가할 수 있습니다:
import { trace } from '@opentelemetry/api';
@Injectable()
export class TraceMetadataInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler) {
const request = context.switchToHttp().getRequest();
const span = trace.getActiveSpan();
if (span) {
span.setAttribute('http.user_id', request.user?.id ?? 'anonymous');
span.setAttribute('http.request_id', request.headers['x-request-id']);
span.setAttribute('http.controller',
context.getClass().name);
span.setAttribute('http.handler',
context.getHandler().name);
}
return next.handle().pipe(
tap({
error: (err) => {
span?.setAttribute('error.type', err.constructor.name);
span?.setAttribute('error.message', err.message);
},
}),
);
}
}
이 Interceptor를 NestJS Interceptor RxJS 심화에서 다룬 글로벌 등록 방식으로 적용하면 모든 요청에 자동으로 메타데이터가 추가됩니다.
커스텀 Metric 수집
비즈니스 메트릭도 OTel API로 수집할 수 있습니다:
import { metrics } from '@opentelemetry/api';
const meter = metrics.getMeter('order-service');
const orderCounter = meter.createCounter('orders.created', {
description: 'Total orders created',
});
const orderDuration = meter.createHistogram('orders.duration_ms', {
description: 'Order creation duration',
unit: 'ms',
});
@Injectable()
export class OrderService {
async createOrder(dto: CreateOrderDto) {
const start = Date.now();
try {
const order = await this.repo.save(dto);
orderCounter.add(1, {
'order.type': dto.type,
'order.region': dto.region,
});
return order;
} finally {
orderDuration.record(Date.now() - start);
}
}
}
운영 베스트 프랙티스
- 샘플링 적용: 프로덕션에서 모든 요청을 추적하면 비용이 폭증합니다.
TraceIdRatioBasedSampler(0.1)로 10% 샘플링 권장 - 민감 정보 제외: Span attribute에 비밀번호, 토큰 등을 절대 넣지 마세요
- Span 이름 규칙:
ServiceName.methodName형태로 일관되게 유지 - 에러 기록:
span.recordException()으로 예외 스택트레이스를 포함하세요 - 리소스 제한: Collector의
memory_limiter와batchprocessor로 메모리 보호 - 헬스체크 제외:
/health,/ready경로는ignoreIncomingRequestHook으로 추적에서 제외
마무리
OpenTelemetry는 NestJS 마이크로서비스의 관측 가능성(Observability)을 구현하는 표준입니다. 자동 계측으로 HTTP·DB 호출을 즉시 추적하고, 커스텀 Span과 Metric으로 비즈니스 로직까지 모니터링할 수 있습니다. Context Propagation을 통해 서비스 간 요청 흐름을 하나의 트레이스로 연결하면, 장애 원인 분석과 성능 최적화가 비약적으로 쉬워집니다.