useCallback과 useMemo 기초
이번에는 함수와 값의 재생성을 막아 최적화된 리액트 애플리케이션을 구축하는 데 필수적인 훅인 useCallback
과 useMemo
에 대해 심층적으로 다루겠습니다. 두 훅의 작동 원리, 정확한 사용법, 그리고 실제 적용 시 고려사항을 상세히 알아보겠습니다.
useCallback
과 useMemo
는 왜 필요한가?
리액트 함수형 컴포넌트는 매 렌더링마다 전체 함수 본문이 다시 실행됩니다. 이 과정에서 컴포넌트 내부에 선언된 모든 변수, 객체, 배열, 함수들이 새로 생성됩니다.
문제점
- 함수 재생성: 컴포넌트가 리렌더링될 때마다
onClick
,onChange
등의 이벤트 핸들러 함수가 새로 생성됩니다. 이 함수들은 이전 렌더링 시의 함수와 다른 메모리 주소(참조) 를 가지게 됩니다. React.memo
무력화: 만약 이 재생성된 함수를React.memo
로 감싸진 자식 컴포넌트에 프롭스로 전달한다면,React.memo
는 얕은 비교를 통해 프롭스가 변경되었다고 판단하여 자식 컴포넌트를 불필요하게 리렌더링하게 됩니다.- 고비용 계산의 반복: 컴포넌트 내에서 복잡한 계산을 통해 얻은 결과값(객체나 배열)이 매 렌더링마다 다시 계산되고 재생성된다면, 이 또한 불필요한 성능 저하를 유발합니다. 이 재생성된 값을 자식 컴포넌트의 프롭스로 전달하면 마찬가지로
React.memo
를 무력화시킬 수 있습니다.
useCallback
과 useMemo
는 이처럼 "매 렌더링마다 재생성되는 참조 타입의 값(함수, 객체, 배열 등)을 최적화"하여 불필요한 리렌더링을 방지하고 성능을 향상시키는 데 사용됩니다.
useCallback
: 함수 메모이제이션
useCallback
훅은 특정 함수를 메모이제이션합니다. 즉, 의존성 배열(dependency array)에 있는 값들이 변경되지 않는 한, 이전에 생성된 함수를 재사용하고 새로운 함수를 생성하지 않습니다.
문법
const memoizedCallback = useCallback(
() => {
// 실행될 함수 로직
},
[dependency1, dependency2, ...], // 의존성 배열
);
- 첫 번째 인자: 메모이제이션할 함수 정의.
- 두 번째 인자 (의존성 배열
deps
): 이 배열에 포함된 값들 중 하나라도 변경되면,useCallback
은 새로운 함수를 생성하여 반환합니다. 배열이 비어있다면([]
), 컴포넌트가 마운트될 때 단 한 번만 함수를 생성하고 이후로는 계속 동일한 함수를 재사용합니다.
언제 사용하는가?
React.memo
로 감싸진 자식 컴포넌트에 콜백 함수를 프롭스로 전달할 때: 가장 흔하고 중요한 사용 사례입니다.useCallback
으로 함수를 메모이제이션하여 자식 컴포넌트의 불필요한 리렌더링을 막습니다.useEffect
나useMemo
의 의존성으로 함수를 사용할 때: 함수가 매번 재생성되면useEffect
나useMemo
가 불필요하게 다시 실행될 수 있습니다. 이때 함수를useCallback
으로 감싸주면 이를 방지할 수 있습니다.
예시
// src/components/UseCallbackDemo.js
import React, { useState, useCallback } from 'react';
// ✅ React.memo로 감싸진 자식 컴포넌트
const ButtonWithMemo = React.memo(({ onClick, label }) => {
console.log(`ButtonWithMemo [${label}] 렌더링됨`); // 이 로그가 찍히는 시점을 확인!
return (
<button onClick={onClick} className="button" style={{ marginRight: '10px' }}>
{label}
</button>
);
});
function UseCallbackDemo() {
const [parentCount, setParentCount] = useState(0); // 부모 컴포넌트의 상태
const [childCount, setChildCount] = useState(0); // 자식 컴포넌트에게 전달될 상태
const [textInput, setTextInput] = useState(''); // 텍스트 입력 상태
// 🚨 일반 함수: ParentComponentMemoDemo가 리렌더링될 때마다 재생성됨
const handleRegularClick = () => {
setChildCount(prev => prev + 1);
};
// ✅ useCallback 적용 (의존성 없음): 컴포넌트 마운트 시 한 번만 생성됨
const handleOptimizedClickNoDeps = useCallback(() => {
setChildCount(prev => prev + 1);
console.log("Optimized Click (No Deps) executed.");
}, []); // 의존성 배열이 비어있음
// ✅ useCallback 적용 (의존성 있음): textInput이 변경될 때만 재생성됨
const handleOptimizedClickWithDeps = useCallback(() => {
alert(`Current Text Input: ${textInput}`);
console.log("Optimized Click (With Deps) executed.");
}, [textInput]); // textInput이 변경될 때만 함수가 재생성됨
return (
<div style={{ maxWidth: '600px', margin: '30px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 10px rgba(0,0,0,0.05)', backgroundColor: '#fff' }}>
<h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '30px' }}>`useCallback` 기초 예시</h2>
<p>Parent Count: {parentCount}</p>
<p>Child Count: {childCount}</p>
<p>Text Input: {textInput}</p>
<button onClick={() => setParentCount(parentCount + 1)} className="button" style={{ marginBottom: '15px' }}>
Parent Count 증가 (부모 리렌더링)
</button>
<br/>
<input
type="text"
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
placeholder="텍스트 입력"
style={{ width: '100%', padding: '8px', marginBottom: '20px', border: '1px solid #ccc', borderRadius: '4px' }}
/>
<hr style={{ margin: '20px 0' }}/>
{/* 1. 일반 함수 프롭스: 부모 리렌더링 시 ButtonWithMemo도 매번 리렌더링됨 */}
<ButtonWithMemo onClick={handleRegularClick} label="Regular Button" />
{/* 2. useCallback (의존성 없음) 프롭스: 부모 리렌더링 시 ButtonWithMemo는 리렌더링 안 함 (childCount 변경 시만 리렌더링) */}
<ButtonWithMemo onClick={handleOptimizedClickNoDeps} label="Optimized Button (No Deps)" />
{/* 3. useCallback (의존성 있음) 프롭스: textInput 변경 시에만 ButtonWithMemo가 리렌더링됨 */}
<ButtonWithMemo onClick={handleOptimizedClickWithDeps} label="Optimized Button (With Deps)" />
<p style={{ marginTop: '20px', fontSize: '0.9em', color: '#555' }}>
`Parent Count 증가` 버튼을 클릭하면서 콘솔 로그를 확인해보세요.<br/>
`Regular Button`은 부모가 리렌더링될 때마다 함께 리렌더링되지만,<br/>
`Optimized Button (No Deps)`는 `handleOptimizedClickNoDeps` 함수 참조가 고정되어 불필요하게 리렌더링되지 않습니다.<br/>
`Optimized Button (With Deps)`는 `textInput`을 변경할 때만 리렌더링됩니다.
</p>
</div>
);
}
export default UseCallbackDemo;
useCallback
의존성 배열의 중요성
- 빈 배열
[]
: 컴포넌트가 마운트될 때 단 한 번만 함수를 생성합니다. 이 함수는 클로저(closure)를 형성하여 마운트 시점의 상태나 프롭스를 "기억"합니다. 만약 이 함수 내에서 최신 상태나 프롭스에 접근해야 한다면 주의해야 합니다. (예:setCount(count + 1)
대신setCount(prevCount => prevCount + 1)
처럼 함수형 업데이트를 사용하면 클로저 문제를 피할 수 있습니다.) - 의존성 포함
[dep1, dep2]
: 배열 안의dep1
이나dep2
중 하나라도 변경되면 새로운 함수를 생성합니다. 최신 값을 사용하면서도 불필요한 재생성을 막을 때 사용합니다.
useMemo
: 값 메모이제이션
useMemo
훅은 비용이 많이 드는 계산의 결과 값을 메모이제이션합니다. 의존성 배열에 있는 값들이 변경되지 않는 한, 이전에 계산된 값을 재사용하고 새로운 계산을 수행하지 않습니다.
문법
const memoizedValue = useMemo(
() => computeExpensiveValue(dep1, dep2),
[dep1, dep2, ...], // 의존성 배열
);
- 첫 번째 인자: 계산을 수행할 함수. 이 함수는 값을 반환해야 합니다.
- 두 번째 인자 (의존성 배열
deps
): 이 배열에 포함된 값들 중 하나라도 변경되면,useMemo
는 첫 번째 인자의 함수를 다시 실행하여 새로운 값을 계산하고 반환합니다. 배열이 비어있다면([]
), 컴포넌트 마운트 시 단 한 번만 계산을 수행합니다.
언제 사용하는가?
- 복잡한 계산 결과 캐싱: 컴포넌트 렌더링 시마다 반복적으로 수행하기에 비용이 많이 드는 계산(예: 대규모 데이터 필터링, 정렬, 복잡한 통계 계산)의 결과를 캐싱할 때.
React.memo
로 감싸진 자식 컴포넌트에 참조 타입(객체, 배열) 프롭스를 전달할 때: 객체나 배열이 매 렌더링마다 새로 생성되어React.memo
를 무력화하는 것을 방지하기 위해 사용합니다.
예시
// src/components/UseMemoDemo.js
import React, { useState, useMemo } from 'react';
// ✅ React.memo로 감싸진 자식 컴포넌트 (배열 프롭스를 받음)
const ItemList = React.memo(({ items }) => {
console.log('ItemList 렌더링됨'); // 이 로그가 찍히는 시점을 확인!
return (
<div style={{ padding: '15px', border: '1px solid #ccc', borderRadius: '5px', marginTop: '15px' }}>
<h4>아이템 목록 ({items.length}개)</h4>
<ul>
{items.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
);
});
// 비용이 많이 드는 계산을 시뮬레이션하는 함수
const calculateExpensiveValue = (num) => {
console.log('비용이 많이 드는 계산 수행됨...');
let sum = 0;
for (let i = 0; i < num * 1000000; i++) { // 의도적으로 지연을 만듦
sum += i;
}
return sum;
};
function UseMemoDemo() {
const [count, setCount] = useState(0); // 다른 상태 변화를 유도하여 리렌더링 테스트
const [inputValue, setInputValue] = useState(10); // useMemo의 의존성
// 🚨 useMemo를 사용하지 않으면, count가 변경될 때마다 calculateExpensiveValue가 다시 실행됨
// const expensiveResult = calculateExpensiveValue(inputValue);
// ✅ useMemo를 사용하여 inputValue가 변경될 때만 계산 결과를 다시 계산
const expensiveResult = useMemo(() => {
return calculateExpensiveValue(inputValue);
}, [inputValue]); // inputValue가 변경될 때만 다시 계산
// ✅ useMemo를 사용하여 배열 참조를 고정
// count가 변경될 때 ItemList가 불필요하게 리렌더링되는 것을 방지
const itemsArray = useMemo(() => {
console.log('itemsArray 생성됨'); // 이 로그가 찍히는 시점을 확인!
return [`Item A - ${count}`, `Item B`, `Item C`]; // count 값은 내부에서 사용되지만, 배열 자체는 count 변경 시에만 재생성되지 않음
}, [count]); // count가 변경될 때만 itemsArray가 재생성됨
return (
<div style={{ maxWidth: '600px', margin: '30px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 10px rgba(0,0,0,0.05)', backgroundColor: '#fff' }}>
<h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '30px' }}>`useMemo` 기초 예시</h2>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)} className="button" style={{ marginBottom: '15px' }}>
Count 증가 (부모 리렌더링)
</button>
<p>Input Value for Expensive Calculation: {inputValue}</p>
<input
type="number"
value={inputValue}
onChange={(e) => setInputValue(Number(e.target.value))}
style={{ width: '100%', padding: '8px', marginBottom: '20px', border: '1px solid #ccc', borderRadius: '4px' }}
/>
<p>Expensive Result: {expensiveResult}</p>
<hr style={{ margin: '20px 0' }}/>
{/* ItemList 컴포넌트: itemsArray가 변경될 때만 리렌더링됨 */}
<ItemList items={itemsArray} />
<p style={{ marginTop: '20px', fontSize: '0.9em', color: '#555' }}>
`Count 증가` 버튼을 클릭하면서 콘솔 로그를 확인해보세요.<br/>
`비용이 많이 드는 계산 수행됨...` 로그는 `inputValue`가 변경될 때만 찍히고,<br/>
`itemsArray 생성됨` 로그는 `count`가 변경될 때만 찍힙니다.<br/>
이를 통해 `ItemList` 컴포넌트의 불필요한 리렌더링도 방지됩니다.
</p>
</div>
);
}
export default UseMemoDemo;
useMemo
의존성 배열의 중요성: useCallback
과 마찬가지로 useMemo
의 의존성 배열은 언제 계산을 다시 수행할지 결정합니다. 의존성을 정확하게 명시하는 것이 중요합니다.
useCallback
vs useMemo
특징 | useCallback | useMemo |
---|---|---|
목적 | 함수 자체를 메모이제이션 | 함수의 실행 결과 값을 메모이제이션 |
반환 값 | 메모이제이션된 함수 | 메모이제이션된 값 (함수 실행 결과) |
주요 사용처 | React.memo 컴포넌트에 콜백 함수 전달, useEffect /useMemo 의존성으로 함수 사용 | 고비용 계산 결과 캐싱, React.memo 컴포넌트에 객체/배열 프롭스 전달 |
핵심 차이: useCallback(() => fn, deps)
은 fn
이라는 함수 자체를 기억하고, useMemo(() => value, deps)
는 value
라는 값을 기억합니다.
최적화 적용 시 주의사항 및 고려사항
useCallback
과 useMemo
는 강력한 최적화 도구이지만, 다음과 같은 점을 항상 염두에 두어야 합니다.
- 메모이제이션의 비용:
useCallback
과useMemo
자체도 의존성 배열을 비교하고 메모리에 값을 저장하는 오버헤드가 있습니다. 최적화로 얻는 이득이 이 오버헤드보다 커야 의미가 있습니다. - 남용 금지 (Premature Optimization): 성능 문제가 발생하기 전에 모든 함수와 값을 메모이제이션하는 것은 오히려 코드의 복잡성을 증가시키고 디버깅을 어렵게 만들 수 있습니다. 항상 React Developer Tools의 Profiler를 사용하여 실제 성능 병목 지점을 파악한 후 최적화를 적용해야 합니다.
- 정확한 의존성: 의존성 배열을 올바르게 명시하는 것이 중요합니다.
- 의존성 누락: 함수나 값 내부에서 사용되는 변수가 의존성 배열에 포함되지 않으면, 오래된(stale) 클로저 값을 참조하게 되어 버그가 발생할 수 있습니다.
- 불필요한 의존성: 의존성 배열에 불필요한 값을 포함하면, 해당 값이 변경될 때마다 불필요하게 함수나 값이 재생성되어 메모이제이션 효과가 상쇄될 수 있습니다.
- 컴포넌트 구조 개선: 때로는 메모이제이션보다 컴포넌트를 더 작게 분리하거나, 상태를 필요한 곳으로 끌어내리는 등 컴포넌트 구조를 개선하는 것이 더 근본적인 성능 향상으로 이어질 수 있습니다.
10장 4절 "useCallback
, useMemo
기초"는 여기까지입니다. 이 장에서는 useCallback
과 useMemo
훅의 작동 원리를 이해하고, 각각 함수와 값의 불필요한 재생성을 막는 방법을 상세히 살펴보았습니다. 또한, React.memo
와 함께 사용하여 React.memo
의 얕은 비교 한계를 극복하는 시너지 효과를 확인했습니다.
이제 여러분은 리액트 애플리케이션에서 발생할 수 있는 주요 성능 문제 중 하나인 "불필요한 리렌더링"을 효과적으로 진단하고 방지할 수 있는 기본적인 도구들을 모두 익히셨습니다.