SPA 메모리 누수 soak test

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


R1. 일단 돌려보기

// 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) }
}
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번이다.

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

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번쯤 나간다.

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

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

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

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

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 자체는 여전히 실제 시간에 걸린다. 가짜 시계는 타이머만 속인다.
#590