useMemo와 useCallback, 언제 사용해야 할까?
1. 최적화에 대한 막연한 기대
'무한 렌더링' 문제를 해결하면서, 자연스럽게 React의 렌더링 최적화에 더 깊은 관심을 두게 되었다. 그 과정에서 useMemo
와 useCallback
의 사용에 대한 고민을 정리해 보려고 한다.
처음에는 이 훅들의 효용성에 매료되어, 예방적 최적화라는 명목으로 코드의 많은 부분에 적용하기 시작한다. 모든 함수 선언을 useCallback
으로, 조금이라도 계산이 들어가는 값은 useMemo
로 감싼다. 코드가 훅으로 점차 채워지는 것을 보며, 애플리케이션의 성능이 한 단계 더 견고해질 것이라 막연히 기대하게 되는 것이다.
하지만 이 접근법은 이내 새로운 질문들을 가져오게 된다. 코드의 가독성은 떨어졌고, '이것이 정말 효과적인가?'에 대한 확신은 떨어진다. 최적화의 본질을 제대로 이해하지 못하고 있었던 것이다.
2. useMemo
와 useCallback
은 공짜가 아니다
가장 먼저 인지해야 할 사실은 모든 최적화에는 비용이 따른다는 점이다. useMemo
와 useCallback
은 성능 개선을 제공하지만, 다음과 같은 비용을 청구한다.
- 코드 복잡성과 가독성 저하
훅으로 감싸진 코드는 직관성이 떨어진다. 간단한 값 할당이나 함수 선언이 복잡한 구문으로 변경되어 동료 개발자의 코드 이해를 방해할 수 있다. - 메모리 사용량 증가
메모이제이션(Memoization)은 계산된 값을 메모리에 저장해두고 재사용하는 기법이다. 이는 CPU 사용량을 줄이는 대신 메모리 사용량을 늘리는 명백한 트레이드오프 관계에 있다. - 비교 연산의 오버헤드
React는 매 렌더링마다 훅의 의존성 배열을 이전 렌더링의 것과 비교한다. 이 비교 과정 자체에도 미미한 연산 비용이 발생하게 된다. 메모이제이션으로 얻는 성능 이점보다 이 비교 비용이 더 크다면 오히려 손해인 것이다.
"섣부른 최적화는 모든 악의 근원이다." (Premature optimization is the root of all evil.)
우리는 최적화가 필요하다는 명확한 근거가 있을 때만 신중하게 접근해야 한다.
3. useMemo
와 useCallback
의 올바른 사용 시점
그렇다면 이 비용을 지불할 가치가 있는, 명확한 사용 시점은 언제일까?
useMemo
가 필요한 순간
- 반복적인 고비용 연산을 피할 때
`고비용 연산'이란?: 수천 개의 배열을 필터링/정렬/매핑하거나, 복잡한 데이터를 시각화를 위해 재가공하는 등, 실행에 수 밀리초(ms) 이상 소요되어 UI의 반응성에 영향을 주는 계산을 의미한다.
- 판단 기준: 해당 연산으로 인해 다른 UI 인터랙션(e.g., 타이핑)이 버벅거리는 것이 느껴진다면, useMemo 사용을 고려해야 한다.
// 10,000개의 항목을 필터링하는 '고비용' 계산
function TodoList({ todos, filter }) {
// filter가 바뀔 때만 이 복잡한 계산을 다시 수행한다.
const visibleTodos = useMemo(() => {
console.log('고비용 필터링 연산 실행...');
return todos.filter(t => t.text.includes(filter));
}, [todos, filter]);
return <ul>{visibleTodos.map(todo => <li key={todo.id}>{todo.text}</li>)}</ul>;
}
- 자식 컴포넌트의 렌더링을 방지하기 위한 참조 동일성 유지
React.memo로 최적화된 자식 컴포넌트에 객체나 배열을 prop으로 전달할 때, 부모가 리렌더링되면 새로운 참조가 생성되어 React.memo를 무력화 시킨다.useMemo
는 이때 참조 동일성을 보장하여 자식의 불필요한 리렌더링을 막는다.
function UserProfile({ user }) {
// user.isAdmin 값이 바뀔 때만 styleConfig 객체를 새로 생성한다.
const styleConfig = useMemo(() => ({
color: user.isAdmin ? 'red' : 'blue',
fontWeight: 'bold'
}), [user.isAdmin]);
// styleConfig의 참조가 유지되므로 MemoizedAvatar는 불필요하게 리렌더링되지 않는다.
return <MemoizedAvatar style={styleConfig} />;
}
const MemoizedAvatar = React.memo(Avatar);
useCallback
이 필요한 순간
useCallback
(fn, deps)은 useMemo
(() => fn, deps)와 기능적으로 동일하다. 함수의 참조 동일성을 보장하는 데 특화되어 있는 것이다.
- React.memo로 최적화된 자식 컴포넌트에 함수를 전달할 때
useMemo
의 두 번째 케이스와 같은 맥락이다. 부모 리렌더링 시 함수가 새로 생성되는 것을 막아 자식의 불필요한 리렌더링을 방지한다.
function Parent() {
const [count, setCount] = useState(0);
// 의존성 배열이 비어있으므로, handleClick은 항상 동일한 참조를 유지한다.
const handleClick = useCallback(() => {
console.log('Button clicked!');
}, []);
return <MemoizedButton onClick={handleClick} />;
}
const MemoizedButton = React.memo(Button);
- 다른 훅(e.g., useEffect)의 의존성으로 함수가 사용될 때
컴포넌트 내에 정의된 함수를useEffect
의 의존성으로 사용하면,useCallback
없이는 매 렌더링마다 이펙트가 재실행될 수 있다. 이는 의도치 않은 API 중복 호출 등의 심각한 버그로 이어질 수 있다.
function SearchComponent({ query }) {
// query가 변경될 때만 fetchData 함수가 새로 생성된다.
const fetchData = useCallback(() => {
api.get(`/search?q=${query}`).then(/* ... */);
}, [query]);
useEffect(() => {
// fetchData의 참조가 안정적이므로, query가 바뀔 때만 이펙트가 실행된다.
fetchData();
}, [fetchData]);
return <input value={query} /* ... */ />;
}
4. 최적화를 바라보는 더 넓은 시야
useMemo
와 useCallback
은 유용한 도구지만, React 최적화의 일부일 뿐이다. 때로는 더 구조적인 접근이 훨씬 효과적으로 작용한다.
-
측정하고, 추측하지 마라
최적화의 첫걸음은 항상 React DevTools의 Profiler로 시작해야 한다. 어떤 컴포넌트가, 왜, 얼마나 자주 리렌더링되는지 데이터에 기반하여 병목 지점을 정확히 파악해야 한다. -
컴포넌트 구조 개선
- 상태 하향 배치: 전역적인 상태를 사용하는 컴포넌트 가까이로 내려보내면, 불필요한 전파를 막고 리렌더링 범위를 좁힐 수 있다. 이는 가장 효과적인 최적화 중 하나이다.
- 콘텐츠 분리: 리렌더링이 잦은 컴포넌트에서 정적인 부분을 children prop으로 분리해 전달하면, 해당 부분의 리렌더링을 자연스럽게 방지할 수 있다.
- 대규모 목록에는 가상화(Virtualization) 적용
수백 개 이상의 아이템을 렌더링해야 한다면useMemo
만으로는 한계가 있다. 화면에 보이는 부분만 렌더링하는 react-window나 TanStack Virtual 같은 가상화 라이브러리 도입을 검토해야야 할 타이밍일 수 있는 것이다.
5. 균형 잡힌 최적화를 위해
최적화는 모든 곳에 훅을 적용하는 기계적인 작업이 아니다. 그것은 성능적 이점과 코드의 복잡성 및 유지보수 비용을 저울질하여, 가장 합리적인 결정을 내리는 사고 과정이다.
useMemo
와 useCallback
은 특정 문제를 해결하기 위한 도구이다. 이 도구들을 언제, 왜 사용해야 하는지 이해하고, Profiler라는 객관적인 데이터를 근거로 사용할 때 비로소 그 가치가 빛을 발할 것이다.