Skip to content

Heap Snapshot

라몽이 edited this page Sep 19, 2025 · 1 revision

Chrome DevTools — Heap Snapshot 분석 가이드

실무에서 메모리 누수(또는 과도한 메모리 사용)를 진단하고 해결하기 위한 단계별 튜토리얼입니다.


개요 / 대상

  • 대상: Frontend 개발자(React/Next.js) + Node.js 백엔드 개발자
  • 목적: DevTools로 힙 스냅샷을 찍고 증가/누수를 확인하는 법, 결과 해석법, 자주 나오는 누수 패턴과 해결법, 자동화 스크립트 예시
  • 전제: Chrome(또는 Chromium 계열) 최신 버전, Node.js(테스트용), 로컬 개발서버 접근 가능

빠른 체크리스트(핵심 흐름)

  1. 기준(Baseline) 힙 스냅샷 찍기
  2. 문제 재현 시나리오를 반복(수동 또는 자동)
  3. 두 번째 힙 스냅샷 찍기
  4. 스냅샷 비교 → 증가한 타입/객체 찾기
  5. Retainers(참조 경로)를 따라 원인 코드 탐색
  6. 코드 수정 → 다시 재현 + 스냅샷 비교로 검증

1) DevTools에서 힙 스냅샷 찍는 방법

Memory 탭에서

  • Memory 탭 접속 → Heap snapshot 선택 → Take snapshot 클릭
  • 왼쪽 사이드바에 "Snapshot 1"처럼 저장됨
  • 권장 순서: Snapshot(기준) → reproduce scenario → Snapshot(이후)

스냅샷 비교

  • 왼쪽에서 두 개 이상의 스냅샷 선택(또는 오른쪽 클릭하여 비교)
  • Comparison 뷰에서 count(객체 수), shallow size, retained size 증가를 확인

Tip: retained size(해당 객체 때문에 살아있는 전체 메모리)를 우선 확인합니다. shallow size는 혼동을 줄 수 있음.


2) Allocation timeline (시간 기반 할당 추적)

  • Memory 탭 → Allocation instrumentation on timeline 선택 → Record
  • 반복 동작(예: modal 열기/닫기 50회) 수행 → Stop
  • 타임라인에서 특정 구간을 확대하면 어떤 함수에서 할당이 일어났는지 확인 가능

사용처: 반복 액션에서 메모리가 지속적으로 증가하는 패턴(누수)을 찾을 때 매우 유용


3) 주요 뷰(해석 포인트)

  • Summary: 타입별 객체 개수와 메모리 점유
  • Comparison: 두 스냅샷의 변화 (증가한 항목부터 조사)
  • Containment: 객체가 어떻게 포함되어 있는지 트리로 확인
  • Retainers: 특정 객체를 잡고 있는 참조 체인을 보여줌(누수가 풀리지 않는 이유 추적)
  • (Detached): DOM 노드가 Detached로 남아있으면 DOM 누수 의심

4) 흔한 누수 패턴 & 코드 예제 (원인 → 수정)

A. 이벤트 리스너를 제거하지 않음

문제(React/vanilla)

// vanilla 예시 (누수)
function showModal() {
  const btn = document.getElementById('ok');
  btn.addEventListener('click', () => console.log('ok'));
  // 닫을 때 removeEventListener 하지 않음
}

수정

function showModal() {
  const btn = document.getElementById('ok');
  function onClick() { console.log('ok'); }
  btn.addEventListener('click', onClick);
  return () => btn.removeEventListener('click', onClick);
}

React에서는 useEffect의 cleanup을 항상 작성하세요.


B. 타이머(setInterval) 또는 Background job 정리 누락

문제

function startPolling () {
  setInterval(doWork, 1000); // 멈추는 로직 없음
}

수정

let id;
function startPolling() { id = setInterval(doWork, 1000); }
function stopPolling() { clearInterval(id); id = null; }

React: useEffect(() => { const id = setInterval(...); return () => clearInterval(id); }, []);


C. Closure가 큰 데이터를 참조함

문제

function mk() {
  const big = new Array(1e6).fill('x');
  return function () { /* 참조 유지 */ };
}
const fn = mk(); // big가 GC되지 않음

수정: 큰 데이터 사용 후 big = null 또는 scope 밖으로 빼서 참조 끊기.


D. Detached DOM node (DOM이 제거되었지만 JS에서 참조가 남음)

  • Heap snapshot의 Detached 항목을 찾으세요
  • 원인: DOM을 제거했지만 배열/캐시/전역변수에서 참조 유지
  • 해결: 해당 캐시/변수에서 참조 제거

5) DevTools에서 유용한 단축/커맨드

  • Memory 탭의 Collect garbage 버튼: 수동 GC 트리거(테스트용)
  • Console: getEventListeners($0) (현재 Elements 패널에서 선택한 노드의 리스너 보기)
  • DevTools에서 소스맵 활성화하면 스택/할당 위치를 더 정확히 볼 수 있음

6) 프로덕션 힙덤프 수집 (Node.js)

주의: 힙 스냅샷은 메모리를 추가로 소모할 수 있으므로 서비스 영향 고려(복제/비활성 인스턴스 권장)

A. --heapsnapshot-signal 사용

node --heapsnapshot-signal=SIGUSR2 server.js
# 프로세스에 SIGUSR2 보내면 .heapsnapshot 파일 생성
kill -SIGUSR2 <PID>

B. 코드에서 직접 힙 스냅샷 생성 (내부/인증된 엔드포인트에서만)

// debug-snapshot.js
const v8 = require('v8');
const fs = require('fs');

function saveSnapshot(filename = `heap-${Date.now()}.heapsnapshot`) {
  const stream = v8.getHeapSnapshot();
  const out = fs.createWriteStream(filename);
  stream.pipe(out);
  out.on('finish', () => console.log('saved', filename));
}

module.exports = { saveSnapshot };

7) DevTools로 .heapsnapshot 열기

  • DevTools Memory 탭으로 드래그&드랍하거나, 스냅샷 리스트에서 Load 기능 사용
  • 열면 동일한 Summary/Comparison/Retainers 뷰로 분석 가능

8) 자동화: Puppeteer를 이용해 자동 재현 + 힙 스냅샷 수집(예시)

이 예시는 로컬 개발서버를 대상으로 동작합니다. 실제 운영에서는 접근 보안/헤드리스 브라우저 권한을 신중히 설정하세요.

// tools/collectHeapWithPuppeteer.js
// Node.js + Puppeteer로 페이지를 열고 동작을 반복하고 CDP를 통해 힙 스냅샷을 수집해 파일로 저장

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

async function saveHeapSnapshot(url, outPath) {
  const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] });
  const page = await browser.newPage();
  const client = await page.target().createCDPSession();

  // HeapProfiler 이벤트 수집을 위해 enable
  await client.send('HeapProfiler.enable');

  // 힙 스냅샷 조각(chunk)을 모아서 저장
  let chunks = [];
  client.on('HeapProfiler.addHeapSnapshotChunk', ({ chunk }) => {
    chunks.push(chunk);
  });

  await page.goto(url);

  // TODO: 여기서 자동화 동작(모달 열기/닫기 등)을 반복
  // ex) for (let i=0; i<100; i++) { await page.click('#open'); await page.click('#close'); }

  // 힙 스냅샷 촬영
  await client.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false });

  // 파일로 저장
  const data = chunks.join('');
  fs.writeFileSync(outPath, data);

  await browser.close();
}

// 사용 예
if (require.main === module) {
  const url = process.argv[2] || 'http://localhost:3000';
  const out = process.argv[3] || 'puppeteer-heapsnapshot.heapsnapshot';
  saveHeapSnapshot(url, out).then(() => console.log('done')).catch(console.error);
}
  • 위 스크립트는 CDP 이벤트 HeapProfiler.addHeapSnapshotChunk를 모아 .heapsnapshot 파일로 쓴다.
  • 생성한 파일은 DevTools로 열어 분석 가능

9) 분석 우선순위(현장 팁)

  1. Comparison에서 증가량이 큰 항목(count 또는 retained size)을 먼저 파고든다
  2. Retainers/Retainer path를 따라가면서 어떤 전역/클로저가 참조를 유지하는지 확인
  3. Detached DOM, event listeners, timers(Interval), global caches를 우선 체크
  4. 문제가 되는 컴포넌트/함수의 코드 위치를 찾으면 해당 리소스 해제(또는 약한 참조 사용) 적용

10) 체크리스트(문제 발견 시 바로 적용할 것)

  • 이벤트 리스너 제거(또는 cleanup) 확인
  • setInterval / setTimeout 정리 확인
  • 큰 데이터(이미지, 배열)를 상태/state에 그대로 두지 않음
  • 사용 후 참조(null 할당 등) 끊기
  • React: 불필요한 마운트/언마운트 반복 제거
  • Source map 활성화(로컬 디버깅 시)

11) 참고(짧은 요약)

  • Heap snapshot: 시점 기반 전체 힙 구조 분석
  • Allocation timeline: 시간 기반 증가 추적
  • Allocation sampling: 함수/위치별 할당량 샘플링

12) 추가 자료 및 다음 단계 제안

  • 이 문서로 충분히 로컬/복제 환경에서 문제를 찾을 수 있음
  • 다음으로는 프로덕션 메트릭 수집(Prometheus, APM)과 CI에서의 시크릿 스캔/메모리 회귀 테스트 자동화를 권장합니다

원하면 이 문서를 PDF로 변환하거나, pre-commit 설정·GitHub Actions · prometheus alert rule · puppeteer 스크립트 등 필요한 파일을 바로 만들어 드립니다.