메모이제이션(Memoization)의 원리와 함수 구현해보기, 적용

     

    프로그래밍을 하다 보면, 같은 연산을 반복해서 수행하는 상황을 자주 만납니다. 이런 경우, 이미 계산한 결과를 저장해 두었다가 재활용하면 성능을 크게 개선할 수 있는데요. 이 기법이 바로 메모이제이션(Memoization) 입니다.

    리액트에서의 useMemo 말고 직접 함수 구현은 처음이라 하나씩 공부하면서 정리해둔 내용입니다. 

    단순히 갖다 쓰기만 할줄 알았는데, 내부 로직을 직접 짜보니 흥미롭네요. 

     

    이번 글에서는 메모이제이션의 원리에서 시작하여 구현하며 비교해본 다양한 방법들, 그리고 적용까지 정리해 보겠습니다.


    1. 메모이제이션의 기본 원리

    메모이제이션은 간단히 말해 “입력 → 출력 결과”를 캐시에 저장해 두고, 같은 입력이 다시 들어왔을 때는 계산하지 않고 캐시에서 꺼내 쓰는 방식입니다.

    ex, 피보나치 수열을 단순 재귀로 계산하면 같은 값이 여러 번 계산되는데, 메모이제이션을 적용하면 중복 계산을 없앨 수 있습니다.

    function memoize(fn) {
      const cache = new Map();
      
      return function(...args) {
        const key = JSON.stringify(args);
        
        if (cache.has(key)) {
          return cache.get(key); // 캐시 히트
        }
        
        const result = fn.apply(this, args); // 캐시 미스
        cache.set(key, result);
        return result;
      };
    }
     

    장점: 성능 향상, 중복 계산 방지
    단점: 메모리 사용 증가, 캐시 무효화 전략 필요


    2. Map vs Object vs WeakMap

    위의 코드에서는 메모이제이션 캐시를 구현할 때 Map 을 사용했는데, 다른 것들도 비교해봤습니다. 

     

    Map

    • 다양한 타입을 키로 사용 가능 (객체, 숫자, 문자열 등)
    • map.size로 크기 확인이 간단
    • 삽입 순서가 보장되어 순회 시 예측 가능
    • 메모이제이션 캐시에 적합한 선택

    Object

    • 키가 문자열로 변환되어 원본 타입 정보 손실
    • Object.keys(obj).length로 크기 확인해야 해서 불편
    • 내장 메서드 충돌 위험 존재

    WeakMap

    • WeakMap 객체는 키가 약하게 참조되는 키/값 쌍의 컬렉션으로, 키는 반드시 객체여야만함!!
    • 가비지 컬렉션 자동 지원 → 메모리 누수 방지
    • 하지만 크기를 확인하거나 키를 순회할 수 없음
    • DOM 요소 캐싱 같은 특수한 상황에서 유용

     

    대부분의 현대 프로젝트는 프레임워크 기반으로 구현되다 보니 이런 기법을 일반적으로 활용할 일은 드물다. 그렇지만 내부적으로 객체 자체를 키로 삼아 데이터를 관리해야 하는 상황이라면 일반 객체나 Map을 쓰기 전에 WeakMap을 고려해보는 것도 좋은 선택이 될 수 있을 것 같습니다. 


    3. JSON.stringify 사용하는게 최선일까? 

     

    위 코드에서는 JSON.stringify(args)로 키를 만들었는데, 이 방식에서 유의해야할 점이 있다. 

    const obj1 = {a: 1, b: 2};
    const obj2 = {b: 2, a: 1};
    JSON.stringify(obj1) === JSON.stringify(obj2); // false (순서 차이)
    • 속성 순서가 다르면 다른 키가 됨
    • 순환 참조 객체는 직렬화 불가
    • 함수, undefined 등은 무시됨

    해결책으로는 속성을 정렬한 뒤 직렬화하거나, 해시 함수 / 안정적인 직렬화 라이브러리(fast-json-stable-stringify) 를 사용할 수 있습니다.

     

    간단한 정규화함수를 구현해봤습니다. 

    function normalize(obj) {
      const keys = Object.keys(obj).sort();
      const sorted = {};
      keys.forEach(key => {
        sorted[key] = obj[key];
      });
      return JSON.stringify(sorted);
    }



    4. this 컨텍스트 문제

    JavaScript에서는 함수 호출 방식에 따라 this가 달라집니다. 만약 메모이제이션 내부에서 단순히 fn(...args)로 호출하면 this가 깨져 예상치 못한 결과가 나올 수 있습니다.

    이를 해결하려면 fn.apply(this, args)를 사용해 현재 컨텍스트를 유지해야 합니다.

     

    const context = { 
        value: 10, 
        getValue: function(x) { 
        	return this.value + x; 
        }
    };
    
    const memoizedFn = memoize(context.getValue); 
    memoizedFn.call(context, 5); // 정상 동작 (this 유지)
    // 잘못된 방법
    memoizedFn(5); // this가 undefined



    5. 성능 최적화 아이디어

    1) 캐시 크기 제한 (LRU)

    LRU(Least Recently Used) 정책을 적용해 오래된 캐시를 제거합니다.

    2) TTL(Time To Live)

    일정 시간이 지나면 캐시를 무효화하는 방식입니다. 주로 API 요청 캐싱에 활용됩니다.

    // 1. 캐시 크기 제한
    function memoizeWithLimit(fn, maxSize = 1000) {
      const cache = new Map();
      
      return function(...args) {
        const key = JSON.stringify(args);
        
        if (cache.has(key)) {
          return cache.get(key);
        }
        
        // LRU 구현
        if (cache.size >= maxSize) {
          const firstKey = cache.keys().next().value;
          cache.delete(firstKey);
        }
        
        const result = fn.apply(this, args);
        cache.set(key, result);
        return result;
      };
    }
    
    // 2. TTL (Time To Live)
    function memoizeWithTTL(fn, ttl = 60000) {
      const cache = new Map();
      
      return function(...args) {
        const key = JSON.stringify(args);
        const now = Date.now();
        
        if (cache.has(key)) {
          const entry = cache.get(key);
          if (now - entry.timestamp < ttl) {
            return entry.value;
          }
          cache.delete(key);
        }
        
        const result = fn.apply(this, args);
        cache.set(key, { value: result, timestamp: now });
        return result;
      };
    }



     

    6. 에러 처리 전략

    함수가 에러를 던졌을 때, 결과를 캐시에 저장할지 말지는 전략에 따라 달라집니다.
    보통은 캐시하지 않지만, 실패 결과를 일정 시간 캐싱(Failure Caching) 하면 불필요한 반복 호출을 막을 수 있습니다.

     

    function memoizeWithErrorHandling(fn) {
      const cache = new Map();
      
      return function(...args) {
        const key = JSON.stringify(args);
        
        if (cache.has(key)) {
          return cache.get(key);
        }
        
        try {
          const result = fn.apply(this, args);
          cache.set(key, result);
          return result;
        } catch (error) {
          // 에러는 캐시하지 않음 (선택적)
          throw error;
        }
      };
    }

     

     

     

    7. 비동기 함수 지원

    Promise를 반환하는 함수에도 메모이제이션을 적용할 수 있습니다.

    동시에 같은 요청이 들어오면 pending 상태를 공유하도록 설계했습니다. 

     

    function memoizeAsync(fn) {
      const cache = new Map();
      const pending = new Map(); // 진행 중인 요청
      
      return async function(...args) {
        const key = JSON.stringify(args);
        
        if (cache.has(key)) {
          return cache.get(key);
        }
        
        if (pending.has(key)) {
          return pending.get(key); // 동시 요청 처리
        }
        
        const promise = fn.apply(this, args);
        pending.set(key, promise);
        
        try {
          const result = await promise;
          cache.set(key, result);
          pending.delete(key);
          return result;
        } catch (error) {
          pending.delete(key);
          throw error;
        }
      };
    }
     
     

    프론트엔드에서는 검색 자동완성 API, 백엔드에서는 DB 조회 결과에 적용하면 효과적입니다.

     

     

    8. 적용 사례

    • 프론트엔드
      • 이미지 크기 변환, DOM 측정 값(getBoundingClientRect)
      • React의 useMemo로 연산 결과 캐싱
    • 백엔드
      • API 응답 캐싱, 데이터 변환 로직 최적화
      • 머신러닝 모델 예측 결과 캐싱
    • 알고리즘 문제 풀이
      • DFS/DP 기반 경로 탐색, 최소 비용 계산


    9. 언어별 메모이제이션 도구

    • JavaScript: Lodash _.memoize, React useMemo
    • Python: functools.lru_cache
    • Java: Guava Cache
    • C#: MemoryCache

     

    10. 정리 

    메모이제이션은 강력한 최적화 기법이지만, 모든 경우에 무조건 적용하기보다, 필요할 때 사용해야합니다. 

    • 중복 호출이 적다면 오히려 불필요한 메모리 낭비가 될 수 있고,
    • 캐시 전략(LRU, TTL, 에러 처리)을 잘못 세우면 성능이 떨어질 수도 있습니다.

    따라서 1) 중복 계산이 많고 2) 입력 크기가 제한적이며 3) 결과 재활용 가치가 큰 함수에 적용하는 것이 가장 이상적이라고 합니다. 

     

     

     

    🔗 참고자료 

    https://ui.toast.com/posts/ko_20210901 

    https://www.youtube.com/watch?v=p8JSGLd0yj8

     

    반응형

    댓글