2015년 6월 ES2015가 나온 후 수년이 흘렀다. 필자는 이전 위클리 픽들을 작성하면서 "Proxy
나 Reflect
처럼 내가 사용하지 않는 ES2015 문법이 무엇이 있지 🤔 " 라는 생각을 하게 되었다. 가장 먼저 떠오른 건 WeakMap
과 WeakSet
이었다. 약한 참조에 관심이 생겨 지난 위클리 픽에서 관련된 내용을 번역했었고(링크) 관심이 더 커져 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);
}
메모리 할당 상황을 개발자 도구로 확인해 봤다.
이제 new Map()
대신 new WeakMap()
으로 컬렉션 객체를 바꾸어 동일한 코드를 실행하면 다음과 같이 메모리 할당이 해제된 것을 볼 수 있다. (사실 말만 들었지 이렇게 확인해본 건 필자도 처음이라 신기하다. 👀 )
혹시 개발자도구에 익숙하지 않아 이 스냅샷이 익숙치 않다면 천천히 읽어 보는 Chrome 개발자 도구 설명서(링크)와 크롬 문서(링크)를 추천한다.
WeakMap
은 전역 객체의 속성이다.new
키워드와 함께 호출하여 생성자를 호출해 주어야 한다.[[Prototype]]
내부 슬롯을 가지고 있으며 그 값은 Function.prototype
이다.
(WeakMap.prototype.constructor.proto === Function.prototype
)extends
키워드를 통해 상속을 하는데 사용할 수 있다.WeakMap
메서드는 아래 4가지만 존재한다. 앞서 언급한 것과 같이 열거에 관련된 메서드를 지원하지 않는다. 이유는 가비지 컬렉션 때문이다. ECMAScript 스펙에 의하면, WeakMap
의 구현체는 키/값 쌍에 접근할 수 없게 되는 시간과 키/값 쌍이 WeakMap
에서 제거되는 시간 사이에 지연이 있을 수 있다고 한다. 따라서 키/값 쌍 전체를 열거하는 것과 관련된 작업은 불가능하다.
key
와 매핑된 모든 값을 제거한다. WeakMap.prototype.has(key)
는 이후 false
를 반환한다.
key
와 매핑된 값을 반환하거나, 매핑된 값이 없을 경우 undefined
를 반환한다.
WeakMap
인스턴스 객체 내 key
와 매핑된 값 존재 여부를 단언하는 boolean
을 반환한다.
WeakMap
인스턴스 객체 내 key
에 매핑되는 값(value
)을 설정하고, WeakMap
인스턴스 객체를 반환한다.
clear()
메서드는 보안 이슈로 인해 IE를 제외한 모든 브라우저에서 deprecated 상태이다. WeakMap
의 키/값 매핑은 WeakMap
과 키를 모두 가진 사람만 관찰하거나 영향을 줄 수 있어야 한다. clear()
를 사용하게 되면, 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);
}
memo
를 Map
으로 생성했을 때 메모리 할당 상황을 개발자 도구로 확인해보면 다음과 같다.
이제 캐시 객체를 Map
에서 WeakMap
으로 바꿔보면 위에 남아있던 객체는 제거되고, 실제 상단 바 차트에서도 메모리가 해제되어 파란 게이지가 조금 내려간 것을 볼 수 있다.
간단하게 바닐라 프로젝트를 진행하다 보면 종종 커스텀 이벤트 객체가 필요할 때가 있다. 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));
}
}
}
아래 코드가 실행되면 WeakMap
에 user
가 키로 등록되기 때문에 user
와 handlers
객체의 메모리가 가비지 컬렉터에 의해 수거된다.
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
를 구현했을 경우 user
와 handler
가 수거되지 못한 상황이다.
WeakMap
을 사용하면 가비지 컬렉터에 의해 객체가 수거된 것을 확인할 수 있다.
이건 바벨의 트랜스파일링 결과물에서도 쉽게 찾아볼 수 있다. 다음 코드는 #
키워드로 접근 제한자를 지정하는 코드이다.
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의 인스턴스 두 개를 다 알고 있어야 값을 이용할 수 있다.WeakMap
인스턴스를 제외하고 존재하지 않는다면 메모리가 자동으로 회수된다.좀 더 자세한 내용은 링크를 참조한다.
결론적으로 WeakMap
은 Map
처럼 열거 메서드를 지원해주진 못하지만 참조를 약하게 유지하여 메모리 누수 관리에 메리트가 있는 자료구조이다.
이번 위클리 픽을 작성하면서 메모리 회수가 되지 않는 상황의 예제를 보여주기 위해 코드를 의도적으로 복잡하게 작성했지만, 이는 가비지 컬렉터가 언제 메모리를 회수할 수 있고 못하는지 보여주기 위함이다.
WeakMap
은 Vue 3 반응형 외에도 lodash
에서 _.memoize
사용법에 캐시 객체를 WeakMap
으로 바꿀 수 있다고 예시로 작성한 것도 있고, 정보 은닉을 위해 private 접근 제한자를 폴리필 하는데도 사용되고 있다. 브라우저 지원 범위에 IE11이 들어가기 때문에(참조) 프레임워크나 라이브러리 레벨에서 유용하게 사용할 수 있다고 생각한다.
사실 요즘 대부분의 프로젝트들이 프레임워크 기반으로 구현되어 일반적인 상황에서 사용할 일이 많지 않겠지만, 프로젝트 내 객체를 식별자로 갖는 자료구조가 필요하다면 일반 객체나 Map
을 바로 사용하기 전에 WeakMap
의 사용을 한 번 고려하면 어떨까 한다.