ES6 WeakMap과 memoize

indongyoo edited this page Feb 10, 2018 · 4 revisions

기존의 함수형 라이브러리들에 구현되었던 memoize의 경우는 사실 대부분 심각한 메모리 누수가 있었습니다.

함수 내부에 클로저로 결과값을 캐시하거나 함수 객체 자체에 결과값을 계속해서 캐시하는 방식이기 때문에 함수가 실행 될수록 메모리 누수가 일어납니다. 해당 함수의 캐시된 값을 일일히 직접 지워주거나, 함수를 일시적으로 사용하고 버리는 경우에만 의미가 있다고 볼 수 있습니다.

이런 방식이 메모리 누수에 취약한 이유는 함수는 글로벌 공간에 계속해서 존재할 가능성이 높아서 입니다. 함수에 집어넣은 인자가 계산된 후 참조할 필요가 없어버려져도, 여전히 결과값과 키값은 그 함수에 붙어있어서 메모리 누수가 발생하게 됩니다. 때문에 이러한 방식은 아주 좋지는 않습니다.

그리고 사실상 문자열로 변환 가능한 값에 대해서만 메모이즈를 구현할 수 있었습니다. 물론 캐시할 키값을 동적으로 정하는 방법을 기술한 함수를 함께 넘겨주는 식으로 인자의 불변성을 보장하는 방식이 있지만 실용적이거나 실무에서 사용될만한 기술이 아니고, 배보다 배꼽이 더 큰 경우도 있습니다.

그런데 ES6에는 WeakMap이 생겼고, WeakMap은 약한 참조를 하기 때문에, key에 사용된 객체가 null로 초기화되거나 더이상 사용되지 않으면 WeakMap에서도 사라집니다. 때문에 메모이즈의 캐시로 WeakMap을 사용하는 것이, 기존의 메모리 관리 지식만 있으면 별도의 관리 없이 편하게 사용이 가능하며, 메모리 누수에 대한 걱정도 없게 됩니다. WeakMap은 내부적으로 약한 참조를 하기 위해 결국은 key에 사용된 객체 자체에 WeakMap의 value를 캐시해두는 식으로 구현되었을 겁니다. 그렇게 하려면 key로 사용할 수 있는 타입은 Object여야 하게 됩니다. WeakMap은 그래서 Map과 달리 key로 Object가 아닌 값을 사용할 수 없습니다.

대신 메모이제이션에 WeakMap을 사용하겠다는 얘기는 memoize로 만든 함수에 사용가능한 인자 역시 Object여야 한다는 이야기입니다. 어쨌든 너무나 방가운 것은 ES6에 WeakMap이 있다는 것입니다. 때문에 메모리 걱정 없이 또 너무나 쉽게 메모이제이션이 가능하게 되었습니다.

키에 사용할 인자의 개수가 한 개이면서 인자의 타입이 Object인 함수만 메모이션을 지원하기로 하면, 메모리 누수 걱정 없는 memoize 함수는 아래처럼 쉽게 구현할 수 있습니다. 물론 WeakMap을 사용할 때 쉽습니다.

function memoize(f) {
  const wm = new WeakMap();
  return function(obj, ...rest) {
    if (typeof obj != 'object') return f(obj, ...rest);
    if (wm.has(obj)) return wm.get(obj);
    const val = f(obj, ...rest);
    wm.set(obj, val);
    return val;
  }
}

var val = memoize(function(obj) {
  console.log('함수 본체 실행');
  return obj.val;
});

function f1() {
  var a = { val: 10 };
  var b = { val: 20 };
  console.log( val(a) );
  // 함수 본체 실행
  // 10
  console.log( val(a) );
  // 10

  console.log( val(b) );
  // 함수 본체 실행
  // 10
  console.log( val(b) );
  // 10
  console.log( val(b) );
  // 10
}

f1();

넘나 간결하고, 메모리 걱정도 없습니다. f1();이 실행되고 나면 더이상 a와 b를 사용할 필요가 없게되고 가비지 컬렉션의 대상이되며, a, b가 사라지면 두 번째 라인의 wm의 a, b를 키로 가졌던 값 역시 사라지게 됩니다.

WeakMap이 없어도 IE9까지는 Object.defineProperty를 이용하여 Polyfill 구현이 가능하고 실제로 잘 구현된 Polyfill 도 있습니다. (JSON.stringify(a); 를 했을 때 캐시해둔 값들이 지저분한게 남지 않아야합니다.)

그러나 IE 7-8의 경우는 Object.defineproperty가 없어 약한 참조를 구현하려면 해당하는 Object에 메서드를 달거나 하는식으로 구현해야 약한 참조 메모이제이션을 구현할 수 있습니다. key가 객체에 그대로 노출되게 되면 JSON.stringify 를 했을 때 함께 나오게 되기 때문입니다. (Partial.js는 위 기능을 IE7-8에서도 동작하도록 요상한 방법으로 만든 memoize2 함수를 가지고 있습니다. JSON.stringify도 문제 없이 사용할 수 있습니다. https://marpple.github.io/partial.js/docs/#memoize2)

어쨌든 실무적인 관점에서는 fibonacci 같은 것을 할 일이 거의 없기 때문에 Number 보다는 오히려 객체에 해당하는 위와 같은 메모이제이션이 더 필요하고 실용적입니다. 큰 배열을 한 번 필터링 하면 다시 필터링 할 필요 없게 만드는 식으로 다루는 것이죠. 이것은 생각보다 실용적으로 사용이 가능하며, 심지어는 TODO 앱을 만들 때에도 적용될 수 있습니다!. 아니면 IO 시도를 줄인다던지에도 사용이 가능하겠죠. 게다가 보통 웹 어플리케이션에서는 숫자를 넣어서 결과를 얻을 함수들은 보통 다 단순하며 이미 굉장히 적은 자원을 사용하고 빠르게 연산될 함수들이 대부분이기에, 굳이 객체가 아닌 값을 위해서는 메모이즈가 구현될 필요가 없다고 생각합니다.

물론 둘다 만들어두면 되겠지요 ^^ 대신 일반 값에 대해서는 함수콜에서 복사가 자동으로 이루어지므로 Map이나 key/value 쌍으로 캐시를 해야하기 때문에 메모리 누수를 관리해줘야합니다.

어쨌든 생각보다 메모이제이션은 매우 실용적으로 사용될 수 있습니다! 물론 값을 불변적으로 다룬다는 전제에서만 사용이 가능하죠. (함수형 자바스크립트 프로그래밍 책에도 이런 내용이 소개가 되어있습니다.)

마지막으로 의문이 들 수 있습니다. 왠지 인자가 하나뿐인 함수에만 쓸 수 있어 별로 실용적이지 않을거 같다는 생각이 듭니다.

그런데 함수형 프로그래밍에는 커링이라는 개념이 있습니다!

function get(key, obj) {
  console.log('get 본체!');
  return arguments.length == 1 ?
    obj => get(key, obj) :
    obj[key];
}

const u = { name: 'id', age: 34 };
log( get('name', u) );
// get 본체!
// id
log( get('name', u) );
// get 본체!
// id
log( get('age', u) );
// get 본체!
// 34
log( get('age', u) );
// get 본체!
// 34

const name = memoize(get('name')), age = memoize(get('age'));

log( name(u) );
// get 본체!
// id
log( name(u) );
// id
log( age(u) );
// get 본체!
// 34
log( age(u) );
// 34

변할 재료와 부재료가 될 것들을 나누어 함수 조합을 통해 인자를 하나만 받는 함수로 만들어서 메모이제이션을 동작시킬 수가 있습니다.

그래도 단순한 함수고 성능적 이득도 없겠다고 생각할 수 있습니다. 다음은 reduce와 함께 써본 예제입니다.

function simpleRange(n) {
  return [...Array(n).keys()];
}

const nums = simpleRange(10000);
console.time();
log( reduce((a, b) => a + b, nums) );
// 결과: 49995000
console.timeEnd();
// 소요 시간: 2.003173828125ms

console.time();
log( reduce((a, b) => a + b, nums) );
// 결과: 49995000
console.timeEnd();
// 소요 시간: 3.111083984375ms

const addAll = memoize(reduce((a, b) => a + b));
console.time();
log( addAll(nums) );
// 결과: 49995000
console.timeEnd();
// 소요 시간: 2.603173828125ms

console.time();
log( addAll(nums) );
// 결과: 49995000
console.timeEnd();
// 소요 시간: 0.063173828125ms

const nums2 = simpleRange(1000000);
console.time();
log( addAll(nums2) );
// 결과: 499999500000
console.timeEnd();
// 소요 시간: 82.6181640625ms

console.time();
log( addAll(nums2) );
// 결과: 499999500000
console.timeEnd();
// 소요 시간: 0.083173828125ms

메모이제이션, 커링, 리듀스, 익명 함수, 일급 함수, 클로저들이 협력하며 재밌는 사례를 만들고 있네요 :)

어쨌든 ES는 점점 아름다워지고 있고, 많은 것들을 할 수 있습니다. 자바스크립트에서 함수형을 더 우아하게 사용할 수 있는 숨은 해법들도 많습니다. 자바스크립트 화이팅 :)


Clone this wiki locally
You can’t perform that action at this time.
You signed in with another tab or window. Reload to refresh your session. You signed out in another tab or window. Reload to refresh your session.
Press h to open a hovercard with more details.