ReactMedium#15
useCallback과 useMemo의 차이점과 사용 시기를 설명해주세요.
#React#훅#최적화#메모이제이션
힌트
함수 메모이제이션과 값 메모이제이션의 차이를 생각해보세요.
정답 및 해설
useCallback과 useMemo의 차이점과 사용 시기를 설명해주세요.
useCallback과 useMemo는 모두 메모이제이션(Memoization)을 위한 React 훅으로, 불필요한 재계산이나 재렌더링을 방지합니다. useMemo는 계산 비용이 큰 값을 캐싱하고, useCallback은 함수 참조를 캐싱합니다. 두 훅 모두 의존성 배열의 값이 변경될 때만 새 값이나 함수를 생성하며, 남용하면 오히려 성능이 저하될 수 있습니다.
메모이제이션이 필요한 이유
React 컴포넌트는 상태나 props가 변경될 때마다 함수 전체가 재실행됩니다. 이 과정에서 모든 변수와 함수가 새로 생성됩니다.
function Parent() {
const [count, setCount] = useState(0);
// 매 렌더링마다 새로운 함수 참조 생성
const handleClick = () => {
console.log('클릭됨');
};
// 매 렌더링마다 새로운 객체 참조 생성
const config = { color: 'blue', size: 'large' };
return (
<div>
<button onClick={() => setCount(c => c + 1)}>증가</button>
{/* count가 변경될 때마다 Child도 리렌더링됨 */}
<Child onClick={handleClick} config={config} />
</div>
);
}
useMemo
개념
useMemo는 계산 결과 값을 메모이제이션합니다. 의존성 배열의 값이 변경되지 않으면 이전에 계산된 값을 반환합니다.
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
기본 사용법
import { useState, useMemo } from 'react';
function ProductFilter({ products, searchTerm, sortBy }) {
// searchTerm 또는 sortBy가 변경될 때만 재계산
const filteredProducts = useMemo(() => {
console.log('필터링 실행'); // 실제로 실행되는 시점 확인
return products
.filter(p => p.name.includes(searchTerm))
.sort((a, b) => {
if (sortBy === 'price') return a.price - b.price;
if (sortBy === 'name') return a.name.localeCompare(b.name);
return 0;
});
}, [products, searchTerm, sortBy]);
return (
<ul>
{filteredProducts.map(p => (
<li key={p.id}>{p.name} - {p.price}원</li>
))}
</ul>
);
}
복잡한 계산 최적화
function DataAnalytics({ dataset }) {
const [selectedYear, setSelectedYear] = useState(2024);
// 데이터셋 전체에 대한 통계 계산 (비용이 큰 연산)
const statistics = useMemo(() => {
const total = dataset.reduce((sum, item) => sum + item.value, 0);
const average = total / dataset.length;
const max = Math.max(...dataset.map(item => item.value));
const min = Math.min(...dataset.map(item => item.value));
const stdDev = Math.sqrt(
dataset.reduce((sum, item) => sum + Math.pow(item.value - average, 2), 0) / dataset.length
);
return { total, average, max, min, stdDev };
}, [dataset]); // dataset이 변경될 때만 재계산
// 선택된 연도 데이터 필터링
const yearData = useMemo(() => {
return dataset.filter(item => item.year === selectedYear);
}, [dataset, selectedYear]); // dataset 또는 selectedYear가 변경될 때만
return (
<div>
<p>총합: {statistics.total}</p>
<p>평균: {statistics.average.toFixed(2)}</p>
<select onChange={e => setSelectedYear(Number(e.target.value))}>
<option value={2024}>2024</option>
<option value={2023}>2023</option>
</select>
<DataChart data={yearData} />
</div>
);
}
객체 동일성 유지
function ChildComponent({ config }) {
// config 객체가 동일한 참조일 때만 useEffect 스킵
useEffect(() => {
applyConfig(config);
}, [config]);
return <div>...</div>;
}
function Parent() {
const [theme, setTheme] = useState('dark');
// useMemo 없이: 매 렌더링마다 새 객체 생성 → 매번 useEffect 실행
// const config = { theme, size: 'large' };
// useMemo 사용: theme이 변경될 때만 새 객체 생성
const config = useMemo(() => ({
theme,
size: 'large',
animations: true
}), [theme]);
return <ChildComponent config={config} />;
}
useCallback
개념
useCallback은 함수 자체를 메모이제이션합니다. 의존성 배열의 값이 변경되지 않으면 동일한 함수 참조를 반환합니다.
const memoizedCallback = useCallback(() => {
doSomething(a, b);
}, [a, b]);
동등 관계
// useCallback과 useMemo의 관계
useCallback(fn, deps) === useMemo(() => fn, deps)
기본 사용법
import { useState, useCallback } from 'react';
const ExpensiveChild = React.memo(({ onSubmit, onCancel }) => {
console.log('ExpensiveChild 렌더링');
return (
<div>
<button onClick={onSubmit}>제출</button>
<button onClick={onCancel}>취소</button>
</div>
);
});
function Form() {
const [value, setValue] = useState('');
const [count, setCount] = useState(0);
// useCallback 없이: count가 변경될 때마다 새 함수 → React.memo 무효화
// const handleSubmit = () => submitData(value);
// const handleCancel = () => setValue('');
// useCallback 사용: value가 변경될 때만 새 함수 생성
const handleSubmit = useCallback(() => {
submitData(value);
}, [value]);
const handleCancel = useCallback(() => {
setValue('');
}, []); // 의존성 없음 → 항상 동일한 함수 참조
return (
<div>
<input value={value} onChange={e => setValue(e.target.value)} />
<button onClick={() => setCount(c => c + 1)}>카운트 {count}</button>
{/* count가 변경되어도 ExpensiveChild는 리렌더링되지 않음 */}
<ExpensiveChild onSubmit={handleSubmit} onCancel={handleCancel} />
</div>
);
}
커스텀 훅과 useCallback
function useSearch(items) {
const [query, setQuery] = useState('');
// 외부에 노출되는 함수를 useCallback으로 안정적으로 유지
const search = useCallback((newQuery) => {
setQuery(newQuery);
}, []); // 의존성 없음, 항상 동일한 참조
const results = useMemo(() =>
items.filter(item =>
item.name.toLowerCase().includes(query.toLowerCase())
),
[items, query]
);
return { results, search, query };
}
function SearchPage({ products }) {
const { results, search } = useSearch(products);
return (
<div>
<SearchBar onSearch={search} /> {/* search 참조가 안정적 */}
<ResultList items={results} />
</div>
);
}
useEffect 의존성과의 관계
function DataFetcher({ userId }) {
const [data, setData] = useState(null);
// fetchUser를 useCallback으로 감싸지 않으면
// 매 렌더링마다 새 함수 → useEffect 매번 실행 → 무한 루프 위험
const fetchUser = useCallback(async () => {
const response = await fetch(`/api/users/${userId}`);
const json = await response.json();
setData(json);
}, [userId]); // userId가 변경될 때만 새 함수
useEffect(() => {
fetchUser();
}, [fetchUser]); // fetchUser가 안정적이면 불필요한 재실행 없음
return <div>{data?.name}</div>;
}
useMemo vs useCallback 비교
function Example({ items, onSelect }) {
// useMemo: 값(배열)을 반환
const sortedItems = useMemo(() => {
return [...items].sort((a, b) => a.name.localeCompare(b.name));
}, [items]);
// useCallback: 함수를 반환
const handleSelect = useCallback((id) => {
onSelect(id);
console.log('선택됨:', id);
}, [onSelect]);
// 동일한 효과 (useMemo로 useCallback 구현)
const handleSelectAlt = useMemo(() => {
return (id) => {
onSelect(id);
console.log('선택됨:', id);
};
}, [onSelect]);
return (
<ul>
{sortedItems.map(item => (
<li key={item.id} onClick={() => handleSelect(item.id)}>
{item.name}
</li>
))}
</ul>
);
}
언제 사용해야 하는가
사용해야 할 때
// 1. React.memo와 함께: 자식 컴포넌트에 전달하는 함수/객체
const MemoizedChild = React.memo(({ onClick, config }) => <div>...</div>);
function Parent() {
const onClick = useCallback(() => { /* ... */ }, []);
const config = useMemo(() => ({ key: 'value' }), []);
return <MemoizedChild onClick={onClick} config={config} />;
}
// 2. useEffect 의존성에 함수가 포함될 때
function DataLoader({ id }) {
const loadData = useCallback(() => fetch(`/api/${id}`), [id]);
useEffect(() => { loadData(); }, [loadData]);
}
// 3. 계산 비용이 실제로 큰 경우 (1ms 이상)
const result = useMemo(() => heavyComputation(largeDataset), [largeDataset]);
사용하지 말아야 할 때
// 1. 단순한 값 계산 - useMemo 오버헤드가 더 클 수 있음
const doubled = useMemo(() => count * 2, [count]); // 불필요
const doubled2 = count * 2; // 이것으로 충분
// 2. 자식에게 전달하지 않는 함수
const handleLocalClick = useCallback(() => {
setCount(c => c + 1); // 이 컴포넌트 내부에서만 사용
}, []); // useCallback이 불필요
// 3. React.memo 없는 자식에게 전달
// React.memo가 없으면 부모가 리렌더링될 때 자식도 항상 리렌더링됨
// → 안정적인 함수 참조가 의미 없음
function RegularChild({ onClick }) {
return <button onClick={onClick}>버튼</button>;
}
// React.memo 없이는 useCallback이 도움이 되지 않음
성능 측정으로 판단하기
import { useMemo } from 'react';
// 실제 성능 측정 후 결정
function measurePerformance() {
console.time('계산');
const result = expensiveOperation();
console.timeEnd('계산');
// 1ms 미만이면 useMemo 불필요
// 1ms 이상이면 useMemo 고려
}
// React DevTools Profiler로 리렌더링 확인 후 최적화
// "measure first, optimize second" 원칙
요약 비교표
| 항목 | useMemo | useCallback |
|---|---|---|
| 반환값 | 계산된 값 (any) | 함수 |
| 목적 | 비싼 계산 결과 캐싱 | 함수 참조 안정성 |
| 동등 표현 | useMemo(() => value, deps) | useMemo(() => fn, deps) |
| 주요 사용 사례 | 필터링/정렬/통계 계산 | React.memo 자식에 전달하는 함수 |
| useEffect와 연관 | 의존성으로 전달하는 객체 | 의존성으로 전달하는 함수 |
| 남용 시 문제 | 불필요한 메모리 사용 | 불필요한 메모리 사용 |
핵심 정리
useMemo와useCallback은 성능 최적화 도구이지만, 항상 최적화가 필요한 것은 아닙니다.React.memo와 함께 사용할 때 가장 효과적이며, 그렇지 않으면 의미가 없는 경우가 많습니다.- "먼저 측정하고, 그다음에 최적화" 원칙을 따르고 React DevTools Profiler를 활용합니다.
- 두 훅 모두 메모리를 추가로 사용하므로, 불필요하게 남용하면 메모리 낭비와 코드 복잡성만 증가합니다.