메모이제이션에 대한 오해와 진실

27 min read

메모이제이션(Memoization)

메모이제이션(Memoization)은 이전에 계산한 값을 저장해두고 동일한 입력이 들어왔을 때 재사용하는 최적화 기법입니다. 메모이제이션은 무겁고 복잡한 계산일수록 성능 최적화 측면에서 유리하게 사용할 수 있습니다. 예를 들어 알고리즘에서 흔하게 볼 수 있는 피보나치 수열 계산을 한다고 가정해보겠습니다.

const fibonacci = (n) => {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
};

n이 5일때 피보나치 수열을 계산할 경우 fibonacci(1)은 5번, fibonacci(2)은 3번, fibonacci(3)은 2번 중복 계산됨을 알 수 있습니다. n이 커짐에 따라 중복 계산은 훨씬 더 많이 발생할 것이며 피보나치 수열은 O(n2n^{2})의 복잡도를 가집니다.

Picture1.png

그렇다면 앞서 발생한 중복 계산에 대해 값을 미리 저장해놓으면 어떻게 될까요? 아래와 같이 첫 번째로 발생한 계산에 대해서만 계산 후 값을 저장해두면 이후에는 상수 시간으로 값을 계산할 수 있습니다.

Picture2.png

또한 아래와 같이 새롭게 fibonacci(6)을 계산할 경우 캐싱된 fibonacci(5)fibonacci(4)의 값을 통해 매우 빠르게 계산할 수 있습니다. 이처럼 메모이제이션은 복잡하고 무거운 계산에 있어 성능 개선에 큰 도움을 주며 뿐만 아니라 React에서는 이러한 메모이제이션 기법을 통해 컴포넌트의 불필요한 재렌더링을 방지할 수도 있습니다.

Picture3.png

React의 메모이제이션 기법

React 내부 메커니즘의 이해

리액트에서는 이러한 메모이제이션을 어떻게 구현하고 처리하는지 간단히 실제 코드를 기반으로 살펴보겠습니다. 먼저 리액트에는 훅을 실행하는 Dispatcher가 존재합니다. 이때 훅을 어떤 시점에서 실행하느냐에 따라 다른 Dispacher를 사용하는데요. 마운트 시점에서는 하단의 HookDispatcherOnMount를 사용합니다. 이때 useMemo의 경우 mountMemo 함수가 매칭됩니다.

const HooksDispatcherOnMount: Dispatcher = {
    useCallback: mountCallback,
    useContext: readContext,
    useEffect: mountEffect,
    useMemo: mountMemo,
    useReducer: mountReducer,
    useRef: mountRef,
    useState: mountState,
    useMemoCache,
    // ... 생략 
};

다음은 mountMemo에서 핵심적인 부분에 대한 로직을 포함한 코드입니다. 마운트 시점인 만큼 새로 hook을 생성하여 Fiber에 연결합니다. 이후 정규화 및 상태 저장을 통해 이후 updateMemo에서 사용할 수 있도록 합니다.

function mountMemo<T>(
    nextCreate: () => T, // 메모이제이션할 값을 생성하는 함수
    deps: Array<mixed> | void | null // 의존성 배열
): T {
    // 1. 새로운 hook을 생성하고 현재 Fiber에 연결
    const hook = mountWorkInProgressHook();
 
    // 2. deps 정규화 - undefined일 경우 null로 변경
    const nextDeps = deps === undefined ? null : deps;
 
    // 3. 값 생성
    const nextValue = nextCreate();
 
    // 4. 훅의 상태 저장
    hook.memoizedState = [nextValue, nextDeps];
 
    // 5. 계산된 값 반환
    return nextValue;
}

mountWorkInProgressHook 내에선 몇 가지 값들로 구성된 hook 객체를 생성합니다. 이때 hook은 React 내부적으로 연결 리스트로 관리되고 있는데요. 그래서 아래와 같이 .next를 이용하여 연결리스트에 추가하는 방식을 확인할 수 있습니다.

function mountWorkInProgressHook() {
    const hook: Hook = {
        memoizedState: null,
        baseState: null,
        baseQueue: null,
        queue: null,
        next: null,
    };
 
    if (workInProgressHook === null) {
        // 첫 번째 hook
        currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
    } else {
        // hook을 연결 리스트에 추가
        workInProgressHook = workInProgressHook.next = hook;
    }
 
    return workInProgressHook;
}

마운트 시점 이후에 updateMemo 내에서 메모이제이션된 값을 기반으로 의존성을 비교하여 업데이트 여부를 결정합니다. 마운트 시점에서 생성한 훅의 상태를 가져와 현재 의존성 배열과 비교하여 값의 재사용 여부를 결정하고 있습니다.

function updateMemo<T>(
    nextCreate: () => T,
    deps: Array<mixed> | void | null
): T {
    // 1. 현재 작업 중인 훅을 가져옴
    const hook = updateWorkInProgressHook();
 
    // 2. 의존성 배열 정규화
    const nextDeps = deps === undefined ? null : deps;
 
    // 3. 이전 메모이제이션 상태 가져오기
    const prevState = hook.memoizedState;
 
    // 4. 의존성 배열 비교 후 같다면 이전 값 재사용 
    if (nextDeps !== null) {
        const prevDeps: Array<mixed> | null = prevState[1];
        if (areHookInputsEqual(nextDeps, prevDeps)) {
            return prevState[0];
        }
    }
 
    // 5. 의존성이 다르거나 없을 경우 새로운 값 계산
    const nextValue = nextCreate();
 
    hook.memoizedState = [nextValue, nextDeps];
    return nextValue;
}

위 내용들을 정리하여 메모이제이션된 훅이 렌더링(혹은 재렌더링)되었을 때의 핵심적인 파이프라인을 시각화해보겠습니다.

React.memo()

이제 본격적으로 React에서 제공하는 메모이제이션 기법을 살펴보겠습니다. 첫 번째로 React에서는 컴포넌트를 메모이제이션하기 위해 memo라는 고차 컴포넌트(HOC, Higher Order Component)를 제공합니다.
::: info ::: 고차컴포넌트는 컴포넌트를 인자로 받아 새 컴포넌트를 반환함수 함수로 컴포넌트 로직을 재사용하기 위한 패턴입니다.

아래와 같이 메모이제이션하고자 하는 컴포넌트를 인자로 넘겨 memo를 사용할 수 있으며 props가 변경되지 않는 한 컴포넌트는 리렌더링되지 않습니다.

const MyComponent = React.memo(function MyComponent(props) {
  return (
    <div>{props.name}</div>
  );
});

memo 함수의 동작 원리를 이해하기 위해 useRef훅을 이용하여 간단히 구현해보겠습니다. (useRef를 사용함으로써 리렌더링 시에도 값 보존 가능) 컴포넌트를 인자로 받아 초기 컴포넌트 props를 계산하여 useRef훅 값에 저장하고 리렌더링이 발생했을 때 props 값이 변경되었는지 비교함수를 통해 확인하여 값의 업데이트 여부를 결정할 수 있습니다.

export function memo<P extends object>(
        Component: ComponentType<P>,
        _equals = shallowEquals,
) {
 const MemoizedComponent = (props: P) => {
  const prevPropRef = useRef<P | null>(null);
  const prevComponentRef = useRef<ReactElement<P> | null>(null);
 
  // props가 변경되었는지 등을 확인
  const shouldUpdate =
          prevPropRef.current === null || !_equals(prevPropRef.current, props);
 
  prevPropRef.current = props;
  if (shouldUpdate) {
   prevComponentRef.current = createElement(Component, props);
  }
 
  return prevComponentRef.current;
 };
 return MemoizedComponent;
}

그렇다면 memo는 모든 상황에서 유용할까요? React 공식 문서에선 다음과 같은 상황에서 메모이제이션이 불필요하다고 설명하고 있습니다.

  1. 컴포넌트가 리렌더링 될 때 인지할 수 있을만큼의 지연이 발생하지 않는 경우
  2. 항상 다른 props가 컴포넌트로 전달되는 경우

특히 2번의 경우 부모 컴포넌트 내에서 정의된 함수나 객체 등을 인자로 받을 경우 부모 컴포넌트가 리렌더링될 때마다 해당 객체와 함수가 매번 리렌더링되기 때문에 메모이제이션이 적용되지 않습니다.

아래 예제의 경우 부모 컴포넌트에 count에 대한 state를 두고 자식 컴포넌트에는 각각 선언된 유저 정보와 클릭 이벤트 핸들러 함수를 인자로 넘겨주고 있습니다. 언뜻 보았을 땐 userInfo, handleClick은 count state와 무관하기 때문에 상태가 업데이트되어도 메모이제이션이 유지될 것 처럼 보이지만 count state가 변경될 경우 해당 객체와 함수 모두 새로 생성되기 때문에 그렇지 않습니다.

function ParentComponent() {
  const [count, setCount] = useState(0);
  
  // 🚨 매 렌더링마다 새로운 객체가 생성됨
  const userInfo = {
    name: "John",
    age: 30
  };
  
  // 🚨 매 렌더링마다 새로운 함수가 생성됨
  const handleClick = () => {
    console.log("버튼 클릭됨");
  };
 
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>
        카운트 증가 (현재: {count})
      </button>
      
      {/* memo를 사용했지만 매번 리렌더링됨 */}
      <MemoizedChildComponent 
        userInfo={userInfo}        // 매번 새로운 객체
        onClick={handleClick}      // 매번 새로운 함수
      />
    </div>
  );
}

이런 상황을 위해 useMemo, useCallback 훅과 같이 객체 혹은 함수 값을 메모이제이션하여 다시 생성되는 것을 방지할 수 있습니다. 각각 훅에 대해 자세히 살펴보겠습니다.

useMemo()

useMemo는 리렌더링이 발생할 때 계산 결과를 캐싱하여 제공해주는 훅입니다. 아래와 같이 비용이 높은 계산이 있다고 가정해보겠습니다.

const value = computeExpensive(a, b);

복잡한 연산을 하는 computeExpensive 함수의 경우 인자인 a,b에 의해서만 값이 변경되고 외부 사이드 이팩트가 존재하지 않는다면 useMemo를 통해 의존성(a,b)이 변경될때만 값을 재계산하도록 할 수 있습니다.

const value = useMemo(() => computeExpensive(a, b), [a, b]);

앞서 memo와 같이 useMemouseRef훅을 활용하여 직접 구현해볼 수 있습니다. 의존성 객체 배열에 대해 얕은 비교를 하는 함수를 구현한 뒤 훅 내부에서 의존성이 변경되었다고 판단될 경우 새롭게 의존성과 값을 초기화해주도록 합니다.

export function useMemo<T>(
        factory: () => T,
        _deps: DependencyList,
        _equals = shallowEquals,
): T {
 const ref = useRef<{
  _deps: DependencyList;
  value: T;
 } | null>(null);
 
 if (ref.current === null || !_equals(_deps, ref.current._deps)) {
  ref.current = {
   _deps: _deps,
   value: factory(),
  };
 }
 
 return ref.current.value;
}

이러한 useMemo는 모든 경우에 적용하는 것이 좋을까요? 위 구현부에서 볼 수 있듯이 메모이제이션이 전혀 필요 없는 상황에서 useMemo를 사용할 경우 최적화를 함으로써 얻는 이득보다 비용이 더 발생할 수 있습니다. 가장 대표적인 예시로 문자열, 숫자, 불리언과 같은 스칼라 값은 useMemo가 불필요합니다.

아래 예시에서 doubleCount는 단순 산술 연산을 거치기 때문에 원시 타입을 리턴하게 됩니다. 이 경우 자바스크립트에서는 메모리 위치에 대한 참조가 아닌값 그 자체이기 때문에 오히려 메모리 사용 측면에서 오버헤드가 발생합니다.

const [count, setCount] = useState(0);
const doubleCount = useMemo(() => {
    return count * 2;
}, [count]);

useCallback()

useCallbackuseMemo와 동일한 메커니즘으로 동작하며 useMemo와는 달리 메모이제이션된 콜백 함수를 반환하는 훅입니다. 가장 쉬운 예시로 Todo List를 들어보겠습니다. 아래는 목록에 할 일을 새롭게 추가하는 함수입니다.

const handleTodoAdd = useCallback((newTodo) => {
  setTodos((prev) => [...prev, newTodo]);
}, [setTodos]);

위의 경우 어차피 todo 상태가 업데이트되면 함수도 재생성되어야하기 때문에 언뜻보기엔 메모이제이션이 필요하지 않아보입니다. 그러나 함수를 하위 컴포넌트의 콜백으로 전달하는 경우 얘기가 달라집니다. 아래와 같이 렌더링 비용이 높은 하위 컴포넌트에 콜백 함수로 전달하는 경우 메모이제이션하지 않으면 불필요한 상황(todo 외 다른 상태가 변경)에 해당 하위 컴포넌트 또한 리렌더링 됩니다.

const MyComponent = () => {
 const [todos, setTodos] = useState([]);
 const [note, setNote] = useState('');
 
  const handleTodoAdd = useCallback(...);
  
  const handleNoteChange = useCallback(...);
 
 return (
   <ExpensiveComponent onAdd={handleTaskAdd} /> // todo 상태 변경시에만 리렌더링
   <NoteComponent onNoteChange={handleNoteChange} /> // note 상태 변경시에만 리렌더링      
 );
}
Picture4.png

useCallback은 본질적으로 함수를 위한 useMemo와도 같습니다. 직접 구현 시 위에서 구현한 useMemo를 활용하여 함수를 인자로 받아 useMemo에 넘겨주여 메모이제이션하도록 간단하게 구현할 수 있습니다.

export function useCallback<T extends Function>(
        factory: T,
        _deps: DependencyList,
) {
 return useMemo(() => factory, _deps);
}

useCallback 또한 useMemo와 마찬가지로 불필요한 상황이 존재합니다. 아래 예시처럼 onclick 핸들러인 increment를 useCallback을 통해 메모화 하면서 얼핏보면 렌더링 최적화를 이룬 것처럼 보이지만 button의 경우 브라우저 네이티브 엘리먼트이고 호출 가능한 리액트 함수 컴포넌트가 아니기 때문에 increment를 메모화해서 얻는 이점이 거의 없습니다. 해당 버튼 엘리먼트는 리액트 컴포넌트처럼 서브 트리가 존재 않아 재생성 비용이 큰 이슈가 아닙니다.

const increment = useCallback(() => { 
    setCount((prev) => prev + 1);
}, []);
// ... 생략
return (
    <button onClick={increment}>Click me</button>
);

메모이제이션 전략

React에서 핵심적으로 사용하는 메모이제이션 기법(memo, useMemo, useCallback)에 대한 개념과 적절한 사용 방법에 대해서 살펴보았습니다. 흐름대로라면 계산 비용이 높은 케이스에 대해 메모이제이션을 적용하는 것이 가장 효율적인 방법이라고 느껴집니다. 여기서 생각해봐야하는 것은 계산 비용이 비싼 컴포넌트, 함수의 기준은 무엇일까요? 또한 메모이제이션을 적용하지 않았을 경우의 렌더링 비용, 메모이제이션을 모두 적용했을 때의 메모리 비용은 어느정도일까요?

실제 메모이제이션을 모든 곳에 적용하자는 의견과 필요한 곳에서만 사용하자는 의견에 대한 논쟁은 매우 오랜 시간동안 이어져오고 있습니다. 스택 오버플로우의 When should you NOT use React memo? 글을 보면 꽤 긴 논쟁이 이어진 것을 확인할 수 있는데요. 메모이제이션을 모든 곳을 적용했을 때 발생하는 오버헤드에 대한 의견과 메모이제이션을 모든 함수 컴포넌트에 적용해도 문제가 없는 이유 등에 대해 설명하고 있습니다.

메모이제이션을 남발할 경우 분명히 오버헤드가 발생하는 것은 명확하지만 그럼에도 메모이제이션을 모든 곳에서 적용하는 방식을 권장하고 싶습니다.

그럴거면 앞에서 구구절절 왜 설명하셨나요?..

위와 같이 생각이 들 수도 있지만 메모이제이션이 어떤 상황에서 실질적으로 최적화를 이룰 수 있는지와 필요 없는 상황을 인지하고 사용하는 것과 무작정 적용하는 것과는 장기적인 개발에 있어 하늘과 땅과 같은 차이입니다. 그렇다면 본론으로 넘어와서 왜 모든 상황에 적용하는 것이 좋을까요? 아래와 같은 관점에서 살펴보겠습니다.

  1. props 및 의존성 비교 비용
  2. 불필요한 메모이제이션의 메모리 낭비 비용
  3. 메모이제이션 의사결정 비용

props 혹은 상태 등이 변경되었을 때 React는 내부적으로 파이버 재조정(Fiber Reconciliation) 과정을 거치게 됩니다.

위와 같이 React에서 리렌더링이 발생하는 과정을 살펴보면 의존성과 props를 비교하는 비용보다 memo를 적용하지 미처 적용하지 못해 리렌더링이 발생했을 때 훨씬 더 큰 비용이 발생함을 예상할 수 있습니다.

또한 모든 곳에 메모이제이션을 적용한다해서 현대 컴퓨터의 CPU 혹은 메모리에서 유효한 리소스 낭비가 발생한다고도 보기 어렵습니다. 특히 메모리의 경우 React의 재조정자는 리렌더링 시 항상 이전 결과와 후속 값을 비교하기 위해 이전 렌더링 결과를 저장하고 있기 때문에 더더욱 그렇습니다. (memo의 메모리 비용 참고)

특히나 메모이제이션으로 최적화를 시도해보신 분들이라면 한번쯤 어디에서 메모이제이션을 적용해야 효율적인지 고민하는 데에 대한 의사결정 과정을 경험해보셨을 것 입니다. 예를 들어 이상적인 최적화를 위해 아래와 같이 계산 비용이 크다고 예상되는 컴포넌트를 메모이제이션한다고 가정해보겠습니다.

const ExpensiveComponent = memo((props) => {
  // 무거운 작업 가정
  return <div {...props}></div>;
});

이때 ExpensiveComponentParentComponent에 의해 호출되고 있습니다. 또한 ParentComponent는 컴포넌트 내부에 선언된 핸들러 함수를 ExpensiveComponent로 넘겨주고 있습니다.

function ParentComponent(style, onClick, onMouseEnter) {
  const handleClick = () => {
    console.log("ParentComponent1에서 실행하는 handleClick");
    onClick();
  };
 
  const handleMouseEnter = () => {
    console.log("ParentComponent1에서 실행하는 handleMouseEnter");
    onMouseEnter();
  };
 
  const newStyle = { backgroundColor: "#f5f5f5", ...style }
 
  return (
    <div>
      <ExpensiveComponent className="a b c" id="test" style={newStyle} />
      <ExpensiveComponent id="test2" onClick={handleClick} />
      <ExpensiveComponent id="test3" onMouseEnter={handleMouseEnter} />
      <ExpensiveComponent id="test4" title="타이틀입니다." />
    </div>
  );
}

ExpensiveComponentmemo를 통해 메모이제이션하였지만 부모 컴포넌트에서 전달되는 props의 영향을 받아 매번 리렌더링될 것입니다. 그렇다면 부모 컴포넌트 내 변수와 함수도 메모이제이션해주겠습니다.

 const handleClick = useCallback(() => {
    console.log("ParentComponent1에서 실행하는 handleClick");
    onClick();
  }, [onClick]);
 
  const handleMouseEnter = useCallback(() => {
    console.log("ParentComponent1에서 실행하는 handleMouseEnter");
    onMouseEnter();
  }, [onMouseEnter]);
 
  const style = useMemo(
    () => ({ backgroundColor: "#f5f5f5", ...defaultStyle }),
    [defaultStyle],
  );

위와 같이 컴포넌트 내에 선언되어 인자로 넘겨지는 모든 값을 메모이제이션하였습니다. 이제 불필요한 렌더링이 방지될까요? 그렇지 않습니다. 위 컴포넌트 내부 값들은 자세히 보면 부모로부터 받은 props를 이용하고 있기 때문에 해당 props의 원본 값도 메모이제이션 해주어야 합니다.

따라서 아래와 같이 ParentComponent를 호출하는 GrandParentComponent 또한 메모이제이션을 적용해주어야 합니다.

function GrandParentComponent() {
    const style = useMemo(() => ({ color: "#09F" }), []);
    const handleClick = useCallback(() => null, []);
    const handleMouseEnter = useCallback(() => null, []);
 
    return (
        <ParentComponent
            style={style}
            onClick={handleClick}
            onMouseEnter={handleMouseEnter}
        />
    );
}

이처럼 어플리케이션의 구조가 커지고 복잡해짐에 따라 메모이제이션을 적용했다하더라도 체인으로 연결된 모든 값들을 제대로 메모이제이션해주지 않으면 제대로 최적화를 이룰 수 없습니다. 뿐만 아니라 어느 지점에서 최적화가 실패했는 지에 대한 디버깅도 매우 까다롭기 때문에 일관성있게 메모이제이션을 적용하는 것이 좋은 전략입니다.

ReactTypeScriptJavaScriptPerformance

댓글 0