Node.js 메모리 누수 추적기 - heapdump와 Chrome DevTools
문제 상황
프로덕션 서버의 메모리 사용량이 지속적으로 증가하다가 결국 OOM으로 재시작되는 현상이 반복됐다. 모니터링 그래프를 보니 일주일에 한 번 정도 메모리가 4GB까지 올라가며 크래시가 발생했다.
분석 과정
heapdump 패키지를 사용해 힙 스냅샷을 수집했다.
const heapdump = require('heapdump');
const path = require('path');
// SIGUSR2 시그널로 덤프 생성
process.on('SIGUSR2', () => {
const filename = path.join('/tmp', `heapdump-${Date.now()}.heapsnapshot`);
heapdump.writeSnapshot(filename, (err) => {
if (err) console.error(err);
else console.log('Heap dump written to', filename);
});
});
서버에 kill -USR2 <pid> 명령으로 시간대별 스냅샷을 여러 개 생성한 뒤, Chrome DevTools의 Memory 탭에서 비교 분석했다.
원인
WebSocket 연결 처리 로직에서 EventEmitter의 리스너를 제대로 제거하지 않았다.
// Before - 문제 코드
function handleConnection(ws) {
const dataHandler = (data) => {
// process data
};
eventBus.on('data', dataHandler);
ws.on('close', () => {
// eventBus 리스너를 제거하지 않음!
});
}
연결이 끊어져도 eventBus에 등록된 리스너가 남아있어 클로저로 인해 관련 객체들이 GC되지 않았다.
해결
// After - 수정 코드
function handleConnection(ws) {
const dataHandler = (data) => {
// process data
};
eventBus.on('data', dataHandler);
ws.on('close', () => {
eventBus.removeListener('data', dataHandler);
});
}
수정 후 스테이징에서 부하 테스트를 돌렸고, 메모리가 일정 수준에서 안정적으로 유지되는 것을 확인했다.
교훈
- EventEmitter 사용 시 반드시 removeListener를 짝으로 관리해야 한다
- heapdump + Chrome DevTools 조합이 메모리 디버깅에 매우 유용했다
- 프로덕션 환경에서도 디버깅 도구를 미리 심어두는 것이 중요하다