본문 바로가기
Backend2024년 10월 25일6분 읽기

Node.js 메모리 누수 탐지와 해결 — Heap Snapshot 분석

YS
김영삼
조회 546

메모리 누수의 일반적 원인

Node.js에서 메모리 누수는 가비지 컬렉터가 회수할 수 없는 객체가 지속적으로 쌓이는 현상입니다. V8 엔진의 힙 메모리가 계속 증가하다가 결국 OOM(Out of Memory)으로 프로세스가 종료됩니다.

메모리 누수의 5대 원인

원인설명빈도
전역 변수 축적배열/맵에 데이터를 계속 추가하고 제거하지 않음매우 높음
이벤트 리스너 누적리스너 등록 후 해제하지 않음높음
클로저 참조클로저가 큰 객체를 참조하여 GC 방해중간
타이머 미정리setInterval/setTimeout 미해제중간
스트림 미종료읽기/쓰기 스트림을 닫지 않음낮음

프로그래밍 방식 Heap Snapshot

const v8 = require('v8');
const fs = require('fs');

function takeHeapSnapshot(label = 'snapshot') {
  const filename = label + '-' + Date.now() + '.heapsnapshot';
  const snapshotStream = v8.writeHeapSnapshot(filename);
  console.log('Heap snapshot written to: ' + snapshotStream);
  return snapshotStream;
}

// 메모리 모니터링 미들웨어
function memoryMonitor(req, res, next) {
  const before = process.memoryUsage();
  res.on('finish', () => {
    const after = process.memoryUsage();
    const heapDiff = (after.heapUsed - before.heapUsed) / 1024 / 1024;
    if (heapDiff > 10) {
      console.warn('[MEMORY] ' + req.url + ': +' + heapDiff.toFixed(2) + 'MB');
    }
  });
  next();
}

// 주기적 메모리 로깅
setInterval(() => {
  const mem = process.memoryUsage();
  console.log(JSON.stringify({
    rss: (mem.rss / 1024 / 1024).toFixed(1) + 'MB',
    heapUsed: (mem.heapUsed / 1024 / 1024).toFixed(1) + 'MB',
    heapTotal: (mem.heapTotal / 1024 / 1024).toFixed(1) + 'MB',
    external: (mem.external / 1024 / 1024).toFixed(1) + 'MB',
  }));
}, 30000);

일반적인 메모리 누수 패턴과 수정

// BAD: 전역 캐시 무한 증가
const cache = {};
app.get('/user/:id', async (req, res) => {
  if (!cache[req.params.id]) {
    cache[req.params.id] = await db.findUser(req.params.id);
  }
  res.json(cache[req.params.id]);
});

// GOOD: LRU 캐시로 크기 제한
const { LRUCache } = require('lru-cache');
const cache = new LRUCache({
  max: 1000,
  ttl: 1000 * 60 * 5,
  maxSize: 50 * 1024 * 1024,
  sizeCalculation: (value) => JSON.stringify(value).length,
});

// BAD: 이벤트 리스너 누적
app.get('/stream', (req, res) => {
  emitter.on('data', (data) => res.write(data));
});

// GOOD: 연결 종료 시 리스너 해제
app.get('/stream', (req, res) => {
  const handler = (data) => res.write(data);
  emitter.on('data', handler);
  req.on('close', () => {
    emitter.off('data', handler);
  });
});

Chrome DevTools로 분석

# Node.js를 inspect 모드로 실행
node --inspect server.js

# 또는 프로덕션에서 시그널로 디버거 활성화
kill -USR1 <pid>

디버깅 체크리스트

  • Chrome DevTools의 Memory 탭에서 Heap Snapshot 3-point 비교법을 사용합니다
  • process.memoryUsage()의 heapUsed가 지속적으로 증가하면 누수를 의심합니다
  • --max-old-space-size로 힙 크기를 제한하여 OOM을 일찍 감지합니다
  • 프로덕션에서는 clinic.js 또는 0x 도구로 프로파일링합니다
  • WeakRef와 FinalizationRegistry로 약한 참조를 활용합니다

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록