useCallback, useMemo, React.memo: 언제 쓰고 언제 버려야 할까
세 가지 메모이제이션 도구의 정확한 역할과 관계, 흔한 실수, 그리고 React Compiler 시대의 새로운 접근법.
저번 글에서 React Element 참조와 리렌더링을 다루면서 React.memo를 잠깐 살펴봤습니다.
useCallback은 언제 쓰는 건지, useMemo랑 뭐가 다른 건지, React.memo는 왜 가끔 안 먹히는 건지. 아직은 잘 감이 안잡힐 수 있어요.
프로젝트에서 “일단 감싸두면 좋겠지”하고 남발했다가, 오히려 코드만 복잡해진 경험도 있습니다.
이번 글에서는 이 세 도구를 각각 뭘 하는지, 어떤 관계인지, 언제 쓰고 언제 쓰지 말아야 하는지 정리하려고 합니다.
먼저, 리렌더링은 나쁜 게 아닙니다.
메모이제이션을 이야기하기 전에 꼭 짚고 넘어갈 게 있습니다.
리렌더링 ≠ DOM 업데이트입니다.
React가 컴포넌트를 “리렌더링”한다는 건 함수를 다시 호출해서 새로운 React Element를 만드는 겁니다.
이 결과를 이전과 비교(reconciliation)해서, 실제로 달라진 부분만 DOM에 반영합니다.
컴포넌트 리렌더링 (함수 재호출)
→ 새 React Element 생성
→ 이전 Element와 비교 (reconciliation)
→ 달라진 부분만 DOM 업데이트React의 diffing 알고리즘은 매우 빠릅니다.
대부분의 컴포넌트에서 리렌더링은 밀리초 단위로 끝나고, 사용자는 전혀 느끼지 못합니다.
메모이제이션은 “리렌더링을 줄이는 도구”가 아니라, 정말로 비용이 큰 리렌더링을 방지하는 도구입니다.
그 전에, “계산 비용”이 뭔가요?
메모이제이션 글마다 “비싼 계산을 피하라”고 합니다.
그런데 뭐가 비싸고 뭐가 싼 건지, 기준이 없으면 판단을 할 수가 없습니다.
계산 비용이란, 코드가 실행될 때 CPU가 쓰는 시간입니다.
브라우저의 메인 스레드는 한 번에 하나의 작업만 처리합니다.
JavaScript 실행, 레이아웃 계산, 화면 페인팅이 전부 같은 스레드에서 일어납니다.
어떤 함수 하나가 오래 걸리면, 그 시간만큼 화면 업데이트가 밀립니다.
60fps 기준으로 한 프레임은 약 16ms입니다.
이 안에 JavaScript 실행과 렌더링을 모두 끝내야 사용자가 “버벅임 없다”고 느낍니다.
싼 계산 (< 1ms)
문자열 결합: `${first} ${last}`
숫자 연산: price * quantity
객체 하나 생성: { id: 1, name: 'item' }
배열 몇 개 순회: [1, 2, 3].map(x => x * 2)
비싼 계산 (> 1ms, 상황에 따라 수십~수백 ms)
10,000개 배열 정렬: items.sort((a, b) => ...)
깊은 트리 탐색: 재귀적으로 수천 노드 순회
Fuzzy search: 대량 텍스트에 대한 유사도 매칭
복잡한 정규식: 긴 문자열에 백트래킹이 많은 패턴 적용물론 정확한 경계는 데이터 크기와 디바이스 성능에 따라 다릅니다.
하지만 감을 잡는 방법은 간단합니다.
console.time('filter');
const result = items.filter(item => complexCondition(item));
console.timeEnd('filter');
// filter: 0.2ms → 그냥 계산하세요
// filter: 12ms → 메모이제이션을 고려할 시점입니다“비싸다/싸다”는 감이 아니라 측정의 영역입니다.
이 기준을 알고 나면, 이후에 나올 세 도구를 언제 꺼내야 하는지가 훨씬 명확해집니다.
세 도구의 정체!
React.memo — 컴포넌트를 감쌉니다.
React.memo는 컴포넌트를 감싸는 고차 컴포넌트(HOC)입니다.
props가 이전과 같으면 리렌더링을 건너뜁니다.
const ExpensiveList = React.memo(function ExpensiveList({ items }) {
return (
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
});부모가 리렌더링되면 원래 자식도 리렌더링됩니다.
하지만 React.memo로 감싸면, React는 리렌더링 전에 props를 얕은 비교(shallow comparison)합니다.
일반 컴포넌트: 부모 리렌더링 → 무조건 리렌더링
memo 컴포넌트: 부모 리렌더링 → props 비교 → 같으면 스킵useMemo — 값을 기억합니다.
useMemo는 계산 결과를 기억합니다.
의존성 배열의 값이 바뀌지 않으면 이전 결과를 그대로 반환합니다.
function ProductPage({ products, filter }) {
const filtered = useMemo(
() => products.filter(p => p.category === filter),
[products, filter]
);
return <ProductList items={filtered} />;
}products나 filter가 바뀌지 않으면, 이전에 필터링한 결과를 그대로 씁니다.
매 렌더링마다 수천 개의 배열을 다시 필터링하는 비용을 아낄 수 있습니다.
하지만 useMemo에는 또 하나의 중요한 역할이 있습니다.
// ❌ 매 렌더링마다 새 객체 → 참조가 매번 달라짐
const style = { color: 'red', fontSize: 14 };
// ✅ 의존성이 안 바뀌면 같은 객체 반환 → 참조 유지
const style = useMemo(() => ({ color: 'red', fontSize: 14 }), []);참조 안정성(referential stability)입니다. 이건 뒤에서 자세히 다루겠습니다.
useCallback — 함수를 기억합니다.
useCallback은 함수의 참조를 기억합니다.
useMemo의 특수한 형태라고 생각하면 좋을것 같아요.
// 이 둘은 완전히 동일합니다.
const handleClick = useCallback(() => {
console.log(count);
}, [count]);
const handleClick = useMemo(() => {
return () => console.log(count);
}, [count]);즉, useCallback은 useMemo(() => fn, deps)의 단축 표현입니다.
function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []);
return <Child onClick={handleClick} />;
}count가 바뀌어서 Parent가 리렌더링되어도, handleClick은 같은 함수 참조를 유지합니다.
메모이제이션은 체인입니다.
참조를 안정시키려는 목적으로 useCallback이나 useMemo를 단독으로 쓰면 거의 아무 효과가 없습니다.
비싼 계산을 캐싱하는 useMemo는 단독으로도 의미가 있습니다. 10,000개 배열을 매 렌더링마다 정렬하지 않으려고 useMemo를 쓰는 건 React.memo 없이도 효과가 있습니다.
하지만 “이 함수의 참조를 유지하고 싶어서” useCallback을 쓰거나, “이 객체의 참조를 유지하고 싶어서” useMemo를 쓰는 경우라면, 그 참조를 받는 쪽에서 비교하지 않으면 소용이 없습니다.
(추가 예외: useEffect의 의존성으로 들어가는 함수처럼, React.memo 없이도 참조 안정성 자체가 필요한 경우가 있습니다. 이건 뒤에서 다룹니다.)
useCallback만 쓴 경우
function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []);
return (
<>
<p>{count}</p>
<Child onClick={handleClick} />
</>
);
}
function Child({ onClick }) {
console.log('Child 렌더링');
return <button onClick={onClick}>+</button>;
}count가 바뀌면 Parent가 리렌더링됩니다. handleClick 참조는 유지됩니다.
하지만 Child는 React.memo가 아니기 때문에, 부모가 리렌더링되면 무조건 따라갑니다.
useCallback은 함수 참조를 안정시켰지만, 그걸 받는 쪽에서 비교하지 않으니 의미가 없습니다.
직접 눌러보세요 — Child도 매번 리렌더링됩니다.
useCallbackReact.memo만 쓴 경우
function Parent() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(c => c + 1);
};
return (
<>
<p>{count}</p>
<MemoChild onClick={handleClick} />
</>
);
}
const MemoChild = React.memo(function Child({ onClick }) {
console.log('Child 렌더링');
return <button onClick={onClick}>+</button>;
});MemoChild는 React.memo로 감싸져 있어서 props를 비교합니다.
하지만 handleClick은 매 렌더링마다 새로 만들어지는 함수입니다.
참조가 매번 달라지니, React.memo의 비교 결과는 항상 “다르다”입니다.
memo를 감쌌지만, props의 참조가 안정적이지 않으니 무력화됩니다.
직접 눌러보세요 — memo를 감쌌지만 MemoChild도 매번 리렌더링됩니다.
React.memo체인이 완성된 경우
function Parent() {
const [count, setCount] = useState(0);
// 1. 함수 참조 안정화
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []);
return (
<>
<p>{count}</p>
<MemoChild onClick={handleClick} />
</>
);
}
// 2. props 비교 활성화
const MemoChild = React.memo(function Child({ onClick }) {
console.log('Child 렌더링');
return <button onClick={onClick}>+</button>;
});이제 두 단계가 모두 갖춰졌습니다.
Parent 리렌더링
→ handleClick: useCallback 덕분에 같은 참조 유지
→ MemoChild: React.memo가 onClick 비교
→ 참조가 같다 → 리렌더링 스킵 ✅이것이 제가 가장 오랫동안 헷갈렸던 부분이고, 이해하고 나면 나머지는 전부 응용입니다.
직접 눌러보세요 — MemoChild는 렌더 1회에서 멈춥니다!
useCallbackReact.memo한 가지 예외: Context를 구독하는 컴포넌트
React.memo로 감싼 컴포넌트라도 useContext로 Context를 구독하고 있으면, Context 값이 바뀔 때 리렌더링됩니다.
const MemoChild = React.memo(function Child() {
const theme = useContext(ThemeContext);
return <div>{theme}</div>;
});
// theme이 바뀌면 memo를 감쌌어도 리렌더링됨이건 체인이 완성되어 있어도 막을 수 없습니다.
Context 변경은 부모 → 자식 경로를 타지 않고, React가 구독자를 직접 찾아서 리렌더링시키기 때문입니다. memo는 부모로부터의 리렌더링만 막을 수 있습니다.
각 도구가 진짜 필요한 순간
React.memo: 리렌더링 비용이 큰 컴포넌트
const DataTable = React.memo(function DataTable({ rows, columns }) {
return (
<table>
{rows.map(row => (
<tr key={row.id}>
{columns.map(col => (
<td key={col.key}>{row[col.key]}</td>
))}
</tr>
))}
</table>
);
});리렌더링 비용이 눈에 띄게 큰 컴포넌트에 효과적입니다.
- 수백 개 이상의 항목을 렌더링하는 리스트
- 복잡한 차트나 그래프
- 깊은 서브트리를 가진 컴포넌트
useMemo: 비싼 계산
function SearchResults({ items, query }) {
// ✅ 10,000개 항목에 대한 fuzzy search
const results = useMemo(
() => fuzzySearch(items, query),
[items, query]
);
// ❌ 이런 건 그냥 계산하세요
// const fullName = useMemo(() => first + ' ' + last, [first, last]);
return <ResultList items={results} />;
}비싼 계산이란 구체적으로:
- 큰 배열의 정렬, 필터링, 변환
- 복잡한 정규식 매칭
- 재귀적 계산
- 트리 구조 탐색
문자열 결합, 간단한 조건 분기, 객체 한두 개 만드는 것은 비싼 계산이 아닙니다.
useCallback: 참조 안정성이 필요한 함수
function SearchPage() {
const [query, setQuery] = useState('');
// MemoizedList에 전달 → 참조 안정성 필요
const handleSelect = useCallback((item) => {
console.log('Selected:', item);
}, []);
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<MemoizedList items={items} onSelect={handleSelect} />
</>
);
}
const MemoizedList = React.memo(function List({ items, onSelect }) {
return items.map(item => (
<li key={item.id} onClick={() => onSelect(item)}>{item.name}</li>
));
});또 하나, useEffect의 의존성으로 들어가는 함수에서도 필요합니다.
function ChatRoom({ roomId }) {
const createConnection = useCallback(() => {
return new WebSocket(`/chat/${roomId}`);
}, [roomId]);
useEffect(() => {
const ws = createConnection();
return () => ws.close();
}, [createConnection]);
}useCallback 없이는 매 렌더링마다 createConnection의 참조가 바뀌고,
useEffect가 매번 다시 실행되어 WebSocket이 끊겼다 연결되기를 반복합니다.
흔한 실수들
1. 네이티브 요소에 useCallback
function Form() {
// ❌ <button>은 React.memo가 아닙니다
const handleSubmit = useCallback(() => {
submitForm();
}, []);
return <button onClick={handleSubmit}>제출</button>;
}<button>, <div>, <input> 같은 네이티브 HTML 요소는 props 비교를 하지 않습니다.
부모가 리렌더링되면 무조건 리렌더링됩니다.
useCallback을 써봤자 함수를 저장하는 메모리만 추가로 쓰는 셈입니다.
네이티브 요소에 useCallback 데모
두 시나리오의 Parent 렌더 횟수를 비교해보세요. 차이가 없습니다.
<button>memo 없음<button>memo 없음결론
네이티브 HTML 요소는 props 비교를 하지 않습니다. useCallback을 써도 렌더 횟수가 동일합니다. 함수를 메모리에 저장하는 비용만 추가됩니다.
2. 싼 계산에 useMemo
// ❌ 문자열 결합은 useMemo의 비교 비용보다 쌉니다
const greeting = useMemo(() => `안녕하세요, ${name}님!`, [name]);
// ✅ 그냥 계산하세요
const greeting = `안녕하세요, ${name}님!`;useMemo는 공짜가 아닙니다.
의존성 배열을 매 렌더링마다 비교하고, 이전 값을 메모리에 보관합니다.
이 비용이 계산 비용보다 크다면 오히려 손해입니다.
3. 의존성 배열 빠뜨리기
function SearchBar({ onSearch }) {
const [query, setQuery] = useState('');
// ❌ query가 의존성에 없음 → 항상 초기값 빈 문자열로 검색
const handleSearch = useCallback(() => {
onSearch(query);
}, []);
// ✅ query가 바뀌면 새 함수가 필요
const handleSearch = useCallback(() => {
onSearch(query);
}, [query, onSearch]);
}의존성을 빠뜨리면 함수가 오래된 값(stale closure)을 참조합니다.
참조는 안정되지만, 동작이 틀립니다. 가장 잡기 어려운 버그 중 하나입니다.
Stale Closure 데모
양쪽에 같은 텍스트를 입력한 뒤, 검색 실행을 눌러보세요.
useCallback(() => query, [])현재 query: ""
useCallback(() => query, [query])현재 query: ""
관찰 포인트
- 빈 배열 — 항상 초기값(빈 문자열)으로 검색됩니다. 함수가 마운트 시점의 query를 기억하고 있기 때문입니다.
- 올바른 의존성 — 현재 입력값으로 정상 검색됩니다. query가 바뀔 때마다 새 함수가 생성됩니다.
4. “일단 다 감싸기”
// ❌ 모든 컴포넌트에 React.memo
export default React.memo(function Header() { ... });
export default React.memo(function Footer() { ... });
export default React.memo(function Divider() { ... });props 비교 자체도 비용입니다.
리렌더링이 가벼운 컴포넌트를 memo로 감싸면, “렌더링 비용”이 “비교 비용”으로 바뀔 뿐 이득이 없습니다.
memo 전에 할 수 있는 것들
1. 상태를 아래로 내리기
// ❌ 입력할 때마다 ExpensiveChart까지 리렌더링
function Dashboard() {
const [search, setSearch] = useState('');
return (
<div>
<input value={search} onChange={e => setSearch(e.target.value)} />
<ExpensiveChart data={chartData} />
</div>
);
}// ✅ 검색 상태를 별도 컴포넌트로 분리
function Dashboard() {
return (
<div>
<SearchBar />
<ExpensiveChart data={chartData} />
</div>
);
}
function SearchBar() {
const [search, setSearch] = useState('');
return <input value={search} onChange={e => setSearch(e.target.value)} />;
}상태가 바뀌면 그 상태를 가진 컴포넌트와 하위 트리만 리렌더링됩니다.
상태를 필요한 곳으로 내리면, 리렌더링 범위 자체가 줄어듭니다.
2. children 패턴으로 올리기
function AnimatedContainer({ children }) {
const [position, setPosition] = useState({ x: 0, y: 0 });
return (
<div
style={{ transform: `translate(${position.x}px, ${position.y}px)` }}
onMouseMove={e => setPosition({ x: e.clientX, y: e.clientY })}
>
{children}
</div>
);
}
function App() {
return (
<AnimatedContainer>
<ExpensiveContent /> {/* position이 바뀌어도 리렌더링 안 됨 */}
</AnimatedContainer>
);
}<ExpensiveContent />를 App이 만들었고, App은 리렌더링되지 않으므로 Element 참조가 유지됩니다.
React는 같은 참조를 보고 서브트리를 건너뜁니다.
구조적 최적화 데모
memo 없이 구조만 바꿔서 불필요한 리렌더링을 제거합니다.
1. 상태를 아래로 내리기
입력할 때마다 Chart까지 리렌더링
SearchBar만 리렌더링, Chart는 유지
2. children 패턴
마우스를 움직여보세요
마우스를 움직여도 ExpensiveContent는 리렌더링 안 됨
핵심
구조를 바꾸는 것만으로 해결된다면, 메모이제이션은 필요 없습니다.
Dan Abramov가 Before You memo()에서 강조하는 핵심이기도 합니다.
React Compiler: 자동 메모이제이션의 시작
2025년 10월, React Compiler가 안정 버전(v1.0)으로 정식 릴리스되었습니다.
React Compiler는 빌드 타임에 코드를 분석해서 useMemo, useCallback에 해당하는 최적화를 자동으로 삽입합니다.
우리가 수동으로 하던 일을 컴파일러가 대신 해주는 겁니다.
// 우리가 작성하는 코드
function ProductList({ products, filter }) {
const filtered = products.filter(p => p.category === filter);
const handleClick = (id) => navigate(`/product/${id}`);
return filtered.map(p => (
<ProductCard key={p.id} product={p} onClick={handleClick} />
));
}// React Compiler가 빌드 시 변환하는 결과 (개념적)
function ProductList({ products, filter }) {
const filtered = useMemo(
() => products.filter(p => p.category === filter),
[products, filter]
);
const handleClick = useCallback(
(id) => navigate(`/product/${id}`),
[]
);
return useMemo(
() => filtered.map(p => (
<ProductCard key={p.id} product={p} onClick={handleClick} />
)),
[filtered, handleClick]
);
}컴파일러가 바꾸는 것
| 이전 | 이후 (Compiler 도입) |
|---|---|
| useMemo/useCallback 직접 작성 | 컴파일러가 동등한 캐싱 로직 자동 생성 |
| React.memo 수동 판단 | props 수준의 세밀한 비교 자동 적용 |
| 의존성 배열 실수 가능 | 정적 분석으로 정확한 의존성 |
| 과도하거나 부족한 메모이제이션 | 최적의 수준으로 적용 |
Meta에서는 이미 프로덕션에 적용해 Quest Store의 초기 로딩과 페이지 전환이 최대 12% 개선되었고, 일부 인터랙션은 2.5배 빨라졌다고 합니다.
수동 메모이제이션은 쓸모없어진 건가요?
아닙니다. React 공식 문서에서도 수동 메모이제이션을 deprecated하지 않았습니다.
컴파일러가 최적화를 포기하는 경우가 있습니다.
Rules of React를 위반하는 코드에서는 컴파일러가 안전하게 물러나고, 이때 수동 메모이제이션이 필요합니다.
function Profile({ user }) {
// ❌ 렌더 중 외부 변수를 변경 → Rules of React 위반
// 컴파일러가 이 코드를 만나면 최적화를 건너뜁니다.
window.lastRenderedUser = user.name;
const displayName = formatName(user);
return <h1>{displayName}</h1>;
}렌더 중 side effect 실행, 렌더 중 ref 읽기, arguments 접근 등이 대표적인 de-opt 케이스입니다.
또한 아직 React Compiler를 도입하지 않은 프로젝트가 대부분입니다.
당장 메모이제이션 지식이 쓸모없어진 건 아닙니다.
정리: 우리의 새로운 접근법
메모이제이션을 대하는 순서를 정리합니다.
1단계: 그냥 작성합니다.
function ProductPage({ products }) {
const sorted = [...products].sort((a, b) => a.price - b.price);
const handleClick = (id) => navigate(`/product/${id}`);
return sorted.map(p => (
<ProductCard key={p.id} product={p} onClick={handleClick} />
));
}처음부터 useMemo, useCallback을 쓰지 않습니다.
깔끔하고 읽기 쉬운 코드를 먼저 작성합니다.
2단계: 느려지면 구조를 봅니다.
체감될 정도로 느리다면, 먼저 구조적 해결을 시도합니다.
- 상태를 필요한 곳으로 내리기
- children 패턴 활용하기
- 컴포넌트 분리하기
3단계: 프로파일링합니다.
React DevTools의 Profiler로 실제로 느린 컴포넌트를 찾습니다.
”느릴 것 같은” 컴포넌트가 아니라, 측정으로 확인된 컴포넌트입니다.
4단계: 체인을 완성합니다
프로파일링으로 확인된 병목에만, 체인을 완성하는 형태로 적용합니다.
// 보내는 쪽: 참조 안정화
const handleClick = useCallback((id) => {
navigate(`/product/${id}`);
}, []);
const sorted = useMemo(
() => [...products].sort((a, b) => a.price - b.price),
[products]
);
// 받는 쪽: 비교 활성화
const MemoizedCard = React.memo(ProductCard);5단계: React Compiler를 검토합니다.
프로젝트에 React Compiler를 도입하면 수동 메모이제이션 대부분을 제거할 수 있습니다.
기존 useMemo, useCallback, React.memo가 있어도 충돌 없이 동작합니다.
마치며
돌아보면 메모이제이션이 헷갈렸던 이유가 보입니다.
각 도구를 따로 이해하려 했기 때문입니다.
useCallback이 뭘 하는지, useMemo가 뭘 하는지 개별적으로는 알겠는데,
언제 조합해야 하고 언제 필요 없는지가 불분명했습니다.
핵심은 하나입니다.
메모이제이션은 체인입니다. 보내는 쪽과 받는 쪽이 모두 갖춰져야 의미가 있고, 체인이 끊기면 아무리 감싸도 효과가 없습니다.그리고 대부분의 경우, 이 체인을 만들 필요조차 없습니다.
구조를 잘 잡으면 됩니다.
React Compiler가 보편화되면 이 체인을 수동으로 관리할 일은 점점 줄어들 겁니다.
하지만 왜 이런 도구가 존재하는지, 어떤 문제를 해결하는지를 이해하는 것은 여전히 중요합니다.
도구가 자동화되어도 원리를 아는 사람이 더 나은 코드를 작성하니까요.
참고 자료
- Before You memo() — Dan Abramov
- When to useMemo and useCallback — Kent C. Dodds
- Understanding useMemo and useCallback — Josh W. Comeau
- React Compiler v1.0 — React 공식 블로그
- memo - React 공식 문서
- useMemo - React 공식 문서
- useCallback - React 공식 문서
- A (Mostly) Complete Guide to React Rendering Behavior — Mark Erikson