TroubleShooting

[TroubleShooting] 메모리 누수 디버깅하기

teddy bear 2026. 7. 13. 09:49
반응형

메모리 누수 디버깅기: 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의 그래프가 계단식에서 정상적인 톱니 모양으로 돌아온 걸 확인하고 나서야 마음을 놓을 수 있었다.


정리: 이번 경험에서 얻은 체크리스트

메모리 누수는 재현이 어렵고 원인도 코드 곳곳에 흩어져 있는 경우가 많아, 감이 아니라 도구 기반의 절차를 갖추는 게 중요하다는 걸 다시 느꼈다.

  1. Performance Monitor로 먼저 증상을 정량화한다 (JS Heap / DOM Nodes / Listeners 추이)
  2. Heap Snapshot은 3장 이상 비교해서, "생성"과 "누적"을 구분한다
  3. # New 기준 정렬 후 Detached 노드를 최우선으로 의심한다
  4. Retainers 트리를 따라가면 원인 코드 위치까지 역추적할 수 있다
  5. useEffect, addEventListener, setInterval은 항상 cleanup 세트로 작성하는 습관을 들인다

무엇보다, 이번 이슈를 계기로 팀 코드 리뷰 체크리스트에 "이펙트 등록 시 해제 로직이 있는가?" 항목을 추가했다. 같은 실수가 반복되지 않도록 만드는 것도 트러블슈팅의 연장선이라고 생각한다.


읽어주셔서 감사합니다. 궁금한 점이나 다른 디버깅 경험이 있다면 댓글로 나눠주세요 🙌