지나가던 개발(zigae)

리액트 useCallback, useMemo 언제 사용 할까?

2021년 8월 1일 • ☕️ 3 min read

본글은 useCallback, useMemo에 대해 설명하는 글이 아님을 알린다.

useCallback

아래 코드를 살펴보자.

const Foo = () => {
  const [count, setCount] = useState(0);

  // case 1
  const handleIncrement = useCallback(() => {
    setCount(count + 1);
  }, []);

  // case 2
  const handleIncrement = () => {
    setCount(count + 1);
  };

  return <Box onClick={handleIncrement} />;
};

case1과 case2 중에 비용이 적게 드는 코드는 어느쪽일까? 많은 아티클에서 inline 함수는 useCallback 으로 안에 넣는 것이 성능에 더 좋다 말하고 있기 때문에 case1을 선택할 수 있겠지만 실상은 그렇지 않다.

모든 추상화 및 최적화 코드에는 비용이 들기 마련이다. 이때 발생하는 비용을 상쇄 시킬만한 비용절약이 있지 않으면 오히려 비용 증가가 일어난다. 최적화 관점에서 useCallback을 사용하기 위해선 전후 성능을 비교 후에 사용하는 것이 옳다. 최근에는 함수가 맡는 롤이 작기 때문에 필자는 대부분 사용하지 않고 있다.

useMemo

useMemouseCallback과 어떤 차이가 있을까? 어떠한 타입에 상관 없이 memoization 한다는 것을 제외하면 useCallback과 굉장히 유사하다. useMemo의 콜백 함수는 디펜던시 배열에 값이 변화되어야 할때만 재선언 되는 값이 되도록합니다. 매 랜더마다 initialize 배열을 초기화 하기 싫으면 아래와 같이 작성 할 수 있다.

- const initialFruits= ['apple', 'banna', 'coconut']+ const initialFruits = React.useMemo(
+  () => ['snickers', 'skittles', 'twix', 'milky way'],
+  [],
+ )

문제는 해결 됐다. 하지만 이 해결방법은 간단한 코드를 더 복잡하게 만들었고 좋은 코드는 아니다. 이 경우엔 useMemo를 사용하는 것이 좋지 않을 수 있다. 왜냐하면 또 다시 매렌더마다 useMemo를 호출하고 있고 메모리를 할당하고 있기 때문이다. 여기선 아래와 같이 작성하는 것이 더 좋은 방법이다.

+ const initialFruits = ['apple', 'banna', 'coconut']
  function Fruit() {
-   const initialFruits = ['apple', 'banna', 'coconut']    const [fruits, setFruits] = React.useState(initialFruits)

하지만 위와 같은 고민을 하는 것에 시간을 들일 필요 없다. 대부분의 값은 함수안에서 초기화 되거나 prop으로 부터 오기 때문이다. 이러한 최적화로 부터 오는 이점은 작기 때문에 이러한 고민보다 product를 개선하는데 시간을 들이자.

참조 동일성

그럼 도대체 언제 사용하라는 말인가? useMemo, useCallback을 사용할 때 많이들 놓치는 부분이 있다. 바로 참조 동일성의 이점을 고려하지 않는다. 아래 코드를 살펴보자.

완성형 코드가 아니니 작성 된 코드의 맥락만 이해하길 바란다.

const Child = ({ bar, baz }) => {
  const options = { bar, baz };

  useEffect(() => {
    handleOptions(options);
  }, [options]);

  return <Box>foo</Box>;
}

useEffectoptions에 대해서 equality 체크를 매 렌더마다 하게 되고, 자바스크립트가 오브젝트 비교 방식 때문에, options는 매 시간마다 매번 새롭게 만들어진다. 그래서 리액트는 options가 매 렌더마다 변화 했는지 체크를 하는 테스트를 할때마다 항상 true를 반환하게 됩니다. 이를 해결 하기 위한 방법은 아래와 같다.

const Child = ({ bar, baz }) => {

  useEffect(() => {
    const options = { bar, baz };
    handleOptions(options);
  }, [bar, baz]); 

  return <Box>foo</Box>;
}

아주 간단한 방법이고, 실제로 해결 될 수만 있다면 더할나위 없다. 하지만 문제는 bar, baz가 객체일 경우이다. 아래를 살펴 보자.

const Parent = () => {
  const bar = () => {};
  const baz = { a: 123 };

  return <Foo bar={bar} baz={baz} />;
}

bar, baz는 이제 오브젝트다. Foo컴포넌트의 useEffect는 equality 체크에서 매번 true를 반환하게 되고 매번 렌더링을 다시 하게 됩니다. 문제의 코드를 수정해보자.

const Parent = () => {
  const bar = useCallback(() => {}, [])
  const baz = useMemo(() => { a: 123 }, [])
  
  return <Child bar={bar} baz={baz} />;
}

이제 bar, baz의 레퍼런스를 유지 시켜 equality 체크에서 false를 반환 할 것이다. 최적화의 관점에서 보면 분명 비용증가가 일어 날 수 있다. 하지만 레퍼런스를 유지시 켜준다는 것은 개발자가 의도하지 않은 렌더링으로 부터 사이드이펙트가 발생하는 것을 방지 했을때 가져다주는 이점이 훨씬 크다.

커스텀 훅도 마찬가지다. 외부로 나가는 함수는 참조동일성을 지켜서 밖으로 내보내주는 것이 커스텀훅을 사용하는 개발자를 배려하는 것이다.

결론

useCallback, useMemo는 오히려 코드 복잡도를 증가 시킬 수 있고, 디펜던시 배열에서 실수를 유발 할 수 있다. 우리의 코드가 최적화가 되지 않아 퍼포먼스에 크리티컬한 영향이 있는게 아니라면 최적화의 관점 보다 참조 동일성의 관점에서 useCallback, useMemo를 사용하고, 최적화가 필요하다면 비용 측정을 먼저 하는것이 선행 돼야 한다.