콜백의 참조를 유지하기 위한 커스텀 훅

·

5 min read

리액트엔 컴포넌트의 props가 변경될 때 리렌더링이 발생한다는 규칙이 있다. props를 원시값으로 가정하면 당연히 그러는게 타당하다고 생각할 수 있지만, props가 객체나 함수인 경우엔 불필요하게 리렌더링이 발생하는 경우가 생긴다.

const Component = (props) => {
  const callback = () => {};
  const object = { a: 1 };

  return (
    <Child callback={callback} object={object} />
    )
}

예를들어 위 예제의 경우, 어떤 이유에서든 Component가 리렌더링 되었다면, 컴포넌트의 Body가 다시 실행되므로 callbackobject는 다시 평가된다. 내용이 바뀌지는 않았는데, 참조값이 바뀌니까 Child역시 리렌더링된다.

대부분의 경우엔 이런 상황에 리렌더링이 한번 더 되어도 성능 문제는 발생하지 않으며, 발생한다고 해도 useCallback이나 useMemo를 통해 참조를 유지하는 방법을 제공하니 문제없이 넘어갈 수 있다.

단, useCallback이나 useMemo는 기본적으로 성능 최적화를 위해서만 사용해야 한다.

You should only rely on useCallback as a performance optimization. If your code doesn’t work without it, find the underlying problem and fix it first. Then you may add useCallback back.

출처

사용하든 안하든 어플리케이션은 제대로 동작해야 하며, 이 훅을 사용함으로써 동작하지 않던 문제가 해결되는 경우엔, 이 훅이 적절한 해결책이 아니라는 의미이다.

하지만 어떤 라이브러리에선 특정 케이스에서 참조값이 동일하지 않을 때 발생하는 버그때문에 useMemo를 사용해야한다고 소개하는 경우도 있다. (참고)

이렇듯 리액트에선 성능 최적화가 아닌 단순히 참조값을 동일하게 유지하려는 목적때문에 useMemo, useCallback을 사용하는 경우도 분명 존재한다.

참조가 유지되지 않아서 생길 수 있는 문제

리액트 컴포넌트의 렌더링 로직은 순수해야 하므로 몇 번이 실행되도 문제가 없는게 정상이지만, 상태 변경이나 API 호출, 로깅 등의 순수하지 않은 로직이 존재하는 useEffect훅이나 이벤트 핸들러는 외부 시스템 혹은 상태에 변경을 발생시킬 수 있으므로 1번 실행되는것과 2번 실행되는것에 분명한 차이가 있다.

예를들어 intersectionObserver API를 사용하는 경우를 생각해보자. 이 API를 사용하면 관찰중인 엘리먼트가 뷰포트와 교차할 때 호출할 콜백을 등록할 수 있다. 뷰포트 안으로 들어올 때나 바깥으로 나갈 때 둘 다 호출되는데, 예시 코드는 다음과 같다.

const div = document.querySelector('div');
const observer = new IntersectionObserver((entries) => {
  const entry = entries[0];
  console.log(entry.isIntersecting);
});
observer.observe(div);

useEffect훅은 기본적으로 리액트의 외부 시스템과의 동기화를 위해서 사용하는 훅인데, 위 코드에서 관찰중인 요소가 뷰포트 내로 들어왔는지, 바깥으로 나갔는지에 대한 상태(entry.isIntersecting)는 리액트가 아닌, 리액트 외부 시스템인 Web API가 관리한다.

그래서 리액트가 관리하는 상태와 동기화를 하려면 useEffect를 사용해야 한다. (참고)

const useIntersectionObserver = (ref) => {
    const [isIntersecting, setIsIntersecting] = useState(false);

    useEffect(() => {
      const element = ref.current;
      const observer = new IntersectionObserver((entries) => {
        const entry = entries[0];
        setIsIntersecting(entry.isIntersecting);
      });

      observer.observe(element);
      return () => observer.disconnect();
    }, [ref]);

    return isIntersecting;
}

const Component = () => {
  const ref = useRef(null);
  const isIntersecting = useIntersectionObserver(ref);

  useEffect(() => {
    if (isIntersecting) console.log('on intersection');
  }, [isIntersecting])

  return (
    <div>
      <Item />
      <Item />
      <Item />
      <Item />
      <Item />
      <div ref={ref}>Target</div>
    </div>
  )
}

Div엘리먼트가 보일 때 마다 콘솔에 텍스트가 출력된다.

여기서 isIntersectingtrue일 때 호출할 콜백을 전달받는 컴포넌트로 한단계 더 추상화가 가능하다.

interface IntersectionAreaProps extends ComponentPropsWithoutRef<'div'> {
  children?: ReactNode;
  onIntersection?: () => void;
}
const IntersectionArea = (props: IntersectionAreaProps) => {
  const { onIntersection, ...restProps } = props;

  const ref = useRef(null);
  const isIntersecting = useIntersectingObserver(ref);  
  useEffect(() => {
    if (isIntersecting) onIntersection?.();
  }, [isIntersecting, onIntersection])

  return (
    <div {...restProps} />
  )
}

이제 다음과 같이 더 선언적인 코드를 작성할 수 있다.

const Page = () => {
  return (
    <div>
      <Item />
      <Item />
      <Item />
      <Item />
      <Item />
      <IntersectionArea
        style={{ fontSize: 100 }}
        onIntersection={() => console.log('on intersection')}
      >
        In Area
      </IntersectionArea>
    </div>
  )
}

하지만 useEffect훅의 디펜던시 배열에 콜백함수가 포함되어 있어서 isIntersecting의 변화에 따라서만 콜백이 호출되는게 아니라, Page컴포넌트가 리렌더링되어 콜백의 참조가 바뀌어도 호출될 수 있다.

이 문제는 다음과 같은 방법으로 재현할 수 있다.

const Page = () => {
  const [count, setCount] = useState(1);

  return (
    <div>
      <Item />
      <Item />
      <Item />

      <IntersectionArea
        style={{ fontSize: 100 }}
        onIntersection={() => console.log('on intersection')}
      >
        In Area
      </IntersectionArea>

      <button type="button" onClick={() => setCount(p => p + 1)}>
        Increase Count
      </button>

      <div>{count}</div>

      <Item />
      <Item />
      <Item />
    </div>
  )
}

이 때 useCallback을 사용해도 일부 문제는 해결이 가능하지만, 콜백의 디펜던시가 바뀌었을 때 이펙트가 트리거되는건 여전히 해결할 수 없다.

예를들면, 무한스크롤 UI에서 다음 페이지를 로드하면서 기준점이 되는 cursor값이 바뀌는 경우, 전달되는 onIntersection콜백의 참조는 당연히 바뀌어야하지만, 이 결과로 Effect가 트리거되는(=결과적으로 onIntersection이 한번 더 호출되는)건 의도하지 않았을 것이다.

const { items, nextCursor } = useItems();

// cursor가 바뀌면 메모이제이션된 loadMore도 바뀌어야 한다.
const loadMore = useCallback(() => { 
  getItems({ cursor: nextCursor });
}, [nextCursor]);

return (
  <div>
    <Items items={items} />
    <IntersectionArea onIntersection={loadMore} />
  </div>
)

위 코드를 보면, useCallback을 사용했는데도 다음 페이지를 불러왔을 때, nextCursor값이 바뀌니까, loadMore의 참조도 바뀌어서 IntersectionArea의 이펙트가 트리거되고, onIntersection이 다시 호출된다.

use-callback-ref로 참조 유지하기

radix-ui라는 headless 컴포넌트를 제공하는 라이브러리가 있다. 궁금해서 가끔 코드를 살펴보는데, 해당 라이브러리에서 사용되는 내부 유틸리티중에 쓸만한게 정말 많다고 생각한다.

그중에 use-callback-ref라는 모듈이 있는데, 처음엔 ref를 콜백으로 사용하는것에 관련된 훅인가? 했지만, 로직을 보니 useRef를 사용하여 콜백이 변경되어도 참조를 유지할 수 있는 기능을 제공하는 훅이었다.

/**
 * A custom hook that converts a callback to a ref to avoid triggering re-renders when passed as a
 * prop or avoid re-executing effects when passed as a dependency
 */

function useCallbackRef<T extends (...args: any[]) => any>(callback: T | undefined): T {
  const callbackRef = React.useRef(callback);

  React.useEffect(() => {
    callbackRef.current = callback;
  });

  // <https://github.com/facebook/react/issues/19240>
  return React.useMemo(() => ((...args) => callbackRef.current?.(...args)) as T, []);
}

전달받은 함수를 callbackRef.current에 매 렌더링마다 동기화시킨다. 콜백이 prop이나 디펜던시 배열로 전달될 때, 실제로는 동작이 달라져서 참조가 바뀐다고 해도, callbackRef의 참조는 유지되기 때문에 리렌더링을 피하는 용도로 사용된다.

예시는 다음과 같다. 사용하는 쪽에서는 알 필요가 없는 훅이라, 추상화한 컴포넌트 내부에서 사용하였다.

const IntersectionArea = (props: IntersectionAreaProps) => {
  const { onIntersection, ...restProps } = props;

  const ref = useRef(null);
  const isIntersecting = useIntersectingObserver(ref);  
  const memoizedOnIntersection = useCallbackRef(onIntersection);

  useEffect(() => {
    if (isIntersecting) memoizedOnIntersection?.();
  }, [isIntersecting, memoizedOnIntersection])

  return (
    <div {...restProps} />
  )
} 

const Page = () => {
  const [count, setCount] = useState(1);

  return (
    <div style={{ textAlign: 'center' }}>
      <Item />
      <Item />
      <Item />

      <IntersectionArea
        style={{ fontSize: 100 }}
        onIntersection={() => console.log(count)}
      >
        In Area
      </IntersectionArea>

      <button type="button" onClick={() => setCount((p) => p + 1)}>
        Increase Count
      </button>

      <div>{count}</div>

      <Item />
      <Item />
      <Item />
    </div>
  );
};

상태 업데이트에 따라 콜백의 참조가 바뀌어도 이펙트가 트리거되지 않으면서, 업데이트된 콜백과의 바인딩도 유지가 된다.

간단하게 해결이 되었으나, useCallbackRef은 만능이 아니다. 콜백이 변경됨에 따라 리렌더 혹은 이펙트가 트리거되어야 하는 상황이라면 사용하지 않아야 한다.