Node.js 메모리 누수 디버깅 - heapdump로 원인 찾기

문제 상황

사내 API 서버가 배포 후 4~5시간이 지나면 메모리 사용량이 2GB를 넘어서며 OOM으로 죽는 현상이 발생했다. PM2로 자동 재시작하고 있었지만 근본 원인을 찾아야 했다.

heapdump 적용

메모리 스냅샷을 떠서 분석하기 위해 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}`);
  });
});

스테이징 환경에서 부하 테스트를 돌리며 1시간 간격으로 kill -USR2 [pid]를 실행해 스냅샷을 수집했다.

원인 분석

Chrome DevTools의 Memory 탭에서 스냅샷을 비교한 결과, Socket 객체와 관련된 EventEmitter 리스너가 계속 누적되고 있었다.

문제는 WebSocket 연결 처리 로직에서 disconnect 시 리스너를 제거하지 않은 것이었다.

// Before
function handleConnection(socket) {
  socket.on('message', handleMessage);
  socket.on('error', handleError);
}

// After
function handleConnection(socket) {
  const onMessage = (msg) => handleMessage(msg, socket);
  const onError = (err) => handleError(err, socket);
  
  socket.on('message', onMessage);
  socket.on('error', onError);
  
  socket.on('close', () => {
    socket.removeListener('message', onMessage);
    socket.removeListener('error', onError);
  });
}

결과

수정 후 메모리 사용량이 안정화되었고, 장시간 운영해도 500MB 이하를 유지했다. EventEmitter를 사용할 때는 항상 cleanup 로직을 함께 작성해야 한다는 교훈을 얻었다.

Node.js 메모리 누수 디버깅 - heapdump로 원인 찾기