React) usePreservedCallback으로 useCallback 대체하기

2ast·2024년 2월 19일
1

useCallback의 탄생 배경과 쓰임

useCallback은 react에서 제공하는 기본 memoization hook으로, 함수를 메모이제이션할 때 사용한다. 그리고, react에서 함수를 메모이제이션하는 주요한 목적은 component에 넘겨지는 함수 prop의 참조값 고정이다.

const Component = ({Fn}:{Fn:()=>void;}) =><></>
export default React.memo(Component)

위와같이 memo로 감싼 컴포넌트는 props가 변경되었을 때만 리렌더가 발생한다. 문제는 여기서 props가 변경되었는지 판단하는 방법이 "얕은 비교"라는 것이다. js에서 함수는 참조값을 가리키기 때문에, 실제로 함수의 내용이 동일하더라도 리렌더가 발생할때마다 재정의되며 참조값이 매번 달라지고, 그 결과 memo로 감싼 컴포넌트가 리렌더된다.

const Fn = ()=>console.log('Fn is function') // 리런더 될때마다 참조값이 바뀜.
return <Component Fn={Fn}/> // Fn의 참조값이 변경되므로, memo로 감쌌더라도 매번 리렌더가 발생

이런 문제를 해결하고자, useCallback이 탄생했다.

const Fn = useCallback(()=>console.log('Fn is function'),[]) // 리렌더가 발생해도 참조값 유지.
return <Component Fn={Fn}/> // 리렌더가 발생하지 않음

useCallback의 한계

useCallback이 유용한 hook인건 사실이지만, 실제로 개발을 하다보면 useCallback이 그 자체로 그렇게 편리한 hook이 아니라는 것을 알 수 있다. 메모이제이션은 공짜가 아니라는 성능적인 관점(메모이제이션은 조상님이 해주냐)은 제쳐두고라도 가독성 저해나 예상치 못한 버그의 원인이 되고는 하기 때문이다.

const [state,setState] = useState([1,2,3,4,5])

const items = state.map((it)=>`${it}번 째 아이템`)
const getItem = (index:number) => items[index]
const Fn = ()=> console.log(getItem(0));

return <Component Fn={Fn}/> 

위와 같은 코드가 있다고 해보자. 아까와 같이 Fn의 참조값을 유지해주기 위해 Fn을 useCallback으로 감싸주자, exhaustive-deps lint 경고가 발생한다.

const Fn = useCallback(()=> console.log(getItem(0)),[]);
// React Hook useCallback has a missing dependency: 'getItem'. Either include it or remove the dependency array.(react-hooks/exhaustive-deps)

useCallback은 두번 째 인자로 받은 deps가 바뀌지 않는 한 callback을 재정의하지 않고 그대로 사용하므로, 버그를 방지하려면 getItem을 deps에 추가하라는 의미다. 만약 deps를 비워둔 채로 코드를 실행한다면 state가 아무리 바뀌어도 그 변경 사항이 Fn에 반영되는 일은 없을 것이다. 그렇다고 이대로 deps에 getItem을 추가하자니 또 문제가 생긴다. deps 또한 얕은 비교를 수행하기 때문에 getItem이 매번 재정의되며 참조값이 바뀌고 결국 Fn도 매번 다시 계산되어 결국 useCallback으로 감싸지 않은 것과 동일해지기 때문이다. 이를 위해서는 결국 타고타고 올라가며 하나씩 메모이제이션을 걸어주어야 한다.

const [state,setState] = useState([1,2,3,4,5])

  const items = useMemo(() => state.map(it => `${it}번 째 아이템`), [state]);
  const getItem = useCallback((index: number) => items[index], [items]);
  const Fn = useCallback(() => console.log(getItem(0)), [getItem]);

return <Component Fn={Fn}/> 

코드가 많이 어지러워졌다. 지금은 예시코드라서 그나마 볼만하지만 실제 프로덕션 코드에서는 props로 넘어오는 값이 어떻게 생긴 값인지를 판단해야하고, 라이브러리에서 import해 오는 값은 렌더링 전후로 유지가 되는 것인지도 고려해야한다. 그런 값들이 deps에 차곡차곡 쌓이고, 그에 따라 신경써야하는 값들이 늘어나고 코드도 비례해서 길어질 것이다. 그러다 실수로 deps를 빼먹는 날에는 변경사항이 코드에 반영되지 않는 버그를 만나게 될 확률이 높다.

usePreservedCallback의 발명

이런 문제를 해결하기 위해 usePreversedCallback이 탄생했다. usePreservedCallback은 toss의 라이브러리인 slash에 소개된 hook이다. 정의된 부분을 살펴보면 다음과 같이 간단한 구조를 띠고 있다.

import { useCallback, useEffect, useRef } from 'react';

/** @tossdocs-ignore */
export function usePreservedCallback<Callback extends (...args: any[]) => any>(callback: Callback) {
  const callbackRef = useRef<Callback>(callback);

  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  return useCallback(
    (...args: any[]) => {
      return callbackRef.current(...args);
    },
    [callbackRef]
  ) as Callback;
}

// 코드 출처: https://github.com/toss/slash/blob/main/packages/react/react/src/hooks/usePreservedCallback.ts

간단히 설명하자면, usePreservedCallback은 deps없이 callback을 유일한 인자로 받아 함수를 반환하는 형태로 구성되어 있다. 인자로 받은 callback을 내부에서 선언한 useRef에 할당하고, ref.current를 실행하는 함수를 useCallback으로 감싸서 반환하고 있다. useRef가 반환한 ref는 리렌더 되어도 그 참조값이 변하지 않기 때문에 useCallback이 다시 계산될 일은 없지만, ref.current에 할당되는 함수는 항상 최신의 callback이 된다.
글로 설명하니 조금 복잡해 보일 수 있는데, 코드를 찬찬히 읽어보면 결과적으로 usePreservedCallback이 반환하는 함수는 참조값은 불변이면서, 그 실행되는 callback은 항상 최신의 값을 보장받음을 알 수 있다. 이를 아까 코드에 적용하면 이렇게 사용할 수 있다.

const [state,setState] = useState([1,2,3,4,5])

const items = state.map((it)=>`${it}번 째 아이템`)
const getItem = (index:number) => items[index]
const Fn = usePreservedCallback(()=> console.log(getItem(0)));

return <Component Fn={Fn}/> 

이제는 deps lint를 따라서 타고타고 올라가며 메모이제이션을 해줄 필요 없이 실제로 메모이제이션이 필요한 시점에만 usePreservedCallback을 사용해줄 수 있다. 복잡한 deps를 관리하느라 리소스를 할애할 필요도 없다. 코드가 훨씬 간단해졌고, 안정화됐고, 부담도 덜었다.

usePreservedCallback의 한계

이제 모든 useCallback을 usePreservedCallback으로 대체하고, 다시는 useCallback을 쓸 필요가 없을까? 아쉽게도 꼭 그렇지만도 않다. 개발을 하다보면 callback의 재정의가 필요한 시점이 오기 때문이다. 한가지 사례로 react navigation의 useFocusEffect를 들 수 있다. useFocusEffect는 navigation의 스크린이 포커스 되었을 때 트리거되는 Effect hook이다. useFocusEffect를 사용할 때 주의할 점은 스크린이 focus 되었을 때 뿐만 아니라 인자로 넘겨지는 callback이 바뀌었을 때도 트리거 된다는 점이다.

const user = useUser()
const focusCallback = () => {
  initSDKWithUser(user)
  return deinitSDK();
};
useFocusEffect(focusCallback)

즉, user정보를 가져와서 sdk를 initiate하는 로직을 구현해야할 때, 위와같이 작성하면 리렌더 될때마다 focusCallback이 호출된다. 이를 방지하기 위해 이렇게 작성할 수 있다.

const user = useUser()
const focusCallback = usePreservedCallback(() ={
  initSDKWithUser(user);
  return deinitSDK();
})
useFocusEffect(focusCallback)

하지만 이때의 문제는 user 정보가 바뀌었어도 useFocusEffect가 트리거되지 않는다는 점이다. 이런 코드 케이스에서 이상적인 동작은 스크린이 포커스되었을 때뿐만 아니라, user정보가 바뀌었을 때도, 최신 user로 새롭게 sdk가 init되는 것이기 때문이다. 따라서 이런 경우에는 useCallback을 사용해주어야 한다.

const user = useUser()
const focusCallback = useCallback(() ={
  initSDKWithUser(user);
  return deinitSDK();
},[user])
useFocusEffect(focusCallback)

.
.
.
하지만 굳이 useCallback을 사용하고 싶지 않다면, usePreversedCallback에서 두번째 인자로 deps를 optional하게 받도록 확장하면 해결 가능한 문제이기도 하다.

여담

toss 깃헙에 공개된 usePreservedCallback을 그대로 가져다 써도 좋지만, 나는 컨셉을 가져다가 직접 만들어서 쓰고 있다.

import {useCallback, useRef} from 'react';

export const usePreservedCallback = <T extends (...args: any[]) => any>(
  callback: T,
) => {
  const ref = useRef<T>();
  ref.current = callback;

  return useCallback(
    (...args: Parameters<T>): ReturnType<T> => ref?.current?.(...args),
    [],
  );
};

기능적으로는 동일하고, 그냥 취향이 조금 반영됐다.

profile
React-Native 개발블로그

0개의 댓글