post-thumbnail

[React] React 메모이제이션 알고쓰자!

React
CS
321
3

들어가며

일전 프로젝트를 개발하며 React에서 제공하는 메모이제이션 때문에 성능이 안좋아진 경험이 있습니다. 프로젝트를 개발한지 시간이 조금 지났지만 이번 기회에 리액트에서 제공하는 메모이제이션 관련 기능을 다시 한번 정리하고자 합니다.
메모이제이션이란?
메모이제이션(memoization)은 이전에 계산한 값을 저장함으로써 동일한 계산을 반복하지 않도록 하는 기술입니다. 메모이제이션을 사용하면 이전에 계산한 값을 재사용하여 성능을 향상시킬 수 있습니다.
1const arr = [0, 1];
2
3function fibonacci(n) {
4  if (arr[n] !== undefined) {
5    return arr[n];
6  }
7
8  arr[n] = fibonacci(n - 1) + fibonacci(n - 2);
9
10  return arr[n];
11}
12
13// fibonacci 함수를 호출하면 이전 계산 값을 저장하여 재사용
14console.log(fibonacci(10)); // 55

React의 최적화

React는 프론트엔드 개발자들이 가장 많이 사용하는 라이브러리 중 하나입니다. 그만큼 강력한 기능을 제공하고 있으며 컴포넌트 기반 아키텍처를 사용하여 재사용성과 유지보수성을 높여줍니다.
이런 React의 장점을 살리기 위해 React는 여러가지 최적화 기능을 제공하고 있습니다. 대표적으로 React.memo, useMemo, useCallback이 있습니다.

React.memo

React는 먼저 컴포넌트를 랜더링 한 뒤, 이전 랜더링 결과와 비교하여 DOM 업데이트를 결정합니다. 보통에 경우 이전 결과와 비교하는 과정은 빠르지만, 컴포넌트의 렌더링이 빈번하게 발생하는 경우 성능 문제가 발생할 수 있습니다.
이를 해결하기 위해 React.memo를 사용할 수 있습니다.
React.memo는 함수 컴포넌트의 렌더링 성능을 최적화하는 데 사용됩니다. 컴포넌트가 React.memo로 래핑되면 React는 컴포넌트를 메모이징합니다. 그리고 다음 헨더링이 일어날 때 props가 변경되지 않았다면 이전 렌더링 결과를 재사용합니다.
1export const Test = ({ value }) => {
2  return <div>{value}</div>;
3};
4
5export const MemoTest = React.memo(Test);
6
7
8// 첫 렌더시 MemoTest 함수를 호출
9<MemoTest value={1} />
10
11// 리렌더링시 props가 같기 때문에 이전 렌더링 결과를 재사용
12<MemoTest value={1} />

useMemo

useMemo는 계산 비용이 많이 드는 함수의 반환값을 메모이징하는 데 사용됩니다. useMemo 훅은 렌더링 중에 특정 값이 변경되었을 때만 함수를 실행하고 그렇지 않으면 이전에 계산한 값을 재사용합니다. 내부 코드를 살펴보며 어떻게 동작하는지 알아보겠습니다.
1// https://github.com/facebook/react/blob/1a106bdc2abc7af190b791d13b2ead0c2c556f7a/packages/react-server/src/ReactFizzHooks.js#L342-L369
2function useMemo<T>(nextCreate: () => T, deps: Array<mixed> | void | null): T {
3  currentlyRenderingComponent = resolveCurrentlyRenderingComponent();
4  workInProgressHook = createWorkInProgressHook();
5
6  const nextDeps = deps === undefined ? null : deps;
7
8  if (workInProgressHook !== null) {
9    const prevState = workInProgressHook.memoizedState;
10    if (prevState !== null) {
11      if (nextDeps !== null) {
12        const prevDeps = prevState[1];
13
14        // 해당 부분에서 의존성 배열이 변경되었는지 확인
15        // 동일하다면 이전 값을 반환
16        if (areHookInputsEqual(nextDeps, prevDeps)) {
17          return prevState[0];
18        }
19      }
20    }
21  }
22
23  if (__DEV__) {
24    isInHookUserCodeInDev = true;
25  }
26  const nextValue = nextCreate();
27  if (__DEV__) {
28    isInHookUserCodeInDev = false;
29  }
30
31  // 변경되었다면 새로운 값을 저장
32  workInProgressHook.memoizedState = [nextValue, nextDeps];
33  return nextValue;
34}
첫 번째 인자로는 계산 함수를, 두 번째 인자로는 의존성 배열을 전달합니다. 이전 의존성 배열과 현재 의존성 배열을 비교하여 변경되었는지 확인하고 변경되었다면 새로운 값을, 그렇지 않다면 이전 값을 반환합니다.

useCallback

useCallback은 메모이징된 콜백 함수를 반환하는 데 사용됩니다. useCallback 훅은 렌더링 중에 특정 값이 변경되었을 때만 함수를 생성하고 그렇지 않으면 이전에 생성한 함수를 재사용합니다.
1// https://github.com/facebook/react/blob/1a106bdc2abc7af190b791d13b2ead0c2c556f7a/packages/react-server/src/ReactFizzHooks.js#L445-L450
2export function useCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
3  return useMemo(() => callback, deps);
4}
useCallback은 내부적으로 useMemo를 사용하여 구현되어 있습니다. useMemo와 마찬가지로 의존성 배열을 전달하여 의존성 배열이 변경되었을 때만 함수를 생성하고 그렇지 않으면 이전에 생성한 함수를 재사용합니다.

언제 사용해야 할까?

그렇다면 React.memouseMemo, useCallback을 항상 사용해야 할까요? 아래 예제를 보겠습니다.
1function CandyDispenser() {
2  const initialCandies = ["snickers", "skittles", "twix", "milky way"];
3  const [candies, setCandies] = React.useState(initialCandies);
4  const dispense = (candy) => {
5    setCandies((allCandies) => allCandies.filter((c) => c !== candy));
6  };
7  return (
8    <div>
9      <h1>Candy Dispenser</h1>
10      <div>
11        <div>Available Candy</div>
12        {candies.length === 0 ? (
13          <button onClick={() => setCandies(initialCandies)}>refill</button>
14        ) : (
15          <ul>
16            {candies.map((candy) => (
17              <li key={candy}>
18                <button onClick={() => dispense(candy)}>grab</button> {candy}
19              </li>
20            ))}
21          </ul>
22        )}
23      </div>
24    </div>
25  );
26}
해당 예제에서 dispense 함수를 useCallback으로 감싸면 성능이 나아질까요? 답은 아닙니다. 메모이제이션을 생각할 땐 주의해야할 점은 실행되는 코드는 한 줄마다 비용이 든다는 것입니다.
dispense 함수는 내부에서 state나 props등 퍼모먼스에 영향을 미치는 값이 없습니다. 따라서 useCallback의 호출, 의존성 배열을 선언해 주면서 리소스를 낭비하게 되며 함수 자체를 메모이징하기 때문에 추가적인 메모리를 사용하게 됩니다.
또한 dispense 함수를 리턴하는 인라인 함수를 onClick에 전달하고 있기 때문에 useCallback을 감싼 함수는 가비지 컬렉션의 대상이 되지 않지만 인라인 함수는 계속해서 생성되어 useCallback의 성능 이점을 잃게 됩니다. (실제 이전 프로젝트를 진행하며 useCallback등 불필요한 메모이제이션을 제거하여 성능 향상을 얻은 경험이 있습니다.)
그렇다면 initialCandiesuseMemo로 감싸는 것은 어떨까요? initialCandies는 컴포넌트가 렌더링 될 때마다 변경되지 않는 값입니다. useMemo로 감싸는 것은 성능에 도움이 될 수 있습니다.
하지만 initialCandies는 상수이기 때문에 useMemo로 감싸는 것은 불필요합니다. 오히려 useMemo 사용으로 인한 추가적인 메모리 사용이 비용적으로 더 비싸게 나올 수 있습니다.
1const initialCandies = ['snickers', 'skittles', 'twix', 'milky way']
2function CandyDispenser() {
3  const [candies, setCandies] = React.useState(initialCandies)
4  // ...
다음과 같이 변하지 않는 값은 상수로 선언하여 사용하는 것이 더 효율적입니다.
React에서 메모이제이션을 사용해서 최적화를 고려할 때를 정리해보자면 다음과 같습니다.
  • 연산 비용이 많이 드는 함수의 반환값을 메모이징할 때
  • 자식 컴포넌트에서 useEffect가 빈번히 트리거 되어 무한 루프가 발생할 위험이 있을 때
  • 자식 컴포넌트에 props로 함수를 전달할 때 불필요한 렌더링을 방지할 때
  • 부모 컴포넌트가 렌더링 될 때마다 변경되지 않는 값이 있을 때
  • 사용자의 입력 값이 렌더링에 따라 변하지 않고 동일한 참조를 유지할 가능성이 있을 때 (map, filter 등)
  • ref 함수를 부수 작용과 함께 전달하거나 ref로 Wrapper 함수를 만들 때 (ref 함수가 변경될 때 마다 과거 값을 null로 호출하고 새로운 함수를 호출, ref 함수의 이벤트 리스너가 변경되는 등의 불필요한 작업이 일어날 수 있다)

마치며

앞서 설명한 고려사항을 통해 React에서 제공하는 메모이제이션 기능을 사용할 때 어떤 상황에서 사용해야 하는지 알아보았습니다. 하지만 최적화를 진행하기전 꼭 알아야하는 점은 최적화에도 비용이 든다는 것 입니다. 데이터 저장에 대한 비용, 비교 연산에 대한 비용 등 추가적인 비용이 발생할 수 있습니다. 연산 결과 비용 > 최적화 비용 이라면 최적화를 진행하는 것이 오히려 독이 될 수 있습니다.
React 공식문서 또한 메모이제이션을 사용할 때 퍼포먼스 측정을 권장하고 있습니다. 최적화를 진행할 때는 항상 성능 측정을 통해 최적화가 실제로 성능 향상을 가져오는지 확인하는 것이 중요합니다.
기술을 사용하는 것에 초점을 두는 것이 아닌 기술을 사용하는 이유와 목적에 대해 고민하고 사용하는 것이 중요하다고 생각합니다.

참고