useCallback
useCallback은 리액트의 메모이제이션 훅 중 하나로, 함수를 메모리에 저장하여 컴포넌트가 리렌더링되더라도 동일한 함수 참조를 유지할 수 있게 해줍니다. 이는 특히 자식 컴포넌트에 함수를 props로 전달할 때 매우 유용합니다. 메모이제이션된 함수는 의존성 배열의 값이 변경되기 전까지 동일한 참조를 유지하므로, 불필요한 리렌더링을 방지할 수 있습니다.
1. 기본 구조와 동작 방식
const memoizedCallback = useCallback(() => {
// 함수 내용
doSomething(dependency);
}, [dependency]);
useCallback은 두 개의 인자를 받습니다.
- 첫 번째 인자: 메모이제이션하고자 하는 함수
- 두 번째 인자: 의존성 배열
의존성 배열의 값이 변경될 때만 새로운 함수가 생성되며, 그렇지 않은 경우에는 이전에 생성된 함수를 재사용합니다.
2. 의존성 배열의 동작
useCallback의 의존성 배열은 함수가 언제 다시 생성될지를 결정합니다. 빈 배열을 전달하면 컴포넌트가 마운트될 때만 함수가 생성되어 계속 재사용됩니다. 의존성 배열에 값을 포함시키면 해당 값이 변경될 때마다 새로운 함수가 생성됩니다. 의존성 배열을 생략하면 매 렌더링마다 새로운 함수가 생성되어 메모이제이션의 이점을 얻을 수 없습니다.
3. useCallback 실전 활용
3.1 기본적인 사용 예제
간단한 예제를 통해 useCallback의 효과를 살펴보겠습니다.
import React, { useState, useCallback } from 'react';
function ParentComponent() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
// useCallback을 사용하지 않은 경우
const handleCount = () => {
setCount((prev) => prev + 1);
};
// useCallback을 사용한 경우
const handleCountCallback = useCallback(() => {
setCount((prev) => prev + 1);
}, []); // 의존성 배열이 비어있으므로 함수가 재생성되지 않음
return (
<div>
<button onClick={handleCount}>일반 함수로 카운트 증가</button>
<button onClick={handleCountCallback}>
메모이제이션된 함수로 카운트 증가
</button>
<input value={text} onChange={(e) => setText(e.target.value)} />
<ChildComponent onCount={handleCount} />
<ChildComponent onCount={handleCountCallback} />
</div>
);
}
const ChildComponent = React.memo(({ onCount }) => {
console.log('자식 컴포넌트 렌더링');
return <button onClick={onCount}>자식에서 카운트 증가</button>;
});
위 예제에서 input에 텍스트를 입력할 때마다 ParentComponent가 리렌더링됩니다. useCallback을 사용하지 않은 handleCount는 매 렌더링마다 새로 생성되어 ChildComponent의 불필요한 리렌더링을 유발하지만, useCallback을 사용한 handleCountCallback은 동일한 참조를 유지하여 ChildComponent의 리렌더링을 방지합니다.
3.2 실제 상황에서의 활용 예제
실제 개발 환경에서 자주 마주치는 상황을 통해 useCallback의 실질적인 활용법을 알아보겠습니다.
import React, { useState, useCallback } from 'react';
function TodoList() {
const [todos, setTodos] = useState([]);
const [input, setInput] = useState('');
// 할 일 추가 함수
const handleAddTodo = useCallback(() => {
if (!input.trim()) return;
setTodos((prevTodos) => [
...prevTodos,
{
id: Date.now(),
text: input,
completed: false,
},
]);
setInput('');
}, [input]); // input이 변경될 때만 함수 재생성
// 할 일 완료 상태 토글 함수
const handleToggleTodo = useCallback((todoId) => {
setTodos((prevTodos) =>
prevTodos.map((todo) =>
todo.id === todoId ? { ...todo, completed: !todo.completed } : todo,
),
);
}, []); // 의존성이 없으므로 함수가 재생성되지 않음
return (
<div>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="할 일을 입력하세요"
/>
<button onClick={handleAddTodo}>추가</button>
<TodoItems todos={todos} onToggle={handleToggleTodo} />
</div>
);
}
const TodoItems = React.memo(({ todos, onToggle }) => {
console.log('TodoItems 렌더링');
return (
<ul>
{todos.map((todo) => (
<li
key={todo.id}
onClick={() => onToggle(todo.id)}
style={{
textDecoration: todo.completed ? 'line-through' : 'none',
}}
>
{todo.text}
</li>
))}
</ul>
);
});
export default TodoList;
이 예제에서는 할 일 목록을 관리하는 애플리케이션을 구현했습니다. handleAddTodo 함수는 input 값에 의존하므로 input이 변경될 때마다 새로 생성되어야 하지만, handleToggleTodo 함수는 의존성이 없으므로 컴포넌트가 마운트될 때 한 번만 생성됩니다. React.memo와 함께 사용하여 TodoItems 컴포넌트의 불필요한 리렌더링을 방지하고 있습니다.
3.3 함수형 업데이트를 활용한 의존성 배열 최적화
위 3.2 예제에서 handleToggleTodo 함수가 의존성 배열을 비울 수 있었던 이유는 함수형 업데이트를 사용했기 때문입니다. 함수형 업데이트는 useCallback의 효과를 극대화하는 핵심 패턴입니다.
일반 업데이트 vs 함수형 업데이트
function TodoList() {
const [todos, setTodos] = useState([]);
// ❌ 일반 업데이트 - todos를 의존성 배열에 포함해야 함
const handleToggle1 = useCallback(
(todoId) => {
setTodos(
todos.map((todo) =>
todo.id === todoId ? { ...todo, completed: !todo.completed } : todo,
),
);
},
[todos], // todos 변경 시마다 함수 재생성 → useCallback 효과 감소
);
// ✅ 함수형 업데이트 - 의존성 배열 비움
const handleToggle2 = useCallback((todoId) => {
setTodos((prevTodos) =>
prevTodos.map((todo) =>
todo.id === todoId ? { ...todo, completed: !todo.completed } : todo,
),
);
}, []); // 한 번만 생성 → useCallback 효과 극대화
}
비교표
| 구분 | 일반 업데이트 | 함수형 업데이트 |
|---|---|---|
| 의존성 배열 | [todos] - 상태 포함 필요 | [] - 비어있어도 됨 |
| 함수 재생성 빈도 | todos 변경 시마다 | 한 번만 |
| 자식 컴포넌트 렌더링 | 불필요한 리렌더링 발생 가능 | 최소화됨 |
| useCallback 효과 | 제한적 | 최대화 |
핵심 정리
useCallback과 함수형 업데이트를 함께 사용하면 의존성 배열을 최소화하여 함수 재생성을 방지하고, 자식 컴포넌트의 불필요한 리렌더링을 막을 수 있습니다. 상태를 업데이트하는 콜백 함수에는 함수형 업데이트를 사용하는 것을 습관화하세요.
4. 마무리
useCallback은 React 애플리케이션의 성능을 최적화하는 강력한 도구이지만, 모든 함수에 무분별하게 적용하는 것은 오히려 성능을 저하시킬 수 있습니다. useCallback을 효과적으로 사용하기 위해서는 해당 함수가 자식 컴포넌트에 props로 전달되는지, 그리고 그 자식 컴포넌트가 React.memo로 최적화되어 있는지를 고려해야 합니다. 특히 규모가 큰 애플리케이션에서 복잡한 로직을 처리하는 함수나, 자주 리렌더링되는 컴포넌트에 전달되는 함수의 경우 useCallback을 사용하여 최적화하는 것이 효과적입니다. 반면, 간단한 상태 업데이트 함수나 이벤트 핸들러의 경우에는 useCallback 없이도 충분한 성능을 발휘할 수 있습니다.
useCallback을 사용할 때는 의존성 배열을 신중하게 관리해야 합니다. 필요한 의존성을 빠뜨리면 함수가 오래된 값을 참조하는 클로저 문제가 발생할 수 있으며, 불필요한 의존성을 포함시키면 메모이제이션의 이점을 제대로 활용할 수 없습니다.
성능 최적화는 실제 성능 문제가 발생했을 때 적용하는 것이 바람직합니다. React DevTools의 Profiler를 활용하여 컴포넌트의 리렌더링 패턴을 분석하고, useCallback 적용 전후의 성능을 비교하여 실제로 최적화가 필요한 부분을 찾아내는 것이 중요합니다.
끝으로, useCallback은 다른 React 훅들과 함께 사용될 때 더욱 효과적입니다. useMemo로 계산값을 최적화하고, useCallback으로 함수를 최적화하며, React.memo로 컴포넌트 자체를 최적화하는 등 다양한 최적화 기법을 상황에 맞게 조합하여 사용하면 더욱 효율적인 React 애플리케이션을 구축할 수 있습니다.