---
type: note
kind: Recipe
title: SPA 메모리 누수 soak test
description: Playwright + CDP로 같은 플로우를 200번 돌려 리스너·노드 누수를 잡는 절차와, 정상인 앱이 실패하는 네 가지 이유.
tags: ['playwright', 'memory-leak', 'testing', 'cdp', 'spa']
status: release
ctime: 2026-09-01
mtime: 2026-09-01
generated: { by: claude/opus-5, at: 2026-09-01T00:00:00Z }
sources:
  - id: odell-soak-test
    title: "Den Odell — Your SPA Is Leaking Memory. Soak Test It (2026-07)"
---

**언제** — drawer·모달·라우트 왕복처럼 **시작 화면과 끝 화면이 같은** 플로우가 메모리를 흘리는지 확인할 때. Chromium 전용(Playwright 가 CDP 세션을 열 수 있는 게 그것뿐).

---

## R1. 일단 돌려보기

```js
// tests/soak/helpers.js
export async function getPageMetrics(client) {
  await client.send('HeapProfiler.collectGarbage')
  await client.send('HeapProfiler.collectGarbage')     // ← 2회. R4 참조
  const { metrics } = await client.send('Performance.getMetrics')
  const { JSHeapUsedSize, Nodes, JSEventListeners } =
    Object.fromEntries(metrics.map((m) => [m.name, m.value]))
  return { heap: JSHeapUsedSize, nodes: Nodes, listeners: JSEventListeners }
}

export async function soak(page, runFlow, loops = 200, warmup = 5) {
  const client = await page.context().newCDPSession(page)
  await client.send('Performance.enable')
  for (let i = 0; i < warmup; i++) await runFlow()      // ← 워밍업. R5 참조
  const baseline = await getPageMetrics(client)
  for (let i = warmup; i < loops; i++) await runFlow()
  return { baseline, after: await getPageMetrics(client) }
}
```

```js
test('the dashboard drawer does not leak', async ({ page }) => {
  await page.goto('/dashboard')
  const { baseline, after } = await soak(page, () => openAndCloseDrawer(page))
  expect(after.listeners).toBeLessThanOrEqual(baseline.listeners)
  expect(after.nodes).toBeLessThan(baseline.nodes + 100)
})
```

200루프는 보통 몇 분이면 끝난다. Playwright 는 앱이 따라올 수 있는 최대 속도로 클릭한다.

---

## R2. 어떤 플로우를 고를 것인가

**규칙: 시작 화면과 끝 화면이 같아야 한다.**

| 쓸 수 있음 | 쓸 수 없음 |
|---|---|
| drawer 열기 → 닫기 | 무한 스크롤 피드 |
| 필터 걸기 → 해제 | 메시지가 쌓이는 채팅 |
| 모달 open → close | 장바구니에 계속 담기 |
| 라우트 A → B → A | 로그가 누적되는 콘솔 뷰 |

오른쪽은 **설계상 메모리가 늘어나는 게 정상**이라 이 판정식으로는 100% 실패한다. 억지로 넣지 말 것 — 하려면 절대량이 아니라 증가 기울기를 보는 별도 판정식이 필요하다.

---

## R3. 무엇을 assert 할 것인가

| 지표 | 성격 | 판정 |
|---|---|---|
| `JSEventListeners` | `addEventListener`/`remove` 에만 반응. 거의 노이즈 없음 | `after ≤ baseline` |
| `Nodes` | 리스너 없이 참조만 남은 detached DOM 을 잡음 | `after < baseline + 100` |
| `JSHeapUsedSize` | GC 타이밍·lazy chunk·캐시가 다 섞임 | **assert 하지 말 것** |

리스너 누수가 의심되면 리스너 카운트가 가장 확실한 신호고, **노드 카운트는 그물이다.**

---

## R4. 정상인 앱이 6번에 1번씩 실패한다

**원인** — GC 를 1회만 돌리면 프레임워크가 참조를 다 놓기 전에 측정된다. 프레임워크 없는 순수 JS 페이지는 1회로도 깨끗했다.

**처방** — `collectGarbage` 를 연속 2회(R1 에 반영됨).

**확인** — 누수 없는 플로우로 10회 돌려 전부 통과하는지 본다.

---

## R5. 힙이 계단처럼 뛰어서 실패한다

**원인** — 첫 루프에서 lazy chunk 다운로드 + 최초 데이터 fetch 가 일어나고 둘 다 힙에 남는다. 1회성인데 baseline 을 그 앞에서 잡으면 누수처럼 보인다.

**처방** — 워밍업 5루프 후 baseline(R1 에 반영됨).

노드·리스너 카운트에는 원래 워밍업이 필요 없다 — **lazy 코드와 캐시는 JS 힙에 살지 DOM 에 살지 않아서** 카운트는 첫 루프부터 같은 폭으로 오른다. 5루프는 몇 초라 그냥 둔다.

---

## R6. 작은 컴포넌트에서만 실패한다

**원인 — 허용치를 퍼센트로 잡았다.** 33노드짜리 drawer 에서 노드 1개가 새면 3%, 2,000노드 화면에서 같은 1개는 0.05%다. 같은 버그인데 감도가 60배 차이 난다.

**처방** — 고정 상수를 쓴다. 직접 정하려면:

1. 누수 없는 플로우로 소크를 10회 반복
2. `after.nodes - baseline.nodes` 의 평균 μ, 표준편차 σ
3. `ε = μ + 3σ`
4. 검산: `ε / (loops - warmup)` 이 잡고 싶은 누수 크기보다 작은가
   - `ε=100, loops=200` → 루프당 0.5노드 미만은 못 잡는다
   - **더 미세한 걸 잡으려면 ε 를 낮추는 게 아니라 loops 를 올린다**

---

## R7. 타이머 누수가 안 잡힌다

**원인** — 타이머는 클릭이 아니라 **시계에 매달려 있다.** 200루프가 실제 2분이면 30초 폴러는 4번밖에 안 돈다. 실사용 1시간이면 120번이다.

**처방** — 브라우저 시계를 가짜로 바꾸고 루프마다 직접 진행시킨다.

```js
const start = new Date('2026-07-28T09:00:00')
await page.clock.install({ time: start })                       // ① goto 이전
await page.goto('/dashboard')
await page.clock.pauseAt(new Date(start.getTime() + 10_000))    // ② 부팅 후 정지

const { baseline, after } = await soak(page, async () => {
  await openAndCloseDrawer(page)
  await page.clock.runFor(18_000)                               // ③ 루프당 18초
})
```

- ① `install()` 은 `Date`·`performance`·타이머·`requestAnimationFrame` 을 전부 교체한다. **네비게이션 전에 설치해야 앱 부팅 코드가 가짜 시계를 본다**
- ② `install()` 만으로는 시간이 계속 흐른다. **`pauseAt()` 으로 세워야 `runFor()` 만이 시간을 움직인다**
- ③ 커버 시간 = 루프당 진행량 × 루프 수. 1시간을 200루프로 덮으려면 18초

---

## R8. 폴링 횟수가 계산과 안 맞는다

**증상** — 30초 폴러에 1시간치를 태웠는데 요청이 120번이 아니라 100번쯤 나간다.

**원인 — 시계가 두 개다.** 응답 도착 후 스스로 재예약하는 폴러가 범인이다.

```js
async function poll() {
  const res = await fetch('/api/feed')   // 이건 실제 시간에 걸린다
  render(await res.json())
  setTimeout(poll, 30_000)               // 응답이 온 뒤에 예약된다
}
```

`runFor()` 가 타임아웃을 발화시키면 `poll()` 은 `await` 까지 가고 리턴한다. 요청은 아직 떠 있고 응답은 다음 루프 도중 실제 시간에 도착하며, 그 시점의 가짜 시계 위치에서 다음 타임아웃이 잡힌다. **결국 폴링 주기를 API 응답 속도가 결정한다.**

**처방** — 네트워크도 mock 하고, 응답이 도착할 때까지 시계를 못 움직이게 막는다.

```js
await page.route('**/api/feed', route => route.fulfill({ json: feed }))
// …
const landed = page.waitForResponse('**/api/feed')
await page.clock.runFor(30_000)
await landed                              // ← 배리어
```

진행량을 폴러 간격과 같게 두면 루프당 정확히 1회 발화라 세기 쉽다. WebSocket 은 `page.routeWebSocket()` 으로 같은 일을 하고, 역시 navigate 이전에 설치한다.

---

## R9. 테스트는 통과하는데 프로덕션은 샌다

**원인 — mock 응답이 실제보다 훨씬 작다.** 응답을 캐시나 배열에 쌓는 종류의 누수는 크기에 비례하므로, 실제가 50kB 인데 mock 이 200B 면 재현되는 누수가 250분의 1이라 허용치 아래로 사라진다.

**처방** — 실제 응답을 그대로 캡처해서 fixture 로 쓴다(DevTools Network → Copy response). 스키마도 크기도 동일하게.

---

## R10. 샌다는 건 알았는데 어디서 새는지 모른다

카운트는 존재만 알려준다. 위치는 수동으로 찾는다 — DevTools Memory 패널에서 heap snapshot → 클래스 필터에 `Detached` → 남은 항목이 화면에서 제거됐는데 아직 참조가 붙들려 있는 DOM → 클릭해서 retainer 트리에서 어떤 리스너/변수가 잡고 있는지 확인.

---

## R11. CI 에 넣기

**nightly job 에 넣고 PR 게이트에는 넣지 않는다.** 실행 시간(몇 분)이 문제가 아니라 readings 변동으로 인한 flaky 가 PR 흐름을 막는 게 문제다. 실패 시 baseline/after 값을 그대로 로그에 남긴다 — 어느 지표가 얼마나 넘었는지가 다음 액션을 정한다.

**누수 커밋 당일 밤에 실패하므로 탐색 범위가 하루치 커밋으로 줄어든다. 이게 실질적 가치다.**

---

**함정**

- **`listeners ≤ baseline` 은 앱에 따라 flaky 할 수 있다.** `IntersectionObserver`·passive scroll listener 처럼 지연 등록되는 게 있으면 워밍업 5루프로 안 잡힌다. 도입 전에 **워밍업 구간에서 리스너 카운트가 평평해지는지** 한 번 찍어본다.
- **GC 2회는 관측치이지 보장이 아니다.** 자기 앱에서 1/2/3회의 실패율을 재보면 좋다.
- **스트리밍 응답은 못 다룬다.** `route.fulfill()` 은 string/buffer 만 받아 chunk 도착 속도를 재현할 수 없다.
- **fetch 자체는 여전히 실제 시간에 걸린다.** 가짜 시계는 타이머만 속인다.
