Node.js 메모리 누수 추적과 heapdump 활용기
문제 상황
운영 중인 Node.js API 서버의 메모리 사용량이 지속적으로 증가하다가 결국 OOM으로 재시작되는 현상이 발생했다. 모니터링 툴에서 메모리 그래프가 계단식으로 상승하는 전형적인 누수 패턴이었다.
진단 과정
먼저 heapdump를 설치하고 프로세스에 시그널을 보내 힙 스냅샷을 수집했다.
const heapdump = require('heapdump');
// SIGUSR2 시그널 받으면 힙 덤프 생성
process.on('SIGUSR2', () => {
const filename = `/tmp/heapdump-${Date.now()}.heapsnapshot`;
heapdump.writeSnapshot(filename, (err) => {
if (err) console.error(err);
else console.log('Heap dump written to', filename);
});
});
서버 부하 테스트 후 30분 간격으로 3개의 스냅샷을 수집했다. Chrome DevTools의 Memory 탭에서 스냅샷을 비교 분석한 결과, 특정 클로저 객체가 계속 증가하고 있었다.
원인 분석
문제는 이벤트 리스너를 제거하지 않은 코드에 있었다.
// 문제 코드
app.get('/api/stream', async (req, res) => {
const stream = createDataStream();
stream.on('data', (chunk) => {
res.write(chunk);
});
stream.on('end', () => {
res.end();
});
// 클라이언트가 중간에 연결을 끊으면 리스너가 남아있음
});
해결 방법
응답 종료 시점에 명시적으로 리스너를 정리하도록 수정했다.
// 수정 코드
app.get('/api/stream', async (req, res) => {
const stream = createDataStream();
const onData = (chunk) => res.write(chunk);
const onEnd = () => res.end();
stream.on('data', onData);
stream.on('end', onEnd);
res.on('close', () => {
stream.removeListener('data', onData);
stream.removeListener('end', onEnd);
stream.destroy();
});
});
배포 후 메모리 사용량이 안정화되었고, 더 이상 OOM이 발생하지 않았다.
교훈
스트림이나 이벤트 이미터를 다룰 때는 항상 정리 로직을 함께 구현해야 한다. 프로덕션 환경에서는 heapdump 같은 도구를 미리 설정해두면 문제 발생 시 빠른 대응이 가능하다.