useMemo 파헤치기

공식문서를 통해 알아보는 useMemo훅
강석우's avatar
Mar 05, 2024
useMemo 파헤치기

useMemo란?

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

useMemo, 언제 사용해야 할까

간략하게 말하자면 필요하지 않은 계산을 다시 하게되는 경우가 있는데 이러한 사례들을 줄여 계산 비용을 줄이고 싶을 때 사용한다.
아래의 리스트에 대한 자세한 설명은 사용법 파트에 나와있다.

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

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

  • 다른 훅의 의존성 메모화

  • 함수 메모화

useMemo의 구성

useMemo는 calculateValue와 dependencies로 나뉘어진다.
calculateValue : 캐시하려는 값을 계산하는 함수.
순수함수여야 하며 반드시 어떤 타입이든 결과를 반환해야 한다. 만약 dependencies가 변경되지 않았다면 동일한 값을 반환한다. 변경되었다면 calculateValue를 호출하고 그 결과를 반환한다.

dependencies : calculateValue 에 참조되는 값들의 목록.
Object.is 비교 알고리즘을 사용하여 각 dependencies를 이전 값과 비교한다.

useMemo(calculateValue, dependencies)

import { useMemo } from 'react';

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

사용시 주의사항

  • useMemo는 훅이기 때문에 컴포넌트의 최상위 레벨 또는 커스텀 훅에서만 호출이 가능.

  • strict모드에서 React가 의도치 않은 불순물을 찾기 위해 계산 함수를 두 번 호출.

    • 순수함수가 들어가야하기 때문에 만약 이 동작으로 인해 다른 결과가 도출된다면 사용자는 useMemo 훅을 잘못 사용하고 있는 것이다.

사용법

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

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

import { useMemo } from 'react';

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

리액트가 컴포넌트를 재렌더링 시키는 상황은 여러가지가 존재하게 되는데 위와 같이 prop이 변경되게 될 경우에도 컴포넌트는 재렌더링이 되게 된다.
컴포넌트가 재렌더링되게 되면 useMemo 훅이 없다는 가정하에 filterTodos 도 다시 돌아가게 되고 만약 돌려야 할 배열의 길이가 수 천이라면 사용자는 전혀 관련 없는 색상을 바꾸기 위해서 수천길이의 배열을 재계산하는 비용을 지불해야하고 그 비용은 몇 ms 가 될지 모른다.
그렇다면 이번에는 위의 코드와 동일하게 useMemo를 사용해보자.
앞서 구성에서 말한 것과 같이 dependencies가 다르지 않다면 해당 순수함수는 돌아가지 않는다. 따라서 theme 가 변경된다고 하여도 todos와 tab이 변경되지 않았기 때문에 불필요한 재계산을 해줄 필요가 없는 것이다.
이런 종류의 캐싱을 메모화 라고 한다.

일반적으로 수천 개의 객체를 만들거나 반복하는 작업이 아니라면 비용이 많이 들지 않을 것이다. 내가 지금 캐싱하려는 계산이 비용이 많이 드는지 측정하는 방법에는 console.time() 이 있다.

console.time('filter array');
const visibleTodos = filterTodos(todos, tab);
console.timeEnd('filter array');

이렇게 작성하면 filter array : 0.15ms 와 같이 출력이 되게 된다.

예시는 공식문서에 잘 나와있다.

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

useMemo를 사용해서 자식 컴포넌트의 리렌더링 성능을 최적화하는 방법도 있다.

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

기본적으로 컴포넌트가 리렌더링되면 React는 모든 자식 컴포넌트를 리렌더링한다.
하지만 리렌더링이 되었을때 자식에 기존과 같은 컴포넌트가 있다면 이 컴포넌트를 memo로 감싸주어 리렌더링을 예방할 수 있다.

import { memo } from 'react';

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

위와 같이 작성하게되면 모든 prop이 이전 렌더링과 같은 경우 리렌더링을 건너뛸 것이다.

export default function TodoList({ todos, tab, theme }) {
  // theme가 변경될 때마다 매번 다른 배열이 됩니다...
  const visibleTodos = filterTodos(todos, tab);
  return (
    <div className={theme}>
      {/* ... List의 prop은 절대로 같을 수 없으므로, 매번 리렌더링할 것입니다 */}
      <List items={visibleTodos} />
    </div>
  );
}

위의 예제는 prop이 매번 달라지는 경우의 예이다.
위의 예제에서 filterTodos는 항상 다른 배열을 생성하기 때문에 (객체 리터럴이 항상 새로운 객체를 생성하는 것과 비슷한 원리) List의 prop은 같은 값을 가질 수 없게 되고 memo 최적화도 작동할 수 없다. 이럴 때 사용되는게 useMemo이다.

const visibleTodos = useMemo(
    () => filterTodos(todos, tab),[todos, tab]
  );

위와 같이 visibleTodos 계산을 useMemo로 감싸게 되면 의존성이 변경될 때 까지 동일한 값이 보장되게 된다.

자세한 예제는 리액트 공식문서에 잘 나와있다.

3. 다른 훅의 의존성 메모화

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

  const visibleItems = useMemo(() => {
    return searchItems(allItems, searchOptions);
  }, [allItems, searchOptions]);

위와 같은 코드가 존재한다고 생각해보자.
이렇게 객체에 의존하는 것은 메모화의 취지가 아예 사라지게 하기 때문에 코드를 변경해줄 필요가 있다.
정답은 객체를 의존성으로 전달하기 전에 객체 자체를 메모화하는 것이다.

const searchOptions = useMemo(() => {
    return { matchMode: 'whole-word', text };
  }, [text]);

또는 visibleItems 안으로 searchOptions를 보내는 방법도 있다. 이렇게되면 계산은 text에 직접 의존하게된다.

4. 함수 메모화

{} 가 다른 객체를 생성하는 것과 같이 함수 선언, 함수 표현식 은 모두 리렌더링할 때마다 다른 함수를 생성한다.

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} />;
}

Share article

석우의 개발블로그