useMemo 정리 (+ React.memo)

현수·2023년 6월 1일
0

React Hooks

목록 보기
1/4
post-thumbnail

역할

리렌더링이 일어날 때 필요에 따라 계산 결과를 반복하지 않고 캐시해서 값을 재사용하여 성능을 개선한다.

사용법

const cachedValue = useMemo(calculateValue, dependencies)
  • calculateValue: 반복해서 계산하고 싶지 않은 로직의 결과 값을 리턴하는 콜백함수, 첫 렌더링 때 한번 실행해 값을 저장한 이후 재렌더링 될때 마다 dependencies에 들어있는 상태의 값이 변했는지 확인하고 변했으면 콜백함수를 실행해서 다시 캐시, 변하지 않았으면 실행하지 않고 이전에 캐시된 값을 그대로 사용한다.

  • dependencies: calculateValue 로직 내부에서 사용하고있는 변수 목록이고 이는 state, props 그리고 컴포넌트 내부에서 선언된 변수와 함수가 해당한다. 여기에 속한 값의 변경이 감지되면 calculateValue 을 다시 실행해서 캐시한다.

사용예

1. 복잡도가 높은 로직의 재실행 방지

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

TodoList 라는 컴포넌트가 있다고하자. 리액트에서 컴포넌트는 함수이기 때문에 TodoList 컴포넌트가 재렌더링 된다는 것은 TodoList 함수가 재실행된다는 뜻이며 이는 함수 내부의 로직이 처음부터 끝까지 모두 다시 실행됨을 의미한다.

TodoList가 렌더링 될때 마다 내부의 filterTodos 함수가 매번 실행된다는 뜻이며 해당 로직이 만약 큰 배열을 필터링을 하는 등의 비용이 많이 드는 계산을 수행한다면 재렌더링을 할때 마다 성능이 크게 저하될 것이다.

이때 재렌더링하는 과정에서 filterTodos 에서 사용하는 인자인 todos, tab 값이 변화하지 않았다면 인풋 데이터가 같기 때문에 로직을 수행해도 결과값이 변하지 않음을 예상할 수 있다. 이를 이용해 로직에 사용된 변수값이 변하지 않았다면 로직을 실행하지 않고 이전에 캐시된 값을 사용하면 성능을 크게 향상 시킬 것이다.

useMemo를 사용해서 재렌더링(재실행)이 이루어질때 로직의 결과값이 변경될것 같을 상황에만 로직을 다시 실행하게 만들어 컴포넌트의 성능을 개선할 수 있다.

2. 하위 컴포넌트의 리렌더링 방지 - React.memo

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

TodoList 가 자식 요소 List 에게 visibleTodos 를 전달하는 상황이다. 만약 List 컴포넌트의 실행 비용이 높아 부모 컴포넌트가 렌더링 될때마다 자식 컴포넌트를 재렌더링 시키는 것이 부담스럽다면 React.memo 기능을 이용해 컴포넌트의 재렌더링을 막을 수 있다.

import { memo } from 'react';

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

React.memo로 컴포넌트를 래핑하면 List 가 받아오는 propsvisibleTodos 가 변하지 않았다면 List 컴포넌트가 재렌더링 되지 않고 만약 변했다면 재렌더링된다.

export default function TodoList({ todos, tab, theme }) {
  // 매번 다른 참조 값의 배열을 반환하므로 값이 항상 변한다...
  const visibleTodos = filterTodos(todos, tab);
  return (
    <div className={theme}>
      {/* 그래서 List의 props는 절대 같을 수 없고 항상 리렌더링 된다... */}
      <List items={visibleTodos} />
    </div>
  );
}

그런데 위의 예제 코드에서 filterTodos 함수는 재실행할 때마다 항상 다른 참조값의 리터럴 배열을 반환한다. (배열은 참조 객체로 내부 요소값이 같아도 참조하는 메모리가 다를 수 있다) 이는 Listprops 값이 매번 다르다는 뜻이고 이는 이전 props 값과 현재 props 값을 비교해 리렌더링 여부를 결정하는 React.memo의 기능이 동작할 수 없다는 의미이다. 그러나 이를 useMemo로 해결할 수 있다.

export default function TodoList({ todos, tab, theme }) {
  // 계산값을 미리 캐시...
  const visibleTodos = useMemo(
    () => filterTodos(todos, tab),
    [todos, tab] // ...이 종속값이 변하지 않는 이상 계산값이 변하지 않는다..
  );
  return (
    <div className={theme}>
      {/* ...List 컴포넌트는 같은 props를 받고 리렌더링을 피할 수 있다 */}
      <List items={visibleTodos} />
    </div>
  );
}

위의 코드처럼 useMemovisibleTodos 를 래핑하면 함수가 같은 값을 리턴함을 확신할 수 있다. (종속된 변수가 변하지 않는다면) 그리고 이제 React.memo를 정상적으로 사용할 수 있을 것이다.

3. 종속된 리터럴 객체 변수의 캐시

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

  const visibleItems = useMemo(() => {
    return searchItems(allItems, searchOptions);
  }, [allItems, searchOptions]); // 🚩 주의: 컴포넌트 내부에서 생성된 객체에 종속
  // ...

useMemo의 종속된 변수가 컴포넌트 내부에 선언된 리터럴 객체라면 매번 컴포넌트가 렌더링 될 때 마다 새로운 참조값을 가진 리터럴 객체가 선언 및 저장된다. 위의 코드에서는 searchOptionsuseMemo에 종속되어 있지만 컴포넌트 내부에 리터럴로 값을 저장하고 있기 때문에 매 실행마다 새로운 참조값을 가져 useMemo 기능은 동작할 수 없다. 그러나 이또한 useMemo로 해결할 수 있다.

function Dropdown({ allItems, text }) {
  const searchOptions = useMemo(() => {
    return { matchMode: 'whole-word', text };
  }, [text]); // ✅ text가 변경될 때만 새로 캐시

  const visibleItems = useMemo(() => {
    return searchItems(allItems, searchOptions);
  }, [allItems, searchOptions]); // ✅ allItems, searchOptions가 변경될 때만 새로 캐시
  // ...

위의 코드는 text 가 변경되지 않으면 searchOptions 객체도 변하지않아 visibleItemsuseMemo 가 정상 작동된다.

function Dropdown({ allItems, text }) {
  const visibleItems = useMemo(() => {
    const searchOptions = { matchMode: 'whole-word', text };
    return searchItems(allItems, searchOptions);
  }, [allItems, text]); // ✅ allItems, text가 변경될 때만 새로 캐시
  // ...

그러나 새로운 useMemo 래핑을 만드는 것보다 기존 useMemo에 합치는 것이 더 효율적인 해결 방법이다.

4. 캐시 함수 - useCallback

만약 React.memo로 래핑되어있는 자식 컴포넌트의 props 로 컴포넌트 내부에 선언 되어있는 함수를 전달한다면 함수 선언 또한 리터럴 객체처럼 매 실행 마다 새로운 참조값을 가지기 때문에 React.memo 가 작동하지 않는다.

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

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

위의 예제에서 Form 컴포넌트는 React.memo 로 래핑되어있다고 가정한다. 이때 handleSubmit 함수는 매실행마다 재선언되어 새로운 참조값을 가지고 이는 Form 컴포넌트의 React.memo 기능을 이용할 수 없게한다.

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

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

useMemo 를 이용해서 종속된 값이 변할 때에만 새로운 함수 참조값을 반환하게 해도 되지만 리턴을 두번하는 이중 중첩 함수가 만들어져서 권장되지 않는다.

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

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

대신 useCallback을 사용하면 중첩없이 함수 선언에 대한 캐시를 관리할 수 있다.

더 알아보기

로직 수행 비용이 높은지 어떻게 알 수 있을까?

일반적으로 수천개의 객체를 만들거나 반복하지 않는 이상 비용이 많이 들지 않는다. 그래도 더 확신을 갖고싶다면 콘솔로그를 추가해 소요된 시간을 측정할 수 있다.

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

만약 측정된 소요 시간 값이 크다면(예를 들어 1ms 이상) 해당 로직을 메모이제이션하는 것이 합리적일 수 있다.

항상 useMemo를 사용하면 좋을까?

사이트의 상호작용이 단순하고 굵직하다면 일반적으로 메모가 필요하지 않다. 반면에 그림 편집기처럼 상호작용이 세부적이고 복잡하다면 메모이제이션이 유용할 수 있다.

useMemo 최적화는 다음 몇가지 경우에만 유용하다.

  • 로직이 눈에 띄게 느리고 종속된 변수가 거의 변하지 않을 때
  • React.memo로 래핑된 컴포넌트에 전달할 props 에 사용할때

위의 경우가 아니면 useMemo로 크게 효과를 보기 어렵다. 그래도 useMemo를 사용해도 큰 피해는 없으므로 일부 팀은 가능한 많이 메모이제이션하기도 한다. 그러나 이는 코드의 가독성을 떨어뜨리고 항상 새로운 값이 반환되는 변수를 포함하는 로직에 적용할 경우 비효율적이다.

그리고 만약 다음 원칙을 따르면 많은 메모이제이션의 필요를 줄일 수 있다.

  1. 부모 컴포넌트 렌더링에 따른 자식 렌더링을 방지하고 싶다면 Children props를 통해 구현가능하다.
  2. 로컬 state를 최대한 사용하고 필요이상의 상태 끌어올리기 기능 사용을 자제하자.
  3. 순수한 렌더링 로직을 유지해라. 구성 요소를 다시 렌더링하면 문제가 발생하거나 눈에 띄는 시각적 문제가 보인다면 메모이제이션 대신 버그를 수정해라.
  4. 상태를 자꾸 업데이트하는 불필요한 useEffect를 제거하라. React 앱의 대부분 성능 문제는 계속 렌더링을 발생시키는 Effect로부터 발생한다.
  5. useEffect에서 불필요한 의존성을 제거하라. 예를 들어 메모이제이션대신 일부 객체나 함수를 Effect 내부나 컴포넌트 외부로 이동하는 것이 더 간단한 경우가 많다.

추가로 성능 최적화를 위해 React Developer Tools 프로파일러를 사용하면 메모이제이션에서 가장 많은 이점을 얻을 수 있는 컴포넌트를 찾을 수있다.

레퍼런스

리액트 공식문서 - useMemo

0개의 댓글