Frontend

useCallback의 오남용

경아 (KyungA) 2024. 9. 7. 01:08
반응형

그동안 React 내에서 함수, 또는 값을 의미하는 로직을 쓴다고 하면 useMemo, useCallback hook을 쓰는게 습관처럼 되었습니다
그러나 이번에 FSD 아키텍처로 리팩토링 해보면서 props로 넘겨야하는 값들이 많아졌습니다

export async function fetchData(
  menuType: string | undefined,
  reviews: IReview[] | null,
  mapData: IMapData | undefined,
  cafeDataRef: {
    current: ISearchData[];
  },
  setMarkers: React.Dispatch<React.SetStateAction<IMarker[] | undefined>>,
  clusterer: IClusterer | undefined,
  navigate: NavigateFunction,
  searchKeyword?: string
) {
  try {
    const result = await getCafeData(menuType, reviews, mapData, searchKeyword);
    cafeDataRef.current = [...result.data];
    const markers = createMarker(mapData, cafeDataRef.current, navigate);
    clusterer?.addMarkers(markers);
    setMarkers(markers);
  } catch (e) {
    console.error(e);
  }
}

위와 같이 fetch하는 로직에 넘겨야하는 props가 많아졌죠
근데 props로 넘기는 값들을 보면 대부분 전역으로 쓰이고, 업데이트 되어야하는 값들이라서 Context API에서 관리하는 값들입니다. 그래서 해당 비즈니스 로직에 Context를 바로 import해서 사용하면 되긴 합니다만, 이 부분에 대한 장단점이 있더라고요. 직접 import 하는 경우에는 의존성이 높아집니다. 좋은 컴포넌트 설계에서는 의존성을 낮추는게 좋은데 이 부분이 걸리더군요. 그래서 고민하다가 일단 props로 넘겨주는걸로 결정했습니다.

그러나 이렇게 했더니 fetch 함수를 import할때 문제가 발생합니다

const handleFetch = useCallback((type: string) => {
      fetchData(
        type,
        userReview,
        mapData,
        cafeData,
        setMarkers,
        clusterer,
        navigate
      );
    },
    [
      cafeData,
      clusterer,
      mapData,
      userReview,
      navigate,
      setMarkers
    ]
  );

(위는 예시 코드입니다)
이렇게 useCallback 함수의 dep도 길어진다는 것입니다. 이 부분이 매우 가독성 떨어지기도 하고, 불편하다는 느낌이 들면서 문득 의문이 생기더라고요. 함수를 캐싱한다고 했는데... 왜 캐싱해야하지? 제가 이 부분을 어느순간부터 망각하고 그냥 useCallback 함수를 무지성으로 썼다는 생각이 들더라고요. 그래서 이번에 깊이 있게 정리하고자 합니다

 

 

useCallback의 함수 캐싱


(useCallback에 대한 개념 정의는 공식 문서에서 확인해주세요)

 

useCallback – React

The library for web and native user interfaces

ko.react.dev

useCallback은 함수를 메모제이션(캐싱)을 해준다고 표현합니다. 메모제이션의 의미는 동일한 계산을 반복해야 할 경우 한번 계산한 결과를 메모리에 저장해두었다가 꺼내 씀으로써 중복 계산을 방지한다를 의미합니다. 그래서 useCallback 같은 경우 함수를 메모제이션 해주며, dep에 설정한 값들이 변경되지 않으면 같은 함수를 다시 반환하다고 합니다. 근데 이 과정이 왜 필요하고 좋은걸까요?

React는 컴포넌트가 리렌더링될 때마다 내부의 함수가 다시 생성됩니다. 그래서 부모 컴포넌트에서 선언한 함수가 하위 컴포넌트에 props로 전달할 경우 리렌더링 될때마다 함수가 새로 생성되기 때문에 하위 컴포넌트 또한 리렌더링 됩니다. 함수는 각각의 메모리를 차지하기 때문에 이렇게 컴포넌트가 자주 리렌더링 되면 수많은 함수가 재생성 된다를 의미합니다. 함수가 한번 실행되고 삭제되지 않는 이유는 자바스크립트의 가비지 컬렉션과 클로저 같은 동작 원리 때문입니다. 함수가 실행된 후에도 참조가 남아있으면 메모리에 계속 유지되는거죠. 고로 메모리 낭비로 이어질 수 있고, 자바스크립트 엔진이 함수 객체를 계속해서 생성해야하기 때문에 메모리 할당 관리에서도 성능 저하가 될 수 있습니다. 

 

 

많아지는 dep의 문제


일단 저는 앞서 말했다시피 dep가 길어지는게 굉장히 불편했습니다. 또한 dep에 안들어가도 될 참조값들을 빼면 린터 워딩 뜨는것도 보기 불편했고요 (물론 이건 내가 설정해주면 됨) 또한, 참조값이 많아지면 디버깅도 불편해집니다

그래서 발견한게 toss에서 작성한 코드입니다 

https://www.slash.page/ko/libraries/react/react/src/hooks/usePreservedCallback.i18n

 

usePreservedCallback | Slash libraries

컴포넌트가 mount 되어 있는 동안 인자로 주어진 callback 함수의 레퍼런스를 보존합니다.

www.slash.page

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

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;
}

해당 코드는 최신 콜백 함수의 참조를 항상 보존하고 참조 안정성도 유지해줍니다. 

useRef를 사용한 함수 재생성, 리렌더링 방지

useRef를 사용하여 최신 콜백을 참조하는 객체를 만들어, 콜백이 변경 될때마다 useEffect에서 값을 업데이트 합니다. 그래서 최신 콜백 함수를 유지할 수 있습니다. 또한 useCallback 같은 경우에는 참조값이 바뀌면 함수를 재생성하는데, usePreservedCallback 같은 경우에는 ref를 사용했기에 최신 콜백을 참조하기 때문에 함수 재생성 없이 최신 상태를 유지할 수 있습니다. useRef는 리렌더링을 해도 다시 생성되지 않고 동일한 참조값을 유지하며 메모리에 그대로 남아있습니다. 왜냐하면 콜백이 업데이트 되어도 callbackRef의 참조 자체가 변하는 것이 아니기 때문입니다. useRef의 특성이죠. 그렇기에 불필요한 리렌더링도 방지됩니다

간편한 의존성 관리

콜백 함수가 최신 값을 참조하는 상태를 유지하기 때문에 함수의 의존성 관리가 용이해집니다. callbackRef가 변경될때만 의존성 배열이 변경이 됩니다. 한마디로 변경된 최신 함수를 유지한다고 생각하면 됩니다.

  const handleFetch = usePreservedCallback((type: string, keyword?: string) => {
    fetchData(
      type,
      userReview,
      mapData,
      cafeData,
      setMarkers,
      clusterer,
      navigate,
      keyword
    );
  });

(위 코드는 예시입니다)

이렇게 사용해주시면 됩니다. dep 란이 없으니 가독성도 매우 좋고, 참조값이 누락될 수도 있는 불편함도 사라졌습니다.

 

 

 

useCallback의 오남용


그러나 애초에 저렇게 dep를 줄이든 말든... 근본적인 의문이 남아있습니다.
지금 나 useCallback의 장단점을 망각하고 오남용 한 것 같은데? 라는 생각이 들더라고요. 왜냐하면 참조값이 너무 많잖아요? 저 중에 하나라도 참조값이 업데이트 되면 함수는 재생성 되는 것입니다. useCallback은 함수를 호출하지 않아도 참조값이 바뀌면 재생성 합니다. 

const handleFetch = useCallback((type: string) => {
      fetchData(
        type,
        userReview,
        mapData,
        cafeData,
        setMarkers,
        clusterer,
        navigate
      );
    },
    [
      cafeData,
      clusterer,
      mapData,
      userReview,
      navigate,
      setMarkers
    ]
  );


앞서 말한 위 코드는 특정 버튼을 클릭하면 실행하는 함수입니다.
이렇게 참조값이 많은데 과연 useCallback을 쓰는게 맞을까? 싶더라고요. 참조하는 값들은 Context API에 있는 값들도 많으며, 전역 여기저기에서 전역으로 업데이트 되는 값들입니다. 고로 해당 이 함수는 언제든지, 자주 업데이트 될 확률이 높아질 것이며 자주 함수가 재생성될 것입니다. 제 생각에는 위 코드는 useCallback을 사용한 의미가 적다고 봅니다. 그냥 useCallback hook을 사용하지 않은 일반 함수랑 다를바 없다고 생각합니다.

위와 같은 상황을 하나의 예시로 들어보겠습니다

export default function CafeSearchRoute() {
 const [searchInput, setSearchInput] = useState<string>("");
 
 const handleInput = (e) => {
    setSearchInput(e.target.value);
  };
  
  return (
    <>
      <SearchForm
        searchInput={searchInput}
        onChange={handleInput}
      />
    </>
  );
}

export function SearchForm({searchInput, onChange,}: ISearch) {
  return (
      <input
        type="search"
        onChange={(e) => onChange(e)}
        placeholder="찾으시는 카페가 있으신가요?"
      />
  );
}

위와 같이 useCallback, memo 없이 onChange 함수인 handleInput를 하위 컴포넌트 props를 전달합니다. 이 경우 SearchForm에 변화가 없어도 부모 컴포넌트인 CafeSearchRoute에 변화가 생기면 같이 리렌더링 됩니다. 고로 handleInput 함수는 재생성 됩니다. handleInput 함수에 console.log를 찍어보면 매번 찍히는걸 확인하실 수 있습니다

const handleInput = useCallback((e) => {
    setSearchInput(e.target.value);
  }, [isIdle, GNB]);

이번엔 useCallback으로 감싸줬습니다. 이젠 부모 컴포넌트에 변화가 생겨도 handleInput은 재생성되지 않습니다. 다만 참조값이 있죠? 지금은 예시가 조금 잘 못 됐긴한데 (간단하면서도 적절한 예시가 안 떠오름) 함수에 내부 로직이 많아지면서 참조값이 많아지는 경우 onChange 함수가 호출되지 않았지만, isIdle, GNB가 바뀌면 함수가 재생성돼버립니다.

함수가 재생성 되는지 확인하는 방법은 ref를 사용해보면 알 수 있습니다

  const handleInput = useCallback(
    (e) => {
      setSearchInput(e.target.value);
    },
    [isIdle, GNB]
  );

  const prevHandleInput = useRef(handleInput);

  useEffect(() => {
    console.log("이전 함수", prevHandleInput.current);
    console.log("현재 함수", handleInput);
  }, [handleInput]);

이렇게 작성해줍니다. 참조값 이외에 변화가 일어났을땐 console.log가 안찍힐 것입니다. 그러나 onChange 이벤트 핸들러 발생이 아닌, 참조값의 변화가 일어나면 console.log가 찍히는걸 확인하실 수 있습니다. handleInput이 재생성 되었다는걸 의미합니다

한마디로 위 상황같은 상황은 무의미한 결과라고 봅니다. 애초에 로직을 잘 못 설계한것... useCallback을 사용한 의미가 없습니다. 물론 당장에 큰 속도 차이는 못 느낄 수 있겠으나, 이럴 경우 디버깅이 힘들어질 확률이 높습니다. 

또한, 여기서 제가 든 생각은 useCallback도 dep 참조값을 비교하기 위해 React 내부 엔진에서 많은 연산이 들어갈 것입니다. 따라서 최적화와 멀어진다는 생각이 듭니다. 물론 프로젝트 규모에 따라 다를 수 있는데, 함수 재생성은 비교적 저렴한 연산이라고 합니다. 그러나 참조값에 복잡한 객체나 배열이 포함되면 비교 연산이 더 많아집니다. 한마디로 비교 연산 비용이 더 커질 수 있는거죠.

 

 

마무리


https://www.reddit.com/r/reactjs/comments/16ikw76/usememousecallback_usage_am_i_the_completely/

레딧에 이와 비슷한 내용으로 토론하는 글이 있더라고요

제가 생각했을땐 특정 짓기 힘들고, 무조건 잘못됐어!!!는 아니지만 분명 useCallback, useMemo, React.Memo가 그다지 불필요한 상황, 효과가 없는 상황들이 있을겁니다. 근데 그럴때 아무 생각없이 제가 "아 최적화면 무조건 좋은거지~ 안쓰는것보다 나을겨~" 하고 써왔던게 문제인 것 같습니다. 서칭을 하다보면 최적화는 꽁짜가 아니다란 말이 있더라고요. 순간 머리가 얼얼했습니다 ㅎㅎ

앞으로 내가 쓰는 코드 한줄한줄에 대해 의미를 명확하게 해야하는 습관을 더더욱 들여야할 것 같습니다

 

 

 

 

 

 

반응형