WeakMap이 알고 싶다


2015년 6월 ES2015가 나온 후 수년이 흘렀다. 필자는 이전 위클리 픽들을 작성하면서 "ProxyReflect처럼 내가 사용하지 않는 ES2015 문법이 무엇이 있지 🤔 " 라는 생각을 하게 되었다. 가장 먼저 떠오른 건 WeakMapWeakSet이었다. 약한 참조에 관심이 생겨 지난 위클리 픽에서 관련된 내용을 번역했었고(링크) 관심이 더 커져 WeakMap을 주제로 글을 쓰게 되었다.

WeakMap이란?

WeakMap 객체는 키가 약하게 참조되는 키/값 쌍의 컬렉션으로, 키는 반드시 객체여야만 한다. 원시 값은 키가 될 수 없다. 만약 키를 원시 값으로 추가하면 Uncaught TypeError: Invalid value used as weak map key라는 에러가 발생한다.

WeakMap은 특정 키에 대한 값이 있는지 메서드를 통해 확인 가능하지만, 키로 보유한 객체들을 열거하는 방법은 제공되지 않는다. 그리고 잘 알려졌다시피 WeakMap의 키로 쓰인 객체는 가비지 컬렉션 대상이 된다. 프로그램 내 객체에 대한 참조가 WeakMap을 제외하고 존재하지 않는다면, 해당 객체는 가비지로 수거된다.

다음과 같이 긴 배열을 클로저로 반환하는 함수가 있다고 해보자.

// refs: 
// https://developer.chrome.com/docs/devtools/memory-problems/heap-snapshots/
function createLargeClosure() {
  const largeStr = new Array(1000000).join('x');

  const lc = function largeClosure() {
    return largeStr;
  };

  return lc;
}

먼저 Map을 사용하여 메모리 누수를 만들어보도록 하자.

const map = new Map();

function start() {
  const timer = setInterval(() => {
    const lc = createLargeClosure();
    map.set(lc, '##');
  }, 1000);

  setTimeout(function () {
    clearInterval(timer);
  }, 5001);
}

메모리 할당 상황을 개발자 도구로 확인해 봤다. Untitled.png

이제 new Map() 대신 new WeakMap()으로 컬렉션 객체를 바꾸어 동일한 코드를 실행하면 다음과 같이 메모리 할당이 해제된 것을 볼 수 있다. (사실 말만 들었지 이렇게 확인해본 건 필자도 처음이라 신기하다. 👀 ) Untitled 1.png

혹시 개발자도구에 익숙하지 않아 이 스냅샷이 익숙치 않다면 천천히 읽어 보는 Chrome 개발자 도구 설명서(링크)와 크롬 문서(링크)를 추천한다.

WeakMap 생성자 특징

  • WeakMap은 전역 객체의 속성이다.
  • 일반 함수로 호출되도록 구현되지 않았으며 일반 함수처럼 호출했을 경우 에러를 발생시킨다.
  • 항상 new 키워드와 함께 호출하여 생성자를 호출해 주어야 한다.
  • [[Prototype]] 내부 슬롯을 가지고 있으며 그 값은 Function.prototype이다. (WeakMap.prototype.constructor.proto === Function.prototype)
  • extends 키워드를 통해 상속을 하는데 사용할 수 있다.

WeakMap의 메서드

WeakMap 메서드는 아래 4가지만 존재한다. 앞서 언급한 것과 같이 열거에 관련된 메서드를 지원하지 않는다. 이유는 가비지 컬렉션 때문이다. ECMAScript 스펙에 의하면, WeakMap의 구현체는 키/값 쌍에 접근할 수 없게 되는 시간과 키/값 쌍이 WeakMap에서 제거되는 시간 사이에 지연이 있을 수 있다고 한다. 따라서 키/값 쌍 전체를 열거하는 것과 관련된 작업은 불가능하다.

WeakMap.prototype.delete(key)

key와 매핑된 모든 값을 제거한다. WeakMap.prototype.has(key)는 이후 false를 반환한다.

WeakMap.prototype.get(key)

key와 매핑된 값을 반환하거나, 매핑된 값이 없을 경우 undefined를 반환한다.

WeakMap.prototype.has(key)

WeakMap 인스턴스 객체 내 key와 매핑된 값 존재 여부를 단언하는 boolean을 반환한다.

WeakMap.prototype.set(key, value)

WeakMap 인스턴스 객체 내 key에 매핑되는 값(value)을 설정하고, WeakMap 인스턴스 객체를 반환한다.

WeakMap.prototype.clear()

clear() 메서드는 보안 이슈로 인해 IE를 제외한 모든 브라우저에서 deprecated 상태이다. WeakMap의 키/값 매핑은 WeakMap과 키를 모두 가진 사람만 관찰하거나 영향을 줄 수 있어야 한다. clear()를 사용하게 되면, WeakMap만 가진 누군가가 WeakMap과 키/값 매핑에 영향을 줄 수 있게 된다. (참조)

WeakMap의 사용 예시

WeakMap의 간단한 소개가 끝났으니 이제 실 사용 예시를 알아보도록 하자. 대체적으로 메모리 누수가 자주 일어날만한 코드에 사용한다.

캐싱

WeakMap은 메모이제이션에 용이하다. 메모이제이션이란 비용이 큰 계산을 캐싱해두었다가 같은 입력값이 들어왔을 때 캐싱된 결과를 반환하는 것이다.

function createLargeClosure() {
  const largeObj = {
    a: 1,
    b: 2,
    str: new Array(1000000).join('x')
  };

  const lc = function largeClosure() {
    return largeObj;
  };

  return lc;
}

const memo = new WeakMap();
function memoize(obj) {
  if (memo.has(obj)) {
    console.log('Get cached result');
    return memo.get(obj);
  }

  const compute = obj.a + obj.b;
  console.log('Set computed result to caching map');
  memo.set(obj, compute);

  return compute;
}

function start() {
  const lcObj = createLargeClosure();

  const timer = setInterval(() => {
    memoize(lcObj());
  }, 1000);

  setTimeout(function () {
    clearInterval(timer);
  }, 5001);
}

memoMap으로 생성했을 때 메모리 할당 상황을 개발자 도구로 확인해보면 다음과 같다. Untitled 2.png

이제 캐시 객체를 Map에서 WeakMap으로 바꿔보면 위에 남아있던 객체는 제거되고, 실제 상단 바 차트에서도 메모리가 해제되어 파란 게이지가 조금 내려간 것을 볼 수 있다. Untitled 3.png

Custom Event

간단하게 바닐라 프로젝트를 진행하다 보면 종종 커스텀 이벤트 객체가 필요할 때가 있다. TOAST UI의 Code Snippet처럼 인스턴스에 믹스인해서 사용하는 방법도 있지만 더 심플하게 싱글톤으로 구현한다면 WeakMap이 메모리 누수 방어에 도움이 될 수 있다.

다음 코드는 targetObject를 키로 이벤트 핸들러를 등록하고 실행한다.

class EventEmitter {
  constructor() {
    this.targets = new WeakMap();
  }

  on(targetObject, handlers) {
    if (!this.targets.has(targetObject)) {
      this.targets.set(targetObject, {});
    }

    const targetHandlers = this.targets.get(targetObject);

    Object.keys(handlers).forEach(handlerName => {
      targetHandlers[handlerName] = targetHandlers[handlerName] || []; 
      targetHandlers[handlerName].push(handlers[handlerName].bind(targetObject));
    });
  }

  fire(targetObject, handlerName, args) {
    const targetHandlers = this.targets.get(targetObject);

    if (targetHandlers && targetHandlers[handlerName]) {
      targetHandlers[handlerName].forEach(handler => handler(args));
    }
  }
}

아래 코드가 실행되면 WeakMapuser가 키로 등록되기 때문에 userhandlers 객체의 메모리가 가비지 컬렉터에 의해 수거된다.

const emitter = new EventEmitter();

function start() {
  const user = {
    name: 'John'
  };

  const handlers = {
    sayHello: function() {
      console.log(`Hello, my name is ${this.name}`);
    },
    sayGoodBye: function() {
      console.log(`Good bye, my name is ${this.name}`);
    }
  };

  emitter.on(user, handlers);

  const timer = setInterval(() => {
    emitter.fire(user, 'sayHello');
    emitter.fire(user, 'sayGoodBye');
  }, 1000);

  setTimeout(function () {
    clearInterval(timer);
  }, 5001);
}

아래 그림은 Map으로 EventEmitter를 구현했을 경우 userhandler가 수거되지 못한 상황이다. Untitled 4.png

WeakMap을 사용하면 가비지 컬렉터에 의해 객체가 수거된 것을 확인할 수 있다. Untitled 5.png

Private Field

이건 바벨의 트랜스파일링 결과물에서도 쉽게 찾아볼 수 있다. 다음 코드는 # 키워드로 접근 제한자를 지정하는 코드이다.

class A {
  #privateFieldA = 1;
  #privateFieldB = 'A';
}

실제로 트랜스파일링이 되면 결과물은 아래와 같이 나타난다. 이 코드는 바벨의 Try it out(링크)에서 테스트해 볼 수 있다.

"use strict";

var _privateFieldA = /*#__PURE__*/new WeakMap();
var _privateFieldB = /*#__PURE__*/new WeakMap();

class A {
  constructor() {
    _privateFieldA.set(this, {
      writable: true,
      value: 1
    });

    _privateFieldB.set(this, {
      writable: true,
      value: 'A'
    });
  }

}

Private field에 WeakMap을 사용하는 이유는 다음과 같다.

  • 정보 은닉 관점에선 WeakMap 인스턴스와 class A의 인스턴스 두 개를 다 알고 있어야 값을 이용할 수 있다.
  • 메모리 누수 방지 관점에선 class A의 인스턴스 참조가 WeakMap인스턴스를 제외하고 존재하지 않는다면 메모리가 자동으로 회수된다.

좀 더 자세한 내용은 링크를 참조한다.

정리하며

결론적으로 WeakMapMap처럼 열거 메서드를 지원해주진 못하지만 참조를 약하게 유지하여 메모리 누수 관리에 메리트가 있는 자료구조이다.

이번 위클리 픽을 작성하면서 메모리 회수가 되지 않는 상황의 예제를 보여주기 위해 코드를 의도적으로 복잡하게 작성했지만, 이는 가비지 컬렉터가 언제 메모리를 회수할 수 있고 못하는지 보여주기 위함이다.

WeakMap은 Vue 3 반응형 외에도 lodash에서 _.memoize 사용법에 캐시 객체를 WeakMap으로 바꿀 수 있다고 예시로 작성한 것도 있고, 정보 은닉을 위해 private 접근 제한자를 폴리필 하는데도 사용되고 있다. 브라우저 지원 범위에 IE11이 들어가기 때문에(참조) 프레임워크나 라이브러리 레벨에서 유용하게 사용할 수 있다고 생각한다.

사실 요즘 대부분의 프로젝트들이 프레임워크 기반으로 구현되어 일반적인 상황에서 사용할 일이 많지 않겠지만, 프로젝트 내 객체를 식별자로 갖는 자료구조가 필요하다면 일반 객체나 Map을 바로 사용하기 전에 WeakMap의 사용을 한 번 고려하면 어떨까 한다.

이진우2021.09.01
Back to list