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 로직을 함께 작성해야 한다는 교훈을 얻었다.