본문 바로가기
React-study/dil

[DIL] useMemo

by 어느새벽 2024. 6. 17.

useMemo(calculateValue, dependencies) 

useMemo는 리렌더링 사이의 계산 결과를 캐시할 수 있는 리액트 훅이다.

컴포넌트 최상단에서 useMemo를 호출하여 리렌더링 사이의 계산 결과를 캐시한다.

*캐시 : 자주 사용하는 데이터를 임시로 저장해 두었다가, 필요할 때 바로 꺼내 쓰는 저장소.

import { useMemo } from 'react';

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

 

매개변수

  • calculateValue : 캐시하려는 값을 계산하는 함수이다. 이 함수는 순수함수여야 하며, 인자를 받지 않고, 반드시 어떤 타입이든 값을 반환해야 한다. React는 초기 렌더링 중에 함수를 호출한다. 이후의 렌더링에서는 의존성이 이전 렌더링 이후 변경되지 않았다면 동일한 값을 반환한다. 그렇지 않으면 calculateValue 를 호출하고 그 결과를 반환하며, 나중에 재사용할 수 있도록 저장한다.
  • dependencies : calculateValue코드 내에서 참조되는 모든 반응형 값들의 목록이다. 반응형 값에는 props, state 및 컴포넌트 본문 내에서 직접 선언된 모든 변수와 함수가 포함된다. 리액트용으로 구성된 린터를 사용하면, 모든 반응형 값이 의존성으로 올바르게 지정되어 있는지 확인한다. 의존성 목록에는 항목 수가 일정하고 [dep1, dep2, dep3]와 같이 인라인으로 작성되어야 한다. 리액트는 Object.is 비교 알고리즘을 사용하여 각 의존성을 이전 값과 비교한다.

 

반환값 

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

이후 렌더링에서는, 의존성이 변경되지 않은 경우에는 마지막 렌더링에서 저장된 값을 반환하고, 변경된 경우에는 calculateValue를 다시 호출하여 그 결과를 반환한다.

 

주의사항

  • useMemo는 훅이므로 컴포넌트의 최상위 레벨 또는 커스텀 훅에서만 호출할 수 있다. 반복문이나 조건문 안에서 호출할 수 없다. 만약 필요하다면, 새로운 컴포넌트를 생성하고 해당 컴포넌트로 state를 이동시켜야 한다.
  • Strict모드에서는 리액트가 의도치 않은 불순물을 찾기 위해 계산 함수를 두번 호출한다. 이는 개발 전용 동작이며 상용 환경에서는 영향을 미치지 않는다. 계산 함수가 순수하다면(그래야 한다) 이것은 컴포넌트의 로직에 영향을 미치지 않을 것이다. 두번의 호출 중 하나의 결과는 무시된다.
  • 리액트는 특별한 이유가 있지 않는 한 캐시된 값을 유지하려고 한다. 예를 들어, 리액트는 개발 중에 컴포넌트 파일을 수정하면 캐시된 값을 폐기한다. 미래에 리액트는 캐시를 폐기하는 것을 활용하는 더 많은 기능을 추가할 수 있다. 예를 들어 미래에 리액트가 가상화된 목록에 대한 기본 지원을 추가한다면, 가상화된 테이블 뷰포트에서 벗어난 항목에 대한 캐시를 폐기하는 것이 타당할 것이다. useMemo를 성능 최적화를 위해서만 사용하는 경우에는 괜찮을 것이다. 그렇지 않다면 state variable 또는 ref가 더 적절할 수 있다.

반환 값을 캐싱하는 것을 메모화라 하며, 이것이 이 훅을 useMemo라고 부르는 이유이다.

 

사용법

비용이 많이 드는 재계산 생략하기 

리렌더링간의 계산값을 캐시하려면 컴포넌트의 최상단에서 useMemo 호출로 해당 값을 감싸야 한다.

import { useMemo } from 'react';

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

 

useMemo에는 두 가지를 전달해야 한다.

  • 인자( argument )를 받지 않고 (() =>), 원하는 값을 계산하여 반환하는 계산 함수.
  • 컴포넌트 내에서 계산에 사용되는 모든 값을 포함하는 의존성 목록.

초기 렌더링 시에는, useMemo를 통해 얻는 값은 계산 함수를 호출한 결과값이다.

그 이후 모든 렌더링에서, 리액트는 이전 렌더링에서 전달한 의존성과 현재의 의존성을 비교한다. 의존성이 변경되지 않았다면(Object.is), useMemo는 이전에 계산했던 값을 반환한다. 그렇지 않다면, 리액트는 계산을 다시 실행하고 새로운 값을 반환한다.

 

간단히 말해, useMemo는 의존성이 변경되기 전까지 계산 결과를 캐시한다.

기본적으로 리액트는 컴포넌트가 다시 렌더링될 때마다 컴포넌트 전체 본문을 다시 실행한다. 예를 들어, 이 TodoList가 state를 업데이트하거나 부모로부터 새로운 props를 받는 경우, filterTodos 함수가 다시 실행된다.

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

 

대부분의 계산은 매우 빠르기 때문에 이것은 문제가 되지 않는다. 그러나 큰 배열을 필터링하거나, 변환하거나, 고비용의 계산을 수행할 때, 데이터가 변경되지 않았다면 다시 계산하는 것을 건너뛰고 싶을 수 있다. todos와 tab이 이전 렌더링 때와 동일하다면, 이전처럼 계산을 useMemo로 감싸서 이전에 이미 계산해놓은 visibleTodos를 재사용할 수 있다.

이러한 종류읭 캐싱을 메모화라고 한다.

 

useMemo는 성능 최적화 목적으로 사용해야 한다. 이것 없이 코드가 작동하지 않는다면 먼저 근본적인 문제를 찾아 해결해야 한다. 이후에 다시 useMemo를 추가하여 성능을 개선 할 수 있다.

 

컴포넌트의 리렌더링 건너뛰기

어떤 경우에는 useMemo를 사용하여 자식 컴포넌트의 리렌더링 성능을 최적화할 수도 있다. 이를 설명하기 위해, TodoList 컴포넌트가 visibleTodos를 자식 List 컴포넌트에 prop으로 전달한다고 가정해보면

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

 theme prop을 전환하면 앱이 잠시 동안 멈추지만, JSX에서 <List />를 제거하면 빠르게 동작하는 것을 확인했다.  List 컴포넌트의 최적화를 시도해볼 가치가 있다.

 

기본적으로 컴포넌트가 리렌더링되면 리액트는 모든 자식 컴포넌트를 재귀적으로 리렌더링한다. 이 때문에 다른 theme로 TodoList가 리렌더링되면 List 컴포넌트도 리렌더링된다. 이는 리렌더링에 많은 계산이 필요하지 않은 컴포넌트의 경우에는 괜찮다. 그러나 리렌더링이 느리다는 것을 확인했다면, 이전 렌더링과 동일한 prop이 있는 경우 List가 리렌더링을 건너뛰도록 memo로 감싸줄 수 있다.

 

import { memo } from 'react';

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

 

이 변경으로 인해 List는 모든 prop이 이전 렌더링과 같은 경우에는 리렌더링을 건너뛸 것이다. 이는 캐싱 계산이 중요해지는 부분이다. useMemo를 사용하지 않고 visibleTodos를 계산했다고 상상해 보면

export default function TodoList({ todos, tab, theme }) {
  // Every time the theme changes, this will be a different array...
  // theme가 변경될 때마다 매번 다른 배열이 됩니다...
  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의 prop은 절대로 같을 수 없으므로, 매번 리렌더링할 것입니다 */}
      <List items={visibleTodos} />
    </div>
  );
}

 

위 예제에서는 filterTodos 함수가 항상 다른 배열을 생성한다. 이는 {} 객체 리터럴이 항상 새로운 객체를 생성하는 것과 비슷하다. 이는 일반적으로는 문제가 되지 않지만 List의 prop은 결코 값을 가질 수 없고 따라서 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는 같은 props를 전달받게 되어 리렌더링을 건너뛸 수 있게 됩니다 */}
      <List items={visibleTodos} />
    </div>
  );
}

 

visibleTodos계산을 useMemo로 감싸면, 리렌더링 사이에 동일한 값이 보장된다(의존성이 변경될 때까지). 특별한 이유가 없다면 계산을 useMemo를 추가해야 하는 몇 가지 다른 이유에 대해서는 페이지 하단에서 소개하겠다.

다른 훅의 의존성 메모화 

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

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
                                 // 🚩 주의: 컴포넌트 내부에서 생성한 객체 의존성
  // ...

 

이렇게 객체에 의존하는 것은 메모화의 취지를 무색하게 한다. 컴포넌트가 다시 렌더링되면 컴포넌트 본문 내부의 모든 코드가 다시 실행된다. searchOptions 객체를 생성하는 코드 라인도 다시 렌더링할 때마다 실행된다. searchOption 객체를 생성하는 코드 라인도 다시 렌더링할 때마다 실행된다. searchOptions는 useMemo 호출의 의존성이고 매번 다르기 때문에, 리액트는 의존성이 지난번과 다르다는 것을 알고, 매번 searchItems를 다시 계산한다.

이 문제를 해결하려면 searchOptions 객체를 의존성으로 전달하기 전에 객체 자체를 메모화할 수 있다.

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

  const visibleItems = useMemo(() => {
    return searchItems(allItems, searchOptions);
  }, [allItems, searchOptions]); // ✅ Only changes when allItems or searchOptions changes
                                 // ✅ allItems 또는 searchOptions 변경시에만 변경됨
  // ...

 

위의 예에서 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
                        // ✅ allItems 또는 text 변경시에만 변경됨
  // ...

이제 계산은 (객체처럼 "실수로" 다른 값이 될 수 없는 문자열) text에 직접 의존한다.

 

함수 메모화 

컴포넌트가 memo로 감싸져 있다고 가정해보자. 여기에 함수를 prop으로 전달하려고 한다.

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

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

{}가 다른 객체를 생성하는 것과 같이, function() {}함수 선언 및 ()=>{} 표현식 등은 모두 리렌더링할 때마다 다른 함수를 생성한다. 새 함수를 만드는 것 자체는 문제가 되지 않는다. 피해야 할 일이 아니다. 하지만 Form 컴포넌트가 메모화되어 있다면 props가 변경되지 않았을 때 리렌더링하는 것을 건너뛰고 싶을 것이다. prop이 항상 달라지면 메모화의 취지가 무색해진다.

useMemo로 함수를 메모화하려면 계산 함수가 다른 함수를 반환해야 한다.

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

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

 

이는 투박해 보인다! 함수를 메모화하는 것은 충분히 흔한 일이므로 리액트는 이를 위한 특별한 훅을 제공한다. 중첩함수를 추가로 작성할 필요가 없도록 함수를 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을 사용하면 내부에 중첩된 함수를 추가로 작성하지 않아도 된다는 장점이 있다. 그 외에는 다른 기능을 수행하지 않는다. 

 

'React-study > dil' 카테고리의 다른 글

[DIL] useTransition  (0) 2024.06.24
[DIL] useCallback  (0) 2024.06.18
[DIL] useLayoutEffect  (0) 2024.06.11
[DIL] useEffect  (0) 2024.06.04
[DIL] useRef  (0) 2024.05.30