[G1-Sync] Manual knowledge update
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
---
|
||||
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<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)
|
||||
```ts
|
||||
import LRU from 'lru-cache';
|
||||
const cache = new LRU<string, Response>({
|
||||
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]]
|
||||
Reference in New Issue
Block a user