조유성
Software Engineer Server Developer Frontend Developer UI Developer Full Stack Developer Developer OSS Lover 철학 전공자 구조주의자 직장인 ù̴̲̭̼n̴̡͔͍̏d̶̛͇̖̻̅̕e̴̬͇͖̊f̸̢͈͂͒ǐ̶̺̳ͅn̴̝̣̹͂͌e̸̟̯̒d̵̢̉ͅ

You may not need useCallback()

첫 번째로 소개할 React 안티패턴은 useCallback()을 남용하는 경우입니다.

안티패턴 인식하기

useCallback()이 필요한 경우는 생각보다 많지 않습니다. 특히 React 기반의 어플리케이션/서비스 코드에서 useCallback()을 자주 쓰고 있다면, 내가 useCallback()의 역할과 React 컴포넌트의 생애주기(lifecycle)를 정말 잘 이해하고 있는 것이 맞는지 점검해 볼 필요가 있습니다.

여기에 useCallback()이 필요한 상황이 정말 맞는지 확인할 수 있는 아주 간단한 체크 리스트를 준비했습니다. 현실적으로 일반적인 상황에서 useCallback()이 필요한 이유는 아래의 두 가지 외에는 없습니다. 반대로 말해서, 아래의 둘 이외의 이유로 useCallback()을 사용하고 있다면 안티패턴에 해당할 가능성이 높습니다.

  1. memo() 가 적용된 컴포넌트의 재렌더링(re-rendering)을 방지하기 위해

  2. useEffect()의 실행을 의도대로 제어하기 위해

안티패턴에 대응하기

위의 두 가지 이유에 해당하지 않는 useCallback()은 제거하세요. 이 안티패턴은 일반적으로 막연한 습관이나 섣부른 최적화 시도에서 기인한 것이기 때문에 제거한다고 해서 예상되는 손해를 찾기가 오히려 힘듭니다.

자세히 알아보기

정말 이 두 가지 이유 외에는 useCallback()을 쓸 일이 없는 걸까요? 왜 위의 두 상황에서는 useCallback()을 사용해야 하는 것일까요? 이런 의문이 든다면, useCallback()이 필요한 두 가지 상황을 더 자세히 알아보아야 합니다.

상황 1: memo() 가 적용된 컴포넌트의 재렌더링을 방지하기 위해

이 상황을 이해하려면, 당연하게도 우선 React의 memo()가 무엇인지 이해해야 합니다. 그리고 memo()를 소개하려면 먼저 React의 렌더링 동작을 설명할 필요가 있겠습니다. 하지만 이 글의 핵심에서 너무 많이 벗어나지 않기 위해, 우선 React의 렌더링 동작에 관해서는 아주 간략하게만 소개하고 넘어가도록 하겠습니다.

React의 렌더링 동작

React의 렌더링은 크게 두 단계로 이루어집니다. (1) 가상 DOM 렌더링(Virtual DOM Rendering)과 (2) 재조정(Reconciliation)이 그것입니다.

우리가 개발하는 React 컴포넌트들은 렌더링이 되었을 때 그 결과물로 React Element를 반환합니다. React 컴포넌트들이 계층 구조를 갖고 있기 때문에, 자연스럽게 그 결과물인 React Element들도 계층 구조를 이루게 됩니다. 이것을 “가상 DOM”이라고 부릅니다.

하지만 이렇게 컴포넌트를 렌더링하는 것만으로는 아직 실제로 사용자가 보고 있는 화면에 변화가 생기지는 않습니다. 그러기 위해서는 React가 가상 DOM의 변경사항을 실제 DOM에 적용하는 과정을 거쳐야 합니다. 이 과정을 “재조정”이라고 부릅니다.

memo()의 역할

memo()는 두 단계 중 가상 DOM 렌더링과 더 깊은 관련이 있습니다. 컴포넌트를 실제로 렌더링 해보지 않더라도 그 결과1가 이전과 동일하다는 것을 보장할 수 있다면, 렌더링 과정을 건너뛰고 이전에 얻었던 React Element를 재사용할 수 있도록 하는 것이 memo()의 역할이기 때문입니다.

그렇다면 자연스럽게 던지게 되는 질문이 있습니다. 컴포넌트를 실제로 렌더링 해보지 않았는데도 그 결과를 알 수 있는 방법이 있다고요? 네, 그렇습니다. 비록 항상 통하는 방법은 아니지만, “어떤” 컴포넌트들은 바로 직전 렌더링에 때 사용했던 props와 이번 렌더링에 사용 중인 props를 비교함으로써, 두 렌더링의 결과 또한 동일할지 여부를 판단할 수 있습니다2. 이것이 바로 memo()가 하는 일입니다.

구체적으로 memo()는 (1) 컴포넌트가 내부적으로 구독하고 있는 상태의 변화가 아니라 외부적인 이유3로 컴포넌트를 재렌더링이 일어날 때 (2) 직전 렌더링에 사용한 props와 이번 렌더링에 사용 중인 props가 동일하다면, 이번에는 실제 렌더링을 진행하지 않고 직전에 반환되었던 React Element를 재사용 하도록 해줍니다.

Props의 값을 비교하는 데에 필요한 시간 복잡도는 일반적으로 실제로 컴포넌트를 렌더링하는 데에 필요한 시간 복잡도보다 낮기 때문에, React 어플리케이션의 성능 개선에 memo()를 활용할 수 있습니다.

memo()가 props를 비교하는 방법

그렇다면 이 모든 것이 useCallback()과는 도대체 무슨 관련이 있는 걸까요? 이 질문에 답하기 위해서는 memo()가 props 간의 비교를 수행하는 방식을 조금 더 자세히 들여다보아야 합니다.

편의상 직전 렌더링에 사용했던 props를 prevProps라고 부릅시다. 그리고 이번 렌더링에 사용 중인 props는 currentProps라고 부르겠습니다. memo()는 두 props가 동일한지 여부를 어떻게 판단할까요?

prevProps === currentProps로 비교할 수는 없습니다. Props object 자체의 참조(reference)는 매 렌더링마다 변화하기 때문입니다. 대신, 각 props에 포함된 모든 key-value 쌍을 비교해야 합니다. React 공식 문서에서는 이렇게 설명하고 있습니다:

By default, React will compare each prop with Object.is.

코드로 표현하자면 대략 이런 알고리즘이 되겠죠:

function arePropsEqual(prevProps, currentProps) {
  const prevKeys = Object.keys(prevProps);
  const currentKeys = Object.keys(currentProps);

  if (prevKeys.length !== currentKeys.length) {
    return false;
  }

  for (const key in currentKeys) {
    if (!Object.is(prevProps[key], currentProps[key])) {
      return false;
    }
  }

  return true;
}

그러니 결국 memo()를 사용해서 성능에 이득을 보려면, memo()로 감싼 컴포넌트에게 직전 렌더링에서 넘겼던 것과 정확히 동일한 값을 담은 props를 넘겨주어야 하는 것입니다. 이때 “동일한 값”이란, Object.is()로 비교했을 때 true가 반환되는지 여부가 기준입니다.

memo()useCallback()

그런데 JavaScript에서 동일한 함수를 두 번 생성할 수 있는 방법이 없다는 데에서 모든 문제가 시작됩니다. 물론 동일한 동작을 하는 함수는 얼마든지 만들 수 있지만, Object.is()로 비교했을 때 true를 반환하는 함수는 오직 자기 자신 뿐입니다.

Object.is(() => window.alert('hi'), () => window.alert('hi'));
// -> false

const fn = () => window.alert('hi');
Object.is(fn, fn);
// -> true

그래서 memo()로 감싼 컴포넌트가 만약 prop으로 이벤트 핸들러 등의 함수를 받는다면 곤란한 상황이 생겨납니다. 아래와 같은 컴포넌트를 생각해봅시다:

const Parent = () => {
  return (
    <Child
      title="click this"
      onClick={() => window.alert('hi')} />
  );
};

const Child = memo(({ title, onClick }) => {
  return <button onClick={onClick}>{title}</button>;
});

Child 컴포넌트는 memo()로 감싸져있지만, Parent가 재렌더링 될 때마다 매번 새로운 onClick 이벤트 핸들러를 넘겨받게 될 것입니다. Parent 컴포넌트가 재렌더링 될 때마다 memo()가 수행하는 비교 연산을 나열해본다면 아래와 같은 모습이 될 것입니다:

Object.is("click this", "click this");
// -> true

Object.is(() => window.alert('hi'), () => window.alert('hi'));
// -> false

사실상 같은 동작을 하는 함수를 전달 받았음에도 불구하고 구현 상의 한계로 인해 매 렌더링마다 다른 props를 전달받았다고 인식하게 되어서 사실상 memo()를 통한 성능 이득을 볼 수 없는 상황입니다. 단지 이득을 못 볼 뿐만 아니라, 매번 다를 수밖에 없는 props 객체를 비교하고 있고 영영 쓸 수 없는 직전 렌더링 결과물이 메모리에 자리만 차지하고 있으니, 오히려 손해라고도 할 수 있습니다.

바로 이러한 문제를 해결하기 위해 useCallback()을 사용할 수 있습니다. Parent 컴포넌트가 Child 컴포넌트에 넘겼던 onClick 이벤트 핸들러를 useCallback()으로 감싸면 어떨까요?

const Parent = () => {
  const handleClick = useCallback(() => {
    window.alert('hi');
  }, []);

  return (
    <Child title="click this" onClick={handleClick} />
  );
};

useCallback()의 의존성 배열이 비어있으니, Parent가 아무리 여러 번 렌더링되어도 handleClick의 참조가 변하지 않게 됩니다. 다시 한 번 memo()의 비교 연산을 나열해보자면 이렇게 될 것입니다:

Object.is("click this", "click this");
// -> true

Object.is(handleClick, handleClick);
// -> true

이제야 비로소 memo()가 유의미한 최적화를 진행할 수 있게 된 것이죠. 이것이 useCallback()을 사용해야만 하는 첫 번째 상황입니다.

상황 2: useEffect()의 실행을 의도대로 제어하기 위해

첫 번째 상황을 잘 이해했다면, 두 번째 상황도 쉽게 이해할 수 있습니다. 첫 번째 상황이 memo()가 props의 변경을 감지하는 방법과 관련 있었다면, 두 번째 상황은 useEffect()가 의존성 배열(dependency array)의 변경을 감지하는 방법과 관련이 있습니다.

의도보다 자주 실행되는 useEffect()

페이지의 스크롤이 특정 지점 아래로 넘어갔을 때에만 메시지를 표시하는 아래의 간단한 코드를 볼까요?

function Example() {
  const { threshold } = use(ExampleContext);
  const [isVisible, show, hide] = useVisibility();

  function handleScroll() {
    if (window.scrollY > threshold) {
      show();
    } else {
      hide();
    }
  }

  useEffect(() => {
    window.addEventListener('scroll', handleScroll);

    return () => {
      window.removeEventListener('scroll', handleScroll);
    };
  }, [handleScroll]);

  if (!isVisible) {
    return null;
  }

  return <span>hello world</span>;
}

대체로 깔끔하게 작성되었고, 빠뜨리기 쉬운 이벤트 핸들러 정리(cleanup)도 removeEventListener()로 잘 구현했습니다. 아마도 동작만 본다면 잘 작동할 것 같습니다.

하지만 사실 이 코드는 작성자가 의도하지 않은 방식으로 작동하고 있을 가능성이 높습니다. 작성자는 아마도 Example 컴포넌트가 최초로 렌더링 되거나, threshold, show(), hide() 등의 값이 바뀌었을 때에만 useEffect()가 실행되기를 원했을 것입니다.

그러나 이 코드는 handleScroll이 직접적으로 의존하고 있지 않은 isVisible 상태가 바뀌더라도 매번 scroll 이벤트 핸들러를 window에 붙였다 떼어내기를 반복하는 방식으로 작동합니다. 사실, 어떤 이유로든 Example 컴포넌트가 재렌더링 될 때마다 매번 이벤트 핸들러를 떼었다 다시 붙이기를 반복하게 됩니다.

이유는 간단합니다. 매 렌더링마다 handleScroll의 값이 달라지기 떄문입니다. 직전 렌더링에 생성되어 useEffect() 훅(hook)의 의존성 배열에 담겼던 함수는 이번 렌더링에 생성된 함수와 같을 수 없습니다. 앞서 첫 번째 상황에서 이미 한 번 보았듯이, 같은 함수를 두 번 생성할 수는 없기 때문입니다. useEffect() 입장에서는 렌더링이 일어날 때마다 매번 다른 의존성 배열을 받고 있는 것이라서, 매 렌더링마다 주어진 함수를 실행하게 됩니다.

해결 방법도 간단합니다. handleScrolluseCallback()으로 감싸면 됩니다. 이때 useCallback()의 의존성 배열에는 handleScroll에서 사용하고 있는 모든 값들이, 그리고 오직 그 값들만이 담기도록 하면 됩니다.

const handleScroll = useCallback(() => {
  if (window.scrollY > threshold) {
    show();
  } else {
    hide();
  }
}, [threshold, show, hide]);

useEffect(() => {
  window.addEventListener('scroll', handleScroll);

  return () => {
    window.removeEventListener('scroll', handleScroll);
  };
}, [handleScroll]);

또 다른 해결 방법으로는 handleScroll 함수의 선언 자체를 useEffect() 내부로 이동하는 방법도 있습니다.

useEffect(() => {
  function handleScroll() {
    if (window.scrollY > threshold) {
      show();
    } else {
      hide();
    }
  }

  window.addEventListener('scroll', handleScroll);

  return () => {
    window.removeEventListener('scroll', handleScroll);
  };
}, [threshold, show, hide]);

두 방법 모두 동작에는 차이가 없으니, 가독성과 취향에 따라 상황에 맞게 개발자가 선택하면 됩니다.

어쩔 수 없는 억울한 경우

여기까지 읽었을 때 눈치가 빠른 분이라면 들 수 있는 의심이 있습니다. “아니 근데 showhide도 함수인데, 매 렌더링마다 값이 바뀌지 않는다는 건 어떻게 보장하지?” 하는 생각이 들지 않나요?

좋은 지적입니다. 특히나 위의 예시에서는 showhide를 반환하는 useVisibility()의 구현이 나오지 않았기 때문에 우리는 영영 알 길이 없습니다. 만약 useVisibility()가 이런 식으로 구현되어 있다면 우리는 억울하게도 어쩔 수 없이 매번 scroll 이벤트 핸들러가 붙었다 떨어지는 것을 지켜만 보고 있어야 합니다.

function useVisibility() {
  const [isVisible, setIsVisible] = useState(false);
  return [
    isVisible,
    () => setIsVisible(true),
    () => setIsVisible(false),
  ];
}

그래서 커스텀 훅을 개발할 때는, 반환하는 값 전체에 대해서는 아니더라도 적어도 개별 함수들만큼은 꼭 필요한 경우에만 값이 바뀌도록 하는 것이 좋습니다. 아마 여러분이 사용해보셨던 대부분의 라이브러리들이 이미 그렇게 구현되어 있었을 것입니다.

위의 코드를 올바르게 고쳐본다면 아래와 같이 구현할 수 있겠죠:

function useVisibility() {
  const [isVisible, setIsVisible] = useState(false);

  const show = useCallback(() => {
    setIsVisible(true);
  }, []);

  const hide = useCallback(() => {
    setIsVisible(false);
  }, []);

  return [isVisible, show, hide];
}

아니 왜 말이 달라지나요

여기서 또 다시 한 번 의심해볼 만한 점이 있습니다. “아니 아까는 이렇게 말했잖아요! —

useCallback()의 의존성 배열에는 handleScroll에서 사용하고 있는 모든 값들이, 그리고 오직 그 값들만이 담기도록 하면 됩니다.

showhide가 의존하고 있는 setIsVisible()이 의존성 배열에 빠진 것 아닌가요?”

네, 맞습니다. 정당한 지적입니다. 하지만 이 코드는 동작 상 문제가 없습니다. 왜냐하면 React의 useState()가 반환하는 setter 함수의 값은 영원히 변하지 않기 때문입니다. 그래서 setter를 의존성 배열에 넣으나 빼나 동작은 동일합니다.

“아니 그럼 어떤 함수가 값이 안 바뀌는지 기억하고 있다가 의존성 배열에서 빼나요?” 아닙니다. 이런 걸 매번 신경쓰고 있을 수는 없기 때문에 React 생태계에서는 린터(linter)를 사용합니다. ESLint와 eslint-plugin-react-hooks를 사용하면 편집기에 상황에 맞는 경고가 나타나고 심지어 자동으로 고쳐주기도 합니다. 의존성 배열에 넣지 않아도 되는 값에는 useState()의 setter 외에도 useRef()가 반환하는 ref 객체도 있습니다만, 이런 것을 기억할 필요는 없습니다. 그저 의존성 배열에는 함수에서 쓰이는 모든 값을 넣되, 린터가 넣지 않아도 된다고 알려줄 때만 빼면 됩니다.


  1. 렌더링 결과물이 아니라 렌더링의 “결과가 이전과 동일하다”라고 한 것은 의도된 표현입니다. 렌더링의 결과물인 React Element가 동일한 것만으로는 부족합니다. 렌더링 과정에서 발생하는 모든 부수효과(side-effect) 또한 동일해야만 안전하게 memo()를 적용할 수 있습니다. 

  2. 과연 그런 컴포넌트가 얼마나 있는지 의문이 드시나요? 생각보다 많습니다. 심지어, 이상적으로는 모든 React 컴포넌트에 대해 이런 식으로 렌더링 결과가 바뀌는지 여부를 예측할 수 있어야 합니다. 이 관점에 관해서는 다른 기회에 더 자세히 소개해보고 싶습니다. 

  3. 가장 대표적으로는 부모 컴포넌트의 재렌더링이 있겠습니다.