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 조합이 메모리 디버깅에 매우 유용했다
  • 프로덕션 환경에서도 디버깅 도구를 미리 심어두는 것이 중요하다
Node.js 메모리 누수 추적기 - heapdump와 Chrome DevTools