NestJS OpenTelemetry 분산 추적

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_limiterbatch processor로 메모리 보호
  • 헬스체크 제외: /health, /ready 경로는 ignoreIncomingRequestHook으로 추적에서 제외

마무리

OpenTelemetry는 NestJS 마이크로서비스의 관측 가능성(Observability)을 구현하는 표준입니다. 자동 계측으로 HTTP·DB 호출을 즉시 추적하고, 커스텀 Span과 Metric으로 비즈니스 로직까지 모니터링할 수 있습니다. Context Propagation을 통해 서비스 간 요청 흐름을 하나의 트레이스로 연결하면, 장애 원인 분석과 성능 최적화가 비약적으로 쉬워집니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux