본문 바로가기
FRONT-END/React

memo, useMemo 이해하기

by 랄라J 2024. 9. 11.

리액트 공식문서를 보고 정리한 내용입니다.


누군가 내게 memo, useMemo, useCallback 그거 왜 써? 라고 물어본다면 자신 있게 대답할 수 있을까?

"성능 최적화를 위해 쓰지~"
"어떻게 최적화가 되는데?"
"음..."
학습 전 누가 나에게 물어봤다면 이렇게 꿀 먹은 벙어리가 되어버렸을 것 같다...!

하지만 이번에 나만의 React 만들기를 구현하며 memo, useMemo를 직접 구현해보려고 하니 다시 개념을 확실히 짚고 넘어가야 할 필요성을 강하게 느껴 학습해보았다.

사실 당연한 거지 뭔지 알아야 만들 수 있으니까~!
그래서 시작한 공식문서 정리! 이 정리를 바탕으로 정리가 되어 구현까지 진행할 수 있었다!

시작해보자 😁


 

memo

공식문서 : https://ko.react.dev/reference/react/memo

 

memo – React

The library for web and native user interfaces

ko.react.dev

 

정의

const MemorizedComponent = memo(SomeComponent, arePropsEqual?)

memoize 하려는 컴포넌트를 첫 번째 인자로 전달해 호출하면 해당 컴포넌트를 수정하지 않고 새로운 memoized 컴포넌트를 return 한다.

 

메모화는 부모 컴포넌트에서 전달되는 props에만 적용된다.
memo를 사용하면 컴포넌트의 props가 변경되지 않은 경우 리렌더링을 건너뛸 수 있다.
단, memo를 사용해도 컴포넌트의 state가 변경되거나 사용 중인 context가 변경되면 리렌더링 된다.

 

두 번째 인자로 arePropsEqual을 선택적으로 작성할 수 있다. (일반적으로 자주 사용되지는 않음)
컴포넌트의 이전의 props와 새로운 props 두 가지 인수를 받는 함수다.
이전 props와 새로운 props가 동일한 경우 true를 반환하고 아닌 경우 false를 반환한다. 
React는 기본적으로 Object.is로 각 props를 비교한다.

아래 코드와 같이 메모화된 컴포넌트의 props 변경 최소화가 불가능하다면 사용자 정의 비교 함수를 제공해 React의 얕은 비교 대신 정의한 함수로 이전 props와 새 props를 비교할 수 있다. arePropsEqual을 구현하는 경우 함수를 포함해 모든 props를 비교해야 한다.

const Chart = memo(function Chart({ dataPoints }) {
  // ...
}, arePropsEqual);

function arePropsEqual(oldProps, newProps) {
  return (
    oldProps.dataPoints.length === newProps.dataPoints.length &&
    oldProps.dataPoints.every((oldPoint, index) => {
      const newPoint = newProps.dataPoints[index];
      return oldPoint.x === newPoint.x && oldPoint.y === newPoint.y;
    })
  );
}

 


리렌더링을 건너뛰는게 왜 좋은걸까?

리렌더링은 DOM을 다시 계산하고 업데이트하는 과정을 포함하므로 불필요한 리렌더링을 줄이면 성능을 최적화할 수 있기 때문이다.


Object.is 란?

정적 메서드로 두 값이 같은지 확인하는 용도로 사용한다.

console.log(Object.is('1', 1)) // false
console.log(Object.is(NaN, NaN)) // true

const obj = {}
console.log(Object.is(obj, {})) // false

 

기본적인 사용 코드

import { memo } from 'react'

const SomeComponent = memo(function SomeComponent(props) {
    // ...
})

 

코드로 알아보기 - 1. props가 변경되지 않았을 때 리렌더링 건너뛰기

React 컴포넌트는 항상 순수한 렌더링 로직을 가져야 한다.
즉, props, state, context가 변경되지 않으면 항상 동일한 결과를 반환해야 한다.
memo를 사용하면 이 컴포넌트가 요구사항을 준수한다고 알리는 것으로 props가 변경되지 않으면 React는 리렌더링 될 필요가 없다.

단, memo를 사용해도 컴포넌트의 state가 변경되거나 사용 중인 context가 변경되면 리렌더링 된다.

위 코드에서 Greeting 컴포넌트는 name 변경 시에는 리렌더링 되지만, MyApp의 address 변경에는 영향을 받지 않는다.

 

코드로 알아보기 - 2. state를 사용하여 memoization 된 컴포넌트 업데이트 하기

Greeting이 메모화되었지만, 부모에게서 받는 props인 name이 아닌 state인 greeting이 변경되면 리렌더링 된다.

 

코드로 알아보기 - 3. context를 사용하여 메모화 된 컴포넌트 업데이트 하기

Greeting이 메모화되었지만, 부모에게서 받는 props인 name이 아닌 context인 theme이 변경되면 리렌더링 된다.


memoization은 성능을 최적화하는 것이지만,
props로 받는 것 외 state나 context에 따라 react는 여전히 리렌더링 될 수 있다.

즉, memo로 감싸더라도 성능 최적화를 보장하지는 않는다.

 

memo 언제 써야 하는 걸까?

공식문서에서 설명하는 예시로는 그림 편집기 앱을 만드는 상황에서 도형 이동과 같이 대부분의 상호작용이 세분화되어 있다면 memoization이 유용할 수 있지만, 일반적인 경우에는 memoization이 불필요하다고 말한다.

memo로 최적화하는 것은 컴포넌트가 정확히 동일한 props로 자주 리렌더링 되고, 리렌더링 로직 비용이 많이 드는 경우만 유용하다. 단, 컴포넌트가 리렌더링 될 때 인지할 만큼의 지연이 없다면 memo가 필요하지 않다.

한 가지 더 알아두어야 할 점은 props로 전달되는 값이 객체나 함수인 경우에는 Object.is 연산에서 항상 false가 나오기 때문에 memo와 함께 useMemo, useCallback을 함께 써주지 않으면 무용지물이라는 것이다.

 

memo 잘 쓰는 방법 - 1. props 변경 최소화하기

function Page() {
  const [name, setName] = useState('Taylor');
  const [age, setAge] = useState(42);

  const person = useMemo(
    () => ({ name, age }),
    [name, age]
  );

  return <Profile person={person} />;
}

const Profile = memo(function Profile({ person }) {
  // ...
});

memo를 최대한 활용하려면 props가 변경되는 횟수를 최소화해야 한다.
props가 객체인 경우에는 useMemo를 사용해 부모 컴포넌트가 해당 객체를 매번 만드는 것을 방지해야 한다.
props가 함수인 경우 useMemo, useCallback을 사용해 리렌더링 사이의 함수 선언을 캐시 해야 한다.

 

memo 잘 쓰는 방법 - 2. props 전체 객체 대신 개별 값 받기

function Page() {
  const [name, setName] = useState('Taylor');
  const [age, setAge] = useState(42);
  return <Profile name={name} age={age} />;
}

const Profile = memo(function Profile({ name, age }) {
  // ...
});

props의 변경을 최소화하는 좋은 방법은 컴포넌트가 props에 필요한 최소한의 정보만 받도록 하는 것이다.
객체 대신 개별 값을 받아 처리하는 것이 좋다.

 


useMemo

공식문서 : https://ko.react.dev/reference/react/useMemo

 

useMemo – React

The library for web and native user interfaces

ko.react.dev

 

정의

useMemo(calculateValue, dependencies)

캐싱하려는 값을 계산하는 함수와 의존성 배열을 인자로 받아 재렌더링 사이의 결과 값을 캐싱하는 함수다.

calculateValue에 입력되는 함수는 순수해야 하며, 인자를 받지 않고 모든 타입의 값을 반환할 수 있어야 한다.
react는 초기 렌더링 중 calculateValue 함수를 호출하고, 다음 렌더링에서는 dependencies가 변경되지 않았을 때는 동일한 값을 반환한다. 즉, dependencies가 동일하다면 함수를 다시 호출하는 것이 아닌 저장된 값을 반환한다.

dependencies는 calculateValue 코드 내 참조된 모든 반응형의 값들의 목록이다. linter가 react 용으로 설정된 경우 모든 반응형 값이 의존성으로 올바르게 설정되었는지 확인할 수 있다. [dep1, dep2]와 같이 인라인 형태로 작성되어야 한다. Object.is로 각 의존성들을 이전 값과 비교한다. dependencies를 지정하지 않았을 경우 useMemo는 매번 다시 계산을 실행한다.

단, useMemo는 hook이므로 컴포넌트 최상위 레벨 또는 자체 hook에서만 호출할 수 있다.
반복문이나 조건문 내부에서는 호출할 수 없다.

function ReportList({ items }) {
  return (
    <article>
      {items.map(item => {
        // 🔴 반복문에서는 useMemo를 호출할 수 없습니다.
        const data = useMemo(() => calculateReport(item), [item]);
        return (
          <figure key={item.id}>
            <Chart data={data} />
          </figure>
        );
      })}
    </article>
  );
}

만약 반복문 내에서 호출하고 싶다면 컴포넌트를 추출하고 개별 항목에 대해 데이터를 useMemo로 처리하는 것은 가능하다.

function ReportList({ items }) {
  return (
    <article>
      {items.map(item =>
        <Report key={item.id} item={item} />
      )}
    </article>
  );
}

function Report({ item }) {
  // ✅ 최상위 수준에서 useMemo를 호출합니다.
  const data = useMemo(() => calculateReport(item), [item]);
  return (
    <figure>
      <Chart data={data} />
    </figure>
  );
}

위와 동일한 효과를 얻기 위한 다은 방법으로 useMemo를 제거하고 Report 자체를 memo로 감싸는 방법이 있다.
item prop이 변경되지 않으면 Report는 재렌더링을 건너뛰어 Chart도 재렌더링을 건너뛰게 된다.

function ReportList({ items }) {
  // ...
}

const Report = memo(function Report({ item }) {
  const data = calculateReport(item);
  return (
    <figure>
      <Chart data={data} />
    </figure>
  );
});

 


Hook이란?

React 버전 16.8부터 React 요소로 새로 추가되었다.

Hook은 class를 작성하지 않고도 state와 다른 React의 기능을 사용할 수 있게 해 준다.

컴포넌트 사이에서 상태 로직 재사용이 어렵고, 복잡한 컴포넌트들은 이해하기 어려운 문제를 해결하기 위해 나온 대안이다.

최상위에서만 Hook을 호출해야 하고, React 함수 컴포넌트 내에서만 Hook을 호출해야 하는 사용 규칙이 있다.


기본적인 사용 코드

import { useMemo } from 'react';

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

 

코드로 알아보기 - 1. 비용이 높은 로직의 재계산 생략

재렌더링 사이 계산을 캐싱하려면 컴포넌트 최상위 레벨에서 useMemo를 호출해 계산을 감싸면 된다.

큰 배열을 필터링하거나, 비용이 많이 드는 계산을 수행하는 경우 데이터가 변경되지 않았다면 계산을 생략하는 것이 좋은데 이런 경우 useMemo를 사용하는 것이 좋다.

useMemo를 사용함과 하지 않음의 차이를 알 수 있는 코드에 대한 예시 링크

 


비용이 높은 로직은 어떻게 판단할 수 있을까?

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

위 코드와 같이 콘솔 로그를 추가해 소요된 시간을 측정했을 때 1ms 이상이라면 계산을 memo 해두는 게 좋다.

console.time('filter array');
const visibleTodos = useMemo(() => {
  return filterTodos(todos, tab); // todo와 tab이 변경되지 않은 경우 건너뜁니다.
}, [todos, tab]);
console.timeEnd('filter array');

 

코드로 알아보기 - 2.  컴포넌트 재렌더링 건너뛰기

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

위 코드의 경우 theme를 변경해도 아래 하위 컴포넌트까지 영향이 가기 때문에 items로 전달하는 visibleTodos가 바뀌지 않아도 재렌더링이 되어 느려진다. 이때 List 컴포넌트를 최적화할 가치가 있다.

 

import { memo } from 'react';

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

위와 같이 List 컴포넌트를 memo로 감싸면 이제 props가 마지막 렌더링 때와 동일한 경우에는 재렌더링 하지 않는다.

 

export default function TodoList({ todos, tab, theme }) {
  // 재렌더링 사이에 계산을 캐싱하도록 React에 지시합니다...
  const visibleTodos = useMemo(
    () => filterTodos(todos, tab),
    [todos, tab] // ...따라서 해당 종속성이 변경되지 않는 한...
  );
  return (
    <div className={theme}>
      {/* ...List에 동일한 props가 전달되어 재렌더링을 생략할 수 있습니다. */}
      <List items={visibleTodos} />
    </div>
  );
}

단 filterTodos는 항상 다른 배열을 생성하기 때문에 List의 props는 동일하지 않게 되어 memo를 사용한 최적화가 작동하지 않는다.
이 경우 useMemo 사용이 유용하다.
visibleTodos 연산을 useMemo로 감싸면 다시 렌더링 될 때마다 종속성이 변경되기 전까지 같은 값을 갖게 할 수 있다.

 

코드로 알아보기 - 3.  다른 Hook의 종속성 메모화

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

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

컴포넌트 본문에서 직접 생성된 객체에 의존하는 연산이 있는 위와 같은 경우 메모이제이션의 목적을 무색하게 한다.
컴포넌트가 다시 렌더링 되면 컴포넌트 본문 내부의 모든 코드가 다시 실행되기 때문이다.
searchOptions를 생성하는 코드도 다시 렌더링 될 때마다 실행된다.

 

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이 변경될 때만 변경
  // ...

이럴 때 searchOptions 객체 자체를 종속성으로 전달하기 전 메모해 두면 된다.

 

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

더 나은 방법으로는 위와 같이 searchOptions를 useMemo 계산 함수 내부에 선언하는 것이다.
이제 연산은 text에 직접적으로 의존하게 된다.

 

코드로 알아보기 - 4.  함수 메모화

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

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

{}가 다른 객체를 생성하는 것처럼 function(){}과 같은 함수 선언과 () =>{} 표현식은 다시 렌더링 할 때마다 다른 함수를 생성한다. 이 경우에도 Form이 메모화 되어있다고 해도 메모이제이션의 목적을 무색하게 만들 수 있다.

 

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

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

useMemo로 함수를 메모하려면 계산 함수에서 다른 함수를 반환해야 한다.
위 코드는 투박해 보이기 때문에 함수를 메모하기 위한 Hook인 useCallback으로 함수를 감싸서 중첩된 함수를 추가로 작성하지 않도록 한다. 

 

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

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

useMemo로 함수를 메모화 한 것과 useCallback으로 메모화 한 것 모두 동일하게 동작한다.
useCallback의 유일한 장점은 내부에 중첩된 함수를 추가로 작성하지 않아도 된다는 것이다.
단, useMemo는 계산된 값의 메모이제이션이고 useCallback은 함수의 메모이제이션이라는 차이는 알아두자.


useMemo 언제 써야 하는 걸까?

메모이제이션 없이도 코드가 잘 작동하는 경우가 많다. 상호작용이 충분히 빠르다면 메모이제이션은 필요하지 않을 수 있다.
useMemo나 useCallback을 지나치게 사용하면 오히려 코드의 가독성을 떨어뜨리고, 성능에도 부정적인 영향을 줄 수 있다.

- useMemo에 입력하는 계산이 눈에 띄게 느리고 종속성이 거의 변경되지 않는 경우

- memo로 감싸진 컴포넌트에 props로 객체나 함수를 전달할 경우

- 전달한 값을 나중에 일부 Hook의 속성으로 이용할 경우, 예를 들어 다른 useMemo의 계산 값이 종속되거나 useEffect의 값에 종속되는 경우

 


useMemo, state, ref 비교

useMemo가 아닌 state, ref를 사용하는 게 더 적합한 상황이 있을 수 있다.
상황에 맞춰 잘 활용할 수 있어야 한다.

- useMemo : 비싼 연산을 캐싱하고 렌더링 성능을 최적화할 때 사용

- state : 컴포넌트 상태가 변할 때마다 다시 렌더링 되어야 할 때 사용

- ref : 렌더링에 영향 없이 값을 유지하고 싶을 때 사용
(DOM 요소에 직접 접근 - 포커스 이동, 스크롤 위치 조절, 렌더링과 무관하게 변수 값 유지, 타이머와 인터벌 그리고 비동기 작업 시 타이머 ID 저장, 스크롤 위치 제어)


 

결론적으로는 메모이제이션이 불필요하도록 코드를 짜는 게 중요하다.

- 컴포넌트가 다른 컴포넌트를 시각적으로 감쌀 때 JSX를 자식으로 받아들이도록 하기

- local state를 선호하고 필요 이상으로 state를 끌어올리기 하지 말기

- 렌더링 로직 순수하게 유지하기

- state를 업데이트하는 불필요한 effect 피하기 → React 앱에서 대부분의 성능 문제는 컴포넌트를 반복해서 렌더링 하게 만드는 Effect에서 발생하는 일련의 업데이트로 인해 발생한다.

- Effect에서 불필요한 의존성 제거하기

반응형

댓글