Web
Frontend
Web - February 5, 2025
Javascript 에서는 가비지 컬렉터(Garbage Collector) 가 메모리를 관리한다. 자바스크립트 엔진에 따라 그 세부적인 구조나, 알고리즘이 다 다르지만, 그 기준은 제법 명확한 편이다.
자바스크립트는 도달 가능하지 않은 값에 대해서 가비지 컬렉션(Garbage Collection; GC) 를 수행한다. 여기서 도달 가능한 값의 예를 들자면,
이를테면, 다음과 같은 객체와 그것의 참조 값을 저장하는 user 이라는 변수가 있다고 하자.
let user = {
name: 'monognuisy',
age: 20,
};
여기서 user
는 객체 { name: ‘monognuisy’, age: 20 }
를 참조하고 있다. 저 객체는 user
이라는 지역 변수를 통해 접근할 수 있고, user
은 전역 변수이므로, 사용중으로 간주하고 GC 의 대상이 되지 않는다.
하지만, user
의 값을 다른 것으로 덮어 씌우면, 이제는 원래 참조하고 있던 이상 해당 객체에 도달할 방법이 존재하지 않으므로 GC 의 대상이 된다.
user = null; // user 값을 덮어씀.
이러한 GC 는 사용하는 엔진에 따라 수행하는 시점도 달라진다. 중요한 것은, “언제 수행될지 모른다”는 것이다.
자료구조를 구성하는 요소도 자신이 속한 자료구조가 메모리에 남아있는 동안 도달 가능한 값으로 취급된다.
객체의 프로퍼티, 배열의 요소, Map, Set 의 요소도 해당된다.
예를 들어, 위에서 소개된 user
객체를 담는 배열 users
을 생각하자.
let users = [ user ];
user = null;
// 하지만 여전히 users[0] 으로 접근 가능!
console.log(users[0]); // { name: ‘monognuisy’, age: 20 }
이는 마치 users[0] → 원본 객체
로 생각될 수 있다.
Map
이나 Set
의 요소도 그러하다.
const userKeys = new Map();
userKeys.set(user, 'x012n9ogjaoxjzlvkjfmwpohinsd');
user = null;
// 하지만 여전히 Map 에서 원본 객체에 접근 가능
userKeys.keys().forEach(console.log); // { name: ‘monognuisy’, age: 20 }
그렇다면, 앞서 보았던 예시에서 user 가 없어질 때 같이 Map 의 키 또한 없어지게 할 수 있을까?
아마 일반적인 Map 을 사용했더라면, 다음과 같이 작성해야 할 것이다.
userKeys.delete(user);
user = null;
하지만, 이렇게 일일이 수동으로 delete 해줘야 하는 것은 마치 C나 C++ 의 수동 free 같이, 실수를 할 가능성을 높인다. 따라서, 바로 user
가 null
이 되면 자동으로 키 값이 무효화되는, 정확히는 키로 사용된 원본 객체가 자동으로 삭제되는 자료구조가 있으면 좋을 것 같다.
WeakMap 은 그것을 정확히 해주는 자료구조이다.
const userTempKeys = new WeakMap();
userTempKeys.set(user, 'x012n9ogjaoxjzlvkjfmwpohinsd');
user = null;
// 이제는 WeakMap 에서도 없어짐! -> 원본 객체가 GC 됨!
WeakMap 은, 키가 GC 의 대상이 되는 Map 이다. 따라서, GC 의 대상이 되지 않는 원시 타입은 키로 사용될 수 없으며, 유일하게 허용되는 원시 타입은 (Symbol.for(…)
등으로) 등록되지 않은 심볼1↗ 뿐이다.
WeakMap 의 특정 때문에, 지원하는 메서드는 일반 Map 에 비해 훨씬 적다. get
, set
, delete
, has
밖에 지원하지 않기 때문에, 어떠한 값들이 키로 사용되고 있는지 볼 수 없다.
이는 기본적으로 GC 시점의 불확실성에 기인한다. 키 목록이 GC 상태에 따라 달라질 것이기 때문에 비결정적이게 되고, 이는 예기치 못한 부작용을 발생시킬 수 있다.
만약, 외부에서 관리되는 객체에 잠깐 데이터를 추가해야하는 상황이 생겼다고 가정해보자. 이 데이터는 일종의 ‘임시’ 데이터기 때문에, 해당 객체가 죽으면 동시에 사라져야 한다. 예를 들자면,
const user = getSharedUserData(); // 외부에서 관리되는 객체를 가져오기
// user 에는 name, age 필드가 있음
// 근데 여기에 임시로 방문 횟수를 추가하고 싶음
이 문제를 어떻게 해결할까?
일반 프로퍼티로 사용 ❌
다음과 같이 일반 프로퍼티를 이용해 방문 횟수를 저장할 수 있다.
function countUser(user) {
user.count = (user.count ?? 0) + 1;
}
하지만, 이는 다른 코드에서 사용되는 객체에도 영향을 미치게 된다. 외부에서 관리되는 객체이기 때문에, 함부로 객체에 새로운 프로퍼티를 추가하면 안된다.
또한, 이러한 일반 프로퍼티는 다른 코드에서도 쉽게 덮어 씌울 수 있다. 충돌이 일어난다면, 그것을 해결하는 것도 굉장히 큰 공수가 들 것이다.
Symbol 프로퍼티 사용 ⚠️
앞서 본 것 처럼, 일반 프로퍼티는 외부에 영향을 주기 때문에 사용을 하면 안된다는 것을 알게 되었다.
따라서, 외부에 영향을 미치지 않는 Symbol 을 사용하면 될 것 같다.
const visitCount = Symbol('count');
function countUser(user) {
user[visitCount] = (user[visitCount] ?? 0) + 1;
}
사실, 이렇게 하면 앞서 본 문제는 해결된다. 하지만, 만약 해당 객체를 관리하는 외부에서 아예 이러한 문제를 미연에 방지하고자, 객체를 freeze 했으면 상황은 달라진다.
// 외부 코드
const user = Object.freeze({
name: 'monognuisy',
age: 20,
});
export function getSharedUserData() {
return user;
}
이렇게 하면, 이를 사용하는 입장에서는 아무리 Symbol 프로퍼티라도 추가할 수 없다.
따라서, 객체 자체를 수정하지 않으면서도 동일한 기능을 할 수 있는 방법을 찾아야 한다.
WeakMap 사용 ✅
이 때 WeakMap 은 아주 좋은 해결책이 될 수 있다.
const visitCounts = new WeakMap();
function countUser(user) {
const count = visitCounts.get(user) || 0;
visitCounts.set(user, count + 1);
}
// 이후에는 visitCounts.get(user) 로 방문 횟수를 받아올 수 있음.
이렇게 하면 외부에서 관리되는 객체에 그 어떤 수정도 이루어지지 않게 된다. 또한, 해당 객체가 수명을 다하면 자동적으로 방문 횟수도 없어지게 된다. 만약 일반 Map 을 이용했다면, 그렇지 않아서 메모리 누수가 발생했을 것이다.
이는 객체를 키로 사용하는 캐싱을 구현할 때에도 유용하다. 예를 들어, 받아온 user 객체를 이용해 무언가 오랜 시간 연산하는 작업을 해야 한다고 하면, WeakMap 을 이용한 캐싱이 도움될 수 있다.
function cached(callback) {
const cache = new WeakMap();
return async (obj) => {
if (!cache.has(obj)) {
const result = await callback(obj);
cache.set(obj, result);
}
return cache.get(obj);
};
}
const cachedLongTimeJob = cached(longTimeJob);
// 이후 사용
let user = { name: 'monognuisy', age: 20 };
cachedLongTimeJob(user); // cache miss -> 오래 걸림
cachedLongTimeJob(user); // cache hit -> 빠름
user = null; // 캐시도 비워진다.
WeakRef
)WeakMap 은 기본적으로 키 객체에만 약한 참조를 유지한다. 하지만, value 는 그렇지 않기 때문에 만약 해당 value 가 오랫동안 (혹은 영원히) 사용되지 않더라도 key 에 대한 참조가 사라지지 않는 이상 GC 되지 않는다.
예를 들어, 장시간 운영되는 서버에서 큰 데이터를 받아오는 것을 캐싱하는 경우를 생각해보자.
// 이전의 cached 그대로 사용
const cachedLongTimeJob = cached(fetchHugeData);
// 이후 사용
for (let user of manyUsers) {
const result = cachedLongTimeJob(user);
...;
}
// user 참조가 없어지지 않았으므로, 큰 데이터(value) 는 계속 캐시에 있음.
참조가 사라지지 않았기 때문에 GC 되지 않고, 이런 데이터가 지속적으로 쌓인다면 메모리 사용량이 아주 커질 수 밖에 없다.
이를 해결하기 위해 cacheTime 등을 두는 방법이 이상적이지만, GC 를 활용할 수 있는 방법도 존재한다.
WeakRef↗ 는 약한 참조를 제공하는 기능으로, 해당 객체가 GC 로부터 보호되지 않는다. 따라서, 언제든지 GC 될 수 있다. deref
메서드를 이용해 참조된 객체를 가져올 수 있다.
물론, GC 시점은 정확히 예측하는 것이 불가능하기 때문에, 이 시점을 예상하여 사용하는 등의 코드는 짜면 안된다. 공식 문서에서도 가능하면 사용하지 않는 것을 권장한다.
이를, GC 가 되었을 때 알려주는 FinalizationRegistry
와 함께 사용하면 위에 소개한 value 를 GC 하는 캐시를 구현할 수 있다.
function cached(callback) {
const cache = new Map(); // url 과 같은 non-GC 대상들도 담을 수 있도록
// value 가 GC 될 때마다 내부에 콜백이 수행되어 캐시 항목을 제거함
const registry = new FinalizationRegistry((key) => {
// 꼭 WeakRef 가 비었는지 확인하고 해야한다
// 그렇지 않으면 새롭게 받아온 데이터가 GC 될 수 있음
if (!cache.get(key)?.deref()) {
cache.delete(key)
}
};
return async (key) => {
if (cache.has(key)) {
return cache.get(key).deref();
}
const value = await callback(key);
cache.set(key, value);
registry.register(value, key);
return value;
};
}
(아직 설명에 미흡한 부분이 있을 수 있습니다. 추후 보충하겠습니다)
WeakMap 에서 다음과 같이 순환 참조가 되어 있을 때에도 약한 참조가 유지될까?
const wm = new WeakMap();
const key = {};
wm.set(key, { key });
(상상해보자면) WeakMap 은 기본적으로 키에 대해서는 약한 참조를 유지하지만, 값(여기선 { key }
)에 대해서는 강한 참조를 가진다. 또한, 값의 키에대한 참조 또한 강한 참조이기 때문에, 아무리 외부에서 key
에 대한 참조를 끊어도 (key = null
) 값이 키에 대한 강한 참조를 가지고 있기 때문에 GC 되지 않는 문제점이 있다.
따라서, 이 문제를 해결하기 위해 WeakMap 은 특별히 Ephemeron 메커니즘을 통해 키와 값을 관리한다.
Ephemeron 은 특별한 GC 규칙을 따르는 key-value 쌍으로, 연결성을 단방향으로 평가하기 위해 고안되었다.
key → value 의 참조가 value → key 보다 우선되기 때문에, 값이 키를 참조하더라도 GC 시점에는 단방향으로만 평가되어서 weak 참조에서의 순환 참조 문제를 해결할 수 있다.
즉, ephemeron 을 다음과 같은 특별한 객체라고 생각하면
ephemeron = { key, value };
따라서, WeakMap 내부에서 key ↔ value 간 순환 참조가 있어도 제대로 GC 를 수행할 수 있다.
WeakMap 을 알았으면 WeakSet 은 간단하다. 단순히, 그 키가 있는지, 없는지 판단하기 위해 사용한다고 보면 된다. 그런데, weak 를 곁들인.
const visitedUser = new WeakSet();
function visit(user) {
visitedUser.set(user);
}
function hasVisit(user) {
return visitedUser.has(user);
}
유일성이 보장되기 때문. 그것을 참조하는 변수가 없어지면 GC 할 수 있기 때문이다. ↩↗