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배 차이 난다.
처방 — 고정 상수를 쓴다. 직접 정하려면:
- 누수 없는 플로우로 소크를 10회 반복
after.nodes - baseline.nodes의 평균 μ, 표준편차 σε = μ + 3σ- 검산:
ε / (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 자체는 여전히 실제 시간에 걸린다. 가짜 시계는 타이머만 속인다.