Node.js 프로세스 메모리 누수 추적기

문제 상황

사내 API 서버가 운영 중 메모리 사용량이 계속 증가하다가 결국 프로세스가 종료되는 현상이 반복됐다. PM2 로그를 확인하니 약 12시간마다 OOM(Out of Memory) 에러가 발생하고 있었다.

메모리 스냅샷 수집

운영 환경에서 직접 디버깅할 수 없어 스테이징 환경에서 부하 테스트를 진행하며 힙 스냅샷을 수집했다.

const v8 = require('v8');
const fs = require('fs');

function takeSnapshot() {
  const filename = `heap-${Date.now()}.heapsnapshot`;
  const snapshot = v8.writeHeapSnapshot(filename);
  console.log(`Snapshot written to ${snapshot}`);
}

// 1시간마다 스냅샷 저장
setInterval(takeSnapshot, 60 * 60 * 1000);

원인 분석

Chrome DevTools에서 스냅샷을 비교 분석한 결과, EventEmitter 리스너가 계속 누적되고 있었다. WebSocket 연결 처리 코드에서 removeListener를 호출하지 않아 발생한 문제였다.

// 문제 코드
wss.on('connection', (ws) => {
  ws.on('message', handleMessage);
  // disconnect 시 리스너 제거 안 함
});

// 수정 코드
wss.on('connection', (ws) => {
  const messageHandler = (msg) => handleMessage(ws, msg);
  ws.on('message', messageHandler);
  
  ws.on('close', () => {
    ws.removeListener('message', messageHandler);
  });
});

모니터링 개선

재발 방지를 위해 메모리 사용량 모니터링을 추가했다.

const used = process.memoryUsage();
console.log({
  rss: `${Math.round(used.rss / 1024 / 1024)}MB`,
  heapTotal: `${Math.round(used.heapTotal / 1024 / 1024)}MB`,
  heapUsed: `${Math.round(used.heapUsed / 1024 / 1024)}MB`
});

배포 후 일주일간 모니터링한 결과 메모리 사용량이 안정적으로 유지되는 것을 확인했다. 이벤트 리스너 관리의 중요성을 다시 한번 느꼈다.