Contents
see List간헐적으로만 느려지는 Node.js API를 먼저 의심할 곳
Node.js API가 평소에는 빠르지만 특정 시간대에만 모든 요청이 함께 느려진다면 데이터베이스나 네트워크보다 이벤트 루프가 멈추는 상황을 먼저 확인할 필요가 있습니다. Node.js는 JavaScript 실행을 기본적으로 하나의 이벤트 루프에서 처리합니다. 파일 읽기, 소켓 I/O처럼 런타임에 위임되는 작업은 동시에 진행할 수 있지만, 긴 반복문·대용량 JSON 변환·암호화·압축·복잡한 정규식처럼 CPU를 계속 점유하는 JavaScript는 다음 요청의 실행도 밀어냅니다.
이 문제는 평균 응답 시간만 보면 놓치기 쉽습니다. 대부분의 요청은 빠르기 때문에 평균은 정상처럼 보이지만, 이벤트 루프가 300ms 멈춘 순간에는 이미 연결된 모든 요청의 지연 시간이 커집니다. 운영에서는 p95·p99 응답 시간, CPU 사용률, 이벤트 루프 지연을 같은 시점에 비교해야 원인을 좁힐 수 있습니다.
지연을 수치로 기록하기
Node.js의 perf_hooks 모듈은 이벤트 루프 지연을 히스토그램으로 측정할 수 있습니다. 아래 예제는 10초마다 평균과 p99 지연을 밀리초 단위로 출력합니다. 단순 로그부터 시작해 APM 또는 메트릭 수집기로 보내면 배포 이후의 변화도 비교할 수 있습니다.
import { monitorEventLoopDelay } from 'node:perf_hooks';
const delay = monitorEventLoopDelay({ resolution: 20 });
delay.enable();
setInterval(() => {
const ms = 1e6;
console.log({
eventLoopMeanMs: Number((delay.mean / ms).toFixed(2)),
eventLoopP99Ms: Number((delay.percentile(99) / ms).toFixed(2)),
eventLoopMaxMs: Number((delay.max / ms).toFixed(2))
});
delay.reset();
}, 10_000).unref();측정값은 서버의 정상 상태 기준선을 먼저 확보한 뒤 해석합니다. 일반적인 API 서버에서 p99 지연이 수십 ms를 지속적으로 넘거나, 특정 요청 직후 수백 ms 이상 튄다면 해당 요청의 동기 처리 경로를 추적합니다. 다만 컨테이너 CPU 제한, VM의 CPU steal, GC, 과도한 로그 출력도 지연을 만들 수 있으므로 지연 수치 하나만으로 코드 결함이라고 단정하지는 않습니다.
자주 놓치는 차단 코드
JSON.parse와 JSON.stringify는 큰 객체에서 예상보다 긴 시간을 사용합니다. 업로드된 대형 JSON을 통째로 메모리에 올리거나, 응답 직전에 수만 건의 배열을 변환하는 방식은 위험합니다. 또한 동기 파일 API인 readFileSync, 복잡한 정규식의 역추적, 비밀번호 해시와 이미지 변환을 웹 요청 처리 함수 안에서 직접 수행하는 패턴도 점검 대상입니다.
먼저 요청 데이터와 응답 데이터의 최대 크기를 제한하고 페이지네이션을 적용합니다. 외부 API 결과도 필요한 필드만 선별해 반환합니다. CPU를 쓰는 처리가 정말 필요한 경우에는 요청 흐름에서 시간을 쪼개거나 별도 워커로 보냅니다. setImmediate로 작업을 나누는 방식은 짧은 계산을 양보하는 데는 도움이 되지만, 전체 CPU 비용을 줄여 주지는 않는다는 점을 구분해야 합니다.
CPU 작업은 Worker Threads로 분리하기
암호화, 문서 분석, 대형 CSV 집계처럼 CPU 사용 자체가 필요한 작업은 worker_threads로 분리할 수 있습니다. 워커는 별도 JavaScript 실행 환경에서 계산하므로 주 이벤트 루프는 새 HTTP 요청을 계속 받을 수 있습니다. 워커를 요청마다 무한정 생성하면 오히려 생성 비용과 CPU 경합이 커지므로, 실서비스에서는 동시 실행 개수를 제한한 워커 풀을 두는 것이 안전합니다.
// hash-worker.mjs
import { parentPort, workerData } from 'node:worker_threads';
import { createHash } from 'node:crypto';
const digest = createHash('sha256')
.update(workerData.text, 'utf8')
.digest('hex');
parentPort.postMessage({ digest });
// server.mjs
import { Worker } from 'node:worker_threads';
function makeHash(text) {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./hash-worker.mjs', import.meta.url), {
workerData: { text }
});
worker.once('message', resolve);
worker.once('error', reject);
worker.once('exit', code => {
if (code !== 0) reject(new Error('worker exit: ' + code));
});
});
}워커로 넘기는 데이터도 크기를 관리해야 합니다. 객체 복사 비용이 큰 경우에는 파일 경로, 작업 ID, 데이터베이스 키처럼 작은 참조값만 보내고 워커가 필요한 데이터를 읽게 하는 설계가 유리할 수 있습니다. 반대로 메모리 공유가 필요한 고급 사례는 SharedArrayBuffer를 검토하되, 동기화 복잡성과 운영 난도를 함께 고려합니다.
운영에서 재발을 막는 절차
느린 요청이 발생했을 때는 요청 URL, 응답 코드, 처리 시간, 요청·응답 크기, 이벤트 루프 p99, 프로세스 CPU와 메모리를 같은 로그 또는 대시보드에서 연결합니다. 부하 테스트에서는 평균 TPS만 보지 말고, 무거운 요청과 일반 조회 요청을 섞어 실행해 일반 요청의 p99가 유지되는지 확인합니다. 클러스터나 컨테이너 수를 늘리는 것은 처리량에는 도움이 되지만 하나의 프로세스 안에서 발생한 차단 코드를 해결하지는 못합니다.
적용 체크리스트
- 이벤트 루프 평균·p99·최대 지연을 주기적으로 수집한다.
- 대용량 JSON 변환, 동기 I/O, 복잡한 정규식, CPU 계산을 요청 경로에서 찾는다.
- 입력과 응답의 크기 제한 및 페이지네이션을 적용한다.
- 무거운 계산은 동시성 제한이 있는 워커 풀 또는 비동기 작업 큐로 분리한다.
- 배포 전 혼합 부하 테스트에서 일반 API의 p99 응답 시간을 검증한다.
javascript
| No | 작성일 | Title |
|---|---|---|
| 1141 | 2016. 11. 01. | [ AngularJS ] ng-if 의 경우 상위 디렉브를 생성한다. |
| 1139 | 2016. 10. 21. | [ javascript ] 반응형 웹에서 PC 버전보기 구현 |
| 1076 | 2016. 09. 09. | [ AngularJS ] promise then 사용 |
| 1075 | 2016. 09. 07. | [ AngularJS ] 형제 태그 ng-repeat |
| 1074 | 2016. 09. 07. | [ AngularJS ] 의 이벤트 전파 |
| 1041 | 2016. 08. 18. | [ AngularJS ] json parsing spring ajax (and $$hashKey error ) |
| 1040 | 2016. 08. 18. | [ AngularJS ] select option ng-options |
| 1039 | 2016. 08. 18. | [ AngularJS ] ng-repeat 에서 ng-model 사용시 제대로 작동안할때 |
| 1038 | 2016. 08. 16. | [ AngularJS ] 어느 위치에서나 scope 에 접근하기 (전역,지역) |
| 360 | 2016. 05. 31. | [ bootstrap ] bootstrap modal multi modal wheel and focus not work |