WeakRef와 FinalizationRegistry — 자바스크립트의 메모리 관리
이벤트 리스너를 해제하지 않았더니 페이지를 오래 쓸수록 메모리 사용량이 계속 올라갑니다. 가비지 컬렉터가 있는데 왜 이런 일이 생길까요?
GC는 도달 불가능한(unreachable) 객체만 수거합니다. 이벤트 리스너, 클로저, 타이머 등이 의도치 않게 참조를 유지하면 GC가 해당 객체를 수거하지 못합니다.
가비지 컬렉션 기본 — Mark-and-Sweep
자바스크립트 엔진의 가비지 컬렉터(GC)는 더 이상 사용하지 않는 메모리를 자동으로 회수합니다. 핵심 알고리즘은 Mark-and-Sweep 입니다.
동작 과정
- **Mark 단계 **: 루트(전역 객체, 현재 실행 중인 함수의 지역 변수 등)에서 출발해서 도달 가능한(reachable) 모든 객체를 마킹
- **Sweep 단계 **: 마킹되지 않은 객체의 메모리를 해제
let user = { name: '홍길동', age: 25 };
// { name: '홍길동', age: 25 } 객체는 user 변수를 통해 도달 가능
user = null;
// 이제 해당 객체에 도달할 방법이 없음 → GC 대상
Reference Counting의 한계
예전에는 참조 횟수를 세는 Reference Counting 방식도 있었습니다. 하지만 순환 참조를 처리하지 못합니다.
function createCycle() {
const objA = {};
const objB = {};
objA.ref = objB; // A → B
objB.ref = objA; // B → A
// 함수가 끝나도 서로 참조하므로 Reference Counting에서는 GC 안 됨
// Mark-and-Sweep에서는 루트에서 도달 불가능하므로 GC됨
}
createCycle();
현대 엔진(V8, SpiderMonkey 등)은 Mark-and-Sweep 기반이라 순환 참조를 제대로 처리합니다.
V8의 세대별 GC
V8 엔진은 메모리를 두 영역으로 나눕니다.
- Young Generation: 새로 생성된 객체. 자주 GC 수행 (Minor GC / Scavenge)
- Old Generation: 오래 살아남은 객체. 가끔 GC 수행 (Major GC / Mark-Sweep-Compact)
대부분의 객체는 생성 후 금방 사라지기 때문에(세대 가설), 이 전략이 효율적입니다.
WeakMap과 WeakSet — 약한 참조 컬렉션
WeakMap
const cache = new WeakMap();
function process(obj) {
if (cache.has(obj)) {
return cache.get(obj);
}
const result = /* 비용이 큰 계산 */ obj.value * 2;
cache.set(obj, result);
return result;
}
let data = { value: 42 };
process(data); // 계산 후 캐시에 저장
process(data); // 캐시에서 가져옴
data = null; // data 객체가 GC되면, cache의 해당 엔트리도 자동 제거
WeakMap vs Map 차이점
| 구분 | Map | WeakMap |
|---|---|---|
| 키 타입 | 아무 값 | 객체만 |
| GC | 키 객체의 GC를 막음 | 키 객체의 GC를 막지 않음 |
| 열거 가능 | for...of, keys(), size 가능 | 불가능 |
| 사용처 | 일반 키-값 저장소 | 객체에 메타데이터 연결, 캐시 |
WeakMap이 열거 불가능한 이유는 GC 타이밍이 예측 불가능하기 때문입니다. 순회 도중에 항목이 사라질 수 있으니까요.
WeakSet
const visited = new WeakSet();
function processNode(node) {
if (visited.has(node)) return; // 이미 방문한 노드
visited.add(node);
// ... 처리 로직
}
WeakSet은 "이 객체를 이전에 봤는가?"를 추적할 때 유용합니다. 객체가 GC되면 자동으로 Set에서도 사라집니다.
실무 활용 — DOM 요소에 데이터 연결
const elementData = new WeakMap();
function setData(element, data) {
elementData.set(element, data);
}
function getData(element) {
return elementData.get(element);
}
const button = document.getElementById('myButton');
setData(button, { clicks: 0, lastClicked: null });
// DOM에서 button이 제거되면, elementData의 해당 엔트리도 자동 정리
WeakRef — 명시적 약한 참조
ES2021에서 도입된 WeakRef는 객체에 대한 약한 참조를 직접 만들 수 있게 해줍니다.
let target = { name: '큰 데이터', data: new Array(1000000) };
const weakRef = new WeakRef(target);
// 약한 참조를 통해 객체에 접근
console.log(weakRef.deref()); // { name: '큰 데이터', data: [...] }
target = null; // 강한 참조 제거
// GC 이후
console.log(weakRef.deref()); // undefined (GC가 수거한 경우)
deref()의 중요한 규칙
deref()로 가져온 참조는 해당 코드 블록 내에서 사용하고, 변수에 오래 저장하지 않아야 합니다. 변수에 저장하면 강한 참조가 되어 GC를 방해합니다.
// 좋은 패턴
function doSomething(weakRef) {
const obj = weakRef.deref();
if (obj) {
// obj를 사용 — 이 블록 안에서만
console.log(obj.name);
}
// 블록을 벗어나면 obj 참조가 사라짐
}
// 나쁜 패턴
let cached = weakRef.deref(); // 강한 참조가 되어 GC 방해
캐시 구현 예시
class WeakCache {
#cache = new Map();
set(key, value) {
this.#cache.set(key, new WeakRef(value));
}
get(key) {
const ref = this.#cache.get(key);
if (!ref) return undefined;
const value = ref.deref();
if (!value) {
// GC로 수거됨 → Map에서도 제거
this.#cache.delete(key);
return undefined;
}
return value;
}
// 수거된 엔트리를 정리 (FinalizationRegistry와 함께 쓰면 자동화 가능)
cleanup() {
for (const [key, ref] of this.#cache) {
if (!ref.deref()) {
this.#cache.delete(key);
}
}
}
}
FinalizationRegistry — 정리 콜백
FinalizationRegistry는 객체가 GC로 수거된 후 콜백을 실행할 수 있게 해줍니다.
const registry = new FinalizationRegistry((heldValue) => {
console.log(`객체가 수거됨, 정리 대상: ${heldValue}`);
// 외부 리소스 정리, 캐시 엔트리 삭제 등
});
let obj = { name: '임시 객체' };
registry.register(obj, 'obj의 식별자'); // 두 번째 인자는 콜백에 전달될 값
obj = null; // 강한 참조 제거 → 나중에 GC가 수거하면 콜백 실행
주의사항
- **타이밍 보장 없음 **: 콜백이 언제 호출될지, 심지어 호출될지조차 보장되지 않습니다
- ** 핵심 로직에 쓰지 말 것 **: 정리(cleanup)용 보조 수단으로만 사용
- **unregister 가능 **: 등록 해제 토큰을 전달해서 나중에 취소 가능
const registry = new FinalizationRegistry((id) => {
// 외부 리소스 정리
externalResourceMap.delete(id);
});
let resource = createExternalResource();
const token = {}; // 등록 해제용 토큰
registry.register(resource, resource.id, token);
// 나중에 수동으로 정리한 경우, 콜백이 불필요해지면
registry.unregister(token);
WeakRef + FinalizationRegistry 조합
class ResourceManager {
#refs = new Map();
#registry = new FinalizationRegistry((key) => {
console.log(`리소스 ${key} 해제됨`);
this.#refs.delete(key);
});
add(key, resource) {
const ref = new WeakRef(resource);
this.#refs.set(key, ref);
this.#registry.register(resource, key, ref);
}
get(key) {
const ref = this.#refs.get(key);
if (!ref) return undefined;
return ref.deref();
}
}
메모리 누수 패턴
GC가 있어도 메모리 누수는 발생합니다. 도달 가능한(reachable) 상태가 유지되면 GC가 수거하지 못하기 때문입니다.
1. 전역 변수 오남용
// 실수로 전역 변수 생성 (strict mode가 아닐 때)
function handler() {
leakedData = new Array(1000000); // var/let/const 빠짐 → 전역 변수
}
2. 제거되지 않은 이벤트 리스너
// 누수 패턴
function setup() {
const data = loadHugeData();
document.getElementById('btn').addEventListener('click', () => {
console.log(data); // 클로저가 data를 참조 → GC 불가
});
}
// 해결: 리스너 제거
function setup() {
const data = loadHugeData();
const handler = () => console.log(data);
const btn = document.getElementById('btn');
btn.addEventListener('click', handler);
// 정리
return () => btn.removeEventListener('click', handler);
}
3. 해제되지 않은 타이머
// 누수 패턴
const data = loadData();
setInterval(() => {
updateUI(data); // data에 대한 참조가 영원히 유지됨
}, 1000);
// 해결: clearInterval
const timerId = setInterval(() => updateUI(data), 1000);
// 필요 없어지면
clearInterval(timerId);
4. 클로저의 의도치 않은 참조
function createProcessor() {
const hugeArray = new Array(1000000).fill('*');
return function process(index) {
return hugeArray[index]; // 클로저가 hugeArray 전체를 참조
};
}
const processor = createProcessor();
// processor가 살아있는 한 hugeArray도 메모리에 유지됨
5. DOM 참조 유지
// 누수 패턴
const elements = [];
function addElement() {
const div = document.createElement('div');
document.body.appendChild(div);
elements.push(div); // 배열에도 참조 유지
}
function removeElement() {
const div = elements.pop();
document.body.removeChild(div);
// DOM에서는 제거했지만 elements 배열에서 참조가 남아있었다면 누수
}
메모리 누수 디버깅
Chrome DevTools 활용
- Memory 탭 > Heap Snapshot: 현재 메모리 상태 캡처
- ** 두 시점의 스냅샷 비교 **: 증가한 객체를 찾아 누수 원인 파악
- Allocation Timeline: 시간에 따른 메모리 할당 추적
- **Performance 탭 **: 메모리 사용량 그래프 모니터링
간단한 확인 방법
// 메모리 사용량 확인 (Node.js)
console.log(process.memoryUsage());
// {
// rss: 30000000, // 전체 메모리
// heapTotal: 6000000, // 힙 전체 크기
// heapUsed: 4000000, // 사용 중인 힙
// external: 1000000 // 외부 메모리 (Buffer 등)
// }
// 브라우저에서 (비표준이지만 Chrome에서 사용 가능)
console.log(performance.memory);
주의할 점
WeakRef.deref() 결과를 변수에 오래 저장하면 강한 참조가 된다
const obj = weakRef.deref()로 가져온 참조를 전역이나 클로저에 저장하면, 약한 참조의 의미가 사라집니다. deref() 결과는 해당 코드 블록 안에서만 사용하고, 즉시 해제되도록 해야 합니다.
FinalizationRegistry 콜백의 타이밍은 보장되지 않는다
콜백이 언제 호출될지, 심지어 호출될지조차 보장되지 않습니다. 핵심 비즈니스 로직에 FinalizationRegistry를 의존하면 안 되고, 보조적인 정리 수단으로만 사용해야 합니다.
5가지 메모리 누수 패턴
- ** 전역 변수 오남용 **:
var/let/const없이 선언하면 전역에 바인딩 - ** 미제거 이벤트 리스너 **: 클로저가 큰 데이터를 참조하며 GC 방해
- ** 해제되지 않은 타이머 **:
setInterval콜백이 데이터 참조 유지 - ** 클로저의 의도치 않은 참조 **: 필요 없는 외부 변수까지 렉시컬 환경에 포함
- **DOM 참조 유지 **: DOM에서 제거했지만 JavaScript 배열에 참조가 남음
정리
| 항목 | 설명 |
|---|---|
| GC 알고리즘 | Mark-and-Sweep 기반, 루트에서 도달 불가능한 객체 수거 |
| V8 세대별 GC | Young Generation(자주, Minor GC) + Old Generation(가끔, Major GC) |
| WeakMap/WeakSet | 키 객체의 GC를 방해하지 않는 약한 참조 컬렉션, 열거 불가 |
| WeakRef | 개별 객체에 대한 약한 참조, deref()로 접근 |
| FinalizationRegistry | 객체 수거 후 정리 콜백 실행, 타이밍 보장 없음 |