대부분의 React 성능 튜토리얼은 똑같은 잘못된 조언으로 끝납니다. 모든 것을 useMemo와 useCallback으로 감싸면 끝이라는 식이죠. 만약 그 조언을 따랐다면, 아마 애플리케이션을 더 느리게 만들었을 것입니다. 이 훅들은 공짜가 아닙니다. 각각 메모리를 할당하고, 의존성을 비교하며, 캐시된 값을 저장합니다. 목적 없이 사용하면 최적화가 아니라 오버헤드가 됩니다.
핵심적인 내용만 추려보겠습니다.
각 훅이 실제로 하는 일
useMemo는 값을 기억합니다. 무거운 작업을 수행하는 함수를 전달하면, 그 결과값을 반환합니다. 다음 렌더링 시 의존성이 변경되지 않았다면, React는 계산을 건너뛰고 이전 결과값을 돌려줍니다.
useCallback은 함수를 기억합니다. 함수를 대신 실행해주지는 않습니다. 단지 의존성이 동일하게 유지되는 한, 렌더링 사이에서 동일한 함수 인스턴스를 반환할 뿐입니다.
이것이 유일한 차이점입니다. 하나는 계산된 값을 캐싱하고, 다른 하나는 참조(reference)를 캐싱합니다. 이 둘을 혼동하면 최적화된 것처럼 보이지만 실제로는 훅을 사용하지 않은 코드와 똑같이 동작하면서 메모리만 추가로 사용하는 코드가 됩니다.
함수 식별성(Identity)이 트리 구조를 망가뜨리는 이유
컴포넌트가 리렌더링될 때, React는 함수 본문 전체를 다시 실행합니다. 모든 변수가 재생성되며, 모든 인라인 함수는 메모리 내에서 완전히 새로운 주소를 할당받습니다.
JavaScript에서 로직이 완전히 동일한 두 함수는 같지 않습니다. () => {} === () => {}는 false를 반환합니다. 이 규칙은 객체와 배열에도 동일하게 적용됩니다. 부모 컴포넌트가 handleSubmit을 정의하고 자식에게 전달한다면, 자식은 매 렌더링마다 새로운 props를 받게 됩니다. 자식이 React.memo로 감싸져 있더라도, 새로운 함수가 이전 함수와 동일한 일을 한다는 것을 알 수 없습니다. 참조가 변경되었으므로 자식은 리렌더링됩니다.
이것이 바로 useCallback이 해결하고자 하는 근본적인 문제입니다. 속도에 관한 것이 아니라 안정성에 관한 것입니다.
useMemo가 제값을 하는 경우
객관적으로 비용이 많이 드는 작업을 수행하고 있고, 눈에 띄는 지연(lag)이 느껴질 때 useMemo가 필요합니다.
방대한 데이터셋을 필터링하는 상황을 생각해 보세요. 수만 개의 행이 있는 테이블과 검색 입력창이 있다면, 컴포넌트 내부에 다음과 같이 작성할 수 있습니다.
const visibleRows = rows.filter(r => r.name.includes(query));
useMemo가 없다면, 해당 루프는 매 렌더링마다 실행됩니다. 사용자가 사이드바를 토글하는 버튼을 클릭하면 부모가 리렌더링되고, rows와 query가 전혀 바뀌지 않았음에도 필터가 다시 실행됩니다. 대규모 데이터셋에서는 이러한 버벅임이 눈에 보입니다.
useMemo는 결과를 고정함으로써 이 문제를 해결합니다.
const visibleRows = useMemo(() => {
return rows.filter(r => r.name.includes(query));
}, [rows, query]);
이제 React는 의존성이 실제로 변경될 때만 필터를 다시 실행합니다.
복잡한 수학적 계산, API 응답을 차트용 형식으로 변환하는 작업, 또는 그렇지 않으면 계속해서 재계산될 상태를 도출하는 작업에도 동일한 논리가 적용됩니다.
덜 명확하지만 두 번째 사용 사례가 있습니다. 객체나 배열을 로컬에서 생성하고 이를 useEffect 의존성 배열에 포함하면, 매 렌더링마다 의도치 않게 해당 이펙트(effect)를 트리거할 수 있습니다. 인라인 객체와 배열은 매번 새로운 식별자를 가지므로, 이펙트는 의존성이 변경된 것으로 간주하고 다시 실행됩니다. useMemo로 해당 객체를 메모이제이션하면 참조가 안정적으로 유지되어, 실제 데이터가 변경될 때만 이펙트가 실행되도록 할 수 있습니다.
useCallback이 필수적인 경우
useCallback은 React.memo로 최적화된 자식 컴포넌트에 핸들러를 전달할 때 가장 중요합니다.
카운터를 가진 부모 컴포넌트를 상상해 보세요. 이 컴포넌트는 또한 비용이 많이 드는 자식 리스트를 렌더링합니다.
function Parent() {
const [count, setCount] = useState(0);
const handleItemClick = (id) => {
console.log(id);
};
return (
<div>
<button onClick={() => setCount(c + 1)}>{count}</button>
<ExpensiveList onItemClick={handleItemClick} />
</div>
);
}
count가 변경될 때마다 Parent가 리렌더링됩니다. 새로운 handleItemClick이 생성됩니다. ExpensiveList가 새로운 props 참조를 받기 때문에 이 역시 리렌더링됩니다. 만약 ExpensiveList가 React.memo로 감싸져 있더라도, 함수 props가 변경되었기 때문에 해당 메모이제이션은 완전히 낭비됩니다.
useCallback은 참조를 보존합니다.
const handleItemClick = useCallback((id) => {
console.log(id);
}, []);
이제 ExpensiveList는 정말로 필요할 때만 리렌더링됩니다.
또 다른 중요한 상황은 useEffect와 관련이 있습니다. 만약 이펙트가 컴포넌트 내부에서 정의된 함수를 구독하고 있는데, 그 함수가 매 렌더링마다 식별자가 바뀐다면, 이펙트는 반복적으로 해제(teardown)되고 다시 구독(resubscribe)하게 됩니다. 함수를 메모이제이션하면 이펙트를 안정적으로 유지할 수 있습니다.
의존성 배열의 함정과 Stale Closures
두 훅 모두 의존성 배열에 의존하며, 바로 이 지점에서 대부분의 버그가 숨어 있습니다.
의존성 배열에서 변수를 누락하면, 메모이제이션된 함수나 값이 해당 변수의 이전 버전을 캡처하게 됩니다. 이를 stale closure라고 합니다. UI에는 최신 데이터가 표시될 수 있지만, 콜백은 여전히 3번 전의 렌더링 상태를 바라보고 있을 수 있습니다. 해결 방법은 간단하지만 코드 리뷰 중에 놓치기 쉽습니다. 훅 내부에서 사용되는 값 중 변경될 가능성이 있는 모든 값을 포함하세요.
react-hooks/exhaustive-deps ESLint 규칙을 실행하세요. 명백한 누락을 잡아낼 것입니다. 하지만 이를 로봇처럼 맹목적으로 따르지는 마세요. 각 의존성이 왜 중요한지 이해해야 합니다.
과도한 최적화의 숨겨진 비용
초보자들은 안전하다고 느끼기 때문에 모든 함수와 값에 이러한 훅을 적용하곤 합니다. 하지만 그런 습관은 역효과를 불러옵니다.
React는 캐시된 값을 메모리에 저장해야 합니다. 매 렌더링마다 의존성 배열을 순회하며 Object.is를 사용하여 각 항목을 비교해야 합니다. 이 비교는 비용이 적게 들지만, 공짜는 아닙니다. 만약 onClick={() => setOpen(true)}와 같이 사소한 이벤트 핸들러를 useCallback으로 감싼다면, 할당하는 데 순식간이었을 함수를 새로 만들지 않기 위해 메모리와 CPU 비용을 지불하는 셈입니다.
또한 이러한 훅은 코드의 노이즈를 증가시킵니다. useMemo와 useCallback으로 감싸진 코드는 읽기 어렵고 유지보수하기도 까다롭습니다. 모든 의존성 배열은 당신을 괴롭힐 잠재적인 stale closure가 될 수 있습니다.
진정한 원칙은 화려하지는 않지만 효과적입니다. 먼저 일반적인 코드를 작성하세요. 문제가 있다는 증거가 있을 때만 최적화하세요. React DevTools Profiler를 사용하여 어떤 컴포넌트가 무거운지, 어떤 렌더링이 낭비적인지 식별하세요. 렌더링이 몇 밀리초도 걸리지 않는다면 사용자는 전혀 눈치채지 못할 것이며, 당신의 메모이제이션은 아무런 해결책도 되지 못합니다.
결론
useMemo는 비용이 많이 드는 값을 위한 것입니다. useCallback은 안정적인 함수 참조를 위한 것입니다. 두 훅 모두 그 자체로 컴포넌트의 렌더링 속도를 빠르게 만들지는 않습니다. 대신 불필요한 후속 작업을 방지할 뿐입니다. 우선 훅 없이 시작하고, 실제 도구를 사용하여 측정하며, 프로파일러가 병목 현상을 보여주는 정확한 지점에만 훅을 추가하세요. 가끔 재렌더링이 일어나더라도 깔끔하게 작성된 코드가, 모든 것을 메모이제이션하느라 과하게 설계된 코드보다 훨씬 낫습니다.
