구리

[React] React Hook 파헤쳐보기 - useCallback, useMemo (React.memo를 곁들인) 본문

카테고리 없음

[React] React Hook 파헤쳐보기 - useCallback, useMemo (React.memo를 곁들인)

guriguriguri 2024. 11. 16. 14:34

React Hook에 대해 공부하며 정리한 글입니다. 피드백은 언제나 환영입니다.

useMemo과 useCallback

useMemo는 리렌더링 사이에 계산 결과를 캐싱(메모이제이션)할 수 있게 해주는 React Hook으로 React는 초기 렌더링 중에 calculateValue 함수를 호출한다. 그리고 마지막 렌더링 이후 deps가 변경되지 않았을 경우 저장된 값을 반환하거나 변경되었다면 calculateValue를 다시 호출해서 반환된 값을 저장하고 반환한다. 참고로 React는 캐싱된 값을 특별한 이유가 없는 한 버리지 않는다.

const cachedValue = useMemo(calculateValue, dependencies)

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

useCallback은 리렌더링 사이에 인수로 넘겨받은 콜백함수(fn)를 캐싱(메모이제이션)할 수 있게 해주는 React Hook으로 React는 초기 렌더링 중에 콜백함수 자체를 반환한다. 그리고 마지막 렌더링 이후 deps가 변경되지 않았을 경우 저장된 함수를 반환하거나 변경되었다면 인자로 전달 받은 새로운 콜백 함수 자체를 저장하고 반환한다. 참고로 React는 캐싱된 값을 특별한 이유가 없는 한 버리지 않는다.

const cachedFn = useCallback(fn, dependencies);

// example
function ProductPage({ productId, referrer, theme }) {
  const handleSubmit = useCallback((orderDetails) => {
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails,
    });
  }, [productId, referrer]);

그러면 어떤 상황에서 계산 결과를 캐싱하는 게 좋을까? 일반적으로 대부분의 계산은 빠르지만 큰 배열을 필터링 혹은 변환거나 비용이 많이 드는 계산을 수행하는 경우, 데이터가 변경되지 않았다면 캐싱된 데이터를 사용하는 쪽이 불필요한 리소스 소모를 방지한다고 볼 수 있다. 또한 어떤 객체의 참조 동일성을 유지해야 하는 경우 사용할 수 있다.

참조 동일성

useMemo, useCallback은 참조 타입의 불변성을 위해 사용하는 것이다. 원시타입의 경우 useMemo를 쓰든 쓰지 않든 값이 같다면 Call Stack의 주소값은 동일하다.

아래처럼 객체나 배열 같은 참조 타입의 경우, 새로 코드가 실행될 때마다 새로운 주소값을 바라보기에 참조가 동일하지 않게 된다.

true === true // true
false === false // true
1 === 1 // true
'a' === 'a' // true
{} === {} // false
[] === [] // false
() => {} === () => {} // false

그렇다면 React에서는 참조 동일성을 따질 때가 언제일까?

  1. Dependencies List

아래 코드는 예시이기에 코드 자체에 크게 신경쓰지 않고, 개념에 집중해주길 바란다.

Foo 컴포넌트는 리렌더링 될 때마다 내부의 useEffect가 매번 실행되는데 그 이유는 options 객체도 매번 새롭게 생성되기 떄문이다. 따라서 props인 bar 혹은 baz가 변경될 때마다가 아니라 매 렌더마다 useEffect가 실행된다.

그렇다면 어떻게 버그를 수정할 수 있을까?

function Foo({ bar, baz }) {
  const options = { bar, baz };
  React.useEffect(() => {
        console.log('useEffect');
  }, [options]); // we want this to re-run if bar or baz change
  return <div>foobar</div>;
}
function Blub() {
  return <Foo bar="bar value" baz={3} />;
}

첫 번째로는 deps에 bar, baz를 직접 선언하고 options 변수를 useEffect 내부에 선언하는 것이다. 그러나 만약 bar, baz가 원시 타입이 아닌 객체 타입이라면 원하는 대로 동작하지 않게 될 것이다.

// option 1
function Foo({ bar, baz }) {
  React.useEffect(() => {
    const options = { bar, baz };
        console.log('useEffect');
  }, [bar, baz]); // we want this to re-run if bar or baz change
  return <div>foobar</div>;
}

두 번째 방법으로는 useMemo, useCallback을 사용해 참조 동일성을 유지하는 것이다.

Blub 컴포넌트는 리렌더링되어도 bar, baz는 useMemo, useCallback 덕분에 캐싱된 값을 반환하기에 항상 동일한 주소값을 바라보게 된다.

function Foo({ bar, baz }) {
  React.useEffect(() => {
    const options = { bar, baz };
    buzz(options);
  }, [bar, baz]);
  return <div>foobar</div>;
}
function Blub() {
  const bar = React.useCallback(() => {}, []);
  const baz = React.useMemo(() => [1, 2, 3], []);
  return <Foo bar={bar} baz={baz} />;
}

  2. React.memo

아래 코드는 예시이기에 코드 자체에 크게 신경쓰지 않고, 개념에 집중해주길 바란다.

아래는 카운트를 보여주며 카운트를 증가시키는 2개의 CountButton 컴포넌트가 있다. 만약 카운트를 증가하면 DualCounter 컴포넌트가 리렌더링되면서 2개의 CountButton 컴포넌트 모두 리렌더링 될 것이다. 2개의 카운트는 독립적인 상태기에 count1이 업데이트되어도 두번째의 CountButton 컴포넌트는 리렌더링될 필요가 없다. 이런 불필요한 리렌더링을 개선하려면 어떻게 해야할까?

function CountButton({ onClick, count }) {
  return <button onClick={onClick}>{count}</button>;
}
function DualCounter() {
  const [count1, setCount1] = React.useState(0);
  const increment1 = () => setCount1(c => c + 1);
  const [count2, setCount2] = React.useState(0);
  const increment2 = () => setCount2(c => c + 1);
  return (
    <>
      <CountButton count={count1} onClick={increment1} />
      <CountButton count={count2} onClick={increment2} />
    </>
  );
}

바로 React.memo를 써서 props가 바뀔 때만 리렌더링되도록 개선할 수 있다. 하지만 DualCounter 컴포넌트가 리렌더링 될 때마다 setCount1 함수가 새롭게 만들어지기에 CountButton 컴포넌트도 리렌더링될 것이다.

const CountButton = React.memo(function CountButton({ onClick, count }) {
  return <button onClick={onClick}>{count}</button>;
});

이때 useCallback을 사용해 useState의 set 함수의 참조 동일성을 유지시켜 불필요한 리렌더링을 막을 수 있다.

const CountButton = React.memo(function CountButton({ onClick, count }) {
  return <button onClick={onClick}>{count}</button>;
});
function DualCounter() {
  const [count1, setCount1] = React.useState(0);
  const increment1 = React.useCallback(() => setCount1(c => c + 1), []);
  const [count2, setCount2] = React.useState(0);
  const increment2 = React.useCallback(() => setCount2(c => c + 1), []);
  return (
    <>
      <CountButton count={count1} onClick={increment1} />
      <CountButton count={count2} onClick={increment2} />
    </>
  );
}

비싼 연산

비싼 연산의 결과값을 캐싱하기 위해서는 useMemo가 필요하다. (useCallback과는 관련이 없다.) 만약 아래처럼 동기적으로 계산을 하는 복잡한 컴포넌트가 있다고 가정하자. iteration과 multiplier는 매우 느릴 가능성도 있기에 RenderPrimes 컴포넌트가 리렌더링될 때마다 calculatePrimes 함수를 실행하는 것은 위험할 수 있다.

function RenderPrimes({ iterations, multiplier }) {
  const primes = calculatePrimes(iterations, multiplier);
  return <div>Primes! {primes}</div>;
}

이럴 때 useMemo를 사용해 iterations 혹은 multiplier가 변경되었을 때만 calculatePrimes 함수를 실행시킬 수 있다.

function RenderPrimes({ iterations, multiplier }) {
  const primes = React.useMemo(() => calculatePrimes(iterations, multiplier), [
    iterations,
    multiplier
  ]);
  return <div>Primes! {primes}</div>;
}

성능 개선을 위해선 모든 코드에 useMemo, useCallback을 사용해야 할까?

메모이제이션도 값을 비교해 재계산이 필요한지 확인하는 작업, 이전에 결과물을 저장해 두었다가 다시 꺼내와야 한다는 비용이 든다. 따라서 메모이제이션은 어느 정도의 트레이드 오프가 있는 기법이라고 본다.

따라서 성능 최적화를 위해서 무조건적으로 useMemo, useCallback을 항상 써야만 하는 것은 아니다. 성능 최적화를 하기 전 성능을 먼저 측정하는 것이 필수적이며 React DevTools Profiler와 같은 도구를 활용해 성능 측정을 먼저한 후 기대와 다르게 동작하거나 렌더링 시간이 오래 걸리는 컴포넌트를 찾아 성능 최적화를 하는 것이 바람직할 것이다.

참고로 React 19에서는 React 컴파일러가 코드를 자동으로 메모이제이션해준다고 한다고 하니 이런 부분에 대한 고민을 조금을 덜수도 있을 것 같다.

useCallback 대신 useMemo만 사용해도 되지 않을까?

아래처럼 useCallback을 useMemo로 대체할 수 있는데 React에서 왜 굳이 useCallback을 만든 걸까? 라는 생각을 한적이 있다.

function useCallback(fn, dependencies) {
  return useMemo(() => fn, dependencies);
}

아래처럼 useMemo를 사용할 경우, 아래 코드는 값을 캐싱하려는 의도로 작성된 건지, 함수 캐싱인지 혼란스러울 수 있기에 가독성과 의도 명확성을 위해 분리한 것이라는 생각이 든다. 즉, useCallback은 useMemo로 충분히 대체할 수 있지만 함수 캐싱, 값 캐싱이라는 목적을 명확히 구분하기 위해 나눠놓은 것이 아닐까 싶다. 공식 문서에서도 useCallback과 useMemo에 대한 차이점을 설명해주고 있으니 참고하면 좋을 것 같다.

const callback = useMemo(() => {
  return () => {};
}, []);

Custom Hook에서 useCallback을 사용하는 이유

보통 Custom Hook을 구현할 때 useCallback을 많이 사용하는 것 같다.

콜백 함수가 간단한데 왜 굳이 useCallback을 쓸까? 메모이제이션을 하지 않아도 될까? 싶었지만 해당 hook이 다른 deps나 memo의 dependencies가 될 수도 있기에 (어떤 용도로 쓰일지 단정할 수 없기에) 메모이제이션이라는 안전장치를 걸어둔다고 생각된다.

/**
 * 초기값을 전달 받아 input 이벤트로 발생한 입력값에 따라 상태를 변경하는 커스텀 훅
 * @param {T} initialData useState의 초기값
 * @returns {T,(e: ChangeEvent<HTMLInputElement>) => void,Dispatch<SetStateAction<T>>} value, handler, setValue 상태, 입력 값으로 상태를 변경하는 함수, 상태를 변경하는 useState 함수
 */
const useInput = <T,>(initialData: T): ReturnInputTypes<T> => {
  const [value, setValue] = useState(initialData)
  const handler = useCallback((e: ChangeEvent<HTMLInputElement>) => {
    setValue(e.target.value as unknown as T)
  }, [])
  return [value, handler, setValue]
}

React.memo

React Hook은 아니지만 성능 개선에 사용되는 API로 같이 알아보도록 한다.

memo는 컴포넌트의 props가 변경되지 않은 경우 리렌더링을 건너뛸 수 있도록 하는 React API로 고차 컴포넌트 기법을 사용한다.

컴포넌트를 memo로 감싸면 컴포넌트의 memorized 버전을 얻을 수 있게 되며 props가 변경되지 않는 경우 이전에 저장된 컴포넌트를 사용하기에 부모 컴포넌트가 렌더링되어도 해당 컴포넌트는 리렌더링 되지 않는다.

const MemoizedComponent = memo(SomeComponent, arePropsEqual?)

React.memo는 언제 쓰는 것이 좋을까?

props가 자주 변경되지 않는 컴포넌트의 경우 memo를 쓰는 것이 좋지 않을까?

위에서도 말했듯이 메모이제이션은 어느정도 트레이드 오프가 있는 기법이기에 부모 컴포넌트가 자주 렌더링되어도 자식 컴포넌트 리렌더링 로직 비용이 렌더링에 영향이 갈만큼 무겁지 않다면 굳이 쓰지 않아도 된다.

그렇다면 자식 컴포넌트의 리렌더링 비용이 크리티컬한지 어떻게 알 수 있을까?

예를 들면, 앱을 실행할 때 인터렉션으로 인한 렌더링이 현저하게 느리게 느껴질 경우 필요할 수 있을 것 같다. 이때 React Dev Tools Profiler를 이용해 컴포넌트 렌더링 분석 후 memo를 적용해 결과를 비교할 수 있다. (당연한 얘기라 허무할 수도 있다…)

공식 문서에 따르면 “상호작용이 투박한 앱의 경우(페이지 또는 전체 섹션 교체 등) 일반적으로 memoization는 불필요하나, 반면 앱이 그림 편집기이고 도형 이동과 같이 대부분의 상호작용이 세분되어 있다면, memoization가 유용할 수 있다” 고 한다.

난 현재 영상 관련 데이터를 차트로 보여주는 대시보드를 개발하고 있다. (회사 서비스는 고객사만 사용할 수 있기에 사진을 올릴 수 없어 UI가 비슷한 Grafana Playground를 예시로 들었다.)

Grafana playground

대시보드 편집기를 보면 차트, 차트 옵션 패널, 차트 데이터 패널이 존재한다. 이때 차트 옵션을 수정할 경우 차트만 리렌더링되어야 하며 다른 차트 옵션이나 차트 데이터 패널은 관련 없기에 리렌더링되어선 안된다. 그러나 만약 어떤 옵션 변경시 차트 외 다른 컴포넌트가 리렌더링된다면 이때 useCallback이나 useMemo 그리고 memo를 적용해 리렌더링을 방지할 수 있을 것이다.

고차 컴포넌트 (Higher Order Component)

고차 컴포넌트 얘기가 나왔기에 간단히 알아보려 한다. 고차 컴포넌트는 컴포넌트 자체의 로직을 재사용하기 위한 방법으로 고차 함수의 일종이다.
고차 컴포넌트는 횡단 관심사를 분리하는데 사용하며 여기서 횡단 관심사란 각 계층(프론트에서는 컴포넌트라고 볼 수 있겠다)을 넘어 공통으로 필요한 관심사를 의미한다. 프론트엔드에서의 횡단 관심사는 로깅, 인증으로 예를 들 수 있다.

function withLoadingIndicator(Component) {
  return function EnhancedComponent({ isLoading, ...props }) {
    if (isLoading) {
      return <div>Loading...</div>;
    }

    return <Component {...props} />;
  }
}

const DataComponentWithLoading = withLoadingIndicator((props: { value: string }) => {
    return <h2>{props.value}</h2>;
});

export default function App() {
    const isLoading = true;
    return <DataComponentWithLoading value="text" isLoading={isLoading} />
}

단순히 값을 반환하거나 부수 효과를 실행하는 Custom Hook과는 다르게 고차 컴포넌트는 컴포넌트의 결과물에 더욱 큰 영향력을 끼칠 수 있다.

따라서 고차 컴포넌트 사용시 부수효과를 최소화 해야 한다. 예를 들어 기존 컴포넌트의 props를 수정, 삭제하는 행위는 지양하는 것이 좋다. 만약 props를 수정, 삭제하게 된다면 고차 컴포넌트를 사용하는 쪽에서는 props를 예측하기 어려워 부담을 지게 된다.

또한 여러 개의 고차 컴포넌트로 컴포넌트를 감싼다면 복잡성이 커지기에 최소한으로 사용하는 것이 좋다.

Custom Hook vs HOC

둘 다 모두 특정 로직을 공통화해 별도로 관리할 수 있다는 특징이 있다. 그렇다면 각각 어떤 경우에 사용하는 것이 좋을까?

React Hook만으로 공통 로직을 분리할 수 있다면 Custom Hook을 사용하는 것이 좋다.

로그인 정보를 가진 useLogin은 단순히 isLogin에 대한 값만 제공하기에 이에 대한 처리는 컴포넌트를 사용하는 쪽에서 원하는 대로 할 수 있다. 따라서 부수 효과가 비교적 제한적이지만 withLoginComponent는 고차 컴포넌트가 어떤 일을 하는지 직접 보거나 실행하기 전까지는 알 수 없다. 대부분의 고차 컴포넌트는 렌더링에 영향을 미치는 로직이 존재하므로 Custom Hook에 비해 예측하기 어렵다.

따라서 동일한 로직으로 값을 제공하거나 특정 Hook의 작동을 취할 경우 Custom Hook을 사용하는 편이 좋다.

function HookComponent() {
  const { isLogin } = useLogin();
  useEffect(() => {
    if (!isLogin) {
    }
    // do sth
  }, [isLogin]);
}

const HOCComponent = withLoginComponent(() => {
  // do sth
});

반면 컴포넌트의 반환값인 렌더링의 결과물에도 영향을 미치는 공통 로직이라면 고차 컴포넌트를 사용하는 것이 좋다. Custom Hook만으로는 렌더링 결과물에까지 영향을 미치기 어렵기 때문이다.

function HookComponent() {
  const { isLogin } = useLogin();
  if (!isLogin) return <Login />
  return <>Component</>
}

const HOCComponent = withLoginComponent(() => { 
  // isLogin state를 신경쓰지 않고 컴포넌트에 필요한 로직만 추가하면 되기에 간단해졌다.
  return <>Component</>;
})

결론

위 글을 간단히 요약해보면 다음과 같다.

  • useMemo, useCallback은 결과를 캐싱하기 위한 Hook으로 아래의 케이스에 사용하면 좋을 것 같다.
    • 참조 동일성
    • 비싼 연산 재실행 방지
  • React에서는 Dependencies List, React.memo에서 참조 동일성을 따지며 이때 캐싱된 결과를 사용함으로써 리렌더링 혹은 부수 효과 실행을 방지할 수 있다.
  • React.memo는 컴포넌트 자체를 캐싱하며 아래의 케이스에 사용하면 좋을 것 같다.
    • 인터렉션으로 인한 렌더링이 현저하게 느리게 느껴질 경우
    • 인터렉션이 세분화된 경우 (차트 편집기 등)
  • React.memo + useCallback, useMemo 조합으로 많이 사용되며 메모이제이션도 리소스가 소모되는 작업이기에 필요한 곳에서만 메모이제이션을 적용해야 한다.
  • React Hook만으로 공통 로직을 분리할 수 있다면 Custom Hook을 사용해 렌더링에 영향을 최소화하는 것이 좋다.
  • 렌더링 결과물에 영향을 미치는 공통 로직이라면 고차 컴포넌트를 사용하는 것이 좋다.

참고

https://ko.react.dev/reference/react/useMemo

https://ko.react.dev/reference/react/useCallback

https://ko.react.dev/reference/react/memo

https://kentcdodds.com/blog/usememo-and-usecallback