Devin.KR
로그인

자바스크립트 메모리와 가비지 컬렉션 - 누수가 생기는 리스너, 클로저, 전역 캐시 패턴

개발자 조회 1

이 단원에서 배우는 것

17단원에서 코드가 언제 실행되는지를 봤다. 이번에는 그 코드가 만든 객체가 언제 사라지는지를 본다. 자바스크립트는 메모리를 직접 해제하지 않으므로 개발자가 할 일이 없어 보이지만, 실제로는 "해제되지 않도록 붙잡고 있는" 코드를 매일 쓴다. 스터디 관리 대시보드를 열어 두고 몇 시간 지나면 느려진다는 제보가 오는 이유가 대개 여기 있다.

  • 가비지 컬렉터가 "쓰지 않는 객체"가 아니라 "도달할 수 없는 객체"를 지운다는 기준을 이해한다.
  • 리스너, 클로저, 전역 캐시, 타이머, 분리된 DOM이라는 다섯 가지 전형적인 누수 패턴을 코드 수준에서 식별한다.
  • 힙 스냅숏 세 장으로 누수를 재현하고, WeakMapAbortController로 구조적으로 막는다.

기준은 ES2021에 들어간 WeakRef/FinalizationRegistry와 Chrome 기반 브라우저의 개발자 도구다. GC의 구체적 동작은 명세가 아니라 엔진(V8, SpiderMonkey, JavaScriptCore) 구현이라 세부는 다를 수 있다.

왜 필요한가

대시보드에 스터디를 선택하면 참가자 목록을 보여 주는 패널이 있다고 하자. 패널을 열고 닫는 코드는 보통 이렇게 나온다.

function openMemberPanel(study) {
  const panel = document.createElement("div");
  const members = study.members;          // 참가자 3만 명

  function onResize() {
    panel.style.width = window.innerWidth + "px";
  }
  window.addEventListener("resize", onResize);

  panel.addEventListener("click", () => {
    console.log(members.length);
  });

  document.body.append(panel);
  return () => panel.remove();            // 닫기
}

닫기를 누르면 패널이 화면에서 사라진다. 눈에는 정상이다. 그런데 열고 닫기를 30번 반복하면 탭 메모리가 계속 올라간다. panel은 DOM 트리에서 제거됐지만 onResizewindow에 매달린 채 panel을 참조하고, panel에 붙은 클릭 콜백이 members를 참조한다. 결국 참가자 3만 명이 30벌 남는다.

이 코드에는 문법 오류도 없고 린터도 조용하다. 증상은 "쓰다 보면 느려진다" 하나뿐이고, 재현하려면 오래 써 봐야 한다. 그래서 메모리는 배워서 예방하는 쪽이 압도적으로 싸다.

문법과 예제

GC 의 기준은 도달 가능성이다

엔진은 루트(전역 객체, 현재 콜 스택의 지역 변수, 활성 클로저 등)에서 참조를 따라가며 닿는 객체에 표시를 남기고, 표시되지 않은 것을 회수한다. 표시-쓸기(mark and sweep)다. 여기서 중요한 것은 판정 기준이 "앞으로 쓸 것인가"가 아니라 "지금 닿을 수 있는가"라는 점이다.

let roster = { members: [ /* 3만 명 */ ] };
const backup = roster;

roster = null;      // 이것만으로는 회수되지 않는다. backup 이 아직 가리킨다

V8은 여기에 세대 구분을 더한다. 새로 만든 객체는 좁은 영역(new space)에 두고 자주, 빠르게 청소한다. 몇 번의 청소에서 살아남은 객체만 넓은 영역(old space)으로 옮겨 가끔 청소한다. 대부분의 객체가 금방 죽는다는 관찰에 기댄 설계다. 실무적으로 유의미한 함의는 하나다. 오래 살아남은 객체를 만드는 코드가 비싸다. 짧게 쓰고 버리는 임시 객체는 대체로 걱정할 대상이 아니다.

누수 패턴 1 — 리스너

가장 흔하다. 등록은 눈에 잘 띄는데 해제는 잊는다. 8단원에서 본 AbortController를 쓰면 여러 리스너를 한 번에 정리할 수 있다.

function openMemberPanel(study) {
  const controller = new AbortController();
  const { signal } = controller;
  const panel = document.createElement("div");

  window.addEventListener("resize", onResize, { signal });
  document.addEventListener("keydown", onKeydown, { signal });
  panel.addEventListener("click", onClick, { signal });

  function onResize() { panel.style.width = window.innerWidth + "px"; }
  function onKeydown(e) { if (e.key === "Escape") close(); }
  function onClick() { /* ... */ }

  function close() {
    controller.abort();      // 위 세 개가 한꺼번에 해제된다
    panel.remove();
  }

  document.body.append(panel);
  return close;
}

signal 방식의 장점은 리스너를 추가할 때 해제 코드를 새로 쓰지 않아도 된다는 것이다. 해제 목록을 손으로 관리하면 나중에 추가된 네 번째 리스너가 빠진다. 참고로 panel에 붙은 리스너는 panel이 통째로 회수될 때 같이 사라지므로 급하지 않지만, window/document처럼 수명이 페이지 전체인 대상에 붙인 리스너는 반드시 해제해야 한다.

누수 패턴 2 — 클로저가 필요 이상을 붙잡는다

function makeCounter(study) {
  const members = study.members;         // 3만 명
  let count = members.length;
  return () => count;                    // count 만 쓰는데...
}

반환된 함수는 count만 참조하지만, 같은 스코프에 있는 members가 함께 살아남는 경우가 있다. V8은 클로저가 실제로 참조하는 변수만 컨텍스트에 남기는 최적화를 하지만, 같은 스코프에 eval이 있거나 다른 내부 함수가 members를 참조하면 그 최적화가 무력화된다. 안전한 형태는 필요한 값만 끊어 내는 것이다.

function makeCounter(study) {
  const count = study.members.length;    // 숫자만 캡처한다
  return () => count;
}

규칙으로 정리하면 이렇다. 오래 사는 함수는 작은 값만 캡처한다. 콜백을 캐시나 이벤트 시스템에 넘길 때 특히 그렇다.

누수 패턴 3 — 상한 없는 전역 캐시

// 나쁜 예 — 절대 줄지 않는다
const memberCache = new Map();

export function getMember(id) {
  if (!memberCache.has(id)) {
    memberCache.set(id, loadMember(id));
  }
  return memberCache.get(id);
}

모듈 최상위 변수는 페이지가 살아 있는 동안 계속 도달 가능하다. 캐시는 "성능 개선"의 얼굴을 하고 들어와서 메모리를 먹는 대표적인 코드다. 최소한 상한을 둔다.

const MAX = 200;
const memberCache = new Map();

export function getMember(id) {
  if (memberCache.has(id)) {
    const value = memberCache.get(id);
    memberCache.delete(id);
    memberCache.set(id, value);          // 최근 사용으로 재삽입
    return value;
  }
  const value = loadMember(id);
  memberCache.set(id, value);
  if (memberCache.size > MAX) {
    memberCache.delete(memberCache.keys().next().value);   // 가장 오래된 항목
  }
  return value;
}

Map은 삽입 순서를 유지하므로 keys().next().value가 가장 오래 전에 넣은 키다. 이 성질로 LRU를 스무 줄 안에 만들 수 있다.

누수 패턴 4 — 객체를 키로 쓸 때는 WeakMap

DOM 요소나 객체에 부가 정보를 붙이고 싶을 때 Map을 쓰면 그 키가 영원히 살아남는다.

const rowMeta = new WeakMap();

function renderRow(member) {
  const li = document.createElement("li");
  rowMeta.set(li, { memberId: member.id, renderedAt: Date.now() });
  return li;
}

WeakMap의 키 참조는 GC 판정에서 무시된다. li가 DOM에서 제거되고 다른 참조도 없으면 li와 그에 딸린 메타데이터가 함께 회수된다. 대신 WeakMap은 순회할 수 없고 size도 없다. 회수 시점이 관측되면 GC 타이밍이 코드에 노출되기 때문이다. 순회가 필요하면 WeakMap은 답이 아니다.

누수 패턴 5 — 타이머와 분리된 DOM

// 패널을 닫아도 이 인터벌은 계속 돈다
setInterval(() => {
  panel.querySelector(".count").textContent = members.length;
}, 1000);

setInterval은 취소하기 전까지 콜백을 붙잡고, 콜백은 panelmembers를 붙잡는다. 게다가 panel이 DOM에서 제거된 뒤에도 콜백은 매초 실행되므로 CPU까지 먹는다. 반환값을 보관해 clearInterval하거나, 애초에 폴링 대신 이벤트로 갱신한다.

DOM 트리에서 떨어졌는데 자바스크립트가 참조를 쥐고 있는 노드를 분리된(detached) DOM이라 부른다. 아래는 그것을 만드는 전형적인 코드다.

const cachedRows = [];

function render(members) {
  const rows = members.map(createRow);
  cachedRows.push(...rows);              // 매번 쌓인다
  listBox.replaceChildren(...rows);      // 이전 rows 는 트리에서 빠지지만 배열이 잡고 있다
}

측정 — 힙 스냅숏 세 장

추측으로 고치지 말고 재현한다. Chrome 개발자 도구 Memory 패널에서 다음 순서를 따른다.

  1. 대상 화면을 한 번 열고 닫아 초기화한 뒤 스냅숏 1을 찍는다.
  2. 같은 동작(패널 열기/닫기)을 10회 반복한다.
  3. 스냅숏 2를 찍고, 다시 같은 동작을 10회 반복한 뒤 스냅숏 3을 찍는다.
  4. 스냅숏 3을 선택하고 비교 기준을 스냅숏 2로 놓는다. Delta 열에서 개수가 계속 늘어난 생성자를 찾는다.

1번과 2번 사이에는 첫 실행 캐시 때문에 노이즈가 많으므로 2와 3의 차이를 본다. 늘어난 객체를 선택하면 아래쪽 Retainers 창에 "누가 이걸 붙잡고 있는지" 경로가 나온다. 그 경로의 맨 위가 범인이다. Detached 노드를 찾을 때는 스냅숏 필터에 Detached를 입력한다. 스냅숏을 찍기 전에는 Memory 패널의 쓰레기통 아이콘으로 GC를 한 번 강제해서 회수 가능한 객체를 걸러낸다.

단순히 총량 추이만 보려면 Performance 패널 녹화에서 Memory 체크박스를 켜고 JS Heap 그래프를 본다. 톱니 모양으로 오르내리면 정상이고, 톱니의 골짜기 값이 계속 올라가면 누수다.

WeakRef 와 FinalizationRegistry

const cache = new Map();

function getReport(studyId) {
  const ref = cache.get(studyId);
  const cached = ref?.deref();          // 회수됐으면 undefined
  if (cached) return cached;

  const report = buildHeavyReport(studyId);
  cache.set(studyId, new WeakRef(report));
  return report;
}

WeakRef는 "있으면 쓰고 없으면 다시 만든다"는 캐시에 쓸 수 있다. 다만 회수 시점은 엔진이 정하므로 동작이 결정적이지 않다. 개발 중에는 회수되지 않다가 운영 환경의 메모리 압박 상황에서 회수돼 성능 특성이 달라질 수 있다. FinalizationRegistry의 콜백은 아예 실행이 보장되지 않는다(탭이 닫히면 그냥 끝난다). 리소스 해제를 여기에 의존하면 안 된다. 이 두 API는 최적화 수단이지 정리 수단이 아니다. 정리는 명시적으로 한다.

실무에서 자주 틀리는 것

1. 메모리가 오르는 것을 전부 누수로 판단한다

JS 힙 그래프가 오르는 것은 정상이다. GC는 필요할 때만 돌기 때문에 사용량은 톱니 모양으로 오르내린다. 누수의 판정 기준은 최댓값이 아니라 GC 직후 바닥값의 추세다. 스냅숏을 찍기 전 수동 GC를 한 번 돌리고, 같은 동작을 반복한 두 시점의 바닥값을 비교해야 결론이 나온다. 이 구분을 못 하면 멀쩡한 코드를 몇 시간 뜯게 된다.

2. removeEventListener 에 다른 함수를 넘긴다

window.addEventListener("resize", () => onResize(panel));
window.removeEventListener("resize", () => onResize(panel));   // 해제되지 않는다

두 화살표 함수는 서로 다른 객체다. 오류도 안 나고 반환값도 없어서 해제됐다고 착각한 채로 넘어간다. 클래스 메서드를 넘길 때도 같은 함정이 있다. this.onResize.bind(this)는 호출할 때마다 새 함수를 만들기 때문에 등록과 해제에서 각각 다른 함수가 된다. 필드에 한 번 바인딩해 두거나 { signal }을 쓴다.

3. SPA 화면 전환 때 정리 코드를 빼먹는다

페이지를 새로 로드하던 시절에는 모든 것이 초기화됐다. 화면 전환이 자바스크립트로 이뤄지는 앱에서는 아무것도 자동으로 정리되지 않는다. 컴포넌트 하나가 만들 수 있는 것을 세어 보면 리스너, 타이머, IntersectionObserver/ResizeObserver, 진행 중인 fetch, WebSocket, URL.createObjectURL로 만든 blob URL이 있다. 이 중 관찰자(observer)와 blob URL은 거의 항상 잊힌다. observer.disconnect()URL.revokeObjectURL(url)을 정리 함수에 넣는다. 진행 중인 요청도 AbortController로 취소하지 않으면, 사라진 화면을 갱신하려다 에러를 던진다.

4. 콘솔에 찍은 객체 때문에 회수가 막힌다

console.log(hugeMemberArray);   // 개발자 도구가 열려 있으면 이 참조가 유지된다

개발자 도구가 열린 상태에서 콘솔에 출력한 객체는 콘솔이 참조를 붙잡는다. 그래서 "개발자 도구를 켜면 메모리가 안 줄어든다"는 현상이 생긴다. 누수를 측정할 때는 콘솔을 비우고(Clear console) 스냅숏을 찍는다. 반대로 이것 때문에 없는 누수를 있다고 오판하는 경우도 많다.

5. 전역 변수를 임시로 만들어 두고 잊는다

function loadRoster() {
  roster = fetchRoster();     // const/let 없음 → 전역이 된다
}

모듈이 아닌 스크립트에서 선언 키워드를 빠뜨리면 window.roster가 만들어지고 페이지가 끝날 때까지 남는다. 파일 맨 위에 "use strict"를 쓰거나 ES 모듈(type="module")로 작성하면 이 대입은 ReferenceError가 된다. 모듈은 기본이 strict 모드라서 이 실수 자체가 불가능해진다. 디버깅용으로 window.__state = state를 붙여 놓고 지우지 않는 것도 같은 부류다.

스냅숏 화면의 각 열이 무슨 뜻인지는 Chrome DevTools 메모리 문제 문서에 정리돼 있다.

스스로 확인하기

  1. 다음 코드에서 패널을 닫은 뒤에도 회수되지 않는 것은 무엇이고, 그것을 붙잡고 있는 참조 경로는 어떻게 되는가?
    function open(study) {
      const panel = document.createElement("div");
      const members = study.members;
      const timer = setInterval(() => {
        panel.textContent = members.length;
      }, 1000);
      document.body.append(panel);
      return () => panel.remove();
    }
  2. DOM 요소마다 마지막 렌더 시각을 저장하려 한다. Map 대신 WeakMap을 쓰는 이유와, 그 대가로 포기하는 기능 두 가지를 쓰라.
  3. "몇 시간 쓰면 느려진다"는 제보를 받았다. 누수 여부를 판정하기 위한 측정 절차를 순서대로 쓰라.

정답

  1. panelmembers가 모두 남는다. panel.remove()는 DOM 트리에서만 떼어낼 뿐이고, 타이머가 취소되지 않았으므로 브라우저의 타이머 목록이 콜백을 참조하고, 그 콜백의 클로저가 panelmembers를 참조한다. 경로는 "타이머 목록 → 콜백 함수 → 클로저 스코프 → panel, members"다. 콜백은 매초 계속 실행되므로 CPU도 소모한다. 닫기 함수에서 clearInterval(timer)를 호출해야 한다.
  2. Map은 키를 강하게 참조하므로 요소가 DOM에서 제거되고 다른 참조가 없어도 Map 때문에 회수되지 않는다. 요소를 다시 그릴 때마다 항목이 쌓여 분리된 DOM 누수가 된다. WeakMap의 키 참조는 GC 판정에서 무시되므로 요소와 메타데이터가 함께 회수된다. 대신 WeakMap은 키 순회(keys, forEach, for...of)가 불가능하고 size를 알 수 없다.
  3. 먼저 Performance 패널에서 Memory를 켜고 대상 동작을 반복 녹화해 JS Heap의 GC 직후 바닥값이 우상향하는지 본다. 우상향하면 Memory 패널로 옮겨 콘솔을 비우고 수동 GC를 실행한 뒤 스냅숏 1을 찍는다. 동일 동작을 10회 반복하고 스냅숏 2, 다시 10회 반복하고 스냅숏 3을 찍는다. 스냅숏 3에서 비교 대상을 2로 지정해 Delta가 증가한 생성자를 찾고, Retainers 창에서 참조 경로의 최상단을 확인한다. 분리된 DOM이 의심되면 필터에 Detached를 입력해 확인한다.