--- id: perf-web-memory-leak title: Web Memory Leak — Detached DOM / Listener / Closure category: Coding status: draft source_trust_level: B verification_status: conceptual created_at: 2026-05-09 updated_at: 2026-05-09 tags: [performance, memory, web, vibe-coding] tech_stack: { language: "TS / Browser", applicable_to: ["Frontend"] } applied_in: [] aliases: [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 ```ts if ('measureUserAgentSpecificMemory' in performance) { const result = await performance.measureUserAgentSpecificMemory(); console.log(result.bytes); } ``` ### Detached DOM 패턴 ```ts // ❌ 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 ```ts // ❌ 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 ```tsx // ❌ 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 잡힘 ```ts 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 ```ts // 객체 키 — GC 가능 const meta = new WeakMap(); 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) ```ts import LRU from 'lru-cache'; const cache = new LRU({ max: 500, // 최대 N개 ttl: 5 * 60_000, // 5분 }); ``` → Map 무한 자라남 방지. ### React 흔한 leak ```tsx // ❌ 비동기 + 컴포넌트 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 ```tsx // ❌ useEffect(() => { store.subscribe(setState); // unsubscribe 없음 }, []); // ✅ useEffect(() => { const unsub = store.subscribe(setState); return unsub; }, []); ``` ### Detection (long-running app) ```ts // 주기적 메모리 체크 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) 정리 ```ts // Object URL const url = URL.createObjectURL(blob); img.src = url; // 사용 후 URL.revokeObjectURL(url); // Canvas / video videoEl.pause(); videoEl.src = ''; videoEl.load(); ``` ### Service Worker 가 cache 누적 ```ts // 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 ```ts // ❌ 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 추적. ## 🔗 관련 문서 - [[Native_Memory_Profiling]] - [[React_useEffect_Pitfalls]] - [[Web_Service_Worker_Patterns]]