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 열기 → MemoryTake 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
});
0 조회
Back to Blog

관련 글

더 보기 »