본문 바로가기

개발이야기

How to useMemo and useCallback: you can remove most of them

원문: https://www.developerway.com/posts/how-to-use-memo-use-callback

 

React가 처음이 아니라면 적어도 useMemo 및 useCallback 훅에 익숙하실 것입니다. 중대형 애플리케이션에서 작업하는 경우 앱의 일부가 읽고 디버그 할 수 없는 useMemo, useCallback들로 도배되어 있는 것을 볼 수 있습니다. 이러한 훅은 제어할 수 없을 정도로 코드 주위에 퍼져있습니다. 또한 주변 동료들이 모두 작성하고 있다는 이유만으로 작성하는 자신을 발견하게 됩니다. 

 

이 모든 것은 완전히 불필요합니다. 지금 바로 앱에서 모든 useMemo 및 useCallback을 90% 제거할 수 있으며 앱은 문제가 없고 약간 더 빨리질 수 있습니다.

 

오해하지 마세요. useMemo나 useCallback이 쓸모가 없다는 말은 아닙니다. 그것들의 사용은 매우 구체적인 몇가지 경우에 국한됩니다. 그리고 대부분의 경우 불필요한 것들을 래핑하고 있는 경우가 많습니다.

 

개발자가 useMemo와 useCallback에서 저지르는 실수의 종류, 실제 목적은 무엇이며 올바르게 사용하는 방법에 대해 알아보겠습니다. 

 

앱에서 이러한 훅의 확산의 두가지 주요 소스가 존재합니다.

- memoizing props to prevent re-renders

- memoizing values to avoid exprensive calculations on every re-render

 

Why do we need useMemo and useCallback

답은 간단합니다. 리렌더링 간의 메모이제이션입니다. 값이나 함수가 이러한 훅에 래핑되면 리액트는 초기 렌더링 중에 이를 캐시하고 리렌더링 중에 저장된 값에 대한 참조를 반환합니다. 이것이 없으면 배열, 객체 또는 함수와 같이 기본이 아닌 값은 다시 렌더링할 때마다 처음부터 다시 생성됩니다. 메모이제이션은 해당 값을 비교할 때 유용합니다. 그냥 일반적인 자바스크립트입니다. 

 

const a = { "test": 1 };
const b = { "test": 1'};

console.log(a === b); // will be false

const c = a; // "c" is just a reference to "a"

console.log(a === c); // will be true

 

또는 일반적인 React 사용 사례에 더 가깝다면,

 

const Component = () => {
  const a = { test: 1 };

  useEffect(() => {
    // "a" will be compared between re-renders
  }, [a]);

  // the rest of the code
};

 

a는 useEffect 훅의 의존성입니다. 컴포넌트를 렌더링할 때마다 이전 값과 비교합니다. a는 Component 내에 정의된 객체입니다. 즉, 다시 렌더링할 때마다 처음부터 다시 생성됩니다. 따라서 "다시 렌더링하기 전"과 "다시 렌더링한 후"를 비교하면 false가 반환되고 렌더링할 때마다 useEffect를 트리거합니다. 

 

이런 경우를 피하기 위해 우리는 useMemo로 래핑할 수 있습니다.

 

const Component = () => {
  // preserving "a" reference between re-renders
  const a = useMemo(() => ({ test: 1 }), []);

  useEffect(() => {
    // this will be triggered only when "a" value actually changes
  }, [a]);

  // the rest of the code
};

 

이제 useEffect는 값이 실제로 바뀌어야만 실행됩니다. 이는 useCallback의 경우도 마찬가지입니다. 

 

const Component = () => {
  // preserving onClick function between re-renders
  const fetch = useCallback(() => {
    console.log('fetch some data here');
  }, []);

  useEffect(() => {
    // this will be triggered only when "fetch" value actually changes
    fetch();
  }, [fetch]);

  // the rest of the code
};

 

여기서 기억해야 할 가장 중요한 점은 useMemo와 useCallback 모두 리렌더링 단계에서만 유용하다는 것입니다. 초기 렌더링 동안에는 쓸모가 없을 뿐만 아니라 심지어 해로울 수 있습니다. React가 몇가지 추가 작업을 수행하도록 하기 때문이죠. 그리고 여러분의 앱이 수백개의 이런 코드가 있으면 이 느려지는 현상은 측정 가능해질정도일 것입니다. 

 

Memoizing props to prevent re-renders

이제 이러한 훅의 목적을 알았으므로 실제 사용법을 살펴보겠습니다. 그리고 가장 중요하고 가장 자주 사용되는 것 중 하나는 리렌더링을 방지하기 위해 props값을 메모하는 것입니다. 아래 코드를 여러분의 앱에서 본적이있다면 소리지르세요.

 

1. 리렌더링을 막기위해 onClick은 useCallback으로 감싸야합니다. 

const Component = () => {
  const onClick = useCallback(() => {
    /* do something */
  }, []);
  return (
    <>
      <button onClick={onClick}>Click me</button>
      ... // some other components
    </>
  );
};

 

2. 리렌더링을 막기위해 onClick은 useCallback으로 감싸야합니다. 

 

const Item = ({ item, onClick, value }) => <button onClick={onClick}>{item.name}</button>;

const Component = ({ data }) => {
  const value = { a: someStateValue };

  const onClick = useCallback(() => {
    /* do something on click */
  }, []);

  return (
    <>
      {data.map((d) => (
        <Item item={d} onClick={onClick} value={value} />
      ))}
    </>
  );
};

3. onClick의 의존성에 value가 있기 때문에 value는 useMemo로 감싸야합니다.

 

const Item = ({ item, onClick }) => <button onClick={onClick}>{item.name}</button>;

const Component = ({ data }) => {
  const value = useMemo(() => ({ a: someStateValue }), [someStateValue]);
  const onClick = useCallback(() => {
    console.log(value);
  }, [value]);

  return (
    <>
      {data.map((d) => (
        <Item item={d} onClick={onClick} />
      ))}
    </>
  );
};

 

여러분이 이렇게 했거나 다른 사람들이 이렇게 하는 것을 본적이 있습니까? 여러분은 이 사용사례와 이를 해결한 방법에 동의하십니까? 만약 여러분의 대답이 "예"라면, 축하합니다. useMemo와 useCallback은 여러분의 코드를 불필요하게 통제했습니다. 

 

모든 예에서 이러한 훅은 쓸모없고 불필요하고 복잡한 코드이며 초기 렌더링 속도를 늦추고 아무것도 방지하지 못합니다. 

이 이유를 이해하려면 React가 작동하는 방식에 대해 한 가지 중요한 사실을 기억해야합니다. 컴포넌트가 자체적으로 다시 렌더링될 수 있는 이유입니다.

 

왜 컴포넌트는 리렌더링될까요?

"state나 props가 바뀌면 컴포넌트는 리렌더링된다."는 일반적으로 알려진 사실입니다. 그리고 이 진술이 props가 변경되지 않으면 (memoized) 구성 요소가 다시 렌더링되는 것을 방지할 것"이라는 결론으로 이어지는 것입니다.

 

리렌더링에는 또다른 하나 중요한 이유가 있습니다. 바로 부모 컴포넌트의 렌더링입니다. 아래 코드를 보겠습니다. 

 

const App = () => {
  const [state, setState] = useState(1);

  return (
    <div className="App">
      <button onClick={() => setState(state + 1)}> click to re-render {state}</button>
      <br />
      <Page />
    </div>
  );
};

 App 컴포넌트에는 state와 Page 컴폰너트를 포함한 children이 있습니다. 버튼을 클릭하면 상태가 바뀔 것이고, 앱을 리렌더링 시킬 것입니다. 그리고 그 리렌더링은 하위 컴포넌트들을 리렌더링 시킵니다. Props가 없더라도 말이죠.

 

페이지 컴포넌트는 아래와 같이 구성되어있습니다.

 

const Page = () => <Item />;

 

Page는 state도 props도 존재하지 않습니다. 하지만 이는 App이 리렌더링되면 렌더링됩니다. 결국 App컴포넌트의 상태 변화는 app의 모든 컴포넌트의 상태 변화로 이어집니다. 이를 방지할 수 있는 방법은 컴포넌트를 React.memo로 감싸는 것입니다. 이렇게하면 React는 리렌더링하기 전에 props 변화를 체크하고 렌더링을 할지 말지 결정합니다. 

 

const Page = () => <Item />;
const PageMemoized = React.memo(Page);

 

const App = () => {
  const [state, setState] = useState(1);

  return (
    ... // same code as before
      <PageMemoized />
  );
};

 

위 시나리오에서만 props가 메모화되었는지 여부가 중요합니다. 

설명을 위해 Page 컴포넌트에 함수를 받는 onClick prop이 있다고 해봅시다.  메모이제이션 없이 prop을 전달하면 어떻게 될까요?

 

const App = () => {
  const [state, setState] = useState(1);
  const onClick = () => {
    console.log('Do something on click');
  };
  return (
    // page will re-render regardless of whether onClick is memoized or not
    <Page onClick={onClick} />
  );
};

 

App이 리렌더링되면 React는 Page를 리렌더링할 것입니다. onClick이 useCallback으로 감싸져있건 감싸져있지 않건 상관없이 말이죠.

Page를 memo 해준다면?

 

const PageMemoized = React.memo(Page);

const App = () => {
  const [state, setState] = useState(1);
  const onClick = () => {
    console.log('Do something on click');
  };
  return (
    // PageMemoized WILL re-render because onClick is not memoized
    <PageMemoized onClick={onClick} />
  );
};

 

App이 리렌더되면 React는 PageMemoized 컴포넌트를 보고 리렌더링을 잠깐 멈춘후 props가 바뀌었는지 체크합니다. 이 케이스에서는 onClick은 메모이제이션된 함수가 아니므로 props comparison은 실패할 것이고 PageMemoized는 리렌더링될 것입니다.  마지막으로 useCallback의 사용법을 보겠습니다. 

 

const PageMemoized = React.memo(Page);

const App = () => {
  const [state, setState] = useState(1);
  const onClick = useCallback(() => {
    console.log('Do something on click');
  }, []);

  return (
    // PageMemoized will NOT re-render because onClick is memoized
    <PageMemoized onClick={onClick} />
  );
};

 

이제, onClick은 변경되지 않으므로 PageMemoized는 리렌더링되지 않습니다. 

만약 non-memoized value를 PageMemoized에 추가하면 어떻게 될까요? 

 

const PageMemoized = React.memo(Page);

const App = () => {
  const [state, setState] = useState(1);
  const onClick = useCallback(() => {
    console.log('Do something on click');
  }, []);

  return (
    // page WILL re-render because value is not memoized
    <PageMemoized onClick={onClick} value={[1, 2, 3]} />
  );
};

 

리렌더링시 onClick은 그대로이지만 value는 바뀌기 때문에 PageMemoized 컴포넌트는 리렌더링됩니다. 위 예제를 고려해보면,  컴포넌트에 대한 props를 메모하는 것이 의미있는 시나리오는 하나뿐입니다. 모든 단일 prop과 컴포넌트 자체가 메모되는 경우입니다. 다른 모든 것은 메모리 낭비일 뿐이며 코드를 불필요하게 복잡하게 만듭니다. 

 

다음과 같은 경우 코드에서 모든 useMemo 및 useCallback을 제거하세요.

- DOM요소에 전달되는 속성(directly or through a chain of dependencies 직접 또는 종속성 체인을 통해 DOM 요소에 전달되는 속성) 

- Memoized되지 않은 컴포넌트에 직접 또는 종속성 체인을 통해 전달되는 props

- Memoized되지 않은 하나 이상의 prop이 있는 컴포넌트에 전달되는 props (직접 또는 종속성 체인을 통해 전달되는)

 

메모이제이션을 수정하는 것이 아니라 제거하는 이유는 무엇일까요? 글쎄요, 그 영역에서 리렌더링으로 인해 성능 문제가 있었다면 이미 눈치채고 수정했을 것입니다. 그리고 성능 문제가 없기 때문에 고칠 필요가 없습니다. 쓸모없는 useMemo 및 useCallback을 제거하면 기존 렌더링 성능에 부정적인 영향을 미치지 않으면서 코드를 단순화하고 초기 렌더링 속도를 약간 높일 수 있습니다. 

 

Avoiding expensive calculations on every render

리액트 공식문서에의하면 useMemo의 목표는 매렌더링마다 발생하는 고비용 연산을 피하는 것입니다.  고비용 연산이 무엇인지에 대한 설명은 없습니다. 결과적으로 개발자는 때때로 렌더 함수의 거의 모든 계산을 useMemo로 래핑합니다. 

 

자 몇가지 숫자를 살펴보겠습니다. 우리가 국가의 배열(250개)을 가지고 있고 화면에 렌더링하고 사용자가 정렬할 수 있도록 하고 싶다고 상상해봅시다. 

 

const List = ({ countries }) => {
  // sorting list of countries here
  const sortedCountries = orderBy(countries, 'name', sort);

  return (
    <>
      {sortedCountries.map((country) => (
        <Item country={country} key={country.id} />
      ))}
    </>
  );
};

 

250개 요소의 배열을 정렬하는 것의 비용이 많이 드는 작업인가요? 리렌더링시 다시 계산하는 것을 피하기 위해 useMemo로 래핑해야합니다. 성능을 측정해보죠.

 

const List = ({ countries }) => {
  const before = performance.now();

  const sortedCountries = orderBy(countries, 'name', sort);

  // this is the number we're after
  const after = performance.now() - before;

  return (
    // same
  )
};

 

최종 결과는? 메모이제이션없이, CPU를 6배 느리게하고 250개 항목을 정렬하는데 2밀리초 미만이 걸립니다. 비교하건데 이 리스트를 렌더링하는데 20밀리초 이상이 걸립니다. 10배 이상의 차이죠.

 

그리고 실생활에서 배열은 훨씬 더 작아지고 렌더링되는 컴포넌트는 복잡해져서 이 차이는 10배 이상일 것입니다. 배열을 메모이제이션하는 대신 우리는 비용이 많이 드는 컴포넌트를 렌더링하고 업데이트하는 계산을 메모해야합니다. 아래와 같이 말이죠.

 

const List = ({ countries }) => {
  const content = useMemo(() => {
    const sortedCountries = orderBy(countries, 'name', sort);

    return sortedCountries.map((country) => <Item country={country} key={country.id} />);
  }, [countries, sort]);

  return content;
};

 

이 useMemo는 컴포넌트의 불필요한 리렌더링 시간을 20ms에서 2ms 미만으로 떨어뜨립니다. 위의 내용을 고려하면 이것이 제가 소개하고 싶은 비싼 연산을 메모하는 것에 대한 규칙입니다. 실제로 큰 숫자의 계승을 계산하지 않는 한 모든 순수 자바스크립트 연산에서 useMemo훅을 제거하십시오. 자식을 다시 렌더링한다면 항상 병목현상이 발생합니다. 렌더트리의 무거운 부분을 메모할 때에만 useMemo연산을 사용하세요.

 

그래도 제거하는 이유는 무엇일까요? 그냥 다 메모해두는게 좋지 않을까요? 모두 제거하면 성능이 저하되는 복합 효과가 아닐까요? 여기서 1밀리초, 저기서 2밀리초, 이렇게되서 앱이 느려지지 않을까요?

 

좋은 포인트입니다. 그리고 그 생각은 100% 유효할 것입니다. 메모이제이션은 무료로 제공되지 않습니다. 만약 우리가 useMemo를 사용한다면 첫번째 렌더링때 리액트는 그 값을 캐시해두어야합니다. 이것은 시간이 드는 작업이죠. 예 작습니다. 위의 앱에서 정렬된 국가를 메모하는데 밀리초 미만이 걸립니다. 하지만 이것은 진정한 복합 효과가 될 것입니다. 초기 렌더링은 앱이 화면에 처음 나타날때 발생합니다. 표시되어야 하는 모든 컴포넌트는 이를 통과합니다. 수백개의 구성요소가 있는 큰 앱에서는 그 중 3분의 1이 무언가를 메모하더라도 10, 20, 최악의 경우 초기 렌더링에 100밀리초가 추가될 수 있습니다.

 

반면에 리렌더링은 앱의 한 부분이 변경된 후에만 발생됩니다. 그리고 잘 설계된 앱에서는 전체 앱이 아닌 이 특정 작은 부분만 다시 렌더링됩니다. 변경된 부분에 위의 경우와 유사한 계산이 몇개나 될까요? 5개라고 해봅시다. 각 메모이제이션은 2밀리초 미만을 절약합니다. 전체적으로 10밀리초 미만입니다. 일어날 수도 있고 일어나지 않을 수도 있는 10밀리초, 맨눈으로 볼 수 없으며 어쨌든 그 10배의 시간이 쇼요되는 children 리렌더링에서 손실됩니다. 항상 발생하는 초기 렌더링 속도를 늦추는 대가로.

 

Enough for today

 

이제 앱을 검토하고 쓸모없는 useMemo와 useCallback을 모두 제거할 수 있습니다. 요약해보겠습니다. 

 

- useCallback 및 useMemo는 리렌더링에만 유용한 훅입니다. 초기 렌더링에는 실제로 유해합니다.

- props를 넘길 때 사용되는 useCallback과 useMemo는 그 자체로 리렌더링을 막지 않습니다. 모든 props와 컴포넌트 자체가 메모이제이션되어야만 리렌더링을 방지합니다. 한 번의 실수로 모든 것이 무너지고 그 훅을 쓸모없게 만듭니다. 찾으면 제거하십시오.

- useMemo를 "native" JavaScript 계산에서 제거해야합니다. 실제 컴포넌트 업데이트와 비교해서 계산은 정말 빠르고 메모이제이션하는 것은 초기렌더링에 시간과 메모리를 낭비합니다. 

 

이 모든 것이 얼마나 복잡하고 취약한지를 고려할 때 성능 최적화를 위한 useMemo 및 useCallback은 실제로 최후의 수단이 되어야합니다. 다른 성능 최적화 기술을 먼저 시도하십시오. 그리고 당연히, 먼저 측정하십시오!

 

May this day be last day in useMemo and useCallback hell.