Files
2nd/10_Wiki/Topics/Coding/Perf_Web_Memory_Leak.md
T
2026-05-09 21:08:02 +09:00

6.3 KiB

id, title, category, status, source_trust_level, verification_status, created_at, updated_at, tags, tech_stack, applied_in, aliases
id title category status source_trust_level verification_status created_at updated_at tags tech_stack applied_in aliases
perf-web-memory-leak Web Memory Leak — Detached DOM / Listener / Closure Coding draft B conceptual 2026-05-09 2026-05-09
performance
memory
web
vibe-coding
language applicable_to
TS / Browser
Frontend
memory leak
detached DOM
event listener leak
weakref
heap snapshot
Chrome Memory

Web Memory Leak

SPA 가 시간이 지나며 느려지면 leak. Detached DOM, listener, timer, closure, global. Chrome DevTools Memory tab 으로 추적.

📖 핵심 개념

  • Detached DOM: 화면에서 제거됐는데 JS 가 보유.
  • Closure: 함수가 외부 스코프 보유 (큰 객체 잡을 수 있음).
  • Listener: removeEventListener 안 함.
  • Timer: clearInterval 안 함.

💻 코드 패턴

Heap snapshot (Chrome)

DevTools → Memory → Heap snapshot
1. Snapshot
2. 동작 (페이지 N번 이동)
3. Snapshot 다시
4. Comparison: "Allocated between snapshots"
5. Detached DOM 검색

Performance.measureUserAgentSpecificMemory

if ('measureUserAgentSpecificMemory' in performance) {
  const result = await performance.measureUserAgentSpecificMemory();
  console.log(result.bytes);
}

Detached DOM 패턴

// ❌
let cachedNodes = [];
function build() {
  const div = document.createElement('div');
  document.body.appendChild(div);
  cachedNodes.push(div);  // leak — 영원 array 안

  div.remove();  // DOM 제거, but cachedNodes 가 잡음
}

// ✅
cachedNodes.length = 0;  // 명시 cleanup
// 또는 WeakRef

Listener leak

// ❌
function attach() {
  window.addEventListener('resize', handler);  // 매 호출 마다 listener
}

// ✅
const handler = () => { ... };
window.addEventListener('resize', handler);
// cleanup
window.removeEventListener('resize', handler);

// React
useEffect(() => {
  const handler = () => { ... };
  window.addEventListener('resize', handler);
  return () => window.removeEventListener('resize', handler);
}, []);

// AbortController (modern)
useEffect(() => {
  const ctrl = new AbortController();
  window.addEventListener('resize', handler, { signal: ctrl.signal });
  return () => ctrl.abort();
}, []);

Timer / interval

// ❌
setInterval(tick, 1000); // 영원

// ✅
useEffect(() => {
  const id = setInterval(tick, 1000);
  return () => clearInterval(id);
}, []);

// requestAnimationFrame
useEffect(() => {
  let raf: number;
  const tick = () => { update(); raf = requestAnimationFrame(tick); };
  raf = requestAnimationFrame(tick);
  return () => cancelAnimationFrame(raf);
}, []);

Closure 잡힘

function createHandler(bigData: any[]) {
  return () => console.log('clicked');
  // 함수 안 bigData 사용 안 해도 — 일부 V8 가 closure 보존
  // 명시적 nullify:
}

function createHandler2() {
  let bigData = loadBig();
  const handler = () => console.log('clicked');
  bigData = null!;  // 명시
  return handler;
}

WeakMap / WeakRef

// 객체 키 — GC 가능
const meta = new WeakMap<HTMLElement, Metadata>();
meta.set(el, { ... });
// el 이 DOM 에서 제거 + 다른 ref 없음 → 자동 cleanup

// WeakRef (advanced)
const ref = new WeakRef(largeObject);
// 사용
const obj = ref.deref();
if (obj) obj.method();
// largeObject 가 다른 곳 참조 없으면 GC 가능

Cache (TTL)

import LRU from 'lru-cache';
const cache = new LRU<string, Response>({
  max: 500,           // 최대 N개
  ttl: 5 * 60_000,    // 5분
});

→ Map 무한 자라남 방지.

React 흔한 leak

// ❌ 비동기 + 컴포넌트 unmount
useEffect(() => {
  fetch('/data').then(r => r.json()).then(d => setState(d));
  // unmount 후 setState — warning + 그냥 leak 까진 X
}, []);

// ✅ AbortController
useEffect(() => {
  const ctrl = new AbortController();
  fetch('/data', { signal: ctrl.signal }).then(r => r.json()).then(setState).catch(() => {});
  return () => ctrl.abort();
}, []);

// 또는 isMounted (legacy)
let mounted = true;
fetch(...).then(d => mounted && setState(d));
return () => { mounted = false; };

Subscription leak

// ❌
useEffect(() => {
  store.subscribe(setState);
  // unsubscribe 없음
}, []);

// ✅
useEffect(() => {
  const unsub = store.subscribe(setState);
  return unsub;
}, []);

Detection (long-running app)

// 주기적 메모리 체크
setInterval(() => {
  if ((performance as any).memory) {
    const m = (performance as any).memory;
    log('mem', { used: m.usedJSHeapSize, limit: m.jsHeapSizeLimit });
  }
}, 60_000);

⚠️ Chrome only, behind flag.

Big binary (image / video) 정리

// Object URL
const url = URL.createObjectURL(blob);
img.src = url;
// 사용 후
URL.revokeObjectURL(url);

// Canvas / video
videoEl.pause();
videoEl.src = '';
videoEl.load();

Service Worker 가 cache 누적

// SW
self.addEventListener('activate', (e) => {
  e.waitUntil(
    caches.keys().then(keys =>
      Promise.all(keys.filter(k => k !== CURRENT).map(k => caches.delete(k)))
    )
  );
});

// 또는 Workbox expiration

MutationObserver / ResizeObserver

// ❌ disconnect 안 함
const obs = new ResizeObserver(...);
obs.observe(el);
// component unmount → obs 가 el 참조 + el 가 obs 참조

// ✅
useEffect(() => {
  const obs = new ResizeObserver(...);
  obs.observe(ref.current!);
  return () => obs.disconnect();
}, []);

🤔 의사결정 기준

의심 도구
시간 지나며 느려짐 Heap snapshot diff
특정 page 후 큼 Allocation timeline
Listener 의심 Event Listeners panel
Detached DOM Memory > Comparison > Detached
Long task Performance > 시간 측정
자동 production Sentry / Datadog memory metric

안티패턴

  • useEffect cleanup 없음: listener / timer / sub leak.
  • Global cache 무한: TTL / LRU.
  • DOM 직접 보유: WeakRef / cleanup.
  • AbortController 없는 fetch: unmount 후 setState.
  • Object URL revoke 안 함: 큰 image / video.
  • SW cache 무한: expiration.
  • React DevTools 측정 안 함: 추측만.

🤖 LLM 활용 힌트

  • 모든 effect 에 cleanup.
  • AbortController + signal 패턴 표준.
  • Heap snapshot diff 가 leak 추적.

🔗 관련 문서