Node.js 애플리케이션에서 메모리 누수 디버깅하기

문제 상황

프로덕션 서버가 12시간마다 메모리 부족으로 재시작되는 현상이 발생했다. PM2 모니터링 결과 메모리 사용량이 지속적으로 증가하는 패턴을 확인했다.

디버깅 과정

1. Heap Snapshot 분석

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

function takeSnapshot() {
  const snapshot = v8.writeHeapSnapshot();
  console.log('Snapshot written to', snapshot);
}

// 주기적으로 스냅샷 생성
setInterval(takeSnapshot, 60 * 60 * 1000);

Chrome DevTools로 snapshot을 비교 분석한 결과, EventEmitter 인스턴스가 계속 증가하고 있었다.

2. clinic.js로 프로파일링

npm install -g clinic
clinic doctor -- node server.js

clinic doctor 결과 특정 WebSocket 연결 핸들러에서 문제가 있음을 확인했다.

원인

WebSocket 연결이 종료될 때 이벤트 리스너를 제거하지 않아 발생한 문제였다.

// 문제 코드
wss.on('connection', (ws) => {
  const handler = (data) => {
    // 처리 로직
  };
  
  dataStream.on('update', handler);
  
  ws.on('close', () => {
    // 리스너 제거 누락!
  });
});

해결

wss.on('connection', (ws) => {
  const handler = (data) => {
    if (ws.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify(data));
    }
  };
  
  dataStream.on('update', handler);
  
  ws.on('close', () => {
    dataStream.removeListener('update', handler);
  });
});

추가 개선사항

리스너 수 모니터링 코드를 추가했다.

const maxListeners = dataStream.getMaxListeners();
const currentCount = dataStream.listenerCount('update');

if (currentCount > maxListeners * 0.8) {
  logger.warn('EventEmitter listener count high', { currentCount });
}

결과

배포 후 메모리 사용량이 안정적으로 유지되었다. 이후 비슷한 패턴의 코드를 전수 검사해 동일한 문제 3건을 더 발견하고 수정했다.

EventEmitter를 사용할 때는 반드시 cleanup 로직을 함께 작성해야 한다는 교훈을 얻었다.