자바스크립트 고급 #5 메모리 모델과 GC

#4 이벤트 루프 에서 자바스크립트가 시간을 다루는 방법을 봤다면, 이번엔 메모리 차례입니다. 자바스크립트는 메모리를 자동으로 관리해 주지만, 그래서 더 자주 사고가 나는 영역이기도 합니다.

자바스크립트는 자동 메모리 관리 #

C 같은 언어에서는 메모리를 직접 할당/해제합니다. 자바스크립트는 **가비지 컬렉터(GC)**가 알아서 청소해 줍니다.

GC가 청소할지 말지를 결정하는 기준은 단순합니다.

도달 가능한(reachable) 객체는 살리고, 도달 불가능한 객체는 회수한다.

도달 가능성이 핵심입니다.

도달 가능성 (Reachability) #

다음에서 시작해 닿을 수 있는 모든 객체가 도달 가능합니다.

  • 글로벌 객체 (브라우저: window, Node: global/globalThis)
  • 현재 콜 스택의 함수 매개변수와 지역 변수
  • 위 두 곳에서 참조하는 모든 객체
도달 가능 vs 불가능
let user = { name: '커티스' };
// user 가 객체를 참조 → 도달 가능

user = null;
// 더 이상 참조 없음 → 도달 불가능 → GC 대상

user = null로 참조를 끊으면, 그 객체에 닿는 길이 사라집니다. GC가 다음 라운드에서 회수합니다.

두 객체가 서로 참조해도 끊기면 회수됨 #

옛 GC 알고리즘(reference counting)은 순환 참조에서 메모리를 못 풀었습니다. 자바스크립트의 모던 GC(mark-and-sweep)는 그런 한계가 없습니다.

순환 참조도 OK
function makeCycle() {
  const a = {};
  const b = {};
  a.ref = b;
  b.ref = a;   // 서로 참조
}

makeCycle();
// 함수 종료 시 a, b 모두 도달 불가능 → 둘 다 GC

밖에서 a나 b 어느 쪽도 참조하지 않으니, 서로 가리켜도 도달할 수 없어 회수됩니다.

흔한 누수 패턴들 #

GC가 자동이라도 참조를 의도치 않게 살려두면 메모리가 쌓여요. 자주 만나는 패턴들을 살펴봅니다.

1) 글로벌 변수에 쌓이기 #

글로벌 누수
window.cache = window.cache || {};

function fetchUser(id) {
  // 결과를 글로벌 캐시에 쌓음 — 영원히 살아남음
  window.cache[id] = result;
}

window.cache가 글로벌이라 도달 가능 — 그 안의 모든 객체가 영원히 GC 안 됩니다. 캐시는 제때 비우거나 크기 제한을 둬야 합니다.

크기 제한 캐시
const cache = new Map();
const MAX = 100;

function setCached(key, value) {
  if (cache.size >= MAX) {
    const firstKey = cache.keys().next().value;
    cache.delete(firstKey);
  }
  cache.set(key, value);
}

진짜 LRU 캐시는 라이브러리(lru-cache)를 쓰는 게 안전합니다.

2) 떼어진 DOM 노드 참조 #

DOM 누수
const buttons = [];

function setup() {
  const btn = document.createElement('button');
  document.body.appendChild(btn);
  buttons.push(btn);   // 자바스크립트 배열이 참조
}

function cleanup() {
  document.body.innerHTML = '';   // DOM에서는 사라짐
  // 그러나 buttons 배열이 여전히 참조 → GC 안 됨
}

DOM 트리에서 떼어내도 자바스크립트가 여전히 참조하면 메모리에 남습니다. 디테치된 노드(detached node) 누수 — 매우 흔한 패턴입니다.

해결: 더 이상 필요 없으면 자바스크립트 측 참조도 같이 끊기.

해결
function cleanup() {
  document.body.innerHTML = '';
  buttons.length = 0;   // 배열 비우기
}

3) 이벤트 핸들러 등록 해제 안 함 #

핸들러 누수
function attach() {
  const onClick = () => { /* ... */ };
  button.addEventListener('click', onClick);
  // removeEventListener 안 함
}

언마운트 시 또는 더 이상 필요 없을 때 removeEventListener로 빼지 않으면, 핸들러와 그 안의 클로저가 살아 있습니다. 리액트의 useEffect cleanup이 정확히 이 부분을 처리하는 패턴입니다.

리액트 cleanup 패턴
useEffect(() => {
  const onClick = () => { /* ... */ };
  button.addEventListener('click', onClick);

  return () => {
    button.removeEventListener('click', onClick);
  };
}, []);

4) 타이머 청소 안 함 #

타이머 누수
const id = setInterval(() => {
  fetchData();
}, 1000);
// clearInterval 안 부르면 영원히 동작

같은 패턴 — 정리 안 하면 살아남습니다. 컴포넌트 언마운트 시 반드시 정리.

5) 클로저가 큰 객체 캡처 #

#1 클로저 에서 살짝 본 부분.

클로저가 무거운 객체 잡고 있음
function setup() {
  const huge = new Array(1_000_000);   // 큰 배열
  process(huge);

  return function() {
    console.log('hi');   // huge 안 쓰는데도 캡처할 수 있음
  };
}

const fn = setup();   // huge가 fn에 의해 살아남을 수도

모던 엔진은 사용하지 않는 변수를 영리하게 풀어주려 노력하지만 보장은 없습니다. 의식적으로 풀려면:

명시적 풀기
function setup() {
  let huge = new Array(1_000_000);
  process(huge);
  huge = null;   // 더 이상 안 쓰면 풀기

  return function() {
    console.log('hi');
  };
}

WeakRef, WeakMap, WeakSet — 약한 참조 #

GC의 도달 가능 검사를 건너뛰고 싶을 때 쓰는 도구들. 약한 참조는 객체를 살려두지 않습니다.

WeakMap — 키를 약하게 #

WeakMap
const cache = new WeakMap();

function attach(user, data) {
  cache.set(user, data);
}

let u = { id: 'u1' };
attach(u, '데이터');

u = null;   // u 객체가 도달 불가능 → WeakMap 항목도 자동 사라짐

Map은 키 객체를 살려두지만, WeakMap은 그렇지 않습니다. 객체에 메타데이터를 붙이고 싶은데 그 객체가 사라지면 같이 정리되길 바랄 때 적합합니다.

차이:

  • Map — 키가 어떤 값이든 OK, 키 객체 살림, iterable
  • WeakMap — 키는 객체만, 키 객체 안 살림, iterable 아님

WeakSet — 값을 약하게 #

WeakSet
const visited = new WeakSet();

function process(node) {
  if (visited.has(node)) return;
  visited.add(node);
  // ...
}

Set의 weak 버전. 객체 방문 추적 같은 경우에 적합합니다. JSON stringify의 순환 참조 추적(중급 #7)에서 본 패턴.

WeakRef — ES2021 #

객체에 대한 약한 참조 자체.

WeakRef
let user = { name: '커티스' };
const ref = new WeakRef(user);

ref.deref();   // user 객체 (아직 살아 있으면)

user = null;
// ... GC 시점 이후
ref.deref();   // undefined (회수됨)

WeakRef는 흔하지 않습니다. 캐시 구현이나 비주류 자료구조 작성에서 만납니다. 일반 코드에서는 거의 쓸 일이 없습니다.

FinalizationRegistry — 회수 시점 알기 #

객체가 GC 될 때 콜백을 받는 도구.

FinalizationRegistry
const registry = new FinalizationRegistry((token) => {
  console.log(`정리됨: ${token}`);
});

let user = { name: '커티스' };
registry.register(user, 'user-token');

user = null;
// GC 시점 이후 — '정리됨: user-token'

언제 호출될지는 보장되지 않습니다(GC 시점은 엔진 마음). 외부 자원(네이티브 핸들 등) 정리 같은 매우 특수한 경우에만 의미가 있고 일반 앱 코드에서는 거의 안 씁니다.

메모리 디버깅 — 브라우저 도구 #

크롬 DevTools의 Memory 탭이 가장 강력한 도구입니다.

Heap snapshot #

현재 메모리에 있는 모든 객체를 스냅샷으로 떠서 봅니다. “어떤 클래스가 몇 개” 가 보이고, “왜 GC 안 됐는지” 의 참조 경로를 따라갈 수 있습니다.

Allocation timeline #

시간에 따라 메모리가 어떻게 늘어나는지 그래프로 봅니다. 메모리 누수 추적의 표준 도구입니다.

상세한 사용법은 따로 가이드가 필요할 만큼 깊지만, “메모리 누수 의심” 일 때 가장 먼저 들여다볼 도구가 여기라는 것만 기억해 두세요.

자바스크립트 메모리 모델 — 값 타입과 참조 타입 다시 #

기초 #2 에서 본 원시 vs 참조 차이가 메모리 모델에서 다시 의미를 가집니다.

  • 원시값 — 변수 자체에 값이 들어있음. 작고 가벼움.
  • 객체 — 변수에는 객체가 있는 위치만 들어있음. 진짜 객체는 힙(heap)에.

함수의 지역 원시 변수들은 콜 스택에 함께 살다가 함수가 끝나면 같이 사라집니다. 객체는 힙에 따로 있고, 누가 참조하는 동안 살아남습니다.

마무리 #

이번 글에서 정리한 내용:

  • 자바스크립트는 자동 메모리 관리 (GC)
  • GC 기준은 도달 가능성 — 글로벌/스택에서 닿을 수 있는가
  • 모던 GC는 mark-and-sweep — 순환 참조도 회수 가능
  • 흔한 누수 — 글로벌 캐시, 떼어진 DOM, 미정리 핸들러/타이머, 무거운 클로저
  • WeakMap/WeakSet은 키/값을 살려두지 않음
  • WeakRef / FinalizationRegistry는 매우 특수한 경우용
  • 메모리 디버깅은 DevTools Memory 탭

다음 글(#6 Symbol, WeakRef, Proxy)에서는 자바스크립트의 더 색다른 도구들 — 충돌 없는 키를 만드는 Symbol, 객체 동작을 가로채는 Proxy를 다룹니다.

X