Cover image of latest post

Web

Frontend

WeakMap & WeakSet 의 이해

Web - February 5, 2025

#

GC 에 대한 기본적인 이해

##

GC 의 기본

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 }
#

WeakMap

##

기존

그렇다면, 앞서 보았던 예시에서 user 가 없어질 때 같이 Map 의 키 또한 없어지게 할 수 있을까?

아마 일반적인 Map 을 사용했더라면, 다음과 같이 작성해야 할 것이다.

userKeys.delete(user);
user = null;

하지만, 이렇게 일일이 수동으로 delete 해줘야 하는 것은 마치 C나 C++ 의 수동 free 같이, 실수를 할 가능성을 높인다. 따라서, 바로 usernull 이 되면 자동으로 키 값이 무효화되는, 정확히는 키로 사용된 원본 객체가 자동으로 삭제되는 자료구조가 있으면 좋을 것 같다.

##

WeakMap 의 도입

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 상태에 따라 달라질 것이기 때문에 비결정적이게 되고, 이는 예기치 못한 부작용을 발생시킬 수 있다.

##

사용 예시

###

1. 프라이빗한 추가 데이터 관리

만약, 외부에서 관리되는 객체에 잠깐 데이터를 추가해야하는 상황이 생겼다고 가정해보자. 이 데이터는 일종의 ‘임시’ 데이터기 때문에, 해당 객체가 죽으면 동시에 사라져야 한다. 예를 들자면,

const user = getSharedUserData(); // 외부에서 관리되는 객체를 가져오기
// user 에는 name, age 필드가 있음
// 근데 여기에 임시로 방문 횟수를 추가하고 싶음

이 문제를 어떻게 해결할까?

  1. 일반 프로퍼티로 사용 ❌

    다음과 같이 일반 프로퍼티를 이용해 방문 횟수를 저장할 수 있다.

    function countUser(user) {
      user.count = (user.count ?? 0) + 1;
    }
    

    하지만, 이는 다른 코드에서 사용되는 객체에도 영향을 미치게 된다. 외부에서 관리되는 객체이기 때문에, 함부로 객체에 새로운 프로퍼티를 추가하면 안된다.

    또한, 이러한 일반 프로퍼티는 다른 코드에서도 쉽게 덮어 씌울 수 있다. 충돌이 일어난다면, 그것을 해결하는 것도 굉장히 큰 공수가 들 것이다.

  2. 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 프로퍼티라도 추가할 수 없다.

    따라서, 객체 자체를 수정하지 않으면서도 동일한 기능을 할 수 있는 방법을 찾아야 한다.

  3. WeakMap 사용 ✅

    이 때 WeakMap 은 아주 좋은 해결책이 될 수 있다.

    const visitCounts = new WeakMap();
    
    function countUser(user) {
      const count = visitCounts.get(user) || 0;
      visitCounts.set(user, count + 1);
    }
    
    // 이후에는 visitCounts.get(user) 로 방문 횟수를 받아올 수 있음.
    

    이렇게 하면 외부에서 관리되는 객체에 그 어떤 수정도 이루어지지 않게 된다. 또한, 해당 객체가 수명을 다하면 자동적으로 방문 횟수도 없어지게 된다. 만약 일반 Map 을 이용했다면, 그렇지 않아서 메모리 누수가 발생했을 것이다.

###

2. 캐싱

이는 객체를 키로 사용하는 캐싱을 구현할 때에도 유용하다. 예를 들어, 받아온 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; // 캐시도 비워진다.
###

번외) 큰 value 를 가지는 캐시 (feat. 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 };
  1. GC 는 ephemeron 을 발견하면, 즉시 처리하지 않고 지연 큐에 넣는다.
  2. 일반 객체 트레이싱이 끝난 후, ephemeron 큐를 검사한다.
  3. key 가 외부에서 도달할 수 없으면, value 가 key 를 참조하더라도 ephemeron 전체를 수집한다.
  4. key 가 외부에서 도달할 수 있으면, value 도 추적해서 보존한다.

따라서, WeakMap 내부에서 key ↔ value 간 순환 참조가 있어도 제대로 GC 를 수행할 수 있다.

#

WeakSet

WeakMap 을 알았으면 WeakSet 은 간단하다. 단순히, 그 키가 있는지, 없는지 판단하기 위해 사용한다고 보면 된다. 그런데, weak 를 곁들인.

const visitedUser = new WeakSet();

function visit(user) {
  visitedUser.set(user);
}
function hasVisit(user) {
  return visitedUser.has(user);
}
#

Footnotes

  1. 유일성이 보장되기 때문. 그것을 참조하는 변수가 없어지면 GC 할 수 있기 때문이다.