Node.js 메모리 누수(Memory Leak) 발생 시 –inspect 플래그와 Chrome DevTools로 원인 추적하기
메모리 누수는 Node.js 서버가 오랜 시간 운영될수록 서서히 모습을 드러낸다. 처음에는 응답이 조금 느려지고, 나중에는 프로세스가 아예 다운된다. pm2 logs나 클라우드 모니터링 대시보드에서 RSS(Resident Set Size) 수치가 꾸준히 우상향하는 그래프를 본 적 있다면, 이미 메모리 누수를 경험한 것이다.
이 글에서는 로컬 환경과 원격 서버 모두에서 --inspect 플래그를 활성화하고, Chrome DevTools의 Memory 패널을 이용해 힙 스냅샷(Heap Snapshot)을 비교하여 누수 원인을 찾아내는 실제 워크플로를 다룬다.
메모리 누수가 발생하는 주요 원인
Node.js의 가비지 컬렉터(GC)는 참조가 끊긴 객체를 자동으로 회수한다. 문제는 개발자가 의도치 않게 참조를 유지하는 경우다.
- 전역 변수나 모듈 수준 캐시에 데이터를 무한정 축적: 요청마다 데이터를 Map이나 배열에 추가하면서 삭제 로직이 없는 경우
- 이벤트 리스너 미해제:
EventEmitter에 리스너를 등록하고 제거하지 않으면 리스너가 참조하는 클로저와 데이터가 메모리에 남는다 - 클로저가 외부 스코프를 과도하게 참조: 긴 수명의 함수가 대용량 버퍼나 객체를 클로저로 묶어두는 패턴
- 타이머 미정리:
setInterval이나setTimeout콜백 내부에서 외부 변수를 참조할 때 타이머를clearInterval로 정리하지 않는 경우 - Promise 체인이나 async 작업 내 참조 유지: 결코 resolve되지 않는 Promise가 쌓이는 경우
증상이 명확하지 않을 때는 원인을 짐작만 하며 코드를 뒤지는 것보다 도구로 데이터를 먼저 수집하는 편이 훨씬 효율적이다.
–inspect 플래그로 디버거 연결하기
Node.js는 --inspect 플래그를 붙이면 V8 인스펙터 프로토콜을 활성화한다. 기본 포트는 9229다.
node --inspect app.js
터미널에 아래와 같은 메시지가 출력되면 정상이다.
Debugger listening on ws://127.0.0.1:9229/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
이미 실행 중인 프로세스에 인스펙터를 붙이고 싶다면 SIGUSR1 시그널을 보내면 된다.
kill -USR1 <PID>
원격 서버에서 연결할 때
운영 서버에서 디버깅이 필요할 경우, 인스펙터 포트를 외부에 직접 노출하는 것은 보안상 위험하다. SSH 포트 포워딩을 이용해 로컬 포트로 터널링하는 방법을 사용한다.
ssh -L 9229:localhost:9229 user@remote-server
이후 로컬 Chrome에서 chrome://inspect로 접속하면 원격 Node.js 프로세스가 Remote Target 목록에 나타난다.
Chrome DevTools Memory 패널 기본 사용법
Chrome 브라우저 주소창에 chrome://inspect를 입력하면 연결된 Node.js 프로세스 목록이 보인다. inspect 링크를 클릭하면 DevTools 창이 열린다.
Memory 패널에서 사용할 수 있는 프로파일링 타입은 세 가지다.
| 프로파일링 타입 | 용도 |
|---|---|
| Heap snapshot | 특정 시점의 힙 전체 구조를 스냅샷으로 저장 |
| Allocation instrumentation on timeline | 시간대별 메모리 할당 추이를 실시간 기록 |
| Allocation sampling | 낮은 오버헤드로 할당 스택 샘플링 |
메모리 누수 원인 추적에는 Heap snapshot 방식이 가장 직관적이다.
힙 스냅샷 비교로 누수 객체 특정하기
힙 스냅샷의 핵심 전략은 ‘스냅샷 비교(Comparison)’다. 누수가 없다면 GC 이후 힙 크기는 회복되어야 한다. 회복되지 않고 특정 객체 수가 계속 증가한다면 그것이 누수 원인이다.
스냅샷 촬영 순서
- 서버를 시작하고 기준 상태가 될 스냅샷 1을 찍는다.
- 누수가 의심되는 요청이나 동작을 일정 횟수 반복한다 (예: API 엔드포인트를 수십 회 호출).
- 스냅샷 2를 찍는다.
- 동작을 다시 반복한 뒤 스냅샷 3을 찍는다.
Comparison 뷰로 분석하기
스냅샷 2를 선택한 상태에서 상단 드롭다운을 Comparison으로 바꾸고, 비교 대상으로 스냅샷 1을 선택한다. # New(새로 생성된 객체 수)와 # Deleted(삭제된 객체 수) 컬럼을 확인한다.
# New가 크고# Deleted가 거의 0에 가깝다면 해당 타입의 객체가 누적되고 있다는 신호다.Delta컬럼(메모리 증분)이 양수로 지속 증가하는 타입에 집중한다.
Retainers 탐색으로 참조 경로 파악하기
의심 객체를 클릭하면 하단에 Retainers 패널이 열린다. 이 패널은 해당 객체를 메모리에 붙잡아두고 있는 참조 체인을 보여준다.
예를 들어 Array 객체가 누수되고 있다면, Retainers를 따라가다 보면 특정 모듈의 전역 변수나 이벤트 리스너가 그 배열을 참조하고 있는 것을 발견할 수 있다.
참조 경로는 object → property → ...→ (GC root)의 형태로 역방향으로 표시된다. GC 루트에 가까울수록 누수의 실질적인 원인에 해당한다.
실제 누수 시나리오와 수정 예시
다음은 전형적인 메모리 누수 패턴과 수정 방법이다.
이벤트 리스너 누수
// 누수 발생 코드
function handleRequest(req, res) {
process.on('uncaughtException', (err) => {
console.error(err);
});
// 요청마다 리스너가 추가되고 제거되지 않음
}
// 수정: 리스너를 모듈 레벨에서 한 번만 등록
process.on('uncaughtException', (err) => {
console.error(err);
});
function handleRequest(req, res) {
// ...
}
무제한 캐시 누수
// 누수 발생 코드
const cache = new Map();
function fetchData(key) {
if (!cache.has(key)) {
cache.set(key, expensiveCompute(key));
}
return cache.get(key);
}
// 삭제 로직 없이 데이터가 쌓임
// 수정: LRU 캐시 같은 크기 제한 캐시 사용
const LRU = require('lru-cache');
const cache = new LRU({ max: 500 });
힙 스냅샷에서 Map 타입의 # New가 계속 증가한다면 위와 같은 패턴을 의심할 수 있다.
–inspect-brk 와의 차이점
--inspect-brk는 --inspect와 달리 첫 번째 줄에서 실행을 멈추고 DevTools가 연결되기를 기다린다. 시작 시점의 초기화 문제를 디버깅할 때 유용하다. 메모리 누수 추적은 애플리케이션이 정상적으로 실행된 상태에서 진행해야 하므로, 이 경우에는 --inspect를 사용하는 것이 적합하다.
프로덕션 환경에서 주의할 점
인스펙터를 활성화하면 V8이 추가적인 메타데이터를 유지하기 때문에 메모리와 CPU 오버헤드가 발생한다. 운영 환경에서는 다음 사항을 고려한다.
- 인스펙터 포트(
9229)를 방화벽으로 차단하고 반드시 SSH 터널을 통해서만 접근한다. - 힙 스냅샷 자체가 큰 메모리 할당을 유발할 수 있으므로, 힙이 이미 수 GB에 달하는 상황이라면 스냅샷 촬영 시점을 신중하게 선택한다.
- 짧은 시간만 인스펙터를 활성화하고 분석 후 비활성화하는 것이 바람직하다.
node --inspect를 장시간 활성화한 채 운영하는 것은 피한다.
heapdump 모듈을 대안으로 사용하는 경우
Chrome DevTools 접근이 어려운 환경에서는 heapdump 또는 v8.writeHeapSnapshot() API를 사용해 파일로 스냅샷을 내보낼 수 있다.
const v8 = require('v8');
// SIGUSR2 시그널을 받을 때 힙 스냅샷을 파일로 저장
process.on('SIGUSR2', () => {
const filename = v8.writeHeapSnapshot();
console.log(`Heap snapshot written to ${filename}`);
});
저장된 .heapsnapshot 파일은 Chrome DevTools의 Memory 패널에서 Load 버튼으로 불러와 동일하게 분석할 수 있다. CI/CD 파이프라인이나 Docker 컨테이너 환경처럼 직접 브라우저 연결이 어려운 상황에서 유용한 방법이다.
메모리 사용량 지속 모니터링
누수를 발견하고 수정했더라도, 재발 여부를 지속적으로 확인하는 체계가 필요하다. process.memoryUsage()를 주기적으로 기록해 메모리 추이를 모니터링하는 간단한 방법을 프로젝트에 추가해두면 조기 감지에 도움이 된다.
setInterval(() => {
const mem = process.memoryUsage();
console.log({
rss: `${Math.round(mem.rss / 1024 / 1024)} MB`,
heapUsed: `${Math.round(mem.heapUsed / 1024 / 1024)} MB`,
heapTotal: `${Math.round(mem.heapTotal / 1024 / 1024)} MB`,
});
}, 30000);
Datadog, Prometheus, New Relic 같은 APM 도구를 사용한다면 heapUsed 지표에 알림 임계값을 설정해 두는 것이 실용적이다.
메모리 누수 디버깅은 단발성 작업이 아니다. --inspect와 힙 스냅샷 비교를 통해 데이터 기반으로 원인을 특정하고, 수정 후에도 지표를 통해 검증하는 사이클이 안정적인 Node.js 서버 운영의 기반이 된다.