Node.js 서버에서 메모리 누수 추적하기

문제 상황

운영 중인 Express 서버의 메모리 사용량이 시간이 지날수록 계속 증가했다. 배포 직후 200MB였던 메모리가 2~3일 뒤 1.5GB를 넘어서며 OOM 에러로 재시작되는 상황이 반복됐다.

원인 추적

heapdump 패키지를 사용해 힙 스냅샷을 주기적으로 생성했다.

const heapdump = require('heapdump');

app.get('/heapdump', (req, res) => {
  const filename = `heap-${Date.now()}.heapsnapshot`;
  heapdump.writeSnapshot(filename, (err) => {
    if (err) return res.status(500).send(err.message);
    res.send(`Heap dump written to ${filename}`);
  });
});

Chrome DevTools의 Memory Profiler로 스냅샷을 비교 분석한 결과, WebSocket 연결과 관련된 이벤트 리스너가 제거되지 않고 계속 쌓이고 있었다.

해결 방법

문제는 WebSocket 연결 종료 시 이벤트 리스너를 정리하지 않은 것이었다.

// Before
wss.on('connection', (ws) => {
  const handler = (data) => processData(data, ws);
  dataEmitter.on('update', handler);
});

// After
wss.on('connection', (ws) => {
  const handler = (data) => processData(data, ws);
  dataEmitter.on('update', handler);
  
  ws.on('close', () => {
    dataEmitter.removeListener('update', handler);
  });
});

수정 후 메모리 사용량이 안정적으로 유지됐다. process.memoryUsage()로 모니터링 지표도 추가했다.

교훈

EventEmitter를 사용할 때는 항상 리스너 정리를 고려해야 한다. 특히 장시간 연결이 유지되는 WebSocket, SSE 같은 경우 더욱 주의가 필요하다.