• [memoize-one] 캐시를 활용한 라이브러리 :: 마이구미
    알아두면 좋은 라이브러리 2021. 11. 27. 22:02
    반응형
    이 글은 캐시를 활용한 라이브러리 중 하나로 memoize-one 을 다룬다.
    README.md 중심의 내용과 실제 코드를 통해 리뷰해본다.
    조금이나마 도움을 줄 수 있는 라이브러리들을 소개하는 카테고리로 분류된 글이다.
    알아두면 좋은 라이브러리

     

    memoize-one 은 메모이제이션 전략을 사용하는 라이브러리 중 하나이다.

    메모이제이션은 동일한 계산을 반복해야하는 경우, 이전에 계산한 값을 저장하여 반복된 계산 수행 없이 활용하는 기법으로 캐시라고 생각하면 이해하기 쉽다.

     

    "one" 이름처럼 오직 하나, 가장 최근 결과만 기억한다.

    최소화된 기능으로 캐시를 위한 사이즈, 만료 시간, 예외 데이터(blacklist, whitelist) 등은 존재하지 않는다.

    결과적으로 위와 같은 기능들로 인한 메모리 누수를 걱정할 필요없는 1 KB 도 안되는 사이즈를 가진 초경량 라이브러리이다.

     

    "가장 최근 결과만 기억한다" 라는 것의 실제 원리는 가장 최근에 넘어온 전달인자(arguments)를 기억하는 전략이다.

    함수 호출 시 같은 전달 인자를 넘겨주게 되면, 실제 함수는 호출하지 않고 최근 결과를 바로 반환해준다.

    자세한 내용은 예제를 통해 확인해보자.

    import memoizeOne from 'memoize-one';
    
    function add(a, b) {
      console.log('called!');
      return a + b;
    }
    const memoizedAdd = memoizeOne(add);
    memoizedAdd(1, 2); // called! 반환값 => 3

     

    memoizeOne 을 이용해서 add 함수를 기억된 함수(memoizedAdd)로 만들어 전달인자와 함께 호출한다.

    (memoized-one 을 활용하는 함수를 기억된 함수 라고 칭하겠다.)

    add 함수 내부에 있는 console.log() 는 출력되고 3 을 반환해준다.

    그리고 다시 같은 전달인자를 넘겨 호출해보자.

    memoizedAdd(1, 2); // 반환값 => 3

     

    add 함수는 실행되지 않아서 콘솔창에는 "called!" 을 출력하지 않는다.

    함수가 실행되지 않았음에도 캐시된 최근 결과를 반환하여 그대로 3이다.

    전달인자를 변경하여 기억된 함수를 호출해보자.

    memoizedAdd(2, 3); // called! 반환값 => 5
    memoizedAdd(2, 3); // 5
    memoizedAdd(2, 3); // 5

     

    기억된 전달인자가 아닌 다른 전달인자를 넘겨주면 add 함수를 실행하여 "called!" 를 출력하게 된다.

    그 이후에는 마지막 전달인자 [2, 3] 을 기준으로 결과를 재사용하는 모습을 볼 수 있다.

     

    그렇다면, 마지막 전달인자와 새로운 전달인자는 어떻게 비교할까?

    memoize-one 내부에서는 비교 로직은 분리되어있어 확인하기 수월하다. (are-inputs-equals.ts)

    크게 3가지 범위에서 비교한다.

     

    • 마지막 전달인자와 새로운 전달인자의 길이
    • 마지막 전달인자와 새로운 전달인자 사이의 === 비교
    • NaN 

     

    우선 이것들을 이해하기 위해서는 memoize-one 내부에서 전달인자를 다루는 방식을 이해해야한다.

    내부에서는 전달인자를 "나머지 매개변수" 를 통해 배열로 취급한다.

    즉, 마지막 전달인자와 새로운 전달인자 모두 배열로 취급하여 사용한다.

    function memoized(...newArgs) { ... } // memoizedAdd(1, 2) 라면, newArgs 는 [1, 2] 의미함.

     

    배열로 취급하게 되면, 배열에서 제공해주는 length 를 활용할 수 있다.

    배열의 길이가 다르다는 것은 서로 전혀 다른 값이라는 것은 우리는 이미 알고 있다.

    memoizedAdd(1, 2) => [1, 2]
    memoizedAdd(1, 2, 3) => [1, 2, 3]
    function memoized(...newArgs) {
      if (newArgs.length !== lastArgs.length) {
        return false;
      }
    }

     

    그리고 길이가 같다면, 각각의 배열 요소를 === 연산자를 통해 비교하게 된다.

    memoizedAdd(1, 2) => lastArgs = [1, 2]
    memoizedAdd(1, 2) => newArgs = [1, 2]
    
    lastArgs[0] === newArgs[0] => 1 === 1
    lastArgs[1] === newArgs[1] => 2 === 2
    function memoized(...newArgs) {
      if (newArgs.length !== lastArgs.length) {
        return false;
      }
      
      for (let i = 0; i < newArgs.length; i++) {
        if (!(newArgs[i] === lastArgs[i]))) {
          return false;
        }
      }
    }

     

    그리고 특별한 케이스로 NaN 을 대응하고 있다.

    자바스크립트 특성상 NaN === NaN 의 결과는 false 이다.

    여기에서는 true 로 처리하여 예기치 못한 새로운 전달인자를 캐시로 활용하지 않게 한다.

    이것이 내부에서 디폴트로 사용하고 있는 비교 로직이다.

    (디폴트로 사용중인 비교 로직은 커스텀 가능할 수 있어, 밑에서 다시 다룰 예정이다.)

     

    그리고 추가로 위에서 언급한 비교 로직 이외의 방식으로 서로 다른 컨텍스트라면, 마지막 전달인자와 새로운 전달인자가 같지 않다고 판단한다.

    컨텍스트가 변경하더라도 이전 결과를 그대로 반환할 여지가 있기 때문에 this 까지 비교하게된다. (관련 Issue)

     

    여기까지 확인했을 때, 우리는 한가지 의문을 가질 수 있다.

    각각의 배열 요소를 === 를 통해 비교하게 한다면, 배열 요소가 원시값(숫자, 문자 등)이 아닌, 주소값(object, array) 이라면?

    주소값을 전달인자로 사용한다면, 서로의 주소값을 비교하여 동일 여부를 판단하게된다. (주소값 === 주소값)

    알다시피 주소값을 비교하게 되면 많은 문제를 초래한다.

    매번 새로운 주소가 할당되어 마지막 전달인자와 새로운 전달인자 사이의 비교는 항상 false 를 초래할 수 있다.

    function sum(items) {
      console.log('called!');
      return items.reduce((a, b) => a + b, 0)
    }
    const memoizedAdd = memoizeOne(sum);
    
    memoizedAdd([1, 2, 3, 4, 5]); // called! 반환값 => 15
    memoizedAdd([1, 2, 3, 4, 5]); // called! 반환값 => 15
    memoizedAdd([1, 2, 3, 4, 5]); // called! 반환값 => 15

     

    새로운 주소값이 할당되지 않게 원본의 주소값 자체를 넘길 경우에는 항상 true 를 초래할 수 있다.

    function sum(items) {
      console.log('called!');
      return items.reduce((a, b) => a + b, 0)
    }
    const memoizedAdd = memoizeOne(sum);
    const items = [1, 2, 3, 4, 5];
    memoizedAdd(items); // called! 반환값 => 15
    items.push(6);
    memoizedAdd(items); // 반환값 => 15
    memoizedAdd(items); // 반환값 => 15

     

    이러한 문제의 원인은 위에서 다룬 디폴트로 사용되는 비교 로직 때문이다.

    제공해주고 있는 비교 로직을 커스텀할 수 있는 기능을 이용하면 된다.

    디폴트 비교 로직이 얕은 비교(shallowCompare) 였다면, 여기서는 깊은 비교(deepCompare) 를 사용하면 된다.

    import isDeepEqual from 'lodash.isequal';
    
    function sum(items) {
      console.log('called!');
      return items.reduce((a, b) => a + b, 0)
    }
    const memoizedAdd = memoizeOne(sum, isDeepEqual);
    memoizedAdd([1, 2, 3, 4, 5]); // called! 반환값 => 15
    memoizedAdd([1, 2, 3, 4, 5]); // 반환값 => 15
    memoizedAdd([1, 2, 3, 4, 5]); // 반환값 => 15

     

    비교 로직을 커스텀하여 우리가 원하는 결과를 출력할 수 있다.

     


    리액트를 사용한 경험이 있다면, useMemo(), useCallback() 을 떠올렸을 수도 있다.

    마찬가지로 props 의 변화가 없다면, 리랜더링시 다시 실행하지 않고 이전 결과를 그대로 사용한다.

    비슷한 원리로 성능 최적화에 도움을 준다.

     

    memoize-one 의 코드는 실질적으로 50줄도 안된다.

    하지만 간단한 아이디어와 짧은 코드로 엄청난 결과를 낼 수 있다는 것이 인상적인 라이브러리이다.

    반응형

    댓글

Designed by Tistory.