노트

운영 Node 프로세스의 메모리 누수 찾기

Debugging Node.js Memory Leaks with Heap Snapshots

백엔드#debugging#js · 연결된 개념 3개

쉽게 말하면

Node 메모리 누수 찾기는 방이 자꾸 어질러질 때 시간 간격을 두고 사진 두 장을 찍어 비교해서, 무엇이 쌓였고 누가 안 치우고 붙잡고 있는지 찾는 절차예요. 서버를 끄지 않고 살아 있는 채로 해요.

비유가 깨지는 곳 사진과 달리 스냅샷을 찍는 동안엔 메인 스레드가 멈추고 메모리도 더 써서 프로세스가 죽을 수 있어요. 빠져도 되는 인스턴스에서 찍고, inspector는 공개 주소에 열지 않아요.

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. 무엇이 남나: 스냅샷 두 장 비교

  1. 프로세스가 충분히 데워진 뒤(모듈 로딩·JIT 워밍업이 끝난 뒤) GC를 강제로 돌리고(휴지통 아이콘) 첫 스냅샷
  2. 정상 트래픽을 30~60분 흘린다. 트래픽이 없으면 누수도 쌓이지 않는다
  3. 다시 GC 후 두 번째 스냅샷. 두 장은 같은 프로세스여야 한다
  4. 두 번째 스냅샷에서 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

연결된 개념

이 노트를 가리키는 문서

아직 없습니다.

뜻이 가까운 노트

  • Activity와 숨겨진 라우트 보존

    Activity는 React 19.2의 컴포넌트로, UI를 언마운트하지 않고 display: none으로 숨기면서 state와 DOM(Document Object Model)을 보존한다. 숨기는 동안 Effect는 정리(cleanup)된다. 예전 이름은 Offscreen이다.

  • 버전 스큐와 정적 자산 오프로드

    버전 스큐(Version Skew)는 새 버전을 배포한 뒤에도 브라우저에 떠 있는 이전 빌드의 클라이언트가 새 서버와 통신하면서 생기는 불일치다. 대표 증상은 이전 HTML이 참조하던 JS 청크를 늦게 불러오려다 404가 나는 ChunkLoadError다.

  • 스택

    나중에 넣은 것을 먼저 꺼내는(Last In, First Out) 자료구조. 접시를 쌓듯 맨 위에서만 넣고(push) 꺼낸다(pop). 넣기·꺼내기 모두 O(1)이다.

  • 이벤트 루프

    자바스크립트를 실행하는 하나의 스레드가 할 일을 큐에서 하나씩 꺼내 처리하는 반복 구조. JS 엔진 자체에는 타이머도 네트워크도 없고, 브라우저(또는 Node.js)가 이 루프를 돌리면서 콜백을 엔진에 넘겨준다.

  • kubectl 디버깅 치트시트

    kubectl은 쿠버네티스 클러스터를 조회·조작하는 CLI(Command-Line Interface)다. 앱 개발자가 가장 자주 쓰는 건 "내 Pod가 떠 있나, 왜 죽었나"를 확인하는 명령들이다.

보기 옵션