메모리 누수 디버깅기: Chrome DevTools로 원인 찾기
"페이지를 오래 켜두면 브라우저가 느려져요." — 어느 날아온 이슈, 3일간의 추적기의 시작이었다.
🔍 문제 상황
담당하던 대시보드 서비스에서 장시간 사용 시 브라우저 탭이 느려지고, 결국 응답 없음 상태가 된다는 제보가 들어왔다. 재현 조건은 명확했다.
- 실시간 데이터가 갱신되는 대시보드 페이지
- 10~15분 이상 페이지를 열어둔 채 방치
- 탭 전환이나 스크롤 시 눈에 띄는 렉 발생
처음엔 "그냥 데이터가 많아서 그런가?" 싶었지만, 새로고침 한 번으로 증상이 즉시 사라진다는 점이 마음에 걸렸다. 전형적인 메모리 누수(Memory Leak)의 냄새였다.
1단계: 증상부터 정량화하기 — Performance Monitor
감으로 접근하면 삽질만 늘어난다. 가장 먼저 한 일은 의심을 숫자로 바꾸는 것이었다.
Chrome DevTools의 ⋮ 메뉴 → More tools → Performance monitor를 열어 다음 지표를 실시간으로 관찰했다.
- JS heap size
- DOM Nodes
- Event Listeners
[관찰 결과 - 5분 경과]
JS Heap Size : 45MB → 180MB (지속 증가, 감소 없음)
DOM Nodes : 1,200 → 8,700 (증가 후 유지)
Event Listeners : 340 → 2,100 (지속 증가)
정상적인 애플리케이션이라면 GC(Garbage Collection)가 동작하며 힙 사이즈가 톱니 모양(sawtooth) 으로 오르내려야 한다. 하지만 그래프는 계단식으로 우상향하고 있었다. 이건 GC가 회수하지 못하는, 즉 어딘가에서 계속 참조를 붙잡고 있는 객체가 쌓이고 있다는 뜻이었다.
💡 왜 Event Listeners 수치가 중요한가? DOM Nodes와 Event Listeners가 함께 증가한다는 건, 컴포넌트가 언마운트되어도 리스너가 해제되지 않고 있다는 강력한 신호다.
2단계: Heap Snapshot 3연타 비교법
원인 후보를 좁히기 위해 Memory 탭에서 Heap Snapshot을 활용했다. 핵심은 한 장이 아니라 세 장을 찍어 비교하는 것이다.
Snapshot 1 : 페이지 로드 직후 (baseline)
Snapshot 2 : 특정 액션(모달 열기/닫기) 10회 반복 후
Snapshot 3 : 동일 액션 10회 추가 반복 후
이렇게 세 번 찍는 이유는 명확하다.
- Snapshot 1 → 2 차이: 액션으로 인해 새로 생성된 객체
- Snapshot 2 → 3 차이: 반복해도 계속 늘어나는 객체 = 진짜 범인
한 번만 비교하면 정상적으로 생성된 객체와 누수 객체를 구분하기 어렵다. 두 구간의 증가 패턴이 동일하게 반복된다면 그게 바로 누수다.
Snapshot 3에서 Comparison 뷰로 전환하고 # New 컬럼 기준으로 정렬하니, 상위에 낯선 이름이 보였다.
Constructor # New # Deleted Size Delta
─────────────────────────────────────────────────────
(closure) 482 0 +1.2MB
EventListener 482 0 +0.3MB
Detached HTMLDivElement 240 0 +4.1MB ← 🚨
Detached HTMLDivElement. DOM 트리에서는 이미 제거됐지만, JS 코드 어딘가가 여전히 참조를 붙잡고 있어 GC가 회수하지 못하는 노드다.
3단계: 범인을 특정하다 — Retainers 트리 추적
해당 객체를 클릭하고 하단 Retainers(참조 보유자) 패널을 펼쳤다. 이 트리는 "누가 이 객체를 놓아주지 않고 있는가"를 역추적해서 보여준다.
Detached HTMLDivElement
└─ closure (in setupChartListener)
└─ (variable) resizeHandler
└─ Window.addEventListener('resize', ...) ← 여기!
경로를 따라가 보니, 차트 컴포넌트에서 리사이즈 대응을 위해 등록한 window 리스너가 콜백 안에서 모달 DOM을 클로저로 캡처하고 있었다. 문제의 코드는 대략 이랬다.
function ChartModal({ data }) {
useEffect(() => {
const chartEl = document.querySelector('.chart-container');
const resizeHandler = () => {
// chartEl을 클로저로 캡처 → 모달이 사라져도 참조 유지
resizeChart(chartEl, data);
};
window.addEventListener('resize', resizeHandler);
// ❌ cleanup 함수 누락!
}, [data]);
return <div className="chart-container">{/* ... */}</div>;
}
useEffect의 cleanup 함수가 빠져 있었다. 모달이 열릴 때마다 resize 리스너가 새로 등록되지만, 모달이 닫혀도 리스너는 해제되지 않은 채 window 객체에 계속 쌓였다. 이 리스너들이 클로저를 통해 이미 DOM에서 제거된 chartEl을 계속 붙잡고 있었던 것이다.
모달을 열고 닫을 때마다 detached 노드가 하나씩 쌓이는 구조 — Snapshot 비교에서 봤던 패턴과 정확히 일치했다.
4단계: Allocation Timeline로 최종 확인
수정 전, 확신을 얻기 위해 Allocation instrumentation on timeline 프로파일링도 함께 돌렸다. 이 기능은 시간 흐름에 따라 메모리 할당을 파란 막대로 시각화해준다.
- 모달을 열고 닫는 동작을 5회 반복
- 파란 막대(할당)는 계속 나타나는데, 회색으로 옅어지는(=GC로 회수된) 막대가 하나도 없음을 확인
이 시점에서 원인에 대한 확신이 100%에 도달했다.
5단계: 해결 — cleanup 함수 추가
function ChartModal({ data }) {
useEffect(() => {
const chartEl = document.querySelector('.chart-container');
const resizeHandler = () => {
resizeChart(chartEl, data);
};
window.addEventListener('resize', resizeHandler);
// ✅ 컴포넌트 언마운트 시 리스너 해제
return () => {
window.removeEventListener('resize', resizeHandler);
};
}, [data]);
return <div className="chart-container">{/* ... */}</div>;
}
같은 파일을 훑어보니 setInterval로 실시간 데이터를 폴링하는 다른 컴포넌트에서도 동일한 패턴의 실수가 하나 더 발견되어 함께 수정했다.
useEffect(() => {
const timerId = setInterval(fetchLatestData, 3000);
return () => clearInterval(timerId); // 이것도 빠져 있었다
}, []);
6단계: 검증
수정 후 동일한 방식으로 재검증했다.
지표 수정 전 (15분 경과) 수정 후 (15분 경과)
| JS Heap Size | 180MB (우상향) | 42~58MB (톱니 패턴) |
| DOM Nodes | 8,700 | 1,250 |
| Event Listeners | 2,100 | 360 |
| Detached Nodes (Snapshot) | 240개 | 0개 |
Performance Monitor의 그래프가 계단식에서 정상적인 톱니 모양으로 돌아온 걸 확인하고 나서야 마음을 놓을 수 있었다.
정리: 이번 경험에서 얻은 체크리스트
메모리 누수는 재현이 어렵고 원인도 코드 곳곳에 흩어져 있는 경우가 많아, 감이 아니라 도구 기반의 절차를 갖추는 게 중요하다는 걸 다시 느꼈다.
- Performance Monitor로 먼저 증상을 정량화한다 (JS Heap / DOM Nodes / Listeners 추이)
- Heap Snapshot은 3장 이상 비교해서, "생성"과 "누적"을 구분한다
- # New 기준 정렬 후 Detached 노드를 최우선으로 의심한다
- Retainers 트리를 따라가면 원인 코드 위치까지 역추적할 수 있다
- useEffect, addEventListener, setInterval은 항상 cleanup 세트로 작성하는 습관을 들인다
무엇보다, 이번 이슈를 계기로 팀 코드 리뷰 체크리스트에 "이펙트 등록 시 해제 로직이 있는가?" 항목을 추가했다. 같은 실수가 반복되지 않도록 만드는 것도 트러블슈팅의 연장선이라고 생각한다.
읽어주셔서 감사합니다. 궁금한 점이나 다른 디버깅 경험이 있다면 댓글로 나눠주세요 🙌
'TroubleShooting' 카테고리의 다른 글
| [TroubleShooting] 프로토타입, 디바운싱, 쓰로틀링 사례 (1) | 2026.07.15 |
|---|---|
| [TroubleShooting] 대규모 리스트 렌더링 성능 개선 (0) | 2026.07.13 |
| [TroubleShooting] 대규모 리스트 렌더링 성능 개선 (0) | 2026.07.07 |
| [TroubleShooting] 레이스 컨디션 버그를 해결한 경험 (0) | 2026.07.07 |
| [TroubleShooting] 번들 사이즈 30% 줄인 과정 (0) | 2026.07.07 |