Node.js 프로덕션 앱에서 메모리 누수를 디버그하는 방법
발행: (2026년 4월 1일 PM 04:50 GMT+9)
4 분 소요
원문: Dev.to
Source: Dev.to
누수가 맞는지 확인하기
# Watch RSS memory of your Node process over time
watch -n 30 'ps -o pid,rss,vsz,comm -p $(pgrep -f "node server")'
# Or log it from inside the app
setInterval(() => {
const m = process.memoryUsage();
console.log(JSON.stringify({
rss: Math.round(m.rss / 1024 / 1024) + 'MB',
heap: Math.round(m.heapUsed / 1024 / 1024) + 'MB',
time: new Date().toISOString()
}));
}, 60000);
RSS가 꾸준히 증가하고 절대 감소하지 않으면, 누수가 발생한 것입니다.
단계 1: 힙 스냅샷 생성
# Start Node with inspector
node --inspect server.js
# Or send signal to a running process
kill -USR1 # Opens inspector on port 9229
그 다음 Chrome에서: chrome://inspect → 전용 DevTools 열기 → Memory → Take heap snapshot.
스냅샷을 찍고, 몇 가지 동작을 수행한 뒤 다시 스냅샷을 찍어 비교합니다—계속 쌓이는 객체를 찾아보세요.
단계 2: clinic.js 사용 (가장 쉬운 방법)
npm install -g clinic
# Profile your app under load
clinic heapprofile -- node server.js
# In another terminal, run load
npx autocannon -c 10 -d 60 http://localhost:3000/api/endpoint
# Press Ctrl+C to stop clinic – it generates a flamegraph
플레임그래프는 메모리가 할당되는 위치를 보여줍니다. 높은 막대는 많은 메모리를 할당하는 함수를 나타냅니다.
단계 3: 흔한 누수 패턴
패턴 1: 이벤트 리스너 누적
// Leak: adding listener every request without removing
app.get('/stream', (req, res) => {
emitter.on('data', (chunk) => res.write(chunk)); // Never removed!
});
// Fix: remove listener on connection close
app.get('/stream', (req, res) => {
const handler = (chunk) => res.write(chunk);
emitter.on('data', handler);
req.on('close', () => emitter.off('data', handler)); // Cleanup
});
패턴 2: 만료 없는 전역 캐시
// Leak: cache grows forever
const cache = {};
app.get('/user/:id', async (req, res) => {
if (!cache[req.params.id]) {
cache[req.params.id] = await db.getUser(req.params.id);
}
res.json(cache[req.params.id]);
});
// Fix: use a proper cache with TTL
const NodeCache = require('node-cache');
const cache = new NodeCache({ stdTTL: 300, maxKeys: 1000 });
패턴 3: 클로저가 참조를 유지함
// Leak: closure keeps large object alive
function processData(largeArray) {
const summary = computeSummary(largeArray);
return function getSummary() {
return summary; // largeArray stays in memory via closure
};
}
// Fix: only keep what you need
function processData(largeArray) {
const summary = computeSummary(largeArray);
largeArray = null; // Help GC
return function getSummary() { return summary; };
}
패턴 4: 정리되지 않은 타이머
// Leak: interval never cleared
function startMonitoring() {
setInterval(() => {
checkHealth();
}, 5000); // No reference kept, can't clear it
}
// Fix: keep reference and clear on shutdown
let monitorInterval;
function startMonitoring() {
monitorInterval = setInterval(() => checkHealth(), 5000);
}
process.on('SIGTERM', () => clearInterval(monitorInterval));
단계 4: 자동화 테스트로 감지하기
// Add to your test suite
const v8 = require('v8');
test('endpoint does not leak memory', async () => {
global.gc(); // Force GC (run with --expose-gc)
const before = v8.getHeapStatistics().used_heap_size;
// Run the operation 100 times
for (let i = 0; i < 100; i++) {
await request(app).get('/api/users');
}
global.gc();
const after = v8.getHeapStatistics().used_heap_size;
const growthMB = (after - before) / 1024 / 1024;
expect(growthMB).toBeLessThan(5); // Allow <5MB growth
});