React에서 함수나 값을 최적화하기 위해 useMemo, useCallback을 활용해보신 경험이 있으실 것입니다. 이 글에서는 useMemo와 useCallback이 React에 존재하는 이유와 이를 사용해야하는 상황에 대하여 알아보고 대다수의 경우에 useMemo와 useCallback을 제거할 수 있는 이유에 대해 설명해보려고 합니다.

useCallback

모든 값이나 함수에 useMemo와 useCallback을 사용하는것이 성능을 높여줄 수 있을까요?

성능은 수치에 기반해야합니다. 예제를 통해 직접 확인해봅시다.

Full Code

function Toggle() {
  const initialPerson = ['현진', '찬희', '시온', '하령', '영기', '그루트'];
  const [person, setPerson] = useState(initialPerson);

  const hide = (person: string) => setPerson((allPerson) => allPerson.filter((p) => p !== person));

  return (
    <div>
      <h1>Toggle Person</h1>
      <div>
        {person.length === 0 ? (
          <button onClick={() => setPerson(initialPerson)}>show</button>
        ) : (
          <ul>
            {person.map((p) => (
              <li key={p}>
                <button onClick={() => hide(p)}>click</button> {p}
              </li>
            ))}
          </ul>
        )}
      </div>
    </div>
  );
}

저는 Toggle이라는 컴포넌트를 만들었고 버튼을 클릭할 때마다 hide 함수를 호출해서 컴포넌트를 리렌더링 시키면서 렌더링 시간을 측정하였습니다. (6명의 사람, 6번의 버튼 클릭)

먼저 useCallback으로 hide 함수를 감싸지 않고 테스트를 진행했습니다.

측정에는 React Dev tools의 Profiler 탭을 사용해 렌더링시간을 측정했습니다. 각 테스트를 시작할 때 새로고침을 한번씩 해주었습니다.

이 과정을 총 3번 진행하여 아래 표를 작성했습니다.(단위: ms)

다음으로 hide함수를 useCallback으로 감싸서 테스트를 똑같이 진행했습니다.

Full Code

// 나머지 코드 동일
const hide = useCallback(
  (person: string) => setPerson((allPerson) => allPerson.filter((p) => p !== person)),
  [],
);

결과는 아래와 같습니다.(단위: ms)

hide라는 함수를 useCallback으로 감쌌을 때와 감싸지 않았을 때 렌더링시간을 비교해보면 아래와 같습니다.

결과가 말해주는 것은 useCallback으로 감싸면 더 느리게 렌더링된다는 것입니다. 왜 더 느릴까요?

코드를 실행하는데는 비용이듭니다.

// useCallback X
const hide = (person: string) => {
  setPerson((allPerson) => allPerson.filter((p) => p !== person));
};

// useCallback O
const hide = useCallback((person: string) => {
  setPerson((allPerson) => allPerson.filter((p) => p !== person));
}, []);

위 두함수를 비교할 때 두함수는 같은 일을 수행하지만 함수를 useCallback으로 감싸서 더 많은 일을 하고 있습니다. 더 고비용인 것이죠. useCallback은 함수를 정의하는 일 뿐만아니라 의존성 배열도 정의해줘야 합니다. 따라서 초기에 함수 컴포넌트를 실행할 때 useCallback으로 감싸져 있으면 하는 일이 더 많기 때문에 더 느리게 실행됩니다.(초기 렌더링)

컴포넌트가 Re-Rendering 되었을 때는 어떨까요? useCallback은 함수를 다시 만들지 결정하기 위해 의존성 배열의 참조 동일성(Referential Equality)를 체크합니다.

따라서 useCallback을 모든 함수에 적용하시는 분들의 생각은 다음과 같을 것입니다.

“초기 렌더링에는 useCallback을 사용하는것이 악영향을 미치지만 리렌더링시에 배열의 참조 동일성을 체크하는데 걸리는 시간이 함수를 다시 만드는 시간보다 빠르다.”

함수 10000개를 만드는 시간과 객체를 10000번 참조해서 동일성을 체크하는데 걸리는 시간을 측정해봤습니다. 측정에는 브라우저 내장 객체인 Performance 객체를 활용했습니다.

// 10000개의 함수를 만드는데 걸리는 시간
const before = performance.now();

for (let i = 0; i < 10000; i++) {
  const something = () => {};
}

const now = performance.now();
console.log(now - before);

// 10000번의 참조 동일성을 체크하는데 걸리는 시간
const temp = { hyunjin: 'lee' };

const before = performance.now();

for (let i = 0; i < 10000; i++) {
  if (temp.hyunjin === 'lee') {
    // 참조가 같다면 함수를 만들지 않음
  } else {
    // 참조가 다르다면 함수를 만듦
  }
}

const now = performance.now();
console.log(now - before);

performance.now()가 리턴하는 값은 밀리초를 의미합니다. 즉 10000개의 함수를 만드는데에는 1.9ms가 10000번의 참조 동일성을 비교하는데 걸리는 시간은 2.3ms가 걸렸습니다. 함수를 만드는 작업이나 참조 동일성을 확인하는 작업은 둘다 빠릅니다.

컴포넌트가 엄청 뚱뚱해서 함수가 100개 있고 참조 동일성을 100번 비교한다고 해보겠습니다.

둘다 0ms 입니다. 중요한 점은 컴포넌트 내부에서 함수를 만드는것과 참조동일성을 비교하는것 중 뭐가 더 빠른가 비교하는 것이 의미가 없다는 것입니다.

다시 말하면 대다수의 경우에 useCallback을 사용하는 것이 의미가 없다는 것입니다.

의미가 없는 경우를 예제를 들어보겠습니다.

const Component = () => {
  const onClick = useCallback(() => {
    /* 어떤 일을 함 */
  }, []);
  return <button onClick={onClick}>Click me</button>;
};

컴포넌트에서 함수 최적화를 위해 위와 같이 사용하는 것을 많이 보셨을 것입니다. 위와 같은 코드에서 useCallback은 제거해야합니다. 오히려 코드를 읽고 디버그하기 어렵게 만들 뿐입니다.

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

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

  const onClick = useCallback(() => {
    /* 어떤 일을 함. */
  }, []);

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

두번째 예제입니다. 위와 같은 경우도 useCallback을 제거해야합니다. 의미가 없기 때문이죠.

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

세번째 예제입니다. dependency가 얽혀서(chaining) useCallback에 넣어준 경우입니다. 마찬가지로 useCallback을 제거해야합니다.

결국 위 세가지 경우에서 useCallback은 오히려 초기렌더링을 느리게 만들뿐 아니라 좋은 장점이 없습니다. 오히려 의존성 배열을 정의해서 버그의 요인이 되고, 코드를 읽기 어렵게 만듭니다.

그렇다면 useCallback은 언제 써야할까요?

useCallback을 써야하는 시점은 컴포넌트가 렌더링되는 시점을 정의하는 것으로부터 시작합니다. 컴포넌트가 렌더링되는 경우는 3가지가 있습니다.

  1. state가 바뀌면 컴포넌트는 렌더링된다.
  2. props가 바뀌면 컴포넌트는 렌더링된다.
  3. 부모 컴포넌트가 렌더링되면 하위 컴포넌트는 모두 렌더링된다.

주목할 부분은 부모 컴포넌트의 리렌더링은 자식 컴포넌트의 렌더링을 초래한다는 점입니다.

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

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

App 컴포넌트가 렌더링되면 Page, 그 하위의 Item 컴포넌트까지 모두 렌더링됩니다. Page는 state도 props도 없지만 부모 컴포넌트가 렌더링되었다는 이유만으로 렌더링됩니다. React에는 이를 방지할 수 있는 방법이 존재합니다.

React.memo()를 사용하면됩니다.

const Page = () => <Item />;
const PageMemoized = React.memo(Page);
const App = () => {
  const [state, setState] = useState(1);

  return (
    ... // 이전과 동일
      <PageMemoized />
  );
};

위와 같은 시나리오에서만 props를 메모이제이션하는것이 의미있습니다.

const PageMemoized = React.memo(Page);

const App = () => {
  const [state, setState] = useState(1);
  const onClick = () => {
    console.log('Do something on click');
  };
  return (
    ...// 위와 동일한 코드 생략
    // onClick이 계속 새로 만들어지기 때문에 memo를 사용했어도 계속 리렌더링이된다.
    <PageMemoized onClick={onClick} />
  );
};

위 상황을 글로 표현하면 App이 리렌더링되면 onClick 함수를 새로 만들 것이고 PageMemoized 컴포넌트를 보고 리렌더링을 잠깐 멈춘후에 props가 바뀌었는지 체크할 것입니다. onClick은 메모이제이션된 함수가 아니므로 props 참조 동일성 비교는 실패할 것이고 PageMemoized는 리렌더링됩니다.

const PageMemoized = React.memo(Page);

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

  return (
    // onClick을 memoization 해서 Page 리렌더링을 막는다.
    <PageMemoized onClick={onClick} />
  );
};

위 코드에서는 onClick은 다시 만들어지지 않기 때문에 memo로 감싸진 PageMemoized 컴포넌트는 props가 동일한 것을 확인한 후 하위 트리를 렌더링하지 않습니다.

이게 React가 의도한 useCallback입니다.

useMemo

React 공식문서에 의하면 useMemo의 목표는 매렌더링마다 고비용 연산을 피하는 것입니다. 고비용 연산이 무엇인지에 대해서는 자세히 나와있지 않습니다.

많은 분들은 useCallback의 잘못된 사례에서와 같이 컴포넌트 내부의 거의 모든 계산을 useMemo로 감쌉니다.

useMemo에서도 마찬가지로 그렇게 거대한 계산이 아니라면 useMemo로 감쌀 필요가 없습니다.

예를 들어보겠습니다.

function List({ countries }) {
  const sortedCountries = countries.sort();

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

List라는 컴포넌트는 props로 countries를 받고 여기에는 250개의 나라가 담겨있습니다.

const before = performance.now();
countries.sort();
const now = performance.now();

console.log(now - before);

250개의 나라를 정렬하는데 0.2ms가 걸렸습니다. 여기서 말하고 싶은 것은 대부분의 컴포넌트에서의 계산은 생각보다 무거운 계산이 아니라는 것입니다. 즉, React의 공식문서에 나와있는 useMemo의 사용시점과 거리가 있습니다.

React에서 무거운 계산은 컴포넌트를 리렌더링하고 업데이트하는 계산입니다.

즉 위에서 표시한대로 주목해야할 부분은 컴포넌트를 다시 그리는 부분입니다.

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

  return content;
}

React의 공식문서를 확장하면, 병목현상을 만드는 고비용 연산이란 하위 렌더트리를 렌더링하는 것을 말하며 React가 의도한 useMemo는 render tree 내부의 특정 부분을 메모할 때 사용하는 것입니다.(물론 2^n, n!과 같은 계산을 메모할때 사용하는 것도 맞습니다.)

위 경우가 아니라면 useMemo를 코드를 Pure JavaScript 연산으로 바꾸는게 맞습니다.

왜 제거해야하는 것인가?

대부분의 경우에 useMemo와 useCallback을 제거해야합니다.

수백개의 컴포넌트가 존재하는 앱이 있다고 해보겠습니다. 글에서 설명한바와 같이 useMemo와 useCallback은 첫 렌더링 때 React가 그 값을 캐시해두어야합니다. 이것은 시간이드는 작업이죠. 100개의 컴포넌트를 성능개선하겠다고 useMemo, useCallback으로 도배를 해놓았다면 어떨까요? 1ms, 2ms,… 100ms 점점 늘어납니다.

반면에 리렌더링은 어떨까요? 애플리케이션을 잘 설계하면 리렌더링은 특정 부분에서만 일어납니다. 특정 부분에서만 일어나는 리렌더링에서 만들어야할 함수와 값은 개수가 적습니다. 개수가 적을수록 함수를 만드는 연산과 참조 동일성을 체크하는 연산 자체를 비교하는 것 자체가 무의미해지게 됩니다.

이렇기 때문에 초기렌더링에 유리하도록 필요없는 useMemo와 useCallback을 없애고 리렌더링은 변경되어야하는 부분만 일어나게 만들어서 애플리케이션을 최적화할 수 있습니다.

사람들은 성능 개선에는 trade-off가 있다고 많이 말합니다. React에서 성능을 개선하는 것은 useMemo와 useCallback으로 애플리케이션 내부에 모든 함수와 값들을 감싸는 것이 아닙니다. 이렇게 하면 컴퓨터 자원만 의미 없이 사용하게되고 심지어 애플리케이션이 더 느려질 수 있습니다.(역효과)

React가 의도한 성능 최적화는 memo, useMemo, useCallback을 사용해서 컴포넌트와 그 하위 컴포넌트들이 리렌더링되는 것을 막거나(memo), 컴포넌트 하위 트리를 메모이제이션해서 사용하거나(useMemo), memo로 감싸진 컴포넌트에 props를 전달할 때 값이 변하지 않도록 해주는 것(useCallback)을 말합니다.

단순히 값이나 함수를 메모이제이션하는 것은 성능에 미미한 영향을 끼치거나 오히려 애플리케이션이 더 느려질 수 있습니다. React에서 최적화란, 이런 Pure한 JavaScript를 최적화하는 것이아닌 보다 훨씬 오래걸리는 하위 트리의 렌더링을 막는 것입니다.(Reconciliation 과정을 최적화)

역설적으로, 성능에 대해 고민하는 것보다 애플리케이션이 렌더링되고 렌더링되지 않아야 할 부분을 잘 설계하는데 집중하는게 성능을 더 좋게 만들 수 있는 길입니다.

Reference