`useMemo`와 `useCallback`은 언제 사용해야 할까?

2021. 6. 18.

useMemo에 대하여

리액트 공식 문서에 따르면, useMemo메모이제이션된 값을 반환한다고 한다.

메모이제이션 (Memoization)

메모이제이션은 중복되는 연산을 반복해야 하는 상황에서 사용할 수 있는 최적화 기법이다. 이전에 계산한 값을 메모리에 저장하고, 그 값을 반복되는 연산에서 사용함으로써 중복되는 계산을 줄일 수 있다. 알고리즘 공부를 할 때 배우는 동적 계획법(Dynamic Programming)이 이 메모이제이션을 활용한다.

피보나치 수열을 구하는 상황을 예시로 들어보자. 피보나치 수열을 재귀적으로 연산한다면 다음과 같이 코드를 쓸 수 있다.

function fibo(n) {
  if (n === 0) return 0;
  if (n === 1) return 1;
    
  return fibo(n - 1) + fibo(n - 2);
}

console.log(fibo(5)); // 5

// fibo 함수 실행 횟수 : 15

이 과정에서, fibo(5)를 실행하여 fibo 함수가 호출된 횟수는 총 15번이다.

재귀적으로 함수가 호출되면서, fibo(5)의 결과값을 구하기 위해 fibo(4)fibo(3)이 호출되고, fibo(4)의 결과값을 구하기 위해 fibo(3)fibo(2)가 호출되고, fibo(3) 의 결과값을 구하기 위해 fibo(2)fibo(1)가 호출되고... 이런 복잡하고도 반복되는 연산이 계속 이루어진다.

잘 보면 이미 동일한 n으로 호출된 fibo 함수가 있는데, 재귀적으로 호출하다보니 똑같은 fibo(n)을 계속 실행하게 된다.

이런 식으로 똑같은 연산이 수행되지 않도록 메모이제이션을 적용할 수 있다. 이미 함수가 실행되어 연산된 값이 있다면 이를 재활용하여 연산할 수 있도록 하는 것이다. 다음 예시는 피보나치 수열을 연산하는데 DP를 적용한 코드이다.

const dp = [0, 1];

const fibo = function (n) {
  /* 이미 연산된 값이 있다면, 이를 return 한다. */
  if (dp[n] || n <= 1) return dp[n]; 
  
  const result = fibo(n - 1) + fibo(n - 2);
  dp[n] = result;

  return result;
};

console.log(fibo(5)); // 5

// fibo 함수 실행 횟수 : 9

dp 배열에는 이미 연산된 값들이 들어있다. dp 배열에 n 에 해당하는 값이 들어있다면, 이를 바로 반환해주기만 하면 된다. 메모이제이션 적용 전에는 실행 횟수가 15번이었지만, 적용 후에는 9번만 실행되었다. 중복되는 연산이 정말정말 많을 경우에 메모이제이션을 적용하면, 비약적인 성능 개선을 기대할 수 있을 것이다.

다음은 JSBench.Me에서 성능을 비교해본 결과이다.

이름ops/s비교
재귀14478556.39 ops/s91.08% 느림
DP162243751.96 ops/s가장 빠름

다시 useMemo로 돌아오면

useMemo는 메모이제이션된 값을 반환한다. 직전에 연산된 이 있다면, 그 값을 반환한다는 것이다.

useMemo는 다음과 같이 사용한다.

useMemo(() => { /* 값을 연산하여 반환하는 로직 */ }, []);

첫번째 인자에는 값을 연산하여 반환하는 함수, 두번째 인자에는 어떤 값이 변경되었을 때 다시 연산해야할 지 알려주기 위한 배열을 넣어준다. 이를 dependencies array, 줄여서 deps라고 한다.

deps에 담겨져 있는 state나 prop의 값이 변경되었을 때, 첫번째 인자의 함수가 실행된다.

다음 예시 코드는 함수 컴포넌트를 사용하는 리액트 코드에서 computed value를 render하는 상황이다. 예시를 통해 useMemo는 어떻게 동작하는지 확인해보자.

import { useMemo, useState } from "react";

export default function App() {
  const [value1, setValue1] = useState(0);
  const [value2, setValue2] = useState(0);

  const handleClick1 = () => {
    setValue1((prev) => prev + 1);
  };

  const handleClick2 = () => {
    setValue2((prev) => prev + 1);
  };

  const computedValue1 = value1 * value1;
  console.log('computedValue', computedValue);

  return (
    <div className="App">
      <p>{computedValue1}</p>
      <p>{value2}</p>
			<button type="button" onClick={handleClick1}>
        value1 +1
      </button>
      <button type="button" onClick={handleClick2}>
        value2 +1
      </button>
    </div>
  );
}

value1value2라는 두 가지 state가 있고, 각 state의 값을 1씩 증가시키는 버튼이 각각 하나씩 있다. 여기서 value2의 값을 1씩 증가시키는 버튼을 누르면 value2라는 state가 변화되었기 때문에, value1의 값이 바뀌지 않았더라도 리액트가 컴포넌트를 다시 render하기 위해 해당 함수(컴포넌트)를 처음부터 끝까지 다시 실행한다. 이후 return된 JSX element를 다시 render할 것이다.

즉, state2만 바뀌어도 computedValue1은 계속 연산된다.

computedValue1을 다음과 같이 useMemo를 사용한 코드로 변경해보았다.

const computedValue1 = useMemo(() => {
  console.log(value1 * value1);
  return value1 * value1;
}, [value1]);

연산한 값을 반환하고, deps에는 value1만 넣어주었다. 즉 value1이 바뀌었을 때만 함수를 실행한다는 뜻이다. 실행 결과는 다음과 같다.

value2가 아무리 바뀌어도 리액트가 컴포넌트를 다시 렌더하는 과정에서 computedValue1은 다시 연산되지 않고, useMemo가 메모이제이션된 값, 즉 이미 연산된 값을 반환해준다.

useCallback도 이와 비슷한데, useMemo을 반환한다면, useCallback은 함수를 메모이제이션된 콜백 함수, 즉 이미 생성된 함수를 반환해준다. 컴포넌트가 다시 렌더될 때, useCallback의 deps에 있는 state나 prop의 값이 변경되지 않는 한, 해당 함수는 다시 생성되지 않는다.

useMemouseCallback을 사용해야 하는 경우

그렇다고 무조건 useMemouseCallback을 사용한다고 해서 성능이 더 좋아지지는 않는다.

Kent C. Dodds는 이렇게 이야기한다.

Performance optimizations are not free. They ALWAYS come with a cost but do NOT always come with a benefit to offset that cost. Therefore, optimize responsibly.

최적화를 포함한 모든 연산에는 비용이 든다. 그러므로, 책임감 있게 최적화 코드를 작성해야 한다. 즉 useMemouseCallback을 사용하는 것 또한 비용이 발생한다는 것이다.

useMemo 예시 코드는 다음과 같이 분리해서 작성할 수 있다.

const calcValue = () => {
  console.log(value1 * value1);
  return value1 * value1;
}

const computedMemoValue = useMemo(calcValue, [value1]);

컴포넌트가 다시 렌더될 때마다 함수를 생성하는 것은 똑같으며, useMemo라는 함수가 또 한 번 실행되고 있는 구조이다. 즉 useMemo라는 함수가 실행되는 것 또한 비용이다.

useMemo가 내부적으로 동작하는 코드를 찾아보았고, 아래는 그 코드이다.

// https://github.com/facebook/react/blob/1a106bdc2abc7af190b791d13b2ead0c2c556f7a/packages/react-server/src/ReactFizzHooks.js#L342-L369

function useMemo<T>(nextCreate: () => T, deps: Array<mixed> | void | null): T {
  currentlyRenderingComponent = resolveCurrentlyRenderingComponent();
  workInProgressHook = createWorkInProgressHook();

  const nextDeps = deps === undefined ? null : deps;

  if (workInProgressHook !== null) {
    const prevState = workInProgressHook.memoizedState;
    if (prevState !== null) {
      if (nextDeps !== null) {
        const prevDeps = prevState[1];
        if (areHookInputsEqual(nextDeps, prevDeps)) {
          return prevState[0];
        }
      }
    }
  }

  if (__DEV__) {
    isInHookUserCodeInDev = true;
  }
  const nextValue = nextCreate();
  if (__DEV__) {
    isInHookUserCodeInDev = false;
  }
  workInProgressHook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

memoizedState가 존재하면서 deps에 있는 값에 변화가 없을 경우, 기존 값을 그대로 반환한다. 아니라면 useMemo에 들어온 함수를 실행하여 값을 연산하고 해당 값을 memoizedState에 저장한다.

덤으로 함수를 반환하는 useCallback의 코드는 다음과 같다. useMemo를 그대로 이용한다.

// https://github.com/facebook/react/blob/1a106bdc2abc7af190b791d13b2ead0c2c556f7a/packages/react-server/src/ReactFizzHooks.js#L445-L450

export function useCallback<T>(
  callback: T,
  deps: Array<mixed> | void | null,
): T {
  return useMemo(() => callback, deps);
}

DP를 통해 피보나치 수열을 구하는 예시 코드에도 dp 배열에 값을 저장하는 비용, 그리고 dp 배열이 메모리에 차지하고 있는 비용이 추가적으로 발생한다. 하지만 DP를 적용함으로써 이러한 비용을 상쇄하고, 더 좋은 성능을 이끌어낼 수 있었다.

따라서 최적화를 위해서 useMemouseCallback을 사용할 때는 이들을 사용함으로써 비용을 줄일 수 있는 상황이어야 할 것이다.

useMemo의 경우는 컴포넌트를 렌더할 때마다 값을 연산하기에는 큰 비용을 필요로 할 때, useCallback의 경우는 큰 비용을 필요로 하는 함수를 생성할 때 필요한 값의 변경에 의해서만 실행될 수 있도록 사용해보면 좋을 것이다.

극단적인 예시로 눈에 보일 정도로 연산에 비용이 많이 드는 코드를 작성해서 다음과 같이 적용해보았다.

const calcValue = () => {
  let value = value1;
  for (let i = 0; i < 10000; i++) {
    for (let j = 0; j < 10000; j++) {
      value = value + i + j;
    }
  }
  return value;
};

const computedMemoValue = useMemo(calcValue, [value1]);
`useMemo` 를 적용하지 않았을 때`useMemo` 를 적용했을 때
useMemo 를 적용하지 않았을 때useMemo 를 적용했을 때

연산에 필요없는 value2의 값만 변경되었을 때, 굳이 연산할 필요가 없으니 이 때 useMemo를 사용하면 성능 최적화를 이룰 수 있을 것이다.

그런데 이렇게까지 복잡한 연산을 하게 될 일이 있을까 싶다. 그래서 useMemo를 쓸 필요를 느끼지 못했다고 말하는 사람들이 많았나보다.

부록: useEffect에서 함수를 실행할 때, useCallback으로 감싸라는 경고가 뜨는 경우

가끔씩 useEffect에서 함수를 실행할 때, useCallback을 사용하라는 ESLint 경고를 종종 확인할 수 있다.

exhaustive-deps warning

React에서는 Hook을 사용할 때, 자칫 잘못 사용될 경우를 대비하여 ESLint rules를 만들어두었는데, 이는 그 중 하나이다. 즉, 해당 함수는 매번 렌더될 때마다 생성되고 실행될 것이라고 알려준다. 이를 방지하기 위해서는 useCallback으로 감싸서 해당 함수의 deps를 지정해주어야 매번 실행되는 것을 방지할 수 있을 것이다. 꼭 성능 최적화를 위해서만 useCallback을 사용하는 것이 아니라, deps를 지정해주어야 하는 상황에서 useCallback을 사용할 수도 있을 것이다.


심문성

심문성

커피를 유별나게 좋아하는 프론트엔드 개발자입니다.