useCallback 알아보기

우현민·2022년 6월 20일
7

React

목록 보기
4/11
post-thumbnail
post-custom-banner

useCallback 은 리액트에서 함수 메모이제이션에 사용되는 훅입니다. 2022년 6월 20일 기준으로, 공식문서 에서는 이 훅을 다음과 같이 설명합니다. (source)

이 글에서는 useCallback 을 좀더 자세히 알아보겠습니다.

리액트 버전은 v18.2.0 (커밋 9e3b772)을 기준으로 설명합니다.

리액트 소스 코드는 정적 타입 체커에 flow를 이용하고 있습니다. 그래서 코드에 Array<mixed> 같은 타입이 등장하는데, flow 에 익숙하지 않으시다면 그냥 적당히 그렇구나 하고 넘어가주셔도 문제 없으니 읽어주실 때 참고 부탁드립니다!



useCallback

리액트 함수 컴포넌트는 매 렌더마다 실행됩니다. 때문에 함수 컴포넌트의 렌더 단계에서 생성한 변수 및 함수들은, 함수를 실행하며 생기는 변수들이 그렇듯 매 실행(렌더)마다 새로이 계산됩니다.

const Component = () => {
  const temp = 1 * 2 * 3; // 매 렌더마다 다시 계산됨
  const add = (a: number, b: number) => a + b; // 매 렌더마다 새로 생기는 함수
  const expensiveValue = someExpensiveFunction(); // 어디선가 import했다 치고, 아무튼 이런 비싼 함수도 예외 없이 새롭게 실행합니다.
  
  return <h1>Hello World</h1>;
}

이 중 우리는 컴포넌트 렌더 단계에서 생성되는 함수에 주목해 보겠습니다. 아래와 같은 상황에서, 리액트에서는 "렌더 도중 생기는 함수를 메모이제이션해서 전달해야 할 일"이 생기곤 합니다.

  • React.memo 로 감싸져 있는 자식 컴포넌트에 콜백 함수를 전달해야 할 경우
  • 프로그래머 또는 써드파티 라이브러리가 함수의 레퍼런스를 참조해서 뭔가 최적화 작업 같은 걸 해뒀을 경우

이런 상황에서 우리는 useCallback을 이용할 수 있습니다. 이 함수는 1~2 개의 인자를 받아서 1개의 인자를 리턴합니다.

// 실제로 존재하는 정확한 타입은 아닙니다
type useCallback = (callback: 함수, deps?: 디펜던시 배열): 함수;

useCallback hook은 callback 으로 넘겨준 함수를 기억하고 있다가, dependency array에 변경이 감지되면 이번에 넘겨준 함수를, 변경되지 않았다면 저번에 넘겨준 함수를 리턴합니다.



useCallback 의 원리

리액트 소스코드를 간단하게 뜯어보며 useCallback이 어떻게 구현되어 있는지 살펴보겠습니다.

useCallback 의 구현체는 여기에서 찾을 수 있습니다. mount 시에 동작하는 구현체 mountCallbackupdate 또는 rerender 시에 동작하는 구현체 updateCallback 이렇게 두 개의 구현체가 있습니다.

그러니까, 코드를 요약하면 이런 형태입니다.

function mountCallback () { /* */ } // 마운트 상황의 구현체
function updateCallback () { /* */ } // 렌더 상황의 구현체

const HooksDispatcherOnMount = {
  useCallback: mountCallback,
  // ...
}
  
const HooksDispatcherOnUpdate = {
  useCallback: updateCallback,
  // ...
};

/* 컴포넌트 렌더 시에 실행되는 함수 */
export function renderWithHooks() {
  // ...
  ReactCurrentDispatcher.current =
    current === null || current.memoizedState === null // 마운트 상황?
      ? HooksDispatcherOnMount
      : HooksDispatcherOnUpdate;
  // ...
}

실제 소스를 기준으로 보면, 이렇게 연결되어 이런 로직을 통해 구현되어 있습니다.

// mount 시에 동작하는 useCallback 구현체
function mountCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  hook.memoizedState = [callback, nextDeps];
  return callback;
}

// render 시에 동작하는 useCallback 구현체
function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  if (prevState !== null) {
    if (nextDeps !== null) {
      const prevDeps: Array<mixed> | null = prevState[1];
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        return prevState[0];
      }
    }
  }
  hook.memoizedState = [callback, nextDeps];
  return callback;
}

소스코드 중 등장하는 다른 함수들은 이 글에 임베드하기는 너무 길어 링크로 대체합니다.

이제 이 함수들을 하나씩 뜯어보겠습니다.

mountCallback

function mountCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  hook.memoizedState = [callback, nextDeps];
  return callback;
}

mountCallback함수는 컴포넌트가 처음 마운트될 때 useCallback 이 실행될 때 실행되는 함수입니다.

소스코드를 확인해 보면 mountWorkInProgressHook 은 hook 객체를 만들어 리턴하고, 컴포넌트의 hook 연결리스트에 등록합니다. mountCallback 함수는 mountWorkInProgressHook 함수를 통해 훅을 등록한 다음, memoizedState[콜백, 의존성 배열] 을 저장한 후 전달받은 콜백을 리턴합니다.

첫 마운트될 때 useCallback 은 실제로 전달받은 콜백을 그대로 리턴합니다. 굉장히 자연스럽고 명료하게 구현되어 있습니다. 그리고 state에 저장해둔 값을 보아하니 updateCallback 에서는 디펜던시 배열을 비교한 다음 저장해둔 함수 아니면 새로 들어온 함수를 리턴한다는 걸 예상할 수 있겠습니다.

updateCallback

function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  if (prevState !== null) {
    if (nextDeps !== null) {
      const prevDeps: Array<mixed> | null = prevState[1];
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        return prevState[0];
      }
    }
  }
  hook.memoizedState = [callback, nextDeps];
  return callback;
}

소스코드를 확인해 보면 updateWorkInProgressHook 은 다소 긴 함수입니다. 코드가 잘 짜여져 있기 때문에 정확한 동작은 소스를 확인해 보면 될 것 같고, 요약하자면 이전에 연결리스트에 저장되어 있던 hook 객체를 찾아서 리턴하는 게 전부입니다. 다만, 이때 케이스가 좀 많고 예외처리도 해야 해서 코드가 mount에 비해 복잡해졌습니다.

updateCallback 함수는 updateWorkInProgressHook 을 호출하여 hook 객체를 받아온 다음, (조금의 널체크와 함께) 이전 의존성 배열과 이번 의존성 배열을 비교합니다. 만약 의존성 배열이 이전과 동일하다면 이전 callback을 반환하고, 동일하지 않다면 새로 들어온 callback을 반환하면서 hook 에 저장된 "기존 상태"를 업데이트합니다.

널체크 등 몇몇 로직 덕분에 조금은 복잡해졌지만, 마찬가지로 명료하게 구현되어 있습니다.



동작 확인해 보기

먼저 간단한 예제를 보겠습니다.

export default () => {
  const [a, setA] = useState(0);
  const [b, setB] = useState(1);

  const callback = useCallback(() => {
    console.log(a, b);
  }, [a]);

  callback();

  return (
    <div className="App">
      <p>a: {a}</p>
      <p>b: {b}</p>
      <button onClick={() => setA((a) => a + 1)}>a++</button>
      <button onClick={() => setB((b) => b + 1)}>b++</button>
    </div>
  );
};

codesandbox 에서 확인할 수 있습니다.

callback 함수는 a가 변경될 때만 업데이트된 함수를 가리키고, a가 변경되지 않았다면 이전에 저장되어 있던 함수를 가리킵니다. 때문에 a를 클릭했을 때에는 값이 정상적으로 업데이트되어 콘솔에 출력되지만, b를 클릭했을 때에는 값이 업데이트되지 않은 상태로 출력되다가 a를 클릭하면 정신을 차립니다.

다음 예제를 보겠습니다.

interface Props {
  count: number;
  increment: () => void;
}

const Memoized1 = memo(({ count, increment }: Props) => {
  console.log("m1 render");
  return <button onClick={increment}>count1: {count}</button>;
});

const Memoized2 = memo(({ count, increment }: Props) => {
  console.log("m2 render");
  return <button onClick={increment}>count2: {count}</button>;
});

export default () => {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);

  const increment1 = () => setCount1((c) => c + 1);
  const increment2 = useCallback(() => setCount2((c) => c + 1), []);

  return (
    <div className="App">
      <Memoized1 count={count1} increment={increment1} />
      <br />
      <Memoized2 count={count2} increment={increment2} />
    </div>
  );
};

codesandbox 에서 확인할 수 있습니다.

count1 을 증가시키면 'm2 render'가 출력되지 않지만, count2를 증가시키면 'm1 render'가 출력됩니다.

Memoized1 컴포넌트는 React.memo 로 최적화되어 있지만, increment1 함수가 매 렌더마다 변경되기 때문에 최적화가 하나도 의미가 없어졌습니다. 반면 Memoized2 컴포넌트의 경우 increment2 함수가 변경되지 않기 때문에 잘 최적화되어 있습니다.

이렇게, memo 로 자식 컴포넌트가 최적화되어 있고 콜백을 넘겨줘야 한다면 useCallback을 이용하여 최적화가 망가지지 않게 지켜줄 수 있습니다.



흔한 오해

useCallback은 함수를 메모이제이션하므로 함수를 다시 생성하지 않게 해 준다.

제가 했던 오해입니다. useCallback에 넘겨주는 함수는 매 렌더마다 무조건 생성됩니다. 생각해보면 함수를 생성해서 useCallback에 넘겨주는 것인데 함수의 생성을 막아 최적화한다는 건 모순입니다. 더해서 함수의 생성은 비용이 매우 저렴하기 때문에 굳이 최적화할 필요가 없기도 합니다. (실행이 비싼 거지, 생성은 저렴합니다. 실행이 비싸기 때문에 사용하는 훅이 useMemo입니다.)

다만 useCallback이 하는 일은 앞에서 봤듯이 이번에 새로 생성한 함수를 리턴하는지, 이전에 생성해서 저장해놨던 함수를 리턴하는지 그 차이를 만드는 것 뿐입니다.

따라서 위에서 설명한 useCallback이 꼭 필요한 상황이 아니라면, 굳이 사용하지 않는 편이 가독성 측면에서도 성능 측면에서도 이득입니다. 훅이 하나 늘어나면, 렌더때마다 참조해야 하는 연결리스트의 길이가 하나 늘어나는 거니까요!



참고자료

profile
프론트엔드 개발자입니다
post-custom-banner

2개의 댓글

comment-user-thumbnail
2022년 8월 10일

useCallback의 비밀을 알게됬네요.. 잘 읽었습니다!

1개의 답글