jotai가 WeakMap 기반으로 구현되어 있다는 사실을 알고 Map과 어떤 차이점이 있는 지 알아보고자 한다.
먼저 Map부터 알아보자.
Map은 key-value 쌍으로 이뤄진 객체이다. key는 특정 Map에서 유일하게 존재한다.
Map에서 모든 값(객체, 함수, 원시 값 모두)은 키 또는 값으로 사용할 수 있다.
예시 코드를 통해 Map 객체의 메서드에 대해 보자.
// Map 생성
const map = new Map();
// 키-값 쌍 추가
map.set('key1', 'value1');
map.set(42, 'value2');
map.set({obj: 'key'}, 'value3');
// 값 가져오기
console.log(map.get('key1')); // 'value1'
console.log(map.get(42)); // 'value2'
// 크기 확인
console.log(map.size); // 3
// 키 존재 여부 확인
console.log(map.has('key1')); // true
console.log(map.has('key2')); // false
// 삭제
map.delete('key1');
// 모든 엔트리 삭제
map.clear();
// 순회
map.forEach((value, key) => {
console.log(key, value);
});
// 모든 키 가져오기
console.log([...map.keys()]);
// 모든 값 가져오기
console.log([...map.values()]);
// 엔트리 가져오기
console.log([...map.entries()]);
set(key, value) - key-value 쌍을 추가한다.get(key) - 주어진 키에 해당하는 값을 반환한다.has(key): 주어진 키가 존재하는지 확인한다.delete(key): 주어진 키와 해당 값을 삭제한다.clear(): 모든 키-값 쌍을 삭제한다.size: Map의 크기(키-값 쌍의 수)를 반환한다.forEach(): 각 키-값 쌍에 대해 콜백 함수를 실행한다.keys(): 모든 키의 Iterator를 반환한다.values(): 모든 값의 Iterator를 반환한다.entries(): 모든 key-value 쌍의 Iterator를 반환한다.Map은 key-value에 대해 강한 참조를 유지한다. 강한 참조는 해당 객체가 메모리에서 제거되는 것을 방지하는 방식을 뜻한다.
코드를 통해 살펴보자.
// Map 생성
// Map 생성
let map = new Map();
// 객체를 키로 사용
let obj = { id: 1 };
map.set(obj, "Value for obj");
console.log(map.get(obj)); // "Value for obj"
// obj에 대한 원래 참조 제거
obj = null;
// 가비지 컬렉션이 실행되어도 Map의 키-값 쌍은 여전히 존재
console.log(map.size); // 1
// Map의 모든 키 출력
for (let key of map.keys()) {
console.log(key); // { id: 1 }
}
// 메모리 누수 예방을 위한 명시적 삭제
map.clear();
map.delete(obj); // obj = null을 하지 않고 delete 사용
console.log(map.size); // 0
key로 설정된 obj의 원래 참조가 제거되어도 map 내에는 key-value 쌍이 존재하는 것을 확인할 수 있다.
Map에 저장된 객체는 Map이 존재하는 한 메모리에서 해제되지 않는다. 따라서 의도치 않은 메모리 누수가 발생할 수 있다.
더 이상 사용하지 않는 key-value 쌍은 명시적으로 delete(key) 메서드를 사용해서 지워줘야 한다.
또한, Map이 더 이상 사용되지 않는다면 clear() 메서드를 활용하자.
앞서 Map과 달리 WeakMap에서는 key는 반드시 객체여야 한다. 즉, 원시 타입 값을 key 사용할 수 없는 것이다. value는 아무거나 가능하다.
또한 가장 큰 차이는 WeakMap은 "약한 참조"를 유지한다는 점이다. 즉, 객체에 대한 참조가 WeakMap을 제외하고 존재하지 않는다면, 해당 객체는 가비지 컬렉터의 대상이 된다. 키로 사용된 객체에 대한 다른 참조가 없다면 가비지 컬렉션의 대상이 된다.
// WeakMap 생성
const wm = new WeakMap();
// WeakMap 사용 예제
let obj1 = { name: "Object 1" };
let obj2 = { name: "Object 2" };
wm.set(obj1, "Value for Object 1");
wm.set(obj2, "Value for Object 2");
console.log(wm.get(obj1)); // "Value for Object 1"
console.log(wm.get(obj2)); // "Value for Object 2"
// obj1에 대한 참조 제거
obj1 = null;
// 가비지 컬렉션 후, obj1에 관련된 엔트리는 WeakMap에서 자동으로 제거된다.
WeakMap의 메서드는 다음과 같다. Map에도 동일하게 존재하는 메서드인 것을 볼 수 있을 것이다.
get(key) - key에 해당하는 값을 반환한다.set(key, value) - WeakMap에 새로운 key-value 쌍을 추가한다.delete(key) - WeakMap에서 key와 쌍을 이루는 값을 제거한다.has(key) - WeakMap에 주어진 key가 존재하는지 확인한다.참고로 WeakMap은 size 속성을 가지고 있지 않다. 따라서 WeakMap의 크기를 직접 알 수 없다.
Map에는 존재하는 keys(), values(), entries() 같은 순회 메서드를 제공하지 않는다. 이는 WeakMap의 내용을 직접 순회할 수 없음을 의미한다.
왜그럴까?
만약 WeakMap이 key를 열거하는 기능을 제공한다면, 그 결과는 예측 불가능하다. 왜냐하면, 가비지 컬렉션의 타이밍과 동작이 프로그램 실행 중에 언제든 발생할 수 있기 때문이다.
즉, key 목록은 가비지 컬렉션 상태에 따라 달라질 것이므로 비결정성 발생하는 것이다.
WeakMap은 그러면 어느 상황에서 사용하면 유용할까? 대표적으로 다음과 같다.
클래스의 인스턴스에 프라이빗 데이터를 연결할 때 사용한다.
인스턴스가 가비지 컬렉션되면 관련 프라이빗 데이터도 자동으로 정리된다.
const privateData = new WeakMap();
class User {
constructor(name, age) {
privateData.set(this, { age });
this.name = name;
}
getAge() {
return privateData.get(this).age;
}
}
const user = new User("Alice", 30);
console.log(user.getAge()); // 30
console.log(user.age); // undefined
객체를 키로 사용하여 계산 결과를 캐시할 때 유용하다.
객체가 더 이상 사용되지 않으면 캐시 엔트리도 자동으로 제거된다. 사용될 때만 참조가 유지된다.
const cache = new WeakMap();
function expensiveOperation(obj) {
if (cache.has(obj)) {
return cache.get(obj);
}
const result = /* 복잡한 계산 */;
cache.set(obj, result);
return result;
}
let obj = {/* ... 객체 ... */};
let result1 = expensiveOperation(obj);
let result2 = expensiveOperation(obj); // 캐싱
obj = null // 캐시 데이터 제거