prom-client
서버를 운영하다 보면 "지금 API 응답이 느린 건 어떤 엔드포인트 때문인지", "초당 요청이 몇 건인지", "메모리 사용량이 왜 올라가는지" 같은 질문에 답해야 할 때가 온다. 로그를 뒤져서 알아낼 수도 있지만, 로그는 텍스트 기반이라 시계열 데이터를 다루기에는 적합하지 않다. 이때 필요한 게 메트릭 수집이고, Node.js 생태계에서 Prometheus 형식의 메트릭을 다루는 표준 라이브러리가 prom-client다.
Prometheus 메트릭이 뭔가
Prometheus는 Pull 방식으로 메트릭을 수집한다. 애플리케이션이 /metrics 같은 HTTP 엔드포인트에 현재 상태를 텍스트로 노출하면, Prometheus 서버가 주기적으로 이 엔드포인트를 긁어간다(scrape). 이 텍스트 형식이 Prometheus exposition format이다.
# HELP http_requests_total Total number of HTTP requests
# TYPE http_requests_total counter
http_requests_total{method="GET",route="/api/users"} 1523
http_requests_total{method="POST",route="/api/posts"} 87
각 줄은 메트릭 이름, 라벨(중괄호 안), 값으로 구성된다. prom-client는 이 형식을 코드에서 쉽게 생성할 수 있도록 도와주는 라이브러리다.
설치와 기본 설정
# HELP http_requests_total Total number of HTTP requests
# TYPE http_requests_total counter
http_requests_total{method="GET",route="/api/users"} 1523
http_requests_total{method="POST",route="/api/posts"} 87
가장 기본적인 사용은 레지스트리에서 메트릭을 등록하고, /metrics 엔드포인트에서 텍스트를 내려주는 것이다.
npm install prom-client
collectDefaultMetrics()를 호출하면 prom-client가 자동으로 Node.js 런타임 메트릭을 수집한다. 프로세스 CPU 사용량, 힙 메모리 크기, 이벤트 루프 지연 시간, 활성 핸들/요청 수 같은 정보가 포함된다. 이것만으로도 서버의 기본 건강 상태를 파악하는 데 충분하다.
메트릭 타입
Prometheus는 네 가지 메트릭 타입을 정의한다. 각각 측정하려는 대상의 성격에 따라 선택한다.
Counter
단조 증가만 하는 값이다. 절대 감소하지 않는다. 서버 재시작 시 0으로 리셋되는 것은 Prometheus가 알아서 처리한다.
import { Registry, collectDefaultMetrics } from 'prom-client';
const register = new Registry();
// Node.js 기본 메트릭 수집 (CPU, 메모리, 이벤트 루프 등)
collectDefaultMetrics({ register });
// Express 예시
app.get('/metrics', async (req, res) => {
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
});
Counter는 "총 요청 수", "총 에러 수", "처리한 메시지 수"처럼 누적 횟수를 세는 데 적합하다. "현재 접속자 수"처럼 올라갔다 내려가는 값에는 Counter를 쓰면 안 된다.
Prometheus에서 Counter 값 자체보다는 rate() 함수를 적용해서 초당 변화율로 보는 경우가 대부분이다.
import { Counter } from 'prom-client';
const httpRequestsTotal = new Counter({
name: 'http_requests_total',
help: 'Total number of HTTP requests',
labelNames: ['method', 'route'] as const,
registers: [register],
});
// 요청이 들어올 때마다
httpRequestsTotal.inc({ method: 'GET', route: '/api/users' });
// 한 번에 여러 개 증가
httpRequestsTotal.inc({ method: 'POST', route: '/api/posts' }, 5);
이 쿼리는 최근 5분간 초당 평균 요청 수를 계산한다.
Gauge
올라가기도 하고 내려가기도 하는 값이다. 현재 상태를 나타내는 스냅샷 성격의 메트릭에 사용한다.
rate(http_requests_total[5m])
"현재 접속자 수", "큐에 쌓인 작업 수", "메모리 사용량", "캐시 항목 수" 같은 값에 적합하다. inc(), dec(), set() 세 가지 메서드를 제공한다.
Histogram
값의 분포를 측정한다. 주로 응답 시간이나 요청 크기 같은 것을 측정할 때 사용한다. 미리 정의한 구간(bucket)에 값이 몇 개 들어갔는지를 기록한다.
import { Gauge } from 'prom-client';
const activeConnections = new Gauge({
name: 'active_connections',
help: 'Number of currently active connections',
labelNames: ['room'] as const,
registers: [register],
});
// 연결 시
activeConnections.inc({ room: 'general' });
// 연결 해제 시
activeConnections.dec({ room: 'general' });
// 절대값 설정
activeConnections.set({ room: 'general' }, 42);
buckets 배열이 핵심이다. [0.01, 0.05, 0.1, 0.3, 0.5, 1, 2, 5]로 설정하면 "10ms 이하인 요청이 몇 개", "50ms 이하인 요청이 몇 개", ... 이런 식으로 구간별 누적 카운트가 기록된다. Prometheus에서 이 데이터로 백분위수(percentile)를 계산할 수 있다.
import { Histogram } from 'prom-client';
const httpRequestDuration = new Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route'] as const,
buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 2, 5],
registers: [register],
});
// 방법 1: 직접 값 관찰
httpRequestDuration.observe({ method: 'GET', route: '/api/users' }, 0.235);
// 방법 2: 타이머 사용
const end = httpRequestDuration.startTimer({ method: 'GET', route: '/api/users' });
// ... 작업 수행 ...
end(); // 자동으로 경과 시간을 기록
버킷을 너무 적게 잡으면 분포를 정확히 파악하기 어렵고, 너무 많이 잡으면 카디널리티가 높아져서 Prometheus 서버에 부하가 간다. 애플리케이션의 일반적인 응답 시간 범위를 고려해서 설정해야 한다.
startTimer() 메서드는 특히 유용하다. 호출 시점부터 반환된 함수를 실행하는 시점까지의 경과 시간을 자동으로 observe()한다. 수동으로 Date.now()를 다룰 필요가 없다.
Summary
Histogram과 비슷하지만, 클라이언트(애플리케이션) 측에서 직접 백분위수를 계산한다는 차이가 있다.
# 95번째 백분위 응답 시간
histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))
Summary는 지정한 백분위수를 애플리케이션이 계산해서 노출한다. Histogram은 Prometheus 서버가 쿼리 시점에 bucket 데이터로 근사 계산한다. 일반적으로 Histogram이 더 범용적이다. 여러 인스턴스의 데이터를 합산(aggregation)할 수 있고, 쿼리 시점에 다양한 백분위수를 유연하게 계산할 수 있기 때문이다. Summary는 합산이 불가능해서 다중 인스턴스 환경에서는 각 인스턴스별 백분위수만 볼 수 있다.
라벨(Label)
라벨은 메트릭을 세분화하는 방법이다. 같은 이름의 메트릭이라도 라벨 조합에 따라 별도의 시계열로 기록된다.
import { Summary } from 'prom-client';
const httpRequestDuration = new Summary({
name: 'http_request_duration_summary',
help: 'Duration of HTTP requests (summary)',
labelNames: ['method'] as const,
percentiles: [0.5, 0.9, 0.95, 0.99],
registers: [register],
});
httpRequestDuration.observe({ method: 'GET' }, 0.235);
카디널리티 주의
라벨 조합의 수를 카디널리티(cardinality)라고 한다. 라벨이 많거나 라벨 값이 무한정 늘어나면 시계열 수가 폭발적으로 증가해서 Prometheus 서버의 메모리와 디스크를 잡아먹는다.
나쁜 예:
const counter = new Counter({
name: 'api_calls_total',
help: 'Total API calls',
labelNames: ['method', 'status', 'route'] as const,
registers: [register],
});
// 각각 별도의 시계열
counter.inc({ method: 'GET', status: '200', route: '/api/users' });
counter.inc({ method: 'POST', status: '201', route: '/api/posts' });
counter.inc({ method: 'GET', status: '404', route: '/api/users' });
좋은 예:
// userId는 값이 무한대로 늘어난다 → 카디널리티 폭발
counter.inc({ userId: '12345', method: 'GET' });
라벨 값이 유한하고 예측 가능한 범위 내에 있어야 한다. 사용자 ID, 세션 ID, IP 주소 같은 고유 식별자를 라벨로 쓰면 안 된다.
커스텀 레지스트리
prom-client는 기본적으로 글로벌 레지스트리(globalRegistry)를 제공한다. 메트릭을 생성할 때 registers 옵션을 지정하지 않으면 자동으로 글로벌 레지스트리에 등록된다.
// method, status, route는 값의 범위가 제한적
counter.inc({ method: 'GET', status: '200', route: '/api/users' });
커스텀 레지스트리를 사용하면 외부에 노출할 메트릭과 내부용 메트릭을 분리하거나, 테스트 시 레지스트리를 격리할 수 있다. 여러 레지스트리의 메트릭을 합치려면 Registry.merge()를 사용한다.
import { Registry, Counter } from 'prom-client';
// 커스텀 레지스트리 생성
const appRegistry = new Registry();
const internalRegistry = new Registry();
// 각각 다른 레지스트리에 등록
const publicMetric = new Counter({
name: 'public_requests_total',
help: 'Public requests',
registers: [appRegistry],
});
const internalMetric = new Counter({
name: 'internal_tasks_total',
help: 'Internal tasks',
registers: [internalRegistry],
});
// 서로 다른 엔드포인트에서 노출
app.get('/metrics', async (req, res) => {
res.set('Content-Type', appRegistry.contentType);
res.end(await appRegistry.metrics());
});
app.get('/internal-metrics', async (req, res) => {
res.set('Content-Type', internalRegistry.contentType);
res.end(await internalRegistry.metrics());
});
Default Metrics 상세
collectDefaultMetrics()가 수집하는 메트릭들은 Node.js 런타임의 핵심 상태를 보여준다.
const merged = Registry.merge([appRegistry, internalRegistry]);
주요 기본 메트릭:
| 메트릭 | 타입 | 설명 |
|---|---|---|
process_cpu_user_seconds_total | Counter | 사용자 모드 CPU 시간 |
process_cpu_system_seconds_total | Counter | 시스템 모드 CPU 시간 |
process_resident_memory_bytes | Gauge | 상주 메모리(RSS) |
nodejs_heap_size_total_bytes | Gauge | V8 힙 총 크기 |
nodejs_heap_size_used_bytes | Gauge | V8 힙 사용량 |
nodejs_external_memory_bytes | Gauge | V8 외부 메모리 (Buffer 등) |
nodejs_eventloop_lag_seconds | Gauge | 이벤트 루프 지연 |
nodejs_active_handles_total | Gauge | 활성 핸들 수 |
nodejs_active_requests_total | Gauge | 활성 요청 수 |
nodejs_gc_duration_seconds | Histogram | GC 소요 시간 |
prefix 옵션을 주면 모든 기본 메트릭 이름 앞에 접두어가 붙는다. 여러 애플리케이션의 메트릭을 같은 Prometheus에서 수집할 때 이름 충돌을 방지하는 데 유용하다.
실전 패턴: HTTP 요청 메트릭
API 서버에서 가장 많이 사용하는 패턴은 요청 수, 에러 수, 응답 시간을 함께 측정하는 것이다.
collectDefaultMetrics({
register,
prefix: 'myapp_', // 메트릭 이름 접두어
gcDurationBuckets: [0.001, 0.01, 0.1, 1, 2, 5], // GC 히스토그램 버킷
});
미들웨어나 인터셉터에서 이 세 가지 메트릭을 함께 기록한다.
import { Counter, Histogram, Registry, collectDefaultMetrics } from 'prom-client';
const register = new Registry();
collectDefaultMetrics({ register });
const httpRequestsTotal = new Counter({
name: 'http_requests_total',
help: 'Total HTTP requests',
labelNames: ['method', 'route'] as const,
registers: [register],
});
const httpRequestsFailed = new Counter({
name: 'http_requests_failed_total',
help: 'Failed HTTP requests (5xx)',
labelNames: ['method', 'route'] as const,
registers: [register],
});
const httpRequestDuration = new Histogram({
name: 'http_request_duration_seconds',
help: 'HTTP request duration',
labelNames: ['method', 'route'] as const,
buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 2, 5, 10],
registers: [register],
});
/metrics 경로 자체에 대한 요청은 메트릭에서 제외하는 게 좋다. Prometheus가 주기적으로 scrape하면서 메트릭이 불필요하게 쌓이기 때문이다.
function metricsMiddleware(req, res, next) {
const method = req.method;
const route = req.route?.path || req.path;
const end = httpRequestDuration.startTimer({ method, route });
res.on('finish', () => {
httpRequestsTotal.inc({ method, route });
if (res.statusCode >= 500) {
httpRequestsFailed.inc({ method, route });
}
end(); // 응답 시간 기록
});
next();
}
실전 패턴: 비즈니스 메트릭
기술 메트릭 외에 비즈니스 관점의 메트릭도 유용하다. 채팅 메시지 수, 게시물 작성 수, 알림 발송 수 같은 걸 메트릭으로 노출하면 Grafana 대시보드에서 서비스 활성도를 한눈에 파악할 수 있다.
function metricsMiddleware(req, res, next) {
if (req.path === '/metrics') {
return next();
}
// ...
}
네이밍 컨벤션
Prometheus 커뮤니티에서 권장하는 메트릭 이름 규칙이 있다.
- snake_case 사용:
http_requests_total✅,httpRequestsTotal❌ - 단위를 접미사로:
_seconds,_bytes,_total - Counter는
_total접미사:http_requests_total - 단위는 기본 단위 사용: 밀리초가 아니라 초(
_seconds), 킬로바이트가 아니라 바이트(_bytes) - 앱 접두사 권장:
myapp_http_requests_total
const chatMessagesTotal = new Counter({
name: 'chat_messages_total',
help: 'Total chat messages sent',
labelNames: ['room'] as const,
registers: [register],
});
const activeUsers = new Gauge({
name: 'active_users_count',
help: 'Currently active users',
labelNames: ['room'] as const,
registers: [register],
});
// WebSocket 이벤트에서
socket.on('message', (data) => {
chatMessagesTotal.inc({ room: data.room });
});
socket.on('connect', () => {
activeUsers.inc({ room: socket.room });
});
socket.on('disconnect', () => {
activeUsers.dec({ room: socket.room });
});
Histogram vs Summary 선택 가이드
| 기준 | Histogram | Summary |
|---|---|---|
| 백분위수 계산 위치 | 서버(Prometheus) | 클라이언트(앱) |
| 다중 인스턴스 합산 | ✅ 가능 | ❌ 불가 |
| 쿼리 시 유연성 | ✅ 다양한 백분위수 | ❌ 미리 정한 것만 |
| 정확도 | bucket 정밀도에 의존 | 정확함 |
| 클라이언트 부하 | 낮음 | 높음 (슬라이딩 윈도우) |
| 추천 상황 | 대부분의 경우 | 정확한 백분위수가 꼭 필요할 때 |
대부분의 경우 Histogram을 선택하면 된다. 특히 Kubernetes 같은 다중 인스턴스 환경에서는 Summary의 합산 불가 문제가 치명적이다. 인스턴스 3대의 p99 응답 시간을 알고 싶으면 Histogram은 bucket 데이터를 합산해서 계산할 수 있지만, Summary는 각 인스턴스의 p99만 따로 볼 수 있다.
왜 prom-client인가
Node.js에서 Prometheus 메트릭을 내보내는 방법은 몇 가지가 있다.
OpenTelemetry SDK(@opentelemetry/sdk-metrics)는 벤더 중립적인 관측성 표준을 지향하며, OTLP 프로토콜로 Prometheus 외에도 Datadog, Jaeger 등 다양한 백엔드에 데이터를 보낼 수 있다. 하지만 설정이 복잡하고, Prometheus만 사용하는 환경에서는 오버스펙이다. prom-client는 Prometheus exposition format에 특화되어 있어서 설정이 단순하고, collectDefaultMetrics 한 줄이면 Node.js 런타임 메트릭이 바로 나온다.
직접 텍스트 포맷을 생성하는 것도 가능하지만, Counter/Gauge/Histogram 타입별 시맨틱 처리, 라벨 조합 관리, 레지스트리 분리 같은 기능을 직접 구현하면 실수가 생기기 쉽다. prom-client는 Prometheus 공식 클라이언트 라이브러리 목록에 등재되어 있고, NestJS용 래퍼(@willsoto/nestjs-prometheus)나 Express 미들웨어(express-prom-bundle) 같은 생태계가 잘 갖춰져 있다.
정리
- Prometheus exposition format의 Node.js 표준 구현체로, collectDefaultMetrics 한 줄이면 런타임 메트릭 수집이 시작된다
- Counter/Gauge/Histogram/Summary 네 타입 중 대부분의 경우 Histogram을 선택하고, 라벨 카디널리티 관리가 운영 안정성의 핵심이다
- startTimer()로 수동 타이밍 코드를 제거하고, 커스텀 레지스트리로 외부/내부 메트릭을 분리할 수 있다