useMemo

Chaerin Kim·2023년 11월 27일

리렌더링 사이에 계산 결과를 캐시할 수 있는 React Hook

const cachedValue = useMemo(calculateValue, dependencies)

Reference

useMemo(calculateValue, dependencies)

컴포넌트의 최상위 수준에서 useMemo를 호출하여 렌더링 사이에 계산을 캐시함:

import { useMemo } from 'react';

function TodoList({ todos, tab }) {
  const visibleTodos = useMemo(
    () => filterTodos(todos, tab),
    [todos, tab]
  );
  // ...
}

Parameters

  • calculateValue: 캐시하려는 값을 계산하는 함수. 순수해야 하며 인수를 받지 않고 모든 타입의 값을 반환할 수 있음. React는 초기 렌더링 중에 이 함수를 호출함. React는 다음 렌더링에서 마지막 렌더링 이후 dependencies가 변경되지 않은 경우 동일한 값을 다시 반환함. 그렇지 않으면 calculateValue을 호출하고 결과를 반환한 후 나중에 재사용할 수 있도록 저장함.

  • dependencies: calculateValue 코드 내에서 참조된 모든 반응형 값의 목록. 반응형 값에는 props, state, 컴포넌트 본문에 직접 선언된 모든 변수와 함수가 포함됨. Linter가 React용으로 구성된 경우, 모든 반응형 값이 dependency로 올바르게 지정되었는지 확인함. Dependencies 목록에는 일정한 수의 항목이 있어야 하며 [dep1, dep2, dep3]과 같이 인라인으로 작성해야 함. React는 Object.is 비교를 사용하여 각 dependency를 이전 값과 비교함.

Returns

초기 렌더링에서 useMemo는 인자 없이 calculateValue를 호출한 결과를 반환함.

다음 렌더링 중에는 마지막 렌더링에서 이미 저장된 값을 반환하거나(dependencies가 변경되지 않은 경우) calculateValue을 다시 호출하여 calculateValue이 반환한 결과를 반환함.

Ceveats

  • useMemo는 Hook이므로 컴포넌트의 최상위 레벨이나 커스텀 Hook에서만 호출할 수 있음. 루프나 조건 내부에서는 호출할 수 없음. 필요하다면 새 컴포넌트를 추출하고 state를 그 안으로 옮겨야함.

  • Strict Mode에서 React는 실수로 발생한 불순물을 찾기 위해 calculation 함수를 두 번 호출함. 이는 개발 환경 전용 동작이며 프로덕션에는 영향을 미치지 않음. calculation 함수가 순수하다면 로직에 영향을 미치지 않고 호출 중 하나의 결과는 무시됨.

  • React는 특별한 이유가 없는 한 캐시된 값을 버리지 않음. 예를 들어, 개발 환경에서 컴포넌트의 파일을 편집할 때 React는 캐시를 버림. 개발과 프로덕션 환경 모두에서 컴포넌트가 initial mount 중에 일시 중단되면 React는 캐시를 버림. 향후 React는 캐시 버리기를 활용하는 더 많은 기능을 추가할 수 있음. ?예를 들어, 향후 React에 virtualized list에 대한 기본 지원이 추가되면 virtualized table 뷰포트에서 스크롤되어 넘어가는 항목에 대한 캐시를 버리는 것이 합리적일 것. 성능 최적화를 위해 useMemo에만 의존한다면 괜찮을 것. 또는 state 변수ref가 더 적합할 수 있음.?

Note

이와 같이 반환값을 캐싱하는 것을 memoization 이라고도 함. 이 Hook을 useMemo라고 부르는 이유!


Usage

Skipping expensive recalculations

리렌더링 사이에 계산을 캐시하려면 컴포넌트의 최상위 수준에서 useMemo 호출로 계산을 감싸면 됨:

import { useMemo } from 'react';

function TodoList({ todos, tab, theme }) {
  const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);
  // ...
}

useMemo에는 두 가지를 전달해야함:

  1. () =>와 같이 인수를 받지 않고 계산하고자 하는 값을 반환하는 calculation 함수.
  2. 컴포넌트 내에서 계산에 사용되는 모든 값을 포함한 dependencies 목록.

초기 렌더링에서 useMemo로부터 얻을 수 있는 값은 calculation 함수를 호출한 결과.

이후의 모든 렌더링에서 React는 dependencies를 마지막 렌더링에서 전달한 dependencies와 비교함. Dependencies 중 어떤 것도 변경되지 않았다면(Object.is로 비교했을 때), useMemo는 이전에 이미 계산한 값을 반환함. 그렇지 않으면 React는 계산을 다시 실행하고 새 값을 반환함.

다시 말해, useMemo는 dependencies가 변경될 때까지 재렌더링 사이에 계산 결과를 캐시함.

이 기능이 유용한 예제:

기본적으로 React는 컴포넌트를 다시 렌더링할 때마다 컴포넌트의 전체 바디를 다시 실행함. 예를 들어, 이 TodoList가 state를 업데이트하거나 부모로부터 새로운 props를 받으면 filterTodos 함수가 다시 실행됨.

function TodoList({ todos, tab, theme }) {
  const visibleTodos = filterTodos(todos, tab);
  // ...
}

대부분의 계산은 매우 빠르기 때문에 일반적으로는 문제가 되지 않음. 그러나 큰 배열을 필터링하거나 변환하거나 비용이 많이 드는 계산을 수행하는 경우, 데이터가 변경되지 않았다면 다시 계산하는 것을 건너뛰고 싶을 수 있음. todostab이 모두 마지막 렌더링 때와 동일한 경우, 계산을 useMemo로 감싸면 이전에 이미 계산한 visibleTodos를 재사용할 수 있음.

이러한 유형의 캐싱을 memoization이라고 함.

Note

useMemo는 성능 최적화를 위한 용도로만 사용해야함. useMemo 없이 코드가 작동하지 않는다면 근본적인 문제를 찾아서 먼저 해결할 것. 그런 다음 useMemo를 추가하여 성능을 개선할 수 있음.

Skipping re-rendering of components

경우에 따라 useMemo는 하위 컴포넌트를 다시 렌더링할 때 성능을 최적화하는 데 도움이 될 수 있음. 이를 설명하기 위해 아래 TodoList 컴포넌트가 하위 List 컴포넌트에 prop으로 visibleTodos를 전달한다고 가정:

export default function TodoList({ todos, tab, theme }) {
  // ...
  
  return (
    <div className={theme}>
      <List items={visibleTodos} />
    </div>
  );
}

theme prop을 토글하면 앱이 잠시 멈추는 것을 확인할 수 있지만, JSX에서 <List />를 제거하면 빠르게 느껴짐. 이는 List 컴포넌트를 최적화할 가치가 있다는 것을 알려줌.

기본적으로 컴포넌트가 다시 렌더링될 때 React는 모든 자식을 재귀적으로 다시 렌더링함. 그렇기 때문에 TodoList가 다른 테마로 다시 렌더링되면 List 컴포넌트도 다시 렌더링됨. 다시 렌더링하는 데 많은 계산이 필요하지 않은 컴포넌트는 괜찮지만, 재렌더링이 느리다는 것을 확인했다면 Listmemo로 감싸서 props가 마지막 렌더링과 동일할 때 재렌더링을 건너뛰도록 할 수 있음:

import { memo } from 'react';

const List = memo(function List({ items }) {
  
  // ...
});

이 변경으로 모든 prop이 마지막 렌더링과 동일한 경우 List는 재렌더링을 건너뜀. 여기서 계산을 캐싱하는 것이 중요해짐! useMemo 없이 visibleTodos을 계산했다면?:

export default function TodoList({ todos, tab, theme }) {
  // Every time the theme changes, this will be a different array...
  const visibleTodos = filterTodos(todos, tab);
  return (
    <div className={theme}>
      {/* ... so List's props will never be the same, and it will re-render every time */}
      <List items={visibleTodos} />
    </div>
  );
}

위의 예에서 filterTodos 함수는 {} 객체 리터럴이 항상 새 객체를 생성하는 것과 유사하게 항상 다른 배열을 생성함. 일반적으로는 문제가 되지 않지만, List props은 결코 동일하지 않으며 이는 memo가 작동하지 않는다는 뜻. 이 때 useMemo가 유용함:

export default function TodoList({ todos, tab, theme }) {
  // Tell React to cache your calculation between re-renders...
  const visibleTodos = useMemo(
    () => filterTodos(todos, tab),
    [todos, tab] // ...so as long as these dependencies don't change...
  );
  return (
    <div className={theme}>
      {/* ...List will receive the same props and can skip re-rendering */}
      <List items={visibleTodos} />
    </div>
  );
}

visibleTodos 계산을 useMemo로 래핑하면 dependencies가 변경될 때까지 렌더링 간에 동일한 값을 유지할 수 있음. 특별한 이유가 없는 한 계산을 useMemo로 래핑할 필요는 없음. 이 예제에서는 memo로 감싸진 컴포넌트에 전달하면 다시 렌더링을 건너뛸 수 있기 때문에 useMemo를 사용함.

Memoizing a dependency of another Hook

컴포넌트 본문에서 직접 생성된 객체에 의존하는 계산이 있다고 가정:

function Dropdown({ allItems, text }) {
  const searchOptions = { matchMode: 'whole-word', text };

  const visibleItems = useMemo(() => {
    return searchItems(allItems, searchOptions);
  }, [allItems, searchOptions]); // 🚩 Caution: Dependency on an object created in the component body
  
  // ...

위와 같은 객체에 의존하는 것은 memoization의 취지를 무색하게함. 컴포넌트가 다시 렌더링되면 컴포넌트 본문 내부의 모든 코드가 다시 실행됨. searchOptions 객체를 생성하는 코드도 다시 렌더링할 때마다 실행됨. searchOptionsuseMemo 호출의 dependency이고, 매번 다르므로, React는 dependencies가 다르다는 것을 알고 매번 searchItems를 다시 계산함.

이 문제를 해결하려면 searchOptions 객체를 dependency로 전달하기 전에 searchOptions 객체 자체를 memoize 할 수 있음:

function Dropdown({ allItems, text }) {
  const searchOptions = useMemo(() => {
    return { matchMode: 'whole-word', text };
  }, [text]); // ✅ Only changes when text changes

  const visibleItems = useMemo(() => {
    <return searchItems(allItems, searchOptions);
  }, [allItems, searchOptions]); // ✅ Only changes when allItems or searchOptions changes
                                       
  // ...

위의 예에서 text가 변경되지 않았다면 searchOptions 객체도 변경되지 않음. 그러나 이보다 더 나은 수정 방법은 searchOptions 객체 선언을 useMemo 계산 함수 내부로 이동하는 것:

function Dropdown({ allItems, text }) {
  const visibleItems = useMemo(() => {
    const searchOptions = { matchMode: 'whole-word', text };
    return searchItems(allItems, searchOptions);
  }, [allItems, text]); // ✅ Only changes when allItems or text changes
  
  // ...

이제 계산은 text에 직접적으로 의존함(text는 문자열이므로 '실수로' 달라질 수 없음).

Memoizing a function

Form 컴포넌트가 memo로 감싸져 있고, 여기에 함수를 prop으로 전달하고 싶다고 가정:

export default function ProductPage({ productId, referrer }) {
  function handleSubmit(orderDetails) {
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails
    });
  }

  return <Form onSubmit={handleSubmit} />;
}

{}가 다른 객체를 생성하는 것처럼, function() {}와 같은 함수 선언과 () => {}와 같은 표현식은 렌더링할 때마다 다른 함수를 생성함. 새 함수를 만드는 것 자체는 문제가 되지 않고, 피해야 할 일이 아님! 하지만 Form 컴포넌트가 memoize 되어 있다면 props가 변경되지 않았을 때 다시 렌더링하는 것을 건너뛰고 싶을 것. Props가 항상 달라지면 memoization의 취지가 무색해짐.

useMemo로 함수를 memoize 하려면 calculate 함수가 다른 함수를 반환해야함:

export default function Page({ productId, referrer }) {
  const handleSubmit = useMemo(() => {
    return (orderDetails) => {
      post('/product/' + productId + '/buy', {
        referrer,
        orderDetails
      });
    };
  }, [productId, referrer]);

  return <Form onSubmit={handleSubmit} />;
}

위 코드는 번거로워보임! 함수를 memoize 하는 것은 충분히 흔한 일이며, React에는 이를 위해 특별히 내장된 Hook이 있임. 중첩된 함수를 추가로 작성할 필요가 없도록 함수를 useMemo 대신 useCallback으로 감싸면 됨:

export default function Page({ productId, referrer }) {
  const handleSubmit = useCallback((orderDetails) => {
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails
    });
  }, [productId, referrer]);

  return <Form onSubmit={handleSubmit} />;
}

위의 두 예제는 완전히 동일함. useCallback의 유일한 장점은 내부에 중첩된 함수를 추가로 작성하지 않아도 된다는 것뿐, 그 외에는 아무 것도 하지 않음.

참고: useCallback

0개의 댓글