WeakMap 기반 컴포넌트 상태 관리
프레임워크 없이 컴포넌트 시스템을 직접 만들다 보면 가장 먼저 부딪히는 문제가 "상태를 어디에 저장할 것인가"다. 함수 컴포넌트는 호출될 때마다 새로운 실행 컨텍스트가 생성되기 때문에, 함수 내부 변수로는 상태를 유지할 수 없다. 그렇다고 전역 객체에 상태를 저장하면 컴포넌트 간 상태가 뒤섞이고, 컴포넌트가 제거되어도 상태가 메모리에 남는 문제가 생긴다.
이 문제를 해결하는 방법 중 하나가 WeakMap을 상태 저장소로 사용하는 것이다.
일반 Map의 한계
먼저 일반 Map이나 일반 객체로 상태를 관리하는 방식의 문제를 짚어보자.
const stateStore = new Map();
function render(component) {
if (!stateStore.has(component)) {
stateStore.set(component, { count: 0 });
}
const state = stateStore.get(component);
// ... 렌더링 로직
}
이 방식은 동작은 하지만 메모리 누수의 원인이 된다. Map은 키에 대한 강한 참조(strong reference)를 유지하기 때문에, 외부에서 해당 컴포넌트 객체에 대한 참조가 모두 사라져도 Map 안의 키-값 쌍은 가비지 컬렉션되지 않는다. 컴포넌트가 수백 개 생성되었다가 제거되는 동적인 UI에서는 사라진 컴포넌트의 상태가 계속 메모리에 쌓이게 된다.
직접 delete를 호출해서 정리할 수도 있지만, 컴포넌트의 생명주기를 일일이 추적해서 정리하는 코드를 짜야 한다는 뜻이다. 실수하면 바로 메모리 누수로 이어진다.
WeakMap이란
WeakMap은 ES6에서 도입된 자료구조로, Map과 비슷하지만 결정적인 차이가 있다. 키에 대한 약한 참조(weak reference)를 유지한다.
const weakMap = new WeakMap();
let obj = { name: "hello" };
weakMap.set(obj, "some data");
obj = null; // 외부 참조 제거
// → WeakMap 내부의 { name: "hello" } → "some data" 엔트리도
// 가비지 컬렉터에 의해 자동 수거됨
일반 Map이었다면 obj = null로 외부 참조를 끊어도 Map 자체가 키를 강하게 참조하고 있으므로 GC가 수거하지 못한다. WeakMap은 키에 대한 참조가 자기 자신밖에 없으면 GC가 해당 엔트리를 자동으로 제거한다.
Map vs WeakMap 비교
| 특성 | Map | WeakMap |
|---|---|---|
| 키 타입 | 모든 타입 (원시값 포함) | 객체만 가능 |
| 참조 방식 | 강한 참조 | 약한 참조 |
| GC 대상 | 아님 (명시적 delete 필요) | 외부 참조 없으면 자동 수거 |
| 이터러블 | O (for...of, keys(), values()) | X (순회 불가) |
| size 프로퍼티 | O | X |
WeakMap이 이터러블하지 않은 이유는 GC의 동작 시점이 비결정적이기 때문이다. 언제 어떤 엔트리가 사라질지 예측할 수 없으므로, 특정 시점에 "현재 저장된 모든 키"를 나열하는 것 자체가 의미가 없다.
왜 상태 관리에 WeakMap이 적합한가
컴포넌트 시스템에서 WeakMap을 상태 저장소로 쓰는 핵심 이유는 컴포넌트의 생명주기와 상태의 생명주기를 자동으로 일치시킬 수 있기 때문이다.
const stateStore = new WeakMap();
function mountComponent(component) {
stateStore.set(component, { count: 0, text: "" });
// 컴포넌트가 DOM에 마운트됨
}
function unmountComponent(component) {
// component에 대한 외부 참조가 사라지면
// stateStore 내부의 상태도 자동으로 GC 대상이 됨
// 별도의 cleanup 코드가 필요 없음
}
일반 Map을 쓰면 unmountComponent 안에서 반드시 stateStore.delete(component)를 호출해야 한다. WeakMap은 이 과정이 자동이다. 컴포넌트 객체가 DOM 트리에서 제거되고 어디에서도 참조하지 않으면, WeakMap의 해당 엔트리도 자연스럽게 사라진다.
구현 패턴: 함수 컴포넌트의 상태 저장
함수 컴포넌트에 상태를 부여하려면, 각 컴포넌트 인스턴스를 식별할 수 있는 키가 필요하다. 함수 자체는 같은 함수를 여러 번 호출해서 여러 인스턴스를 만들 수 있으므로, 함수 자체를 키로 쓸 수는 없다.
방법 1: 컴포넌트 인스턴스 객체를 키로 사용
렌더링 시스템이 컴포넌트마다 고유한 "인스턴스 객체"를 생성하는 경우, 이 객체를 키로 쓸 수 있다.
const stateStore = new WeakMap();
function createComponentInstance(renderFn) {
const instance = { render: renderFn, hooks: [] };
stateStore.set(instance, []);
return instance;
}
function getState(instance) {
return stateStore.get(instance);
}
이 방식은 직관적이고 안전하다. 인스턴스 객체가 GC되면 상태도 함께 사라진다.
방법 2: 빈 객체를 컨텍스트 키로 사용
인스턴스 시스템이 없는 간단한 구조에서는 빈 객체 {}를 식별자로 쓰기도 한다.
const stateStore = new WeakMap();
function useState(initialValue) {
const contextKey = {};
if (!stateStore.has(contextKey)) {
stateStore.set(contextKey, []);
}
const states = stateStore.get(contextKey);
const index = states.length;
if (index >= states.length) {
states.push(initialValue);
}
const setState = (newValue) => {
states[index] = newValue;
};
return [states[index], setState];
}
그런데 이 코드에는 치명적인 문제가 있다. const contextKey = {}가 매 호출마다 새 객체를 생성하기 때문에, useState를 호출할 때마다 완전히 새로운 키가 만들어진다. 이전 상태를 절대 찾을 수 없다. 항상 initialValue만 반환하는 셈이다.
이 문제를 해결하려면 컨텍스트 키를 함수 외부에서 관리해야 한다.
올바른 구현: 커런트 컴포넌트 추적
React 내부가 실제로 동작하는 방식과 유사한 패턴이다. 핵심은 현재 렌더링 중인 컴포넌트가 누구인지 전역으로 추적하는 것이다.
const stateStore = new WeakMap();
let currentComponent = null;
let hookIndex = 0;
function renderComponent(instance) {
// 현재 렌더링 중인 컴포넌트를 전역에 등록
currentComponent = instance;
hookIndex = 0;
// 이 컴포넌트의 상태 배열이 없으면 생성
if (!stateStore.has(instance)) {
stateStore.set(instance, []);
}
// 컴포넌트의 render 함수 실행
// → 내부에서 useState가 호출되면 currentComponent를 키로 사용
const vdom = instance.render();
currentComponent = null;
return vdom;
}
function useState(initialValue) {
const component = currentComponent;
const states = stateStore.get(component);
const idx = hookIndex++;
// 최초 렌더링: 초기값 저장
if (idx >= states.length) {
states.push(initialValue);
}
const setState = (newValue) => {
states[idx] = newValue;
// 상태 변경 후 리렌더링 트리거
scheduleRerender(component);
};
return [states[idx], setState];
}
이 구조에서 WeakMap이 하는 역할을 정리하면:
- 키: 컴포넌트 인스턴스 객체 (각 컴포넌트를 고유하게 식별)
- 값: 해당 컴포넌트의 상태 배열 (
[state0, state1, ...]) - 자동 정리: 인스턴스가 트리에서 제거되면 상태도 GC됨
왜 배열인가
상태를 배열로 저장하는 이유는, 하나의 컴포넌트에서 useState를 여러 번 호출할 수 있기 때문이다.
function Counter() {
const [count, setCount] = useState(0); // states[0]
const [name, setName] = useState("hello"); // states[1]
const [active, setActive] = useState(false); // states[2]
// ...
}
각 useState 호출은 hookIndex를 하나씩 증가시키면서 배열의 다음 슬롯을 사용한다. 이것이 바로 React에서 훅을 조건문이나 반복문 안에서 호출하면 안 되는 이유다. 호출 순서가 달라지면 인덱스가 꼬여서 엉뚱한 상태를 참조하게 된다.
// ❌ 잘못된 사용
function Component() {
const [count, setCount] = useState(0); // 항상 states[0]
if (count > 0) {
const [name, setName] = useState(""); // 조건부 → 인덱스 불안정
}
const [active, setActive] = useState(true);
// count === 0일 때: states[2]
// count > 0일 때: states[2]이지만, name이 states[1]을 차지하면서 밀림
}
WeakMap vs 다른 저장 방식 비교
상태를 어디에 저장할지 선택할 때 고려할 수 있는 방식들을 비교해보자.
전역 Map + 문자열 키
const store = new Map();
store.set("counter-1", { count: 0 });
store.set("counter-2", { count: 5 });
문자열 키를 직접 관리해야 한다. 키 충돌 가능성이 있고, 컴포넌트 제거 시 수동으로 delete해야 한다. 간단한 구조에서는 동작하지만 확장성이 떨어진다.
클로저
function createCounter(initial) {
let count = initial;
return {
getCount: () => count,
increment: () => { count++; }
};
}
상태가 클로저에 갇혀 있어서 외부에서 접근하기 어렵다. 디버깅이 불편하고, 여러 상태를 관리할 때 코드가 복잡해진다. 하지만 캡슐화가 강력하다는 장점이 있다.
WeakMap
const store = new WeakMap();
store.set(instance, [0, "", false]);
객체 키만 사용할 수 있다는 제약이 있지만, 메모리 관리가 자동이고 키 충돌이 원천적으로 불가능하다. 컴포넌트 시스템처럼 객체 인스턴스를 자연스럽게 키로 쓸 수 있는 경우에 가장 적합하다.
| 방식 | 메모리 관리 | 키 충돌 | 외부 접근 | 디버깅 |
|---|---|---|---|---|
| Map + 문자열 | 수동 delete | 가능 | 쉬움 | 쉬움 |
| 클로저 | 자동 (스코프 종료 시) | 없음 | 어려움 | 어려움 |
| WeakMap | 자동 (GC) | 없음 | 키 필요 | 어려움 |
WeakMap의 한계와 주의점
순회 불가
WeakMap은 forEach, keys(), values(), entries()를 지원하지 않는다. 저장된 모든 상태를 한 번에 확인하거나 직렬화할 수 없다.
const store = new WeakMap();
store.set(comp1, [1, 2]);
store.set(comp2, [3, 4]);
// ❌ 불가능
// for (const [key, value] of store) { ... }
// store.forEach(...)
// JSON.stringify(store)
이 때문에 Redux DevTools 같은 상태 디버깅 도구를 만들기 어렵다. 실제 React도 상태 디버깅을 위해 별도의 Fiber 트리 구조를 사용하지, WeakMap만으로 모든 것을 해결하지는 않는다.
키가 반드시 객체여야 함
원시값(문자열, 숫자 등)은 키로 사용할 수 없다. 컴포넌트를 문자열 ID로만 식별하는 시스템에서는 WeakMap을 쓸 수 없다.
const store = new WeakMap();
store.set("component-1", data); // ❌ TypeError: Invalid value used as weak map key
store.set(42, data); // ❌ TypeError
store.set(Symbol(), data); // ❌ TypeError (WeakMap은 심볼도 불가)
GC 타이밍 비결정적
WeakMap의 엔트리가 정확히 언제 제거되는지 알 수 없다. GC는 엔진이 필요하다고 판단할 때 실행되므로, 참조가 끊어진 직후에 바로 메모리가 해제되지 않을 수 있다.
let obj = { data: "heavy" };
store.set(obj, largeState);
obj = null;
// 이 시점에서 store의 엔트리가 즉시 사라지는 게 아님
// GC가 실행될 때까지 메모리에 남아있을 수 있음
메모리 해제 시점을 정확히 제어해야 하는 상황이라면 일반 Map + 명시적 delete가 더 적합할 수 있다.
WeakRef와 FinalizationRegistry
ES2021에서 도입된 WeakRef와 FinalizationRegistry를 함께 사용하면 WeakMap의 한계를 보완할 수 있다.
const registry = new FinalizationRegistry((heldValue) => {
console.log(`${heldValue} 인스턴스가 GC되었음`);
// 추가 정리 작업 수행
});
function mountComponent(instance, name) {
stateStore.set(instance, []);
registry.register(instance, name);
// instance가 GC되면 콜백이 호출됨
}
FinalizationRegistry는 등록된 객체가 GC될 때 콜백을 실행해준다. WeakMap과 함께 사용하면 "상태가 자동 정리되면서, 정리 시점도 감지할 수 있는" 구조를 만들 수 있다. 다만 GC 콜백의 실행 시점이 보장되지 않으므로 핵심 로직에 의존하면 안 된다.
정리
WeakMap 기반 상태 관리의 핵심은 "객체의 생명주기에 상태의 생명주기를 묶는 것"이다. 컴포넌트가 살아있는 동안만 상태가 존재하고, 컴포넌트가 사라지면 상태도 자동으로 사라진다. 수동 cleanup 코드를 제거하고 메모리 누수를 구조적으로 방지할 수 있다.
다만 순회 불가, 디버깅 어려움 같은 한계도 분명 존재하므로, 모든 상황에 WeakMap이 최선은 아니다. 상태를 직렬화해야 하거나 모든 상태를 한눈에 봐야 하는 경우에는 일반 Map이 더 적합하다. 중요한 건 각 자료구조의 특성을 이해하고 상황에 맞게 선택하는 것이다.