junyeokk
Blog
DevOps·2025. 05. 12

Prometheus + NestJS

서비스를 운영하다 보면 "지금 API 응답이 느린 건가?", "에러율이 갑자기 올랐나?" 같은 질문에 답하려면 수치 데이터가 필요하다. 로그를 grep 해서 대충 파악할 수도 있지만, 실시간 모니터링과 알림이 필요한 순간이 온다. 이때 가장 널리 쓰이는 조합이 Prometheus + Grafana다.

Prometheus는 Pull 기반 메트릭 수집 시스템이다. 대부분의 모니터링 도구가 에이전트를 설치해서 데이터를 밀어넣는(Push) 방식인 것과 다르게, Prometheus는 주기적으로 대상 서버의 /metrics 엔드포인트를 HTTP로 긁어간다. 이 Pull 방식의 장점은 모니터링 대상이 Prometheus의 존재를 몰라도 된다는 것이다. 그냥 /metrics 엔드포인트만 열어두면 Prometheus가 알아서 수집해 간다.

NestJS에서는 @willsoto/nestjs-prometheus 패키지가 이 연동을 깔끔하게 처리해준다. 이 패키지는 내부적으로 prom-client(Node.js용 Prometheus 클라이언트)를 사용하면서, NestJS의 DI 시스템과 자연스럽게 통합된다.


메트릭의 종류

Prometheus가 수집하는 메트릭에는 네 가지 타입이 있다. 어떤 상황에 어떤 타입을 쓰는지가 중요하다.

Counter

단조 증가하는 누적 값이다. 한번 올라가면 절대 내려가지 않는다. 프로세스가 재시작하면 0부터 다시 시작한다.

  • HTTP 요청 총 수
  • 에러 발생 횟수
  • 처리된 작업 수

Counter의 절대값 자체는 별 의미가 없다. "요청이 총 142,857번 왔다"보다는 "최근 5분간 초당 요청이 50개다"가 유용하다. 그래서 보통 Prometheus의 rate() 함수와 함께 쓴다.

rate(http_requests_total[5m]) // 최근 5분간 초당 요청 수

Gauge

올라가기도 하고 내려가기도 하는 값이다. 현재 상태를 나타낸다.

  • 현재 접속 중인 사용자 수
  • 메모리 사용량
  • 큐에 쌓인 작업 수

Counter와 달리 inc()dec() 둘 다 사용할 수 있고, set()으로 직접 값을 지정할 수도 있다.

Histogram

값의 분포를 측정한다. 미리 정의한 구간(bucket)에 관측값을 분류해서 저장한다.

  • API 응답 시간 분포
  • 요청 크기 분포

Histogram은 하나의 메트릭을 등록하면 내부적으로 세 가지 시계열이 생긴다:

  • _bucket: 각 구간별 누적 카운트
  • _sum: 관측값의 총합
  • _count: 관측 횟수

예를 들어 http_request_duration_seconds를 Histogram으로 만들면:

http_request_duration_seconds_bucket{le="0.1"} → 100ms 이하 요청 수 http_request_duration_seconds_bucket{le="0.3"} → 300ms 이하 요청 수 http_request_duration_seconds_bucket{le="1.5"} → 1.5초 이하 요청 수 http_request_duration_seconds_bucket{le="+Inf"} → 전체 요청 수 http_request_duration_seconds_sum → 전체 응답 시간 합 http_request_duration_seconds_count → 전체 요청 수

여기서 le는 "less than or equal"의 약자다. 각 bucket은 누적이기 때문에 le="0.3"의 값에는 le="0.1"에 해당하는 요청도 포함된다.

bucket 경계를 어떻게 잡느냐가 중요하다. API 응답 시간이라면 [0.1, 0.3, 1.5, 5, 10]처럼 사용자 체감에 의미 있는 구간을 설정하면 된다.

Summary

Histogram과 비슷하지만 클라이언트 측에서 백분위수를 직접 계산한다. 서버에서 미리 p50, p90, p99를 계산해서 내보내기 때문에 Prometheus 서버의 연산 부담이 줄어든다. 하지만 여러 인스턴스의 Summary를 합산할 수 없다는 치명적인 단점이 있어서, 대부분의 경우 Histogram을 권장한다.


NestJS 통합 설정

모듈 등록

@willsoto/nestjs-prometheusPrometheusModule을 등록하면 자동으로 /metrics 엔드포인트가 생긴다.

typescript
rate(http_requests_total[5m])  // 최근 5분간 초당 요청 수

defaultMetrics.enabled: true를 설정하면 prom-client가 제공하는 기본 메트릭(CPU 사용량, 메모리, 이벤트 루프 지연 등)이 자동 수집된다. 이 기본 메트릭만으로도 Node.js 프로세스의 상태를 꽤 잘 파악할 수 있다.

@Global() 데코레이터를 붙이면 다른 모듈에서 MetricsModule을 import하지 않아도 메트릭 Provider를 주입받을 수 있다. 메트릭은 애플리케이션 전반에서 사용하므로 전역으로 등록하는 게 편하다.

커스텀 메트릭 정의

makeCounterProvider, makeGaugeProvider, makeHistogramProvider 헬퍼 함수로 NestJS DI에 등록할 수 있는 메트릭 Provider를 만든다.

typescript
http_request_duration_seconds_bucket{le="0.1"}   → 100ms 이하 요청 수
http_request_duration_seconds_bucket{le="0.3"}   → 300ms 이하 요청 수
http_request_duration_seconds_bucket{le="1.5"}   → 1.5초 이하 요청 수
http_request_duration_seconds_bucket{le="+Inf"}  → 전체 요청 수
http_request_duration_seconds_sum                → 전체 응답 시간 합
http_request_duration_seconds_count              → 전체 요청 수

labelNames는 메트릭을 세분화하는 차원이다. methodroute 라벨을 정의하면 "GET /api/posts는 초당 30건, POST /api/comments는 초당 5건"처럼 라벨 조합별로 값을 따로 추적할 수 있다. 단, 라벨 조합이 너무 많아지면 시계열이 폭발적으로 늘어나므로(cardinality explosion), userId처럼 고유값이 많은 필드는 라벨로 쓰면 안 된다.

정의한 Provider들은 모듈의 providersexports에 등록해야 DI로 주입할 수 있다:

typescript
import { Global, Module } from '@nestjs/common';
import {
  makeCounterProvider,
  makeGaugeProvider,
  makeHistogramProvider,
  PrometheusModule,
} from '@willsoto/nestjs-prometheus';

@Global()
@Module({
  imports: [
    PrometheusModule.register({
      path: '/metrics',
      defaultMetrics: {
        enabled: true,
      },
    }),
  ],
  providers: [],
  exports: [],
})
export class MetricsModule {}

메트릭 수집: Interceptor 패턴

HTTP 요청 메트릭을 모든 컨트롤러에서 일일이 기록하는 건 비효율적이다. NestJS의 Interceptor를 사용하면 모든 요청을 한 곳에서 가로채서 메트릭을 기록할 수 있다.

typescript
const httpRequestsTotalProvider = makeCounterProvider({
  name: 'http_requests_total',
  help: 'Total number of HTTP requests',
  labelNames: ['method', 'route'],
});

const activeUsersProvider = makeGaugeProvider({
  name: 'active_users_count',
  help: 'Number of currently active users',
  labelNames: ['room'],
});

const responseTimeProvider = makeHistogramProvider({
  name: 'http_request_duration_seconds',
  help: 'HTTP request duration in seconds',
  buckets: [0.1, 0.3, 1.5, 5, 10],
  labelNames: ['method', 'route'],
});

핵심 포인트를 짚어보면:

@InjectMetric() 데코레이터: makeCounterProvider로 등록한 메트릭을 이름으로 주입받는다. 반환되는 객체는 prom-clientCounter, Gauge, Histogram 인스턴스다.

finalize 연산자: RxJS의 finalize는 Observable이 완료되거나 에러가 발생했을 때 실행된다. 즉, 응답이 나가기 직전에 메트릭을 기록한다. tap과 달리 에러 시에도 실행되므로 메트릭 누락이 없다.

route 추출: req.route?.path로 라우트 패턴을 가져온다. /api/posts/123이 아니라 /api/posts/:id 형태로 라벨이 기록되어야 카디널리티가 관리 가능하다. req.url은 fallback으로 라우트 매칭 전의 원본 URL을 사용한다.

/metrics 제외: 메트릭 수집 엔드포인트 자체를 모니터링하면 재귀적으로 메트릭이 생성되므로 제외한다.

이 Interceptor를 전역으로 등록하면 모든 HTTP 요청에 자동 적용된다:

typescript
@Global()
@Module({
  imports: [PrometheusModule.register({ ... })],
  providers: [
    httpRequestsTotalProvider,
    activeUsersProvider,
    responseTimeProvider,
  ],
  exports: [
    httpRequestsTotalProvider,
    activeUsersProvider,
    responseTimeProvider,
  ],
})
export class MetricsModule {}

비 HTTP 메트릭 기록

HTTP 요청 외에도 비즈니스 메트릭을 기록할 수 있다. WebSocket 채팅방의 접속자 수처럼 실시간으로 변하는 값은 Gauge로 추적한다.

typescript
import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { InjectMetric } from '@willsoto/nestjs-prometheus';
import { Request, Response } from 'express';
import { Counter, Histogram } from 'prom-client';
import { finalize, Observable } from 'rxjs';

@Injectable()
export class MetricsInterceptor implements NestInterceptor {
  constructor(
    @InjectMetric('http_requests_total')
    private readonly totalCounter: Counter,
    @InjectMetric('http_requests_success')
    private readonly successCounter: Counter,
    @InjectMetric('http_requests_fail')
    private readonly failCounter: Counter,
    @InjectMetric('http_request_duration_seconds')
    private readonly durationHistogram: Histogram,
  ) {}

  intercept(
    context: ExecutionContext,
    next: CallHandler,
  ): Observable<any> {
    const req: Request = context.switchToHttp().getRequest();
    const method = req.method;
    const route = (req.route as { path: string })?.path || req.url;
    const start = Date.now();
    const res = context.switchToHttp().getResponse<Response>();

    // /metrics 엔드포인트 자체는 수집에서 제외
    if (route.includes('metrics')) {
      return next.handle();
    }

    return next.handle().pipe(
      finalize(() => {
        const duration = (Date.now() - start) / 1000;

        if (res.statusCode >= 500) {
          this.failCounter.inc({ method, route });
        } else {
          this.successCounter.inc({ method, route });
          this.durationHistogram.observe({ method, route }, duration);
        }

        this.totalCounter.inc({ method, route });
      }),
    );
  }
}

사용자가 접속하면 inc(), 나가면 dec(). 이렇게 하면 Grafana에서 실시간 접속자 수 그래프를 볼 수 있다.


Prometheus 서버 설정

NestJS 앱에서 /metrics를 노출했으면, Prometheus 서버가 이걸 수집하도록 설정해야 한다. Docker Compose로 실행하는 경우:

yaml
// main.ts
app.useGlobalInterceptors(new MetricsInterceptor(...));

// 또는 모듈에서 APP_INTERCEPTOR로 등록
@Module({
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: MetricsInterceptor,
    },
  ],
})
yaml
@Injectable()
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
  constructor(
    @InjectMetric('active_users_count')
    private readonly userCount: Gauge,
    @InjectMetric('chat_message_count')
    private readonly messageCount: Counter,
  ) {}

  handleConnection(client: Socket) {
    const room = this.getRoom(client);
    this.userCount.inc({ room });
  }

  handleDisconnect(client: Socket) {
    const room = this.getRoom(client);
    this.userCount.dec({ room });
  }

  @SubscribeMessage('message')
  handleMessage(client: Socket, payload: any) {
    const room = this.getRoom(client);
    this.messageCount.inc({ room });
    // ...
  }
}

scrape_interval은 Prometheus가 /metrics를 얼마나 자주 긁어가는지 설정한다. 15초가 일반적이다. 너무 짧으면 앱에 부하가 가고, 너무 길면 메트릭의 해상도가 떨어진다.


/metrics 엔드포인트 출력

/metrics에 접속하면 이런 형태의 텍스트가 나온다:

# HELP http_requests_total Total number of HTTP requests # TYPE http_requests_total counter http_requests_total{method="GET",route="/api/posts"} 1523 http_requests_total{method="POST",route="/api/posts"} 87 # HELP http_request_duration_seconds HTTP request duration in seconds # TYPE http_request_duration_seconds histogram http_request_duration_seconds_bucket{method="GET",route="/api/posts",le="0.1"} 1200 http_request_duration_seconds_bucket{method="GET",route="/api/posts",le="0.3"} 1450 http_request_duration_seconds_bucket{method="GET",route="/api/posts",le="1.5"} 1520 http_request_duration_seconds_bucket{method="GET",route="/api/posts",le="+Inf"} 1523 http_request_duration_seconds_sum{method="GET",route="/api/posts"} 198.45 http_request_duration_seconds_count{method="GET",route="/api/posts"} 1523

이 형식은 Prometheus의 exposition format이다. # HELP는 메트릭 설명, # TYPE은 메트릭 타입, 그 아래가 실제 값이다. 사람이 읽을 수 있는 텍스트 형식이라 디버깅할 때 브라우저에서 직접 확인할 수 있다.


Grafana 연동

Prometheus가 데이터를 수집하면, Grafana로 시각화한다. Grafana에서 Prometheus를 Data Source로 추가하고, PromQL로 쿼리를 작성하면 된다.

자주 쓰는 PromQL 패턴:

# 초당 요청 수 (최근 5분 평균) rate(http_requests_total[5m]) # 에러율 (%) rate(http_requests_fail[5m]) / rate(http_requests_total[5m]) * 100 # 95번째 백분위 응답 시간 histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m])) # 현재 활성 사용자 수 active_users_count

rate()는 Counter의 초당 증가율을 계산한다. Counter의 절대값이 아니라 변화율을 보는 게 일반적이기 때문에 거의 항상 rate()와 함께 쓴다.

histogram_quantile()은 Histogram 데이터에서 백분위수를 계산한다. 0.95를 넣으면 "95%의 요청이 이 시간 안에 처리된다"는 값을 구할 수 있다. 평균 응답 시간보다 p95, p99가 더 유용한 이유는 평균은 극단값에 의해 왜곡되기 쉽기 때문이다. "평균 200ms"인데 실제로는 5%의 요청이 3초 걸린다면 사용자 체감은 좋지 않다.


주의할 점

카디널리티 관리

라벨 조합이 너무 많아지면 Prometheus의 메모리 사용량이 급증한다. 예를 들어 userId를 라벨로 넣으면 사용자 수만큼 시계열이 생긴다. 라벨에는 method, route, status_code처럼 값의 종류가 제한된 것만 사용해야 한다.

네이밍 컨벤션

Prometheus 메트릭 이름에는 규칙이 있다:

  • snake_case 사용
  • 단위를 접미사로 (_seconds, _bytes, _total)
  • Counter는 _total 접미사
  • Histogram/Summary의 시간은 초 단위 (_seconds)
http_requests_total ✅ http_request_duration_seconds ✅ httpRequestsTotal ❌ request_time_ms ❌ (밀리초보다 초가 표준)

메트릭 엔드포인트 보안

/metrics는 내부 정보를 노출하므로 외부에 공개되면 안 된다. 프록시 레벨에서 차단하거나, NestJS Guard로 내부 네트워크만 접근 가능하도록 제한하는 게 좋다.


왜 @willsoto/nestjs-prometheus인가

NestJS에서 Prometheus 메트릭을 연동하는 방법은 크게 세 가지다.

prom-client를 직접 사용하면 레지스트리와 메트릭 인스턴스를 수동으로 관리해야 한다. 서비스에 주입하려면 커스텀 Provider를 직접 작성해야 하고, 모듈 간 공유도 번거롭다. @willsoto/nestjs-prometheusmakeCounterProvider 같은 헬퍼로 DI 등록을 한 줄로 줄이고, @InjectMetric()으로 어디서든 깔끔하게 주입받을 수 있다.

@opentelemetry/sdk-metrics + Prometheus exporter 조합도 가능하다. 멀티 백엔드(Datadog, Jaeger 등)가 필요하거나 분산 트레이싱까지 통합하려면 OpenTelemetry가 적합하지만, Prometheus 단독 환경에서는 설정 복잡도 대비 이점이 적다.

NestJS 생태계에서는 @willsoto/nestjs-prometheus가 가장 많이 쓰이고, PrometheusModule.register() 한 줄로 /metrics 엔드포인트가 자동 생성되는 편의성이 핵심이다.


정리

  • PrometheusModule.register()로 /metrics 자동 생성, @InjectMetric()으로 NestJS DI에 자연스럽게 통합된다
  • Interceptor 패턴으로 모든 HTTP 요청의 수/에러/응답시간을 한 곳에서 수집하고, finalize로 에러 시에도 누락 없이 기록한다
  • 라벨에는 method/route/status처럼 값 범위가 제한된 것만 사용하고, /metrics 엔드포인트는 내부 네트워크로 제한해야 한다

관련 개념

  • prom-client: @willsoto/nestjs-prometheus가 내부적으로 사용하는 Node.js Prometheus 클라이언트. NestJS 없이 Express에서 직접 사용할 수도 있다
  • Grafana: Prometheus 데이터를 시각화하는 대시보드 도구
  • Loki: Grafana Labs에서 만든 로그 수집 시스템. Prometheus가 메트릭이라면 Loki는 로그를 담당한다

관련 문서