Node.js 메모리 누수 디버깅 - heap snapshot 활용기
문제 상황
배포 후 3~4일이 지나면 API 서버의 응답 속도가 느려지고, 결국 OOM으로 재시작되는 현상이 반복됐다. PM2 모니터링을 확인해보니 메모리 사용량이 선형적으로 증가하고 있었다.
디버깅 과정
1. heap snapshot 수집
로컬에서 재현하기 위해 부하 테스트를 돌리면서 주기적으로 heap snapshot을 수집했다.
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}`);
}
// 10분마다 스냅샷 수집
setInterval(takeSnapshot, 10 * 60 * 1000);
2. Chrome DevTools 분석
Chrome DevTools의 Memory 탭에서 스냅샷을 비교했다. Comparison 뷰를 통해 어떤 객체가 계속 증가하는지 확인할 수 있었다.
결과적으로 EventEmitter 리스너가 제거되지 않고 계속 쌓이고 있었다. Redis pub/sub 구독 로직에서 매 요청마다 리스너를 등록하면서 removeListener를 호출하지 않은 것이 원인이었다.
3. 수정
// Before
function subscribeNotification(userId) {
redisClient.on('message', (channel, message) => {
// handle message
});
redisClient.subscribe(`user:${userId}`);
}
// After
const handlers = new Map();
function subscribeNotification(userId) {
const handler = (channel, message) => {
// handle message
};
handlers.set(userId, handler);
redisClient.on('message', handler);
redisClient.subscribe(`user:${userId}`);
}
function unsubscribe(userId) {
const handler = handlers.get(userId);
if (handler) {
redisClient.removeListener('message', handler);
handlers.delete(userId);
}
}
교�훈
- EventEmitter 사용 시 반드시 cleanup 로직 구현
--max-old-space-size설정만으로는 근본 해결 불가- heap snapshot 비교 분석이 가장 확실한 방법
- 프로덕션 환경에서도 메모리 지표 모니터링 필수