useCallback이란?
React 공식 문서에 따르면, useCallback
은 "리렌더링 간 함수 정의를 캐시할 수 있게 해주는 React Hook"이라고 정의되어 있습니다.
이 설명만으로는 useCallback
이 왜 필요한지 와닿지 않거나, 언제 사용해야 하는지 불분명할 수 있습니다. 그래서 React에서도 이에 대한 추가적인 설명을 제공합니다. 공식 설명에 따르면:
만약 상호작용이 주로 페이지나 섹션을 교체하는 방식이라면, 메모이제이션은 대부분 불필요합니다. 반면에 애플리케이션이 그림 편집기처럼 세밀한 상호작용을 요구하는 경우(예: 도형을 이동시키는 작업), 메모이제이션이 매우 유용할 수 있습니다.
useCallback
이 유용한 몇 가지 상황은 다음과 같습니다:
memo
로 감싸진 컴포넌트에 함수를prop
으로 전달할 때: 함수가 변하지 않았을 때 리렌더링을 건너뛰고 싶다면,useCallback
을 사용해 함수가 오직 의존성 배열이 변경될 때만 새로 정의되게 할 수 있습니다.- 함수가 다른 훅의 의존성으로 사용될 때: 예를 들어,
useCallback
으로 감싼 다른 함수가 이 함수에 의존하거나,useEffect
내에서 이 함수에 의존하는 경우 유용합니다.
다른 경우에는 useCallback
을 사용하더라도 큰 이득이 없고, 사용하지 않더라도 큰 문제가 발생하지는 않습니다. 일부 팀들은 이런 이유로 개별적인 상황을 신경 쓰지 않고 가능한 한 많이 메모이제이션을 적용하기도 합니다. 하지만 이는 코드의 가독성을 떨어뜨릴 수 있습니다. 또한 메모이제이션이 항상 효과적이지는 않으며, "항상 새로운 값" 하나만 있어도 컴포넌트 전체의 메모이제이션을 무력화할 수 있습니다.
첫 번째 예시
아래 코드에서 Child
는 React.memo
로 감싸져 있지만, 부모 컴포넌트에서 count
가 증가할 때마다 리렌더링이 발생합니다. 이때 handleClick
함수가 매번 다시 선언되며 새로운 메모리 주소를 갖게 되기 때문에, MemoizedChild
는 onClick
prop
이 변경되었다고 인식해 리렌더링됩니다.
이때 handleClick
을 useCallback
으로 감싸면, 두 번째 인자인 의존성 배열이 변경되지 않는 한 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
내부 코드를 보며 알아보겠습니다:
내부 코드는 굉장히 간단합니다. 초기 렌더링 시에는 useState
나 useRef
등과 마찬가지로 hook.memoizedState
에 callback
과 nextDeps
를 등록하고 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
으로 정의된 함수는 리렌더링 후에도 이전의 상태를 참조합니다. 이 때문에 increaseCount1
과 increaseCount2
는 각각 count1
과 count2
의 변경 이력에 따라 새로운 함수가 생성되지만, 이전의 함수들이 메모리에 남아 있을 수 있습니다. 특히 testFun
함수가 이전 상태를 참조하고 있기 때문에 가비지 컬렉터가 해당 값을 수집하지 못하고, count1
버튼과 count2
버튼을 번갈아가며 누를 때마다 이전 값들이 계속 메모리에 남아 있게 됩니다. 이는 메모리 사용량이 점점 증가하는 메모리 누수로 이어질 수 있습니다.
해결 방법
useCallback
사용 최소화:useCallback
을 모든 함수에 남용하지 말고, 정말 필요한 경우에만 사용해야 합니다. 특히, 단순한 상태 업데이트 함수라면 굳이useCallback
으로 묶을 필요가 없습니다.- 클래스 인스턴스 생성 최적화:
bigMemoryValue
는useRef
를 사용해 한 번만 생성하도록 최적화할 수 있습니다. 이렇게 하면 리렌더링 시마다 새로운 인스턴스를 생성하지 않으며, 기존의 값을 참조할 수 있게 됩니다.
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 |