
useCallback은 리렌더링 사이에 함수 정의를 캐시할 수 있는 React Hook입니다.
const cachedFn = useCallback(fn, dependencies)
리렌더링 사이에 함수 정의를 캐시하려면 컴포넌트의 최상위 수준에서 useCallback을 호출하세요.
import { useCallback } from 'react';
export default function ProductPage({ productId, referrer, theme }) {
const handleSubmit = useCallback((orderDetails) => {
post('/product/' + productId + '/buy', {
referrer,
orderDetails,
});
}, [productId, referrer]);
[dep1, dep2, dep3]처럼 인라인으로 작성되어야 합니다. React는 Object.is 비교 알고리즘을 사용하여 각 종속성을 이전 값과 비교합니다.초기 렌더링 시 useCallback은 전달한 fn 함수를 반환합니다.
후속 렌더링 중에는 마지막 렌더링에서 이미 저장된 fn 함수를 반환하거나(종속성이 변경되지 않은 경우), 렌더링 중에 전달한 fn 함수를 반환합니다.
렌더링 성능을 최적화할 때 하위 컴포넌트에 전달하는 함수를 캐시해야 하는 경우가 있습니다. 먼저 이 작업을 수행하는 방법에 대한 구문을 살펴본 다음 어떤 경우에 유용한지 살펴보겠습니다.
컴포넌트를 리렌더링하는 사이에 함수를 캐시하려면 해당 정의를 useCallback Hook에 래핑하세요.
import { useCallback } from 'react';
function ProductPage({ productId, referrer, theme }) {
const handleSubmit = useCallback((orderDetails) => {
post('/product/' + productId + '/buy', {
referrer,
orderDetails,
});
}, [productId, referrer]);
// ...
Callback을 사용하려면 다음 두 가지를 전달해야 합니다.
초기 렌더링 시 useCallback에서 반환되는 함수는 전달한 함수가 됩니다.
다음 렌더링에서 React는 이전 렌더링 중에 전달한 종속성과 종속성을 비교합니다. 종속성이 변경되지 않은 경우(Object.is와 비교하여) useCallback은 이전과 동일한 함수를 반환합니다. 그렇지 않으면 useCallback은 이 렌더링에 전달한 함수를 반환합니다. (즉, 새로운 함수를 반환하다고 보면 된다)
즉, useCallback은 종속성이 변경될 때까지 리렌더링 간에 함수를 캐시합니다.
이것이 언제 유용한지 알아보기 위해 예제를 살펴보겠습니다.
ProductPage에서 ShippingForm 컴포넌트로 handlerSubmit 함수를 전달한다고 가정해 보겠습니다.
function ProductPage({ productId, referrer, theme }) {
// ...
return (
<div className={theme}>
<ShippingForm onSubmit={handleSubmit} />
</div>
);
theme prop을 토글하면 앱이 잠시 정지되는 것을 보셨겠지만, JSX에서 <ShippingForm />을 제거하면 속도가 빨라지는 느낌이 듭니다. 이는 ShippingForm 컴포넌트를 최적화해 볼 가치가 있음을 알려줍니다.
기본적으로 컴포넌트가 다시 렌더링되면 React는 모든 하위 항목을 재귀적으로 다시 렌더링합니다. 이것이 바로 ProductPage가 다른 테마로 다시 렌더링될 때 ShippingForm 컴포넌트도 다시 렌더링되는 이유입니다. 이는 리렌더링하는 데 많은 계산이 필요하지 않은 컴포넌트에 적합합니다. 그러나 리렌더링이 느린 것을 확인한 경우 props가 마지막 렌더링과 동일할 때 메모로 래핑하여 ShippingForm에 리렌더링을 건너뛰도록 지시할 수 있습니다.
import { memo } from 'react';
const ShippingForm = memo(function ShippingForm({ onSubmit }) {
// ...
});
이 변경으로 ShippingForm은 모든 props가 마지막 렌더링과 동일하면 리렌더링을 건너뜁니다. 함수 캐싱이 중요해지는 순간입니다! useCallback 없이 handlerSubmit을 정의했다고 가정해 보겠습니다.
function ProductPage({ productId, referrer, theme }) {
// Every time the theme changes, this will be a different function...
function handleSubmit(orderDetails) {
post('/product/' + productId + '/buy', {
referrer,
orderDetails,
});
}
return (
<div className={theme}>
{/* ... so ShippingForm's props will never be the same, and it will re-render every time */}
<ShippingForm onSubmit={handleSubmit} />
</div>
);
}
JavaScript에서 함수 () {} 또는 () => {}는 항상 다른 함수를 생성합니다. 이는 {} 객체 리터럴이 항상 새 객체를 생성하는 방식과 유사합니다. 일반적으로 이는 문제가 되지 않지만 이는 ShippingForm props가 결코 동일하지 않으며 메모 최적화가 작동하지 않음을 의미합니다. useCallback이 유용한 곳은 다음과 같습니다.
function ProductPage({ productId, referrer, theme }) {
// Tell React to cache your function between re-renders...
const handleSubmit = useCallback((orderDetails) => {
post('/product/' + productId + '/buy', {
referrer,
orderDetails,
});
}, [productId, referrer]); // ...so as long as these dependencies don't change...
return (
<div className={theme}>
{/* ...ShippingForm will receive the same props and can skip re-rendering */}
<ShippingForm onSubmit={handleSubmit} />
</div>
);
}
useCallback에서 handlerSubmit을 래핑하면 리렌더링 간에(종속성이 변경될 때까지) 동일한 함수가 되도록 보장할 수 있습니다. 특별한 이유가 없는 한 useCallback에서 함수를 래핑할 필요는 없습니다. 이 예에서는 메모로 래핑된 컴포넌트에 전달하여 리렌더링을 건너뛸 수 있기 때문입니다.
Note. 성능 최적화를 위해 useCallback을 사용해야 합니다. 코드가 작동하지 않으면 근본적인 문제를 찾아 먼저 수정하세요. 그런 다음 useCallback을 다시 추가할 수 있습니다. (흠.. 그니까 성능 최적화가 필요한 경우에만 사용하라는 거 같네요. )
useCallback과 함께 useMemo를 자주 볼 수 있습니다. 둘 다 하위 컴포넌트를 최적화하려고 할 때 유용합니다. 이를 통해 전달 중인 내용을 메모할 수 있습니다(즉, 캐시할 수 있습니다).
import { useMemo, useCallback } from 'react';
function ProductPage({ productId, referrer }) {
const product = useData('/product/' + productId);
const requirements = useMemo(() => { // Calls your function and caches its result
return computeRequirements(product);
}, [product]);
const handleSubmit = useCallback((orderDetails) => { // Caches your function itself
post('/product/' + productId + '/buy', {
referrer,
orderDetails,
});
}, [productId, referrer]);
return (
<div className={theme}>
<ShippingForm requirements={requirements} onSubmit={handleSubmit} />
</div>
);
}
차이점은 캐시를 허용하는 것입니다.
product이 변경되지 않는 한 변경되지 않도록 computeRequirements(product) 호출 결과를 캐시합니다. 이를 통해 ShippingForm을 불필요하게 다시 렌더링하지 않고도 요구 사항 개체를 전달할 수 있습니다. 필요한 경우 React는 렌더링 중에 전달한 함수를 호출하여 결과를 계산합니다.useMemo와 달리 사용자가 제공하는 함수를 호출하지 않습니다. 대신, productId 또는 referrer가 변경되지 않는 한 handlerSubmit 자체가 변경되지 않도록 제공한 함수를 캐시합니다. 이렇게 하면 불필요하게 ShippingForm을 다시 렌더링하지 않고도 handlerSubmit 함수를 전달할 수 있습니다. 사용자가 form을 submits할 때까지 코드가 실행되지 않습니다.useMemo에 이미 익숙하다면 useCallback을 다음과 같이 생각하는 것이 도움이 될 수 있습니다.
// Simplified implementation (inside React)
function useCallback(fn, dependencies) {
return useMemo(() => fn, dependencies);
}
앱이 이 사이트와 같고 대부분의 상호 작용이 대략적(예: 페이지 또는 전체 섹션 교체)인 경우 일반적으로 메모가 필요하지 않습니다. 반면에 앱이 그림 편집기에 더 가깝고 대부분의 상호 작용이 세분화된 경우(예: 모양 이동) 메모 기능이 매우 유용할 수 있습니다.
useCallback을 사용하여 함수를 캐싱하는 것은 다음과 같은 몇 가지 경우에만 가치가 있습니다.
그 외에는 useCallback에서 함수를 래핑해도 이점이 없습니다. 그렇게 해도 큰 해가 되지 않으므로 일부 팀에서는 개별 사례에 대해 생각하지 않고 가능한 한 많이 메모하기로 결정합니다. 단점은 코드의 가독성이 떨어진다는 것입니다. 또한 모든 메모이제이션이 효과적인 것은 아닙니다. "항상 새로운" 단일 값은 전체 컴포넌트에 대한 메모이제이션을 중단하기에 충분합니다.
useCallback은 함수 생성을 방해하지 않습니다. 당신은 항상 함수를 생성하지만(괜찮습니다!), React는 이를 무시하고 아무것도 변경되지 않으면 캐시된 함수를 반환합니다.
실제로 다음 몇 가지 원칙을 따르면 많은 memoization를 불필요하게 만들 수 있습니다. (좋은 글이군요)
특정 상호 작용이 여전히 느리게 느껴진다면 React 개발자 도구 프로파일러를 사용하여 어떤 컴포넌트가 메모 기능을 통해 가장 많은 이점을 얻을 수 있는지 확인하고 필요한 곳에 메모 기능을 추가하세요. 이러한 원칙을 사용하면 컴포넌트를 더 쉽게 디버그하고 이해할 수 있으므로 어떤 경우에도 이를 따르는 것이 좋습니다. 장기적으로는 이 문제를 완전히 해결하기 위해 자동으로 메모 기능을 수행하는 연구를 진행하고 있습니다.
때로는 메모된 콜백의 이전 상태를 기반으로 상태를 업데이트해야 할 수도 있습니다.
이 handlerAddTodo 함수는 다음 할 일을 계산하기 때문에 todos을 종속성으로 지정합니다.
function TodoList() {
const [todos, setTodos] = useState([]);
const handleAddTodo = useCallback((text) => {
const newTodo = { id: nextId++, text };
setTodos([...todos, newTodo]);
}, [todos]);
// ...
일반적으로 메모된 함수에는 가능한 한 적은 종속성을 갖기를 원할 것입니다. 다음 상태를 계산하기 위해 일부 상태를 읽는 경우 대신 업데이트 함수를 전달하여 해당 종속성을 제거할 수 있습니다.
function TodoList() {
const [todos, setTodos] = useState([]);
const handleAddTodo = useCallback((text) => {
const newTodo = { id: nextId++, text };
setTodos(todos => [...todos, newTodo]);
}, []); // ✅ No need for the todos dependency
// ...
여기에서는 todos를 종속성으로 만들고 내부에서 읽는 대신 상태(todos => [...todos, newTodo])를 업데이트하는 방법에 대한 지침을 React에 전달합니다.
때로는 Effect 내부에서 함수를 호출하고 싶을 수도 있습니다:
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
function createOptions() {
return {
serverUrl: 'https://localhost:1234',
roomId: roomId
};
}
useEffect(() => {
const options = createOptions();
const connection = createConnection();
connection.connect();
// ...
이로 인해 문제가 발생합니다. 모든 반응 값은 Effect의 종속성으로 선언되어야 합니다. 그러나 createOptions를 종속성으로 선언하면 Effect가 채팅방에 지속적으로 다시 연결됩니다.
useEffect(() => {
const options = createOptions();
const connection = createConnection();
connection.connect();
return () => connection.disconnect();
}, [createOptions]); // 🔴 Problem: This dependency changes on every render
// ...
이 문제를 해결하려면 Effect에서 호출해야 하는 함수를 useCallback으로 래핑하면 됩니다.
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
const createOptions = useCallback(() => {
return {
serverUrl: 'https://localhost:1234',
roomId: roomId
};
}, [roomId]); // ✅ Only changes when roomId changes
useEffect(() => {
const options = createOptions();
const connection = createConnection();
connection.connect();
return () => connection.disconnect();
}, [createOptions]); // ✅ Only changes when createOptions changes
// ...
이렇게 하면 roomId가 동일한 경우 다시 렌더링 간에 createOptions 함수가 동일하게 됩니다. 그러나 함수 종속성의 필요성을 제거하는 것이 훨씬 더 좋습니다. Effect 내에서 함수를 이동하세요.
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
useEffect(() => {
function createOptions() { // ✅ No need for useCallback or function dependencies!
return {
serverUrl: 'https://localhost:1234',
roomId: roomId
};
}
const options = createOptions();
const connection = createConnection();
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ Only changes when roomId changes
// ...
이제 코드가 더 간단해지고 useCallback이 필요하지 않습니다.
custom Hook을 작성하는 경우, useCallback으로 반환되는 모든 함수를 래핑하는 것이 좋습니다.
function useRouter() {
const { dispatch } = useContext(RouterStateContext);
const navigate = useCallback((url) => {
dispatch({ type: 'navigate', url });
}, [dispatch]);
const goBack = useCallback(() => {
dispatch({ type: 'back' });
}, [dispatch]);
return {
navigate,
goBack,
};
}
이렇게 하면 Hook 소비자가 필요할 때 자신의 코드를 최적화할 수 있습니다.
종속성 배열을 두 번째 인수로 지정했는지 확인하세요! 종속성 배열을 잊어버린 경우 useCallback은 매번 새 함수를 반환합니다.
function ProductPage({ productId, referrer }) {
const handleSubmit = useCallback((orderDetails) => {
post('/product/' + productId + '/buy', {
referrer,
orderDetails,
});
}); // 🔴 Returns a new function every time: no dependency array
// ...
아래는 종속성 배열을 두 번째 인수로 전달하는 수정된 버전입니다.
function ProductPage({ productId, referrer }) {
const handleSubmit = useCallback((orderDetails) => {
post('/product/' + productId + '/buy', {
referrer,
orderDetails,
});
}, [productId, referrer]); // ✅ Does not return a new function unnecessarily
// ...
이것이 도움이 되지 않는다면, 문제는 종속성 중 적어도 하나가 이전 렌더링과 다르다는 것입니다. 콘솔에 종속성을 수동으로 기록하여 이 문제를 디버깅할 수 있습니다.
const handleSubmit = useCallback((orderDetails) => {
// ..
}, [productId, referrer]);
console.log([productId, referrer]);
Chart 컴포넌트가 메모로 래핑되어 있다고 가정합니다. ReportList 구성 요소가 리렌더링될 때 목록의 모든 Chart가 리렌더링을 건너뛰려고 합니다. 그러나 루프에서 useCallback을 호출할 수는 없습니다.
function ReportList({ items }) {
return (
<article>
{items.map(item => {
// 🔴 You can't call useCallback in a loop like this:
const handleClick = useCallback(() => {
sendReport(item)
}, [item]);
return (
<figure key={item.id}>
<Chart onClick={handleClick} />
</figure>
);
})}
</article>
);
}
대신, 개별 항목에 대한 구성요소를 추출하고 거기에 useCallback을 넣으세요.
function ReportList({ items }) {
return (
<article>
{items.map(item =>
<Report key={item.id} item={item} />
)}
</article>
);
}
function Report({ item }) {
// ✅ Call useCallback at the top level:
const handleClick = useCallback(() => {
sendReport(item)
}, [item]);
return (
<figure>
<Chart onClick={handleClick} />
</figure>
);
}
또는 마지막 코드 조각에서 useCallback을 제거하고 대신 Report 자체를 메모에 래핑할 수도 있습니다. item prop이 변경되지 않으면 Report는 재렌더링을 건너뛰므로 Chart도 재렌더링을 건너뜁니다.
function ReportList({ items }) {
// ...
}
const Report = memo(function Report({ item }) {
function handleClick() {
sendReport(item);
}
return (
<figure>
<Chart onClick={handleClick} />
</figure>
);
});
상당히 중요한 내용이 많이 나오는군요. 특히 Deep Dive 부분은 면접 질문으로도 자주 나왔습니다. 잘 알아두면 좋겠군요.