useCallback과 useMemo 기초
함수와 계산값의 참조를 재사용하는 두 훅을 구분하고 useTransition·useDeferredValue로 느린 업데이트의 우선순위를 조절합니다.
이번에는 함수 참조와 계산값을 재사용하는 최적화 도구인 useCallback과 useMemo를 심층적으로 다루겠습니다.
두 훅의 작동 원리, 정확한 사용법, 실제 적용 시 고려사항을 상세히 알아보겠습니다.
앞 절의 memo 비교와 함수형 state 갱신을 이어 사용합니다. UseCallbackDemo.js와 UseMemoDemo.js는 독립 예제이므로 App.jsx에서 하나씩 import하여 배치하고 초기 상태로 새로 고칩니다. 문법 블록의 dependency1, dependency2와 ... 표시는 실제 프로젝트에서 읽는 값으로 의존성을 채우라는 자리 표시자입니다. 그 블록 자체를 완성된 예제처럼 복사하지 않습니다.
UseMemoDemo의 반복문은 일부러 계산 비용을 만드는 실습 코드입니다. 입력은 우선 0부터 10까지의 정수만 사용하고, 큰 값을 넣어 화면을 멈추게 하는 실험은 하지 않습니다. 입력이 1이면 0부터 999,999까지 더하며 결과는 499,999,500,000입니다. 반복 횟수 1,000,000을 N이라고 두면 0부터 N−1까지의 합은 N(N−1)/2라서 이 출력과 대조할 수 있습니다. 원 코드는 임의의 입력을 검증하는 서비스용 계산기가 아닙니다.
useCallback과 useMemo는 왜 필요한가?
리액트 함수형 컴포넌트는 렌더링할 때마다 전체 함수 본문이 다시 실행됩니다.
이 과정에서 함수 본문의 표현식이 다시 평가됩니다. 객체·배열·함수 리터럴은 새 참조를 만들지만, state나 전달받은 참조까지 모두 새 객체가 되는 것은 아닙니다.
문제점함수 재생성: 컴포넌트가 리렌더링될 때마다 onClick, onChange 등의 이벤트 핸들러 함수가 새로 생성됩니다.
이 함수들은 이전 렌더링 시의 함수와 다른 메모리 주소(참조)를 가지게 됩니다.
React.memo 무력화: 만약 이 재생성된 함수를 React.memo로 감싸진 자식 컴포넌트에 프롭스로 전달한다면, React.memo는 얕은 비교를 통해 프롭스가 변경되었다고 판단하여 자식 컴포넌트를 불필요하게 리렌더링하게 됩니다.
고비용 계산의 반복: 컴포넌트 내에서 복잡한 계산을 통해 얻은 결과값(객체나 배열)이 렌더링할 때마다 다시 계산되고 재생성된다면, 이 또한 불필요한 성능 저하를 유발합니다.
이 재생성된 값을 자식 컴포넌트의 프롭스로 전달하면 마찬가지로 React.memo를 무력화시킬 수 있습니다.
useCallback과 useMemo는 이처럼 렌더링할 때마다 재생성되는 참조 타입의 값(함수, 객체, 배열 등)을 최적화하여 불필요한 리렌더링을 방지하고 성능을 향상시키는 데 사용됩니다.
useCallback: 함수 메모이제이션
useCallback 훅은 특정 함수를 메모이제이션합니다.
인자로 넘기는 함수 표현식은 매 렌더에서 평가됩니다. useCallback은 의존성을 Object.is로 비교해, 같으면 지금 받은 함수 대신 캐시된 참조를 반환할 수 있습니다. 함수 생성을 막는 훅은 아닙니다.
문법const memoizedCallback = useCallback(
() => {
// 실행될 함수 로직
},
[dependency1, dependency2, ...], // 의존성 배열
);- 첫 번째 인자: 메모이제이션할 함수 정의.
- 두 번째 인자 (의존성 배열
deps): 이 배열에 포함된 값이 바뀌면 현재 렌더에서 전달한 함수 참조를 반환합니다. 빈 배열은 캐시가 유지되는 동안 같은 참조를 재사용하게 합니다.
React.memo로 감싸진 자식 컴포넌트에 콜백 함수를 프롭스로 전달할 때: 가장 흔하고 중요한 사용 사례입니다.useCallback으로 함수를 메모이제이션하여 자식 컴포넌트의 불필요한 리렌더링을 막습니다.useEffect나useMemo의 의존성으로 함수를 사용할 때: 함수가 매번 재생성되면useEffect나useMemo가 불필요하게 다시 실행될 수 있습니다. 이때 함수를useCallback으로 감싸주면 이를 방지할 수 있습니다.
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는 이 버튼의 prop이 아님) */}
<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가 Object.is 비교에서 달라지면 현재 렌더의 함수 참조를 반환합니다. 최신 값을 사용하면서도 불필요한 재생성을 막을 때 사용합니다.
useMemo: 값 메모이제이션
useMemo 훅은 비용이 많이 드는 계산의 결과값을 메모이제이션합니다.
의존성 배열에 있는 값들이 변경되지 않는 한, 이전에 계산된 값을 재사용하고 새로운 계산을 수행하지 않습니다.
문법const memoizedValue = useMemo(
() => computeExpensiveValue(dep1, dep2),
[dep1, dep2, ...], // 의존성 배열
);- 첫 번째 인자: 계산을 수행할 함수. 이 함수는 값을 반환해야 합니다.
- 두 번째 인자 (의존성 배열
deps): 이 배열에 포함된 값들 중 하나라도 변경되면,useMemo는 첫 번째 인자의 함수를 다시 실행하여 새로운 값을 계산하고 반환합니다. 빈 배열이라도 개발 Strict Mode의 순수성 검사나 캐시 폐기 때문에 재계산될 수 있습니다. 계산은 순수해야 하고, 캐시에 정확성을 의존하면 안 됩니다.
- 복잡한 계산 결과 캐싱: 컴포넌트 렌더링 시마다 반복적으로 수행하기에 비용이 많이 드는 계산(예: 대규모 데이터 필터링, 정렬, 복잡한 통계 계산)의 결과를 캐싱할 때.
React.memo로 감싸진 자식 컴포넌트에 참조 타입(객체, 배열) 프롭스를 전달할 때: 객체나 배열이 렌더링할 때마다 새로 생성되어React.memo를 무력화하는 것을 방지하기 위해 사용합니다.
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를 사용하여 배열 참조를 고정
// inputValue만 변경될 때 ItemList가 이전 items 참조를 받을 수 있음
const itemsArray = useMemo(() => {
console.log('itemsArray 생성됨'); // 이 로그가 찍히는 시점을 확인!
return [`Item A - ${count}`, `Item B`, `Item C`]; // 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의 의존성 배열은 언제 계산을 다시 수행할지 결정합니다.
의존성을 정확하게 명시하는 것이 중요합니다.
계산 의존성과 목록 의존성이 다른 경우
UseMemoDemo를 새로 고치면 inputValue는 10, count는 0이고 목록은 Item A - 0, Item B, Item C입니다. 먼저 숫자 입력을 1로 바꾸고 결과 499,999,500,000을 확인하세요. 이 동작은 비싼 계산의 inputValue를 바꾸지만 목록 계산이 읽는 count는 그대로입니다. 캐시가 재사용되는 경로에서 목록 배열은 이전 참조를 유지할 수 있습니다.
다음으로 Count를 두 번 증가시킵니다. 최종 count, 비싼 계산 결과, 세 목록 문자열을 예상하세요. 각 useMemo가 읽는 값과 의존성 배열을 나란히 적고, 두 계산에 모두 [inputValue, count]를 무조건 넣는 것이 정확하지만 덜 선택적인 이유도 설명하세요.
UseCallbackDemo의 독립 동작은 Parent Count 한 번 증가, 일반 버튼 한 번, 의존성 없는 최적화 버튼 두 번, 입력에 TS 입력, 의존성이 있는 최적화 버튼 한 번 클릭입니다. 마지막 세 상태와 alert 문구를 적으세요. 마지막 버튼의 콜백에서 textInput을 읽으면서 의존성을 []로 비우면 왜 출력의 정확성이 깨질 수 있을까요?
각 계산이 실제로 읽는 값을 기준으로 한 풀이
UseMemoDemo의 count는 2이고 inputValue는 1이므로 계산 결과는 계속 499,999,500,000입니다. 목록은 Item A - 2, Item B, Item C입니다. 비싼 계산은 inputValue를 읽고, 목록 계산은 count를 읽습니다. 두 의존성 배열에 모두 두 값을 넣으면 사용하지 않는 값의 변경까지 재계산을 요청하므로 각각의 갱신 범위를 넓힙니다. 정확성을 위해 읽는 반응형 값을 빼면 안 되지만, 관련 없는 값까지 일괄적으로 추가할 필요도 없습니다. 로그의 횟수는 개발 Strict Mode와 캐시 조건에 따라 달라질 수 있으므로 값과 참조 관계를 먼저 확인합니다.
UseCallbackDemo의 parentCount는 1, childCount는 3, textInput은 TS입니다. 일반 버튼과 빈 의존성 버튼 모두 setter의 함수형 갱신으로 childCount를 증가시키므로 1+2=3입니다. 안내는 Current Text Input: TS여야 합니다. textInput을 의존성에서 빼고 최초 콜백을 재사용하면 초기 빈 문자열을 읽을 수 있습니다. 이때 렌더링이 적어졌다는 이유로 성공이라고 판단하면 실제 사용자의 최신 입력을 잃습니다.
useCallback vs useMemo
함수 참조와 계산 결과를 구분해 재사용한다 — 상태 추적
같은 deps와 변경된 deps에서 useCallback 참조/useMemo 결과의 재사용 여부가 달라진다. 콜백을 반환하는 것과 계산을 실행하는 것을 같은 렌더별 값으로 보여 줄 수 있다.
USECALLBACK / USEMEMO / DEPENDENCIES
함수 참조와 계산 결과를 구분해 재사용한다
두 Hook은 각 의존성을 Object.is로 비교한다. 캐시가 유지되고 모두 같으면 이전 대상을 반환한다. 달라지면 useCallback은 현재 함수, useMemo는 새 계산 결과를 반환한다.
FUNCTION / USECALLBACK
함수를 실행하거나 생성 차단하지 않는다
fn 표현식은 렌더에서 평가된다. useCallback은 그 함수 대신 캐시된 함수 참조를 반환할 수 있다. 예제의 [] 콜백은 setChildCount(prev => prev + 1)로 최신 state를 갱신한다.
VALUE / USEMEMO
계산별로 다른 의존성을 비교한다
UseMemoDemo의 비싼 계산은 [inputValue], itemsArray는 [count]에 의존한다. count가 바뀌면 배열과 ItemList가 갱신되고, inputValue만 바뀌면 기존 배열을 재사용할 수 있다.
DEPENDENCIES / COMPLETE
읽는 반응형 값을 모두 포함한다
textInput을 읽는 콜백은 [textInput]이 필요하다. 의존성을 누락해 참조를 고정하면 이전 렌더의 값을 읽을 수 있다. 함수형 state 업데이트는 이전 state를 직접 읽을 필요를 줄인다.
CACHE / LIMITS
순수 계산과 기능을 먼저 보장한다
첫 렌더에서는 계산한다. 개발 Strict Mode는 순수성 검사를 위해 계산을 추가로 호출할 수 있고 캐시도 폐기될 수 있다. 저장이 반드시 필요한 값은 state·ref 등의 목적에 맞는 도구를 사용한다.
useTransition·useDeferredValue는 갱신 우선순위와 값 반영을 조절한다. 참조 캐시나 백그라운드 스레드가 아니며, 하나의 동기 filter 호출을 중간에서 끊지 않는다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 관계·조건 설명 | useCallback의 함수 참조 반환 경로와 useMemo의 계산 결과 반환 경로를 세로로 나누어 배치한다. 각 경로의 의존성 같음/변경을 두 번째 노드에서 구분한다. 두 Hook 사이에는 호출이나 변환을 뜻하는 간선이 없다. 캐시와 순수성 조건은 원래 설명을 유지한다. |
| 원도표 설명 | 함수를 실행하거나 함수 생성을 차단하지 않는다 |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 초기 cache | useCallback: 함수 참조 / useMemo: 계산 값 | useCallback은 fn 참조, useMemo는 calc 결과를 반환한다 | useCallback이 fn을 여기서 실행하는 것은 아니다 |
| 같은 deps | fn: 이전 참조 / 값: 이전 계산 결과 | 동일한 전체 deps 비교에서 이전 결과를 재사용할 수 있다 | cache는 성능 최적화이며 정확성 수명 보장이 아니다 |
| 변경 deps | fn: 현재 함수 참조 / calc: 재계산 결과 | useCallback은 현재 fn을, useMemo는 다시 실행한 calc 결과를 반환한다 | 의존성을 누락해 낡은 값을 고정하지 않는다 |
함수 참조와 계산 결과를 구분해 재사용한다 — 상태 추적
같은 deps와 변경된 deps에서 useCallback 참조/useMemo 결과의 재사용 여부가 달라진다. 콜백을 반환하는 것과 계산을 실행하는 것을 같은 렌더별 값으로 보여 줄 수 있다.
USECALLBACK / USEMEMO / DEPENDENCIES
함수 참조와 계산 결과를 구분해 재사용한다
두 Hook은 각 의존성을 Object.is로 비교한다. 캐시가 유지되고 모두 같으면 이전 대상을 반환한다. 달라지면 useCallback은 현재 함수, useMemo는 새 계산 결과를 반환한다.
FUNCTION / USECALLBACK
함수를 실행하거나 생성 차단하지 않는다
fn 표현식은 렌더에서 평가된다. useCallback은 그 함수 대신 캐시된 함수 참조를 반환할 수 있다. 예제의 [] 콜백은 setChildCount(prev => prev + 1)로 최신 state를 갱신한다.
VALUE / USEMEMO
계산별로 다른 의존성을 비교한다
UseMemoDemo의 비싼 계산은 [inputValue], itemsArray는 [count]에 의존한다. count가 바뀌면 배열과 ItemList가 갱신되고, inputValue만 바뀌면 기존 배열을 재사용할 수 있다.
DEPENDENCIES / COMPLETE
읽는 반응형 값을 모두 포함한다
textInput을 읽는 콜백은 [textInput]이 필요하다. 의존성을 누락해 참조를 고정하면 이전 렌더의 값을 읽을 수 있다. 함수형 state 업데이트는 이전 state를 직접 읽을 필요를 줄인다.
CACHE / LIMITS
순수 계산과 기능을 먼저 보장한다
첫 렌더에서는 계산한다. 개발 Strict Mode는 순수성 검사를 위해 계산을 추가로 호출할 수 있고 캐시도 폐기될 수 있다. 저장이 반드시 필요한 값은 state·ref 등의 목적에 맞는 도구를 사용한다.
useTransition·useDeferredValue는 갱신 우선순위와 값 반영을 조절한다. 참조 캐시나 백그라운드 스레드가 아니며, 하나의 동기 filter 호출을 중간에서 끊지 않는다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 관계·조건 설명 | useCallback의 함수 참조 반환 경로와 useMemo의 계산 결과 반환 경로를 세로로 나누어 배치한다. 각 경로의 의존성 같음/변경을 두 번째 노드에서 구분한다. 두 Hook 사이에는 호출이나 변환을 뜻하는 간선이 없다. 캐시와 순수성 조건은 원래 설명을 유지한다. |
| 원도표 설명 | 함수를 실행하거나 함수 생성을 차단하지 않는다 |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 초기 cache | useCallback: 함수 참조 / useMemo: 계산 값 | useCallback은 fn 참조, useMemo는 calc 결과를 반환한다 | useCallback이 fn을 여기서 실행하는 것은 아니다 |
| 같은 deps | fn: 이전 참조 / 값: 이전 계산 결과 | 동일한 전체 deps 비교에서 이전 결과를 재사용할 수 있다 | cache는 성능 최적화이며 정확성 수명 보장이 아니다 |
| 변경 deps | fn: 현재 함수 참조 / calc: 재계산 결과 | useCallback은 현재 fn을, useMemo는 다시 실행한 calc 결과를 반환한다 | 의존성을 누락해 낡은 값을 고정하지 않는다 |
| 특징 | useCallback | useMemo |
|---|---|---|
| 목적 | 함수 자체를 메모이제이션 | 함수의 실행 결과값을 메모이제이션 |
| 반환값 | 메모이제이션된 함수 | 메모이제이션된 값 (함수 실행 결과) |
| 주요 사용처 | React.memo 컴포넌트에 콜백 함수 전달, useEffect/useMemo 의존성으로 함수 사용 | 고비용 계산 결과 캐싱, React.memo 컴포넌트에 객체/배열 프롭스 전달 |
핵심 차이: useCallback(fn, deps)는 전달한 함수 참조를 재사용하고, useMemo(() => value, deps)는 value라는 값을 기억합니다.
Concurrent Hooks: useTransition, useDeferredValue
입력창에 타이핑할 때 목록 필터링/정렬이 무거우면 키 입력이 끊기는 체감이 발생할 수 있습니다.
이때 Concurrent Hooks를 쓰면 사용자 입력 반응과 무거운 렌더링의 우선순위를 분리할 수 있습니다.
useTransition: 상태 업데이트 우선순위 낮추기
import React, { useMemo, useState, useTransition } from 'react';
type Item = { id: number; name: string };
export default function SearchWithTransition({ items }: { items: Item[] }) {
const [keyword, setKeyword] = useState('');
const [filter, setFilter] = useState('');
const [isPending, startTransition] = useTransition();
const filteredItems = useMemo(
() => items.filter((item) => item.name.toLowerCase().includes(filter.toLowerCase())),
[items, filter],
);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const next = e.target.value;
setKeyword(next); // 입력 반응은 즉시
startTransition(() => {
setFilter(next); // 무거운 목록 갱신은 낮은 우선순위
});
};
return (
<div>
<input value={keyword} onChange={handleChange} placeholder="검색어 입력" />
{isPending && <p>목록 갱신 중...</p>}
<ul>{filteredItems.map((item) => <li key={item.id}>{item.name}</li>)}</ul>
</div>
);
}useDeferredValue: 느린 값을 지연 반영하기
import React, { useDeferredValue, useMemo, useState } from 'react';
type Item = { id: number; name: string };
export default function SearchWithDeferredValue({ items }: { items: Item[] }) {
const [keyword, setKeyword] = useState('');
const deferredKeyword = useDeferredValue(keyword);
const filteredItems = useMemo(
() => items.filter((item) => item.name.toLowerCase().includes(deferredKeyword.toLowerCase())),
[items, deferredKeyword],
);
return (
<div>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="검색어 입력" />
<ul>{filteredItems.map((item) => <li key={item.id}>{item.name}</li>)}</ul>
</div>
);
}useTransition은 업데이트 자체의 우선순위를 낮추고, useDeferredValue는 특정 값 반영을 지연시킨다는 점에서 목적이 다릅니다.
두 훅은 백그라운드 스레드를 만들거나 하나의 동기 filter 호출을 중간에서 끊지 않습니다. 위 코드는 우선순위 분리의 최소 예시이며, 큰 목록에서는 느린 자식의 memo 경계와 가상화 등도 측정해 검토합니다.
작은 데이터로 우선 출력부터 확인하기
두 검색 코드는 TypeScript를 사용하는 React 프로젝트에서 각각 별도 파일로 실행합니다. 한 파일의 두 default export를 합치지 않습니다. App.tsx에서 다음처럼 한 컴포넌트만 선택하고 items를 전달하세요. 두 번째 실험에서는 import 경로와 선택한 컴포넌트를 SearchWithDeferredValue로 함께 바꿉니다.
import SearchWithTransition from './components/SearchWithTransition';
const items = [
{ id: 1, name: 'Alpha' },
{ id: 2, name: 'Beta' },
{ id: 3, name: 'Gamma' },
];
export default function App() {
return <SearchWithTransition items={items} />;
}처음에는 세 항목이 보입니다. 입력에 be를 넣고 갱신이 끝나면 id 2 Beta만 남아야 합니다. Transition 예제에서는 입력창의 keyword와 목록 필터용 filter를 나누어 소유하며, setKeyword는 Transition 바깥에 둡니다. DeferredValue 예제는 state를 하나만 소유하고 목록 계산에 지연된 값을 전달합니다. 진행 중에는 입력창이 be를 표시하면서 목록은 이전 입력에 따른 세 항목을 보여줄 수 있습니다. 작은 데이터에서는 이 차이를 눈으로 보지 못해도 잘못된 실행이라고 단정하지 않습니다.
독립 입력은 ga입니다. 갱신이 끝난 뒤 어떤 항목이 남아야 할까요? setKeyword까지 Transition 안으로 옮기거나, 응답성을 시험하려고 startTransition 콜백 안에서 아주 긴 반복문을 실행하면 어떤 목적과 충돌하는지 설명하세요. 이 두 훅이 일정한 지연 시간이나 네트워크 요청 횟수를 보장하는지도 구분합니다.
입력과 목록 갱신의 역할을 나눈 풀이
마지막 목록은 id 3 Gamma입니다. Transition 예제의 keyword와 filter, DeferredValue 예제의 keyword와 지연된 값은 갱신이 안정된 뒤 모두 ga에 대응합니다. 제어된 입력의 상태를 Transition으로 갱신하지 않고 즉시 반영해야 타이핑 자체를 낮은 우선순위로 밀지 않습니다. startTransition에 전달한 함수는 즉시 실행되므로 그 함수 안의 긴 동기 반복문은 호출한 흐름을 막습니다. 이 예제는 목록 상태 갱신의 우선순위를 나누는 것이지 자바스크립트 반복문을 다른 스레드로 옮기는 방식이 아닙니다.
useDeferredValue는 고정된 밀리초 타이머가 아니며, 입력 때마다 발생하는 네트워크 요청을 자동으로 줄여 주지도 않습니다. 실제 요청을 지연하거나 합치는 정책이 필요하면 그 동작을 별도로 설계해야 합니다. 여기서는 로컬 배열을 필터링하므로 네트워크 요청이 없습니다.
세부 조건은 useTransition 공식 설명과 useDeferredValue 공식 설명을 확인하세요.
최적화 적용 시 주의사항 및 고려사항
useCallback과 useMemo는 최적화 도구이지만, 다음과 같은 점을 항상 염두에 두어야 합니다.
메모이제이션의 비용: useCallback과 useMemo 자체도 의존성 배열을 비교하고 메모리에 값을 저장하는 오버헤드가 있습니다.
최적화로 얻는 이득이 이 오버헤드보다 커야 의미가 있습니다.
남용 금지 (Premature Optimization): 성능 문제가 발생하기 전에 모든 함수와 값을 메모이제이션하는 것은 오히려 코드의 복잡성을 증가시키고 디버깅을 어렵게 만들 수 있습니다.
항상 React Developer Tools의 Profiler를 사용하여 실제 성능 병목 지점을 파악한 후 최적화를 적용해야 합니다.정확한 의존성: 의존성 배열을 올바르게 명시하는 것이 중요합니다.
- 의존성 누락: 함수나 값 내부에서 사용되는 변수가 의존성 배열에 포함되지 않으면, 오래된(stale) 클로저 값을 참조하게 되어 버그가 발생할 수 있습니다.
- 불필요한 의존성: 의존성 배열에 불필요한 값을 포함하면, 해당 값이 변경될 때마다 불필요하게 함수나 값이 재생성되어 메모이제이션 효과가 상쇄될 수 있습니다.
컴포넌트 구조 개선: 때로는 메모이제이션보다 컴포넌트를 더 작게 분리하거나, 상태를 필요한 곳으로 끌어내리는 등 컴포넌트 구조를 개선하는 것이 더 근본적인 성능 향상으로 이어질 수 있습니다.
의존성 배열은 메모이제이션의 효과뿐 아니라 값의 정확성도 결정합니다.
참조를 오래 유지해야 하는 경우와 최신 값을 반드시 반영해야 하는 경우를 구분해야 합니다.
10장 4절 "useCallback, useMemo 기초"는 여기까지입니다.
이 장에서는 useCallback과 useMemo 훅의 작동 원리를 이해하고, 각각 함수와 값의 불필요한 재생성을 막는 방법을 상세히 살펴보았습니다.
또한, React.memo와 함께 사용하여 React.memo의 얕은 비교 한계를 극복하는 시너지 효과를 확인했습니다.
이제는 리액트 애플리케이션에서 발생할 수 있는 주요 성능 문제 중 하나인 불필요한 리렌더링을 효과적으로 진단하고 방지할 수 있는 기본적인 도구들을 모두 익히셨습니다.