성능 모니터링과 프로파일링
응답 시간·처리량·오류율 메트릭을 수집하고 런타임 프로파일로 CPU와 메모리 병목의 증거를 찾아 검증합니다.
9장의 마지막인 이 글에서는 NestJS 애플리케이션의 성능 모니터링(performance monitoring)과 프로파일링(profiling)을 다룹니다.
애플리케이션은 배포한 뒤에도 성능을 계속 관리해야 합니다. 사용 패턴이 바뀌고, 데이터가 늘고, 코드가 바뀌면서 언제든 성능 문제가 생길 수 있으므로, 이를 일찍 발견하고 고치려면 체계적인 모니터링과 깊이 있는 프로파일링이 필요합니다.
측정 근거를 누적하는 성능 개선 루프
관측·범위·가설·표적 profile·최소 변경·동일 조건 검증에서 중앙 운영 근거가 누적된다.
Nest · Performance evidence loop
관측·프로파일·재측정의 루프
관측으로 범위를 좁히고 표적 프로파일로 가설을 확인한 뒤 같은 조건에서 다시 측정합니다. 이 그림은 운영 설계이며, 본문 인터셉터는 Nest handler를 거치는 요청의 기본 지표 예제입니다.
- 관측
latency, throughput, error rate와 CPU, memory, DB 신호를 기준선과 비교합니다.
- 범위 고정
알림 시각, route, release, correlation id를 정해 같은 요청을 추적할 수 있게 합니다.
- 가설 분리
route에서 provider, DB, runtime, infra 순으로 긴 구간과 원인 후보를 좁힙니다.
- 표적 프로파일
CPU flame, event-loop 진단, heap snapshot, trace 가운데 가설을 확인할 도구만 짧게 캡처합니다.
- 최소 변경
관측한 한 원인을 겨냥한 변경을 canary에 배포하고 다른 조건은 고정합니다.
- 같은 조건 검증
전후 p95, error, resource를 비교해 keep·rollback을 기록합니다. 새 기준선이 1번 관측으로 돌아갑니다.
특정 route 또는 외부 대기가 길다
access log와 같은 correlation id로 ingress, Nest 경계, handler, DB·외부 API span을 나눕니다.
계산 또는 event loop 지연을 의심한다
DevTools나 CPU flame으로 넓은 함수 frame을 보고, Clinic Doctor로 동기 I/O와 큰 JSON 처리 신호를 확인합니다.
요청 뒤에도 메모리가 회수되지 않는다
시간차 heap snapshot의 retained object를 비교하고 GC pause와 무한 캐시 같은 보유 경로를 맞춥니다.
pool 대기나 배포 직후 오류가 늘었다
pool wait와 query plan을 함께 보고, error rate 급등은 release 시각과 exception log를 대조합니다.
실선 고리는 실제 개선 순서, 안쪽 점선은 각 단계가 중앙 운영 근거에 쓰는 증거입니다. 마지막 결정이 다음 기준선을 갱신하므로 이 경로는 한 번 끝나는 점검표가 아니라 실제 운영 루프입니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 관측부터 범위 고정, 가설 분리, 표적 프로파일, 최소 변경, 같은 조건 검증까지 시계 방향으로 반복한다. 모든 단계가 중앙 운영 근거에 증거를 쓰고 마지막 결정과 기준선이 다음 관측으로 돌아간다.
- 관측→범위 고정→가설 분리→표적 프로파일→최소 변경→같은 조건 검증을 반복한다. 각 단계는 오른쪽 점선으로 공유 운영 근거를 남기고 검증 결정은 왼쪽 되먹임으로 새 관측 기준이 된다. 이는 추가 계측을 포함한 운영 설계이며 본문 인터셉터는 진입 이후 finish된 응답만 기록한다. CPU/heap/trace는 가설에 맞게 선택하고 Clinic의 현재 유지보수 및 런타임 호환 조건을 확인한다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 측정 근거를 누적하는 성능 개선 루프 |
| 26 | 관측→범위 고정→가설 분리→표적 프로파일→최소 변경→같은 조건 검증을 반복한다. 각 단계는 오른쪽 점선으로 공유 운영 근거를 남기고 검증 결정은 왼쪽 되먹임으로 새 관측 기준이 된다. 이는 추가 계측을 포함한 운영 설계이며 본문 인터셉터는 진입 이후 finish된 응답만 기록한다. CPU/heap/trace는 가설에 맞게 선택하고 Clinic의 현재 유지보수 및 런타임 호환 조건을 확인한다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 관측·범위·가설 관측에서 범위를 고정하고 가설을 분리합니다. | 근거: 관측 · 범위: 고정 · 가설: 분리 | 여러 원인을 한 변경에 섞지 않습니다. |
| 표적 profile·변경 표적 profile로 확인하고 최소한으로 변경합니다. | 근거: profile · 변경: 최소 | 관측한 원인에 맞게 수정합니다. |
| 동일 조건 검증 같은 조건에서 검증하고 결정·기준선을 기록합니다. | 조건: 동일 · 기록: 운영 근거 | 개선 근거가 다음 관측으로 이어집니다. |
측정 근거를 누적하는 성능 개선 루프
관측·범위·가설·표적 profile·최소 변경·동일 조건 검증에서 중앙 운영 근거가 누적된다.
Nest · Performance evidence loop
관측·프로파일·재측정의 루프
관측으로 범위를 좁히고 표적 프로파일로 가설을 확인한 뒤 같은 조건에서 다시 측정합니다. 이 그림은 운영 설계이며, 본문 인터셉터는 Nest handler를 거치는 요청의 기본 지표 예제입니다.
- 관측
latency, throughput, error rate와 CPU, memory, DB 신호를 기준선과 비교합니다.
- 범위 고정
알림 시각, route, release, correlation id를 정해 같은 요청을 추적할 수 있게 합니다.
- 가설 분리
route에서 provider, DB, runtime, infra 순으로 긴 구간과 원인 후보를 좁힙니다.
- 표적 프로파일
CPU flame, event-loop 진단, heap snapshot, trace 가운데 가설을 확인할 도구만 짧게 캡처합니다.
- 최소 변경
관측한 한 원인을 겨냥한 변경을 canary에 배포하고 다른 조건은 고정합니다.
- 같은 조건 검증
전후 p95, error, resource를 비교해 keep·rollback을 기록합니다. 새 기준선이 1번 관측으로 돌아갑니다.
특정 route 또는 외부 대기가 길다
access log와 같은 correlation id로 ingress, Nest 경계, handler, DB·외부 API span을 나눕니다.
계산 또는 event loop 지연을 의심한다
DevTools나 CPU flame으로 넓은 함수 frame을 보고, Clinic Doctor로 동기 I/O와 큰 JSON 처리 신호를 확인합니다.
요청 뒤에도 메모리가 회수되지 않는다
시간차 heap snapshot의 retained object를 비교하고 GC pause와 무한 캐시 같은 보유 경로를 맞춥니다.
pool 대기나 배포 직후 오류가 늘었다
pool wait와 query plan을 함께 보고, error rate 급등은 release 시각과 exception log를 대조합니다.
실선 고리는 실제 개선 순서, 안쪽 점선은 각 단계가 중앙 운영 근거에 쓰는 증거입니다. 마지막 결정이 다음 기준선을 갱신하므로 이 경로는 한 번 끝나는 점검표가 아니라 실제 운영 루프입니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 관측부터 범위 고정, 가설 분리, 표적 프로파일, 최소 변경, 같은 조건 검증까지 시계 방향으로 반복한다. 모든 단계가 중앙 운영 근거에 증거를 쓰고 마지막 결정과 기준선이 다음 관측으로 돌아간다.
- 관측→범위 고정→가설 분리→표적 프로파일→최소 변경→같은 조건 검증을 반복한다. 각 단계는 오른쪽 점선으로 공유 운영 근거를 남기고 검증 결정은 왼쪽 되먹임으로 새 관측 기준이 된다. 이는 추가 계측을 포함한 운영 설계이며 본문 인터셉터는 진입 이후 finish된 응답만 기록한다. CPU/heap/trace는 가설에 맞게 선택하고 Clinic의 현재 유지보수 및 런타임 호환 조건을 확인한다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 측정 근거를 누적하는 성능 개선 루프 |
| 26 | 관측→범위 고정→가설 분리→표적 프로파일→최소 변경→같은 조건 검증을 반복한다. 각 단계는 오른쪽 점선으로 공유 운영 근거를 남기고 검증 결정은 왼쪽 되먹임으로 새 관측 기준이 된다. 이는 추가 계측을 포함한 운영 설계이며 본문 인터셉터는 진입 이후 finish된 응답만 기록한다. CPU/heap/trace는 가설에 맞게 선택하고 Clinic의 현재 유지보수 및 런타임 호환 조건을 확인한다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 관측·범위·가설 관측에서 범위를 고정하고 가설을 분리합니다. | 근거: 관측 · 범위: 고정 · 가설: 분리 | 여러 원인을 한 변경에 섞지 않습니다. |
| 표적 profile·변경 표적 profile로 확인하고 최소한으로 변경합니다. | 근거: profile · 변경: 최소 | 관측한 원인에 맞게 수정합니다. |
| 동일 조건 검증 같은 조건에서 검증하고 결정·기준선을 기록합니다. | 조건: 동일 · 기록: 운영 근거 | 개선 근거가 다음 관측으로 이어집니다. |
위 그림은 성능 문제를 다루는 여섯 단계의 반복 과정을 보여 줍니다. 지표로 이상을 발견하고, 범위를 고정한 뒤, 가설을 나누어 해당 구간만 프로파일링하고, 가장 작은 변경을 적용해 같은 조건에서 다시 측정합니다. 각 단계에서 나온 지표, trace, 프로파일, 결정은 가운데의 기록에 쌓여 다음 기준이 됩니다. 이 그림은 운영 설계이며, 이 글의 interceptor는 Nest handler를 거치는 요청의 기본 지표만 모으는 예제라는 점이 그림 위에 적혀 있습니다.
성능 모니터링이란
성능 모니터링은 애플리케이션과 인프라의 핵심 지표(metric)를 계속 수집하고 시각화하며, 이상한 패턴이나 임계값 초과가 보이면 알림을 보내는 활동입니다. 시스템의 현재 상태를 파악하고, 잠재적인 문제를 미리 감지하며, 장애가 났을 때 원인을 빨리 진단하는 데 도움을 줍니다.
상관관계 ID로 잇는 느린 요청 시퀀스
현재 HttpMetricsInterceptor의 Guard 허용→listener 등록→finish 기록 한 경로만 추적합니다. Guard 거절·미선택 404는 제외하고 correlation·Edge·dependency 전체 tracing은 추가 설계이며 Node finish는 클라이언트 수신 완료가 아닙니다.
Nest · Correlated request sequence
요청을 잇는 경계별 시간 기록
Edge·Nest·Provider·DB의 시간을 연결하면 느린 구간을 좁힐 수 있습니다. correlation id 전파와 계층별 span은 추가 계측이 필요하며 본문의 지표 인터셉터만으로 전체 trace가 생성되지는 않습니다.
- Client
URL, payload, retry 여부와 사용자가 기다린 전체 시간을 기록합니다.
- Edge · ingress
LB/Gateway가 도착 시각, 요청 크기, upstream timeout과 correlation id를 남깁니다.
- Nest 경계
middleware, guard, pipe, interceptor가 정규화된 route와 status, duration을 연결합니다.
- Provider · runtime
controller/service 처리, 직렬화, CPU와 event-loop 구간을 dependency 대기와 분리합니다.
- DB · 외부 API
pool wait, query, 외부 왕복을 span으로 나누고 결과를 같은 trace로 반환합니다.
- Response
status·exception과 반환 시각을 Edge에서 Client까지 이어 전체 latency를 닫습니다.
진입부터 Nest 경계가 길다
access log, 요청 크기, TLS·routing, rate limit, upstream timeout과 특정 route p95를 확인합니다.
Provider 내부가 길다
직렬화와 동기 계산을 나누고 CPU flame, heap, GC, event-loop delay를 같은 시간대에서 봅니다.
DB나 외부 대기가 길다
pool wait, slow query와 실행 계획, cache hit, 외부 API timeout을 dependency span과 맞춥니다.
점선은 비동기 호출을 포함한 응답·반환 방향입니다. route → provider → DB/runtime → infra 순으로 한 경계씩 좁히면 모든 지표가 함께 오른 결과와 실제 병목 원인을 분리할 수 있습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- Client 요청이 Edge와 Nest middleware 및 interceptor를 지나 provider에서 실행되고 DB 또는 외부 API를 호출한다. 반환은 dependency span, handler status, route duration, 전체 latency를 같은 correlation id로 연결한다.
- Client와 Edge 추적은 전체 요청 관측의 추가 설계다. Nest Guard가 허용해야 본문 인터셉터가 시작하고 Pipe/controller/provider/DB/API를 거친 응답 finish를 기록한다. Guard 거절과 미선택 route 404는 본문 인터셉터를 거치지 않아 전체 카운터에 포함되지 않는다. 점선의 왼쪽 우회는 Guard 거절 응답이고 오른쪽 반환은 추가 상관관계 추적이 있을 때 Edge와 Client로 전체 구간을 잇는다. Node finish는 클라이언트 수신 확인이 아니고 미완료 연결 종료는 별도 관측 조건이다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 상관관계 ID로 잇는 느린 요청 시퀀스 |
| 28 | Client와 Edge 추적은 전체 요청 관측의 추가 설계다. Nest Guard가 허용해야 본문 인터셉터가 시작하고 Pipe/controller/provider/DB/API를 거친 응답 finish를 기록한다. Guard 거절과 미선택 route 404는 본문 인터셉터를 거치지 않아 전체 카운터에 포함되지 않는다. 점선의 왼쪽 우회는 Guard 거절 응답이고 오른쪽 반환은 추가 상관관계 추적이 있을 때 Edge와 Client로 전체 구간을 잇는다. Node finish는 클라이언트 수신 확인이 아니고 미완료 연결 종료는 별도 관측 조건이다. |
| 57 | status·exception과 반환 시각을 Edge에서 Client까지 이어 전체 latency 측정을 마칩니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 허용된 요청의 계측 진입 Guard가 허용해 인터셉터에 진입한 요청에서 startedAt과 route label을 잡고 finish listener를 등록합니다. | 진입 조건: Guard 허용·인터셉터 진입 · 제외: Guard 거절·handler 미선택 404 · 등록: response.once(finish)·시작 시간 | 계측 시작점은 앞선 middleware·Guard 뒤입니다. correlation·Edge·dependency 전체 span은 이 코드가 자동 생성하는 값이 아닙니다. |
| handler 처리와 finish 대기 next.handle()로 Pipe·handler 이후 처리를 진행하며 응답의 finish를 기다립니다. | 처리: next.handle()·정상 또는 진입 뒤 예외 응답 · 계측 기록: finish 전에는 해당 listener 기록 없음 · 연결 중단: finish가 없을 수 있어 별도 종료 설계 필요 | 정상·예외 여부만으로 즉시 카운터를 올리지 않고 finish 때의 최종 status를 읽습니다. 전체 요청과 클라이언트 지연은 추가 계측 설계입니다. |
| finish에서 한 번 기록 finish가 발생하면 최종 status와 인터셉터 진입 이후 duration을 기록하고 요청 카운터를 한 번 증가시킵니다. | counter 변화: 해당 finish listener에서 +1 · histogram: (Date.now−startedAt) / 1000 초 · label: method·bounded route·최종 code · finish 의미: 서버가 OS에 넘김, 클라이언트 수신 확인 아님 | once listener가 이 응답의 기록을 한 번 만들며 그 범위는 인터셉터 진입부터 서버 finish까지입니다. Edge·Guard·전체 dependency span 구현을 뜻하지 않습니다. |
상관관계 ID로 잇는 느린 요청 시퀀스
현재 HttpMetricsInterceptor의 Guard 허용→listener 등록→finish 기록 한 경로만 추적합니다. Guard 거절·미선택 404는 제외하고 correlation·Edge·dependency 전체 tracing은 추가 설계이며 Node finish는 클라이언트 수신 완료가 아닙니다.
Nest · Correlated request sequence
요청을 잇는 경계별 시간 기록
Edge·Nest·Provider·DB의 시간을 연결하면 느린 구간을 좁힐 수 있습니다. correlation id 전파와 계층별 span은 추가 계측이 필요하며 본문의 지표 인터셉터만으로 전체 trace가 생성되지는 않습니다.
- Client
URL, payload, retry 여부와 사용자가 기다린 전체 시간을 기록합니다.
- Edge · ingress
LB/Gateway가 도착 시각, 요청 크기, upstream timeout과 correlation id를 남깁니다.
- Nest 경계
middleware, guard, pipe, interceptor가 정규화된 route와 status, duration을 연결합니다.
- Provider · runtime
controller/service 처리, 직렬화, CPU와 event-loop 구간을 dependency 대기와 분리합니다.
- DB · 외부 API
pool wait, query, 외부 왕복을 span으로 나누고 결과를 같은 trace로 반환합니다.
- Response
status·exception과 반환 시각을 Edge에서 Client까지 이어 전체 latency를 닫습니다.
진입부터 Nest 경계가 길다
access log, 요청 크기, TLS·routing, rate limit, upstream timeout과 특정 route p95를 확인합니다.
Provider 내부가 길다
직렬화와 동기 계산을 나누고 CPU flame, heap, GC, event-loop delay를 같은 시간대에서 봅니다.
DB나 외부 대기가 길다
pool wait, slow query와 실행 계획, cache hit, 외부 API timeout을 dependency span과 맞춥니다.
점선은 비동기 호출을 포함한 응답·반환 방향입니다. route → provider → DB/runtime → infra 순으로 한 경계씩 좁히면 모든 지표가 함께 오른 결과와 실제 병목 원인을 분리할 수 있습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- Client 요청이 Edge와 Nest middleware 및 interceptor를 지나 provider에서 실행되고 DB 또는 외부 API를 호출한다. 반환은 dependency span, handler status, route duration, 전체 latency를 같은 correlation id로 연결한다.
- Client와 Edge 추적은 전체 요청 관측의 추가 설계다. Nest Guard가 허용해야 본문 인터셉터가 시작하고 Pipe/controller/provider/DB/API를 거친 응답 finish를 기록한다. Guard 거절과 미선택 route 404는 본문 인터셉터를 거치지 않아 전체 카운터에 포함되지 않는다. 점선의 왼쪽 우회는 Guard 거절 응답이고 오른쪽 반환은 추가 상관관계 추적이 있을 때 Edge와 Client로 전체 구간을 잇는다. Node finish는 클라이언트 수신 확인이 아니고 미완료 연결 종료는 별도 관측 조건이다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 상관관계 ID로 잇는 느린 요청 시퀀스 |
| 28 | Client와 Edge 추적은 전체 요청 관측의 추가 설계다. Nest Guard가 허용해야 본문 인터셉터가 시작하고 Pipe/controller/provider/DB/API를 거친 응답 finish를 기록한다. Guard 거절과 미선택 route 404는 본문 인터셉터를 거치지 않아 전체 카운터에 포함되지 않는다. 점선의 왼쪽 우회는 Guard 거절 응답이고 오른쪽 반환은 추가 상관관계 추적이 있을 때 Edge와 Client로 전체 구간을 잇는다. Node finish는 클라이언트 수신 확인이 아니고 미완료 연결 종료는 별도 관측 조건이다. |
| 57 | status·exception과 반환 시각을 Edge에서 Client까지 이어 전체 latency 측정을 마칩니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 허용된 요청의 계측 진입 Guard가 허용해 인터셉터에 진입한 요청에서 startedAt과 route label을 잡고 finish listener를 등록합니다. | 진입 조건: Guard 허용·인터셉터 진입 · 제외: Guard 거절·handler 미선택 404 · 등록: response.once(finish)·시작 시간 | 계측 시작점은 앞선 middleware·Guard 뒤입니다. correlation·Edge·dependency 전체 span은 이 코드가 자동 생성하는 값이 아닙니다. |
| handler 처리와 finish 대기 next.handle()로 Pipe·handler 이후 처리를 진행하며 응답의 finish를 기다립니다. | 처리: next.handle()·정상 또는 진입 뒤 예외 응답 · 계측 기록: finish 전에는 해당 listener 기록 없음 · 연결 중단: finish가 없을 수 있어 별도 종료 설계 필요 | 정상·예외 여부만으로 즉시 카운터를 올리지 않고 finish 때의 최종 status를 읽습니다. 전체 요청과 클라이언트 지연은 추가 계측 설계입니다. |
| finish에서 한 번 기록 finish가 발생하면 최종 status와 인터셉터 진입 이후 duration을 기록하고 요청 카운터를 한 번 증가시킵니다. | counter 변화: 해당 finish listener에서 +1 · histogram: (Date.now−startedAt) / 1000 초 · label: method·bounded route·최종 code · finish 의미: 서버가 OS에 넘김, 클라이언트 수신 확인 아님 | once listener가 이 응답의 기록을 한 번 만들며 그 범위는 인터셉터 진입부터 서버 finish까지입니다. Edge·Guard·전체 dependency span 구현을 뜻하지 않습니다. |
위 그림은 클라이언트의 요청이 Edge, Nest, provider, 데이터베이스와 외부 API를 차례로 지나갔다가 돌아오는 과정을 시퀀스로 보여 줍니다. 각 구간이 같은 correlation ID로 시작과 끝 시간을 남기면, 전체 응답이 느릴 때 어느 구간에서 시간이 쓰였는지 좁힐 수 있습니다. 다만 correlation ID 전파와 계층별 span은 따로 계측해야 하며, 이 글의 지표 interceptor만으로 전체 trace가 만들어지지는 않습니다.
모니터링에서 보는 주요 지표는 다음과 같습니다.
- 시스템 자원
- CPU 사용률: 프로세서가 얼마나 바쁜지 나타냅니다.
- 메모리 사용량: 애플리케이션이 쓰는 RAM의 양입니다. 메모리 누수는 프로세스를 멈추게 할 수 있습니다.
- 디스크 I/O: 디스크 읽기·쓰기 작업량입니다. 데이터베이스나 파일 시스템에 많이 의존할 때 중요합니다.
- 네트워크 I/O: 네트워크로 주고받는 데이터의 양입니다.
- 애플리케이션 성능
- 응답 시간(latency): 요청이 서버에 도착한 뒤 클라이언트가 응답을 받기까지 걸린 시간입니다.
- 처리량(throughput): 단위 시간당 처리한 요청 수로, 초당 요청 수(RPS)로 나타냅니다.
- 오류율(error rate): 전체 요청 가운데 실패한 요청의 비율입니다.
- 동시 사용자 수: 동시에 시스템을 쓰고 있는 사용자나 연결의 수입니다.
- 큐 길이: 메시지 큐나 작업 큐에서 기다리는 항목의 수입니다.
- 데이터베이스 성능
- 쿼리 실행 시간: 쿼리마다 걸린 시간으로, 느린 쿼리를 찾는 데 씁니다.
- 연결 수: 데이터베이스에 연결된 클라이언트의 수입니다.
- 잠금: 데이터베이스 lock이 생기는지와 그 시간입니다.
- 캐시 hit 비율: 데이터베이스 캐시가 얼마나 효과적인지 나타냅니다.
모니터링 도구로는 AWS CloudWatch, Google Cloud Monitoring, Azure Monitor 같은 클라우드 서비스, 코드 수준 분석과 분산 추적을 제공하는 New Relic, Datadog, Dynatrace, Elastic APM 같은 APM(Application Performance Monitoring) 도구, 그리고 지표 수집의 Prometheus, 시각화의 Grafana, 알림의 Alertmanager로 이루어진 오픈소스 조합이 있습니다.
NestJS 성능 모니터링 구현
NestJS 애플리케이션의 지표를 노출하고, Prometheus로 수집해 Grafana로 시각화하는 방법을 예로 들어 보겠습니다. 흐름은 세 단계입니다. 애플리케이션이 prom-client로 지표를 메모리에 모아 /metrics 경로에서 텍스트로 내보내면, Prometheus가 정해진 간격으로 그 경로를 요청해 값을 시계열로 저장하고, Grafana가 Prometheus에 질의해 그래프를 그립니다. Prometheus가 값을 가져가는 이 방식을 스크레이프(scrape)라고 하며, 애플리케이션은 Prometheus에 값을 보내지 않고 엔드포인트 하나만 열어 두면 됩니다.
지표 노출
Node.js 애플리케이션의 지표를 Prometheus가 수집할 수 있는 형식으로 노출하려면 prom-client 라이브러리를 씁니다. 현재 버전은 15.1이며, TypeScript 선언을 함께 제공하므로 @types/prom-client는 필요하지 않습니다.
npm install prom-client지표를 만들고 기록하는 서비스를 작성합니다. 아래 서비스는 Registry 하나에 세 종류의 지표를 등록합니다. collectDefaultMetrics()가 만드는 Node.js 프로세스 기본 지표, 요청 시간의 분포를 버킷별로 세는 히스토그램(histogram), 요청 수를 늘리기만 하는 카운터(counter)입니다. 두 HTTP 지표의 labelNames가 같으므로, 같은 메서드·route·상태 코드 조합으로 시간과 횟수를 함께 볼 수 있습니다.
import { Injectable } from '@nestjs/common';
import * as client from 'prom-client';
@Injectable()
export class MetricsService {
private readonly register = new client.Registry();
private readonly httpRequestDurationSeconds: client.Histogram<string>;
private readonly totalRequests: client.Counter<string>;
constructor() {
// 기본 메트릭 수집 활성화 (Node.js 프로세스 관련)
client.collectDefaultMetrics({ register: this.register });
// HTTP 요청 응답 시간 히스토그램
this.httpRequestDurationSeconds = new client.Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'code'] as const,
buckets: [0.1, 0.5, 1, 2, 5], // 100ms, 500ms, 1s, 2s, 5s 버킷
registers: [this.register],
});
// 총 요청 수 카운터
this.totalRequests = new client.Counter({
name: 'http_requests_total',
help: 'Total number of HTTP requests',
labelNames: ['method', 'route', 'code'] as const,
registers: [this.register],
});
}
// HTTP 요청을 기록하는 메서드
recordHttpRequest(method: string, route: string, code: number, durationMs: number) {
this.httpRequestDurationSeconds
.labels(method, route, code.toString())
.observe(durationMs / 1000); // 밀리초를 초로 변환
this.totalRequests
.labels(method, route, code.toString())
.inc();
}
getContentType(): string {
return this.register.contentType;
}
// Prometheus가 스크랩할 수 있도록 메트릭을 문자열로 반환
async getMetrics(): Promise<string> {
return this.register.metrics();
}
}히스토그램의 버킷은 누적으로 셉니다. 그래서 0.3초 걸린 요청은 observe(0.3)으로 기록되어 0.5초 이하, 1초 이하, 2초 이하, 5초 이하 버킷에 모두 더해집니다. getMetrics()는 이렇게 모은 값을 Prometheus가 읽는 텍스트 형식의 문자열로 돌려줍니다. 이제 Prometheus 서버가 지표를 가져갈 수 있는 엔드포인트를 만듭니다.
import { Controller, Get, Res } from '@nestjs/common';
import type { Response } from 'express';
import { MetricsService } from './metrics.service';
@Controller('metrics')
export class MetricsController {
constructor(private readonly metricsService: MetricsService) {}
@Get()
async getMetrics(@Res() response: Response): Promise<void> {
// Prometheus 메트릭 포맷으로 응답
response.set('Content-Type', this.metricsService.getContentType());
response.end(await this.metricsService.getMetrics());
}
}이 controller는 Express 어댑터의 Response를 쓰는 예제이며, 데코레이터가 붙은 매개변수에 쓰는 타입이므로 import type으로 가져옵니다. 운영 환경에서는 /metrics를 내부 네트워크나 인증된 수집 경로로만 열어 둡니다.
요청마다 지표를 기록하는 interceptor를 만듭니다.
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { MetricsService } from './metrics.service';
import type { Request, Response } from 'express';
@Injectable()
export class HttpMetricsInterceptor implements NestInterceptor {
constructor(private readonly metricsService: MetricsService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const startedAt = Date.now();
const httpContext = context.switchToHttp();
const request = httpContext.getRequest<Request>();
const response = httpContext.getResponse<Response>();
// raw URL 대신 bounded route template을 label로 사용
const route = request.route?.path ?? 'unmatched';
// 성공과 예외 응답 모두 최종 status로 한 번만 기록
response.once('finish', () => {
this.metricsService.recordHttpRequest(
request.method,
route,
response.statusCode,
Date.now() - startedAt,
);
});
return next.handle();
}
}이 interceptor가 측정하는 범위는 interceptor까지 들어와 finish가 발생한 응답입니다. Nest 요청 수명 주기에서 guard는 interceptor보다 먼저 실행되므로, guard가 거절한 403이나 handler를 찾지 못한 404는 이 코드로 모두 집계되지 않습니다. interceptor에 들어온 뒤 pipe나 handler에서 생긴 예외 응답은 응답이 끝날 때 기록됩니다. 전체 유입 요청 수가 필요하다면 middleware나 HTTP 어댑터처럼 더 앞쪽에서 완료를 측정하고, 중복 기록을 막습니다.
또 Node.js의 응답 finish 이벤트는 서버가 응답을 운영체제에 넘긴 시점이며, 클라이언트가 응답을 다 받았다는 뜻이 아닙니다. 중간에 연결이 끊기면 finish가 발생하지 않을 수 있으므로, 종료나 중단은 따로 처리해야 합니다. 이 코드의 측정 시간은 interceptor에 들어온 뒤의 서버 구간이며, 앞쪽 middleware·guard·네트워크까지 포함한 사용자 체감 지연과는 다릅니다.
사용자 ID나 query string이 들어 있는 원본 URL을 label로 쓰면 시계열의 개수인 카디널리티(cardinality)가 빠르게 늘어납니다. 그래서 이 코드는 request.route?.path처럼 /users/:id 형태의 route 패턴을 label로 씁니다.
앞에서 만든 서비스와 controller를 MetricsModule로 묶습니다. AppModule의 interceptor도 같은 서비스를 받아야 하므로 MetricsService를 exports에 넣습니다.
import { Module } from '@nestjs/common';
import { MetricsController } from './metrics.controller';
import { MetricsService } from './metrics.service';
@Module({
controllers: [MetricsController],
providers: [MetricsService],
exports: [MetricsService],
})
export class MetricsModule {}서비스 등록을 MetricsModule 한곳에 두면 controller와 interceptor가 같은 인스턴스의 Registry를 씁니다. 아래 AppModule은 이 모듈을 가져오고 HttpMetricsInterceptor를 전역 interceptor로 등록합니다.
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core'; // APP_INTERCEPTOR 임포트
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { MetricsModule } from './metrics/metrics.module';
import { HttpMetricsInterceptor } from './metrics/http-metrics.interceptor';
@Module({
imports: [MetricsModule], // MetricsModule 임포트
controllers: [AppController],
providers: [
AppService,
{
provide: APP_INTERCEPTOR, // 전역 인터셉터로 HttpMetricsInterceptor 등록
useClass: HttpMetricsInterceptor,
},
],
})
export class AppModule {}Prometheus와 Grafana 실행
Prometheus의 prometheus.yml에 NestJS 애플리케이션의 /metrics를 수집 대상으로 추가합니다. job_name은 이 대상에서 수집한 시계열에 job label로 붙는 이름이고, targets는 호스트:포트 목록입니다. 경로를 따로 적지 않으면 Prometheus는 기본 경로인 /metrics를 요청합니다.
scrape_configs:
- job_name: 'nestjs_app'
static_configs:
- targets: ['host.docker.internal:3000'] # Docker Desktop에서 호스트의 Nest 앱위 YAML은 prefix가 없는 /metrics route를 기준으로 합니다. 8장의 api prefix를 유지한다면 수집 job에 metrics_path: /api/metrics를 추가하거나, metrics route를 prefix에서 제외해야 합니다. URI 버전 관리도 쓴다면 실제 URL을 확인합니다. 앱은 정상인데 수집 결과가 404라면 먼저 수집 경로와 실제 route를 비교합니다.
Prometheus와 NestJS를 같은 Docker 네트워크에서 실행한다면 host.docker.internal 대신 NestJS 서비스 이름과 포트를 씁니다. 컨테이너 안의 localhost는 Prometheus 컨테이너 자신을 가리키기 때문입니다.
Prometheus는 docker run -p 9090:9090 -v /path/to/prometheus.yml:/etc/prometheus/prometheus.yml prom/prometheus로 실행할 수 있습니다.
Grafana는 docker run -p 3001:3000 grafana/grafana로 실행하고, 기본 계정 admin/admin으로 로그인해 Prometheus를 데이터 소스로 추가한 뒤 대시보드를 만듭니다. http_request_duration_seconds_bucket, http_requests_total 같은 지표로 응답 시간과 요청 수를 시각화할 수 있습니다.
더 깊이: 버킷으로 계산한 p95가 추정값인 이유
대시보드의 p95는 보통 histogram_quantile()로 버킷의 누적 개수에서 계산합니다. 히스토그램은 버킷마다 개수만 저장하고 각 요청의 정확한 시간은 남기지 않으므로, 이 값은 분위수가 들어 있는 버킷 안에서 보간한 추정값입니다. Prometheus 문서는 그 오차가 분위수가 들어 있는 버킷의 너비로 제한된다고 설명합니다. 이 글의 버킷은 0.1초 다음이 0.5초이므로, 그 사이에 몰린 요청의 p95는 최대 400밀리초까지 어긋날 수 있습니다.
문서의 예가 이 차이를 잘 보여 줍니다. 버킷의 상한을 0.1, 0.2, 0.3, 0.45초로 둔 서비스에서 거의 모든 요청이 220밀리초 근처였다가, 라우팅 변경으로 모든 요청에 100밀리초가 더해졌습니다. 실제 p95는 320밀리초 근처인데, 요청이 거의 다 300에서 450밀리초 사이의 넓은 버킷에 들어가므로 추정값은 443밀리초로 나왔습니다. 300밀리초 목표를 조금 넘긴 서비스가 훨씬 나빠 보이게 된 것입니다. 그래서 문서는 SLO의 목표 시간처럼 판단에 쓰는 값 근처에 버킷을 촘촘히 두라고 권하며, 여러 인스턴스의 값을 합쳐 분위수를 구해야 한다면 summary가 아니라 히스토그램을 써야 한다고 설명합니다.
프로파일링이란
프로파일링은 코드 실행을 분석해 특정 함수나 코드 블록이 CPU 시간, 메모리, I/O 같은 자원을 얼마나 쓰는지 자세히 측정하는 과정입니다. 모니터링이 시스템의 전반적인 건강 상태를 확인한다면, 프로파일링은 병목 후보 함수, 호출 경로, 메모리를 붙잡고 있는 객체로 원인을 좁히는 데 씁니다. 다만 샘플링 결과가 항상 정확한 한 줄의 원인을 보장하지는 않습니다.
프로파일링으로 느린 함수, 과도한 CPU 사용, 메모리 누수 같은 병목을 찾고, 성능 개선 작업의 우선순위를 정하며, 애플리케이션이 실행 중에 자원을 어떻게 쓰는지 이해할 수 있습니다.
도구로는 --inspect 플래그로 Chrome DevTools나 VS Code에 연결하는 Node.js 내장 프로파일러, Flamegraph·Doctor·Bubbleprof 같은 시각화를 제공하는 clinic.js, 코드 수준 분석을 함께 제공하는 Datadog·New Relic 같은 APM 도구가 있습니다. CPU 병목, 이벤트 루프 지연, 메모리 누수, 분산 trace 가운데 무엇을 좁히려는지에 따라 도구를 고릅니다.
Node.js 런타임 프로파일링
가장 간단한 방법은 Node.js 내장 inspector를 쓰는 것입니다. 먼저 애플리케이션을 디버그 모드로 실행합니다.
node --inspect dist/main # 또는 nest start --debug콘솔에 Debugger listening on ws://127.0.0.1:9229/... 같은 메시지가 출력됩니다. inspector는 기본적으로 127.0.0.1에만 바인딩되며, 그대로 두어야 합니다.
Chrome 브라우저에서 chrome://inspect에 접속하면 Remote Target 영역에 실행 중인 Node.js 인스턴스가 나타나고, inspect 링크를 누르면 전용 DevTools가 열립니다. Chrome의 공식 Node 프로파일링 안내는 이 Node 전용 DevTools의 Performance 패널을 씁니다. 웹 페이지의 로딩 프로파일과 Node의 CPU 프로파일을 구분하고, 같은 엔드포인트·부하·기록 시간으로 비교합니다.
DevTools의 Performance 탭에서 Record 버튼을 누르고, Postman이나 부하 테스트 도구로 애플리케이션에 요청을 보낸 뒤 Stop 버튼을 누릅니다. 기록이 로드되면 다음 화면으로 결과를 분석합니다.
- Flame Chart: 함수 호출 스택과 각 함수의 실행 시간을 시각적으로 보여 줍니다. 넓은 블록일수록 그 함수가 시간을 많이 썼다는 뜻입니다.
- Bottom-Up: 각 함수가 자기 자신만으로(Self Time), 또는 호출한 함수까지 포함해(Total Time) 얼마나 시간을 썼는지 표로 보여 줍니다. CPU 시간을 많이 쓰는 병목 함수를 찾을 때 씁니다.
- Call Tree: 함수 호출 흐름을 트리 형태로 탐색합니다.
clinic.js 활용
현재 Clinic.js 공식 저장소는 더 이상 적극적으로 유지보수되지 않으며, Node.js 내부 변화 때문에 실행이나 결과의 정확성이 영향을 받을 수 있다고 안내합니다. 아래 명령은 이 도구의 사용 흐름을 설명하기 위한 것이며, Node.js 24에서 실행을 확인했다는 뜻이 아닙니다. 사용 중인 Node.js 버전과의 호환성을 확인하지 않은 채 새 분석의 기본 도구로 쓰지 말고, 먼저 위의 Node.js·DevTools 방법으로 재현할 수 있는 CPU 프로파일을 확보합니다.
clinic.js는 Node.js 애플리케이션의 성능 문제를 진단하고 시각화하는 도구입니다. 전역으로 설치합니다.
npm install -g clinicclinic doctor는 CPU, 이벤트 루프 지연, 메모리, GC(Garbage Collection) 같은 지표를 모아 흔한 Node.js 성능 병목을 진단하고 보고서를 만듭니다. 실행하는 동안 애플리케이션에 부하를 주면, 분석이 끝난 뒤 병목을 시각적으로 보여 주는 HTML 보고서가 열립니다.
clinic doctor -- node dist/main.js # NestJS 앱 실행 명령clinic flame은 CPU 사용량을 자세히 분석해 Flamegraph를 만듭니다. 특정 함수가 CPU를 얼마나 쓰는지 한눈에 파악할 때 씁니다. 마찬가지로 실행 중에 부하를 주면 끝난 뒤 보고서가 열립니다.
clinic flame -- node dist/main.js성능 개선은 알림, 원인 좁히기, 프로파일 확보, 수정 배포, 재측정까지 하나의 반복 과정으로 관리해야 같은 문제가 다시 생기는 것을 줄일 수 있습니다.
정리
- 모니터링은 시스템 자원, 응답 시간·처리량·오류율, 데이터베이스 지표를 계속 수집해 이상을 알리고, 프로파일링은 특정 구간의 CPU·메모리 사용을 자세히 측정해 원인을 좁힙니다.
prom-client로 지표를 만들고/metrics엔드포인트로 노출하면 Prometheus가 수집하고 Grafana가 시각화합니다.- interceptor로 측정한 지표는 guard를 통과해 interceptor에 들어온 요청만 포함하므로, 전체 요청 수가 필요하면 더 앞쪽에서 측정합니다.
- 지표 label에는 원본 URL 대신 route 패턴처럼 개수가 제한된 값을 씁니다.
node --inspect와 Chrome DevTools로 CPU 프로파일을 기록하며, inspector는 공개 주소에 바인딩하지 않습니다.
연습 문제
1. 250ms 요청은 어느 bucket에 들어갈까
같은 label의 기록 하나만 봅니다. 이 interceptor에 들어온 요청이 250ms 뒤 finish를 발생시켰습니다. 관측값은 몇 초이며, 위의 히스토그램에서 le="0.1", le="0.5", le="1", le="2", le="5", +Inf 가운데 어느 누적 bucket이 1씩 늘어날까요? interceptor보다 먼저 guard가 거절한 다른 요청도 이 counter에 더해질까요?
해설 보기
250 ÷ 1_000 = 0.25초입니다. Prometheus의 histogram bucket은 상한 이하의 관측을 누적해서 세므로 0.1은 늘지 않고 0.5, 1, 2, 5, +Inf가 각각 1씩 늘어납니다. 요청 하나가 여러 bucket에 들어가는 것은 요청이 여러 개라는 뜻이 아닙니다. count와 http_requests_total도 1씩 늘어납니다.
guard가 거절한 요청은 interceptor에 들어오지 않았으므로 이 counter에는 기록되지 않습니다. 이 지표를 전체 요청 수라고 보고하려면 측정을 시작하는 위치를 앞쪽으로 옮겨야 합니다.
2. 원본 URL을 label로 쓰면
누군가 interceptor의 route를 request.route?.path 대신 request.originalUrl로 바꾸었습니다. /users/1, /users/2처럼 사용자 ID가 들어가는 요청과 ?page=3 같은 query string이 섞여 들어오면 Prometheus에서 무슨 일이 생길까요?
해설 보기
label 값이 다르면 Prometheus는 서로 다른 시계열로 저장합니다. 사용자 ID와 query string 조합마다 새 시계열이 생기므로, 히스토그램의 bucket 수까지 곱해 시계열이 빠르게 늘어나고 Prometheus의 메모리와 저장 공간, 쿼리 속도가 나빠집니다.
또 /users/:id 단위로 응답 시간을 보고 싶을 때 수많은 시계열을 다시 합쳐야 하므로 대시보드도 쓰기 어려워집니다. label에는 route 패턴처럼 가짓수가 제한된 값을 쓰고, 개별 요청의 정보는 로그나 trace에 남깁니다.
3. 운영 서버에서 inspector를 열면
운영 서버의 CPU 사용률이 높아 원인을 보려고, 팀원이 컨테이너에서 node --inspect=0.0.0.0:9229 dist/main.js로 실행하고 9229번 포트를 외부에 열자고 제안했습니다. 무엇이 문제일까요?
해설 보기
Node.js 공식 문서는 inspector를 0.0.0.0을 포함한 공개 주소에 열린 포트로 바인딩하면, 외부 호스트가 inspector에 접속해 원격 코드 실행 공격을 할 수 있으므로 안전하지 않다고 경고합니다. inspector에 연결하면 프로세스 안에서 임의의 코드를 실행할 수 있기 때문입니다.
운영 환경에서는 inspector를 기본값인 127.0.0.1에 두고 SSH 터널이나 kubectl port-forward처럼 인증된 경로로만 접속하거나, 운영 트래픽을 재현한 별도 환경에서 프로파일링합니다. 지속적인 운영 분석이 필요하다면 접근 통제가 있는 APM 도구의 프로파일링 기능을 씁니다.
이것으로 9장을 마칩니다. 9장에서는 캐시, 데이터베이스 쿼리 최적화, 수평 확장, 모니터링과 프로파일링으로 병목을 찾고 변경 효과를 확인하는 흐름을 이어 보았습니다. 다음 장의 CORS, CSRF, XSS 대응부터는 애플리케이션의 보안을 다룹니다.