자바스크립트 고급 #5 메모리 모델과 GC
#4 이벤트 루프 에서 자바스크립트가 시간을 다루는 방법을 봤다면, 이번엔 메모리 차례입니다. 자바스크립트는 메모리를 자동으로 관리해 주지만, 그래서 더 자주 사고가 나는 영역이기도 합니다.
자바스크립트는 자동 메모리 관리 #
C 같은 언어에서는 메모리를 직접 할당/해제합니다. 자바스크립트는 **가비지 컬렉터(GC)**가 알아서 청소해 줍니다.
GC가 청소할지 말지를 결정하는 기준은 단순합니다.
도달 가능한(reachable) 객체는 살리고, 도달 불가능한 객체는 회수한다.
도달 가능성이 핵심입니다.
도달 가능성 (Reachability) #
다음에서 시작해 닿을 수 있는 모든 객체가 도달 가능합니다.
- 글로벌 객체 (브라우저: window, Node: global/globalThis)
- 현재 콜 스택의 함수 매개변수와 지역 변수
- 위 두 곳에서 참조하는 모든 객체
let user = { name: '커티스' };
// user 가 객체를 참조 → 도달 가능
user = null;
// 더 이상 참조 없음 → 도달 불가능 → GC 대상
user = null로 참조를 끊으면, 그 객체에 닿는 길이 사라집니다. GC가 다음 라운드에서 회수합니다.
두 객체가 서로 참조해도 끊기면 회수됨 #
옛 GC 알고리즘(reference counting)은 순환 참조에서 메모리를 못 풀었습니다. 자바스크립트의 모던 GC(mark-and-sweep)는 그런 한계가 없습니다.
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 노드 참조 #
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이 정확히 이 부분을 처리하는 패턴입니다.
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 — 키를 약하게 #
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, 키 객체 살림, iterableWeakMap— 키는 객체만, 키 객체 안 살림, iterable 아님
WeakSet — 값을 약하게 #
const visited = new WeakSet();
function process(node) {
if (visited.has(node)) return;
visited.add(node);
// ...
}Set의 weak 버전. 객체 방문 추적 같은 경우에 적합합니다. JSON stringify의 순환 참조 추적(중급 #7)에서 본 패턴.
WeakRef — ES2021 #
객체에 대한 약한 참조 자체.
let user = { name: '커티스' };
const ref = new WeakRef(user);
ref.deref(); // user 객체 (아직 살아 있으면)
user = null;
// ... GC 시점 이후
ref.deref(); // undefined (회수됨)
WeakRef는 흔하지 않습니다. 캐시 구현이나 비주류 자료구조 작성에서 만납니다. 일반 코드에서는 거의 쓸 일이 없습니다.
FinalizationRegistry — 회수 시점 알기
#
객체가 GC 될 때 콜백을 받는 도구.
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를 다룹니다.