본문 바로가기
React

useCallback 알아보기

by East-K 2024. 10. 14.

useCallback이란?

React 공식 문서에 따르면, useCallback은 "리렌더링 간 함수 정의를 캐시할 수 있게 해주는 React Hook"이라고 정의되어 있습니다.

이 설명만으로는 useCallback이 왜 필요한지 와닿지 않거나, 언제 사용해야 하는지 불분명할 수 있습니다. 그래서 React에서도 이에 대한 추가적인 설명을 제공합니다. 공식 설명에 따르면:


만약 상호작용이 주로 페이지나 섹션을 교체하는 방식이라면, 메모이제이션은 대부분 불필요합니다. 반면에 애플리케이션이 그림 편집기처럼 세밀한 상호작용을 요구하는 경우(예: 도형을 이동시키는 작업), 메모이제이션이 매우 유용할 수 있습니다.

useCallback이 유용한 몇 가지 상황은 다음과 같습니다:

  1. memo로 감싸진 컴포넌트에 함수를 prop으로 전달할 때: 함수가 변하지 않았을 때 리렌더링을 건너뛰고 싶다면, useCallback을 사용해 함수가 오직 의존성 배열이 변경될 때만 새로 정의되게 할 수 있습니다.
  2. 함수가 다른 훅의 의존성으로 사용될 때: 예를 들어, useCallback으로 감싼 다른 함수가 이 함수에 의존하거나, useEffect 내에서 이 함수에 의존하는 경우 유용합니다.

다른 경우에는 useCallback을 사용하더라도 큰 이득이 없고, 사용하지 않더라도 큰 문제가 발생하지는 않습니다. 일부 팀들은 이런 이유로 개별적인 상황을 신경 쓰지 않고 가능한 한 많이 메모이제이션을 적용하기도 합니다. 하지만 이는 코드의 가독성을 떨어뜨릴 수 있습니다. 또한 메모이제이션이 항상 효과적이지는 않으며, "항상 새로운 값" 하나만 있어도 컴포넌트 전체의 메모이제이션을 무력화할 수 있습니다.


첫 번째 예시

아래 코드에서 ChildReact.memo로 감싸져 있지만, 부모 컴포넌트에서 count가 증가할 때마다 리렌더링이 발생합니다. 이때 handleClick 함수가 매번 다시 선언되며 새로운 메모리 주소를 갖게 되기 때문에, MemoizedChildonClick prop이 변경되었다고 인식해 리렌더링됩니다.

 

이때 handleClickuseCallback으로 감싸면, 두 번째 인자인 의존성 배열이 변경되지 않는 한 handleClick은 다시 선언되지 않으므로, MemoizedChild는 리렌더링되지 않습니다.

const Child = ({ onClick }) => {
  console.log("Child rendered");
  return <button onClick={onClick}>Click Me</button>;
};

const MemoizedChild = React.memo(Child);
const Parent = () => {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    console.log("Button clicked");
  };

//  const handelClick = useCallback(() => {
//    console.log("Button clicked");
//  }, []) -> useCallback을 사용하면 React.memo를 통해 캐싱된 MemoizedChild이 불필요하게 리렌더링 되는 것을 방지할 수 있습니다.

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Increase Count</button>
      <MemoizedChild onClick={handleClick} />
    </div>
  );
};

두 번째 예시

아래 예시에서는 원래 의도한 바는 isToggle이나 fetchData가 변경될 때 useEffect가 실행되도록 하려는 것입니다. 하지만 실제로는 count 값이 변경될 때마다 fetchData가 새로 선언되어 useEffect가 실행됩니다.

const Parent = () => {
  const [count, setCount] = useState(0);
  const [isToggle, setIsToggle] = useState(false);

  // 데이터를 가져오는 함수
  const fetchData = () => {
    console.log("Fetching data...");
    // API 호출 로직 (예시)
  };

  // fetchData 함수가 변경될 때마다 useEffect가 실행됨
  useEffect(() => {
    fetchData();
  }, [fetchData, isToggle]);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Increase Count</button>
      {
        isToggle && <div>테스트</div>
      }
    </div>
  );
};

추가적인 사례

이 외에도, 예를 들어 Custom Hook에서 반환하는 함수의 경우 useCallback을 사용하는 것을 추천합니다(관련 공식 문서). 이 외에도 특정 상황에서는 useCallback을 활용하면 불필요한 함수 재정의를 방지할 수 있습니다.

 

그러나 대부분의 경우 useCallback을 남용하게 되면 메모리 누수 등 다양한 문제가 발생할 수 있습니다. 이제 어떤 경우에 이러한 문제가 발생하는지 useCallback내부 코드를 보며 알아보겠습니다:

 

내부 코드는 굉장히 간단합니다. 초기 렌더링 시에는 useStateuseRef등과 마찬가지로 hook.memoizedStatecallbacknextDeps를 등록하고 useCallback에 등록한 함수인 callback을 반환합니다. 이때 생성된 함수는 클로저로 인해 해당 함수가 선언된 당시의 상태와 변수를 기억합니다. 리렌더링 시에는 현재의 의존성 (nextDeps)과 이전의 의존성 (prevDeps)을 비교하여 값이 같을 경우 기존의 callback을 반환하고, 다르다면 새로운 값을 저장하고 반환합니다. 이러한 과정에서 클로저에 의해 이전의 상태가 메모리에 남아 있을 수 있으며, 필요 이상으로 많은 클로저가 메모리에 유지되면 메모리 사용량이 증가할 수 있습니다.

// packages/react-reconciler/src/ReactFiberHooks.js
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;
}

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 (nextDeps !== null) {
    const prevDeps: Array<mixed> | null = prevState[1];
    if (areHookInputsEqual(nextDeps, prevDeps)) {
      return prevState[0];
    }
  }
  hook.memoizedState = [callback, nextDeps];
  return callback;
}

useCallback과 메모리 누수 문제

해당 코드에서 useCallback과 리렌더링이 어떻게 메모리 사용량에 영향을 미치는지 알아보겠습니다. 중요한 점은 useCallback은 의존성 배열에 따라 함수 정의를 "캐싱"하고, 그로 인해 이전 상태와 참조가 남을 수 있다는 것입니다.
(아래 예시 코드는 원티드 프리온보딩 - 프론트엔드 챌린지(10월) 강의를 기반으로 만들게 되었습니다.)

class TestClass {
  data = new Uint32Array(1024 * 1024 * 10);
}

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

  const bigMemoryValue = new TestClass();
  const increaseCount1 = useCallback(() => {
    setCount1(count1 + 1);
  }, [count1]);

  const increaseCount2 = useCallback(() => {
    setCount2(count2 + 1);
  }, [count2]);

  const testFun = () => {
    increaseCount1();
    increaseCount2();
    console.log(bigMemoryValue, '--');
  }


  return (
    <div className="flex gap-x-4">
      <button onClick={increaseCount1}>increaseCount1 Button</button>
      <button onClick={increaseCount2}>increaseCount2 Button</button>
      <button onClick={testFun}>testFun Button</button>
    </div>
  );
};

export default Counter;

클로저와 메모리 누수

useCallback으로 정의된 함수는 리렌더링 후에도 이전의 상태를 참조합니다. 이 때문에 increaseCount1increaseCount2는 각각 count1count2의 변경 이력에 따라 새로운 함수가 생성되지만, 이전의 함수들이 메모리에 남아 있을 수 있습니다. 특히 testFun 함수가 이전 상태를 참조하고 있기 때문에 가비지 컬렉터가 해당 값을 수집하지 못하고, count1 버튼과 count2 버튼을 번갈아가며 누를 때마다 이전 값들이 계속 메모리에 남아 있게 됩니다. 이는 메모리 사용량이 점점 증가하는 메모리 누수로 이어질 수 있습니다.

해결 방법

  1. useCallback 사용 최소화: useCallback을 모든 함수에 남용하지 말고, 정말 필요한 경우에만 사용해야 합니다. 특히, 단순한 상태 업데이트 함수라면 굳이 useCallback으로 묶을 필요가 없습니다.
  2. 클래스 인스턴스 생성 최적화: bigMemoryValueuseRef를 사용해 한 번만 생성하도록 최적화할 수 있습니다. 이렇게 하면 리렌더링 시마다 새로운 인스턴스를 생성하지 않으며, 기존의 값을 참조할 수 있게 됩니다.
const Counter = () => {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);

  const bigMemoryValue = useRef(new TestClass());  // 인스턴스를 한 번만 생성

  const increaseCount1 = useCallback(() => {
    setCount1(prev => prev + 1);  // 이전 상태 참조 방식으로 안전하게 업데이트
  }, []);

  const increaseCount2 = useCallback(() => {
    setCount2(prev => prev + 1);  // 이전 상태 참조
  }, []);

  const testFun = () => {
    increaseCount1();
    increaseCount2();
    console.log(bigMemoryValue.current, '--');
  }

  return (
    <div className="flex gap-x-4">
      <button onClick={increaseCount1}>increaseCount1 Button</button>
      <button onClick={increaseCount2}>increaseCount2 Button</button>
      <button onClick={testFun}>testFun Button</button>
    </div>
  );
};

정리

  • useCallback은 클로저로 선언 당시의 값을 유지하기 때문에, 상태가 변경될 때마다 새로운 함수가 생성되고 이전 값들이 메모리에 남아 있을 수 있습니다. 이는 메모리 사용량을 증가시킬 수 있습니다.
  • 이러한 문제를 방지하기 위해 useCallback은 꼭 필요한 경우에만 사용하고, 리렌더링시 불필요하게 변수가 다시 생성되지 않도록 최적화해야 합니다.

이로써 useCallback을 언제 사용해야 하는지, 잘못 사용하면 어떤 문제가 발생하는지 등에 대해 알아보았습니다. 잘못된 내용이 있거나 궁금한 점이 있다면 댓글 부탁드려요!

'React' 카테고리의 다른 글

React-Router 기본 원리 알아보기  (0) 2024.11.18
useRef 알아보기  (0) 2024.10.13
useState 깊게 분석하기  (6) 2024.10.10