Node 서버의 메모리가 계속 오르면, 먼저 누수인지 캐시가 차는 중인지를 가르고, 그다음 무엇이 쌓이고 누가 붙잡고 있는지를 힙 스냅샷(heap snapshot)으로 찾는다. 도구는 Node에 내장된 inspector와 Chrome DevTools의 Memory 패널이다.
1. 누수인가, 캐시인가
process.memoryUsage().heapUsed를 30분~1시간 간격으로 기록한다. 값은 GC(Garbage Collection) 때마다 오르내리므로 GC 직후의 바닥값을 본다.
- 바닥값이 트래픽에 비례해 계속 오른다 → 누수 의심
- 일정 높이에서 평평해진다 → 상한 있는 캐시(LRU 등)가 다 찬 것일 수 있다. 상한이 너무 크면 설정 문제지 누수는 아니다(LRU 캐시와 캐시 계층 비교)
2. 실행 중인 프로세스에 inspector 붙이기
kill -USR1 <PID> # 재시작 없이 inspector 켜기(Linux·macOS)
# 로그: Debugger listening on ws://127.0.0.1:9229/<UUID>- 처음부터 켜려면
node --inspect. 기본 주소는127.0.0.1:9229다 - 신호는 래퍼(npm·pnpm 스크립트, 셸)가 아니라 실제 V8이 도는 자식 프로세스에 보내야 한다
- 원격 서버라면 inspector를 외부에 열지 말고 SSH 터널로 가져온다. 그다음 로컬 Chrome의
chrome://inspect에서localhost:9229를 추가하고 inspect를 누른다
ssh -N -L 9229:127.0.0.1:9229 user@server # 로컬 9229 → 서버의 127.0.0.1:9229보안: inspector에 접속하면 그 프로세스에서 임의 코드를 실행할 수 있다. --inspect=0.0.0.0처럼 공개 주소에 바인딩하지 않는다. 운영에서 신호로 켜지는 것 자체를 막으려면 --disable-sigusr1(Node 22.14·23.7부터)을 쓴다. 분석이 끝나면 프로세스를 재시작해 inspector를 닫는다.
3. 무엇을 할당하나: allocation sampling
Memory 패널의 Allocation sampling은 할당을 표본으로만 기록해 부하가 작아 운영 트래픽을 받는 중에도 돌릴 만하다. 10~30분 돌린 뒤 Heavy(Bottom Up) 보기로 할당량이 큰 함수를 본다. 다만 "많이 할당하는 곳"이지 "해제되지 않는 곳"은 아니다. 할당이 많아도 곧 해제되면 정상이다.
4. 무엇이 남나: 스냅샷 두 장 비교
- 프로세스가 충분히 데워진 뒤(모듈 로딩·JIT 워밍업이 끝난 뒤) GC를 강제로 돌리고(휴지통 아이콘) 첫 스냅샷
- 정상 트래픽을 30~60분 흘린다. 트래픽이 없으면 누수도 쌓이지 않는다
- 다시 GC 후 두 번째 스냅샷. 두 장은 같은 프로세스여야 한다
- 두 번째 스냅샷에서 Summary 대신 Comparison 보기를 고르고 첫 스냅샷을 기준으로 둔다.
# Delta(개수 증가)와 크기 증가로 정렬한다
다른 객체는 할당된 만큼 해제되는데 특정 생성자(constructor)만 해제가 0에 가깝다면 그게 범인 후보다.
5. 누가 붙잡나: retainer 읽기
의심 객체를 하나 골라 아래 Retainers 패널을 펼치면, GC 루트에서 그 객체까지 이어지는 참조 경로가 거꾸로 보인다. 흔한 결말은 이렇다.
- 모듈 스코프의
Map·배열·싱글턴 → 요청마다 넣고 빼지 않는다 - 타이머·이벤트 리스너·구독 → 해제(cleanup)가 서버에서는 안 불린다. 예를 들어 서버 렌더링 중 모듈 전역 상태 저장소나 데이터 페칭 클라이언트를 요청 간에 공유하면, 렌더마다 만든 구독이 쌓인다(TanStack Query의 QueryClient는 요청마다 새로 만든다)
- 클로저 → 요청 객체 전체를 캡처한 콜백
주의
- 스냅샷을 찍는 동안 메인 스레드가 멈춘다. 1분 넘게 걸릴 수 있고 메모리를 힙 크기만큼 더 쓸 수 있어 프로세스가 죽을 수도 있다. 트래픽이 적을 때, 빠져도 되는 인스턴스에서 찍는다(로드밸런서에서 먼저 빼기)
- 접속 없이 파일로 남기려면
node --heapsnapshot-signal=SIGUSR2로 띄워 신호를 보내거나, 코드에서v8.writeHeapSnapshot()을 부른다. 받은 파일은 DevTools Memory 패널에서 Load로 연다 - 원인을 찾으면 고친 뒤 같은 절차로 바닥값이 평평해지는지 확인한다
출처: Node.js — Debugging Node.js · Node.js — Using Heap Snapshot · Node.js — Using Heap Profiler · Node.js CLI — --disable-sigusr1 · Chrome DevTools — Record heap snapshots