6.3 KiB
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 |
|
|
|
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 |
❌ 안티패턴
useEffectcleanup 없음: 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 추적.