리액트 useCallback 제대로 쓰기: 참조 안정화로 자식 리렌더 줄이기
useCallback은 함수의 참조를 의존성 배열이 바뀔 때만 새로 만들어 전달하는 훅입니다. “계산 결과”를 캐시하는 useMemo와 달리, 콜백 함수의 레퍼런스를 안정화해 자식의 React.memo와 조합할 때 리렌더를 줄이는 데에 쓰입니다.
1) 언제 useCallback을 쓸까?
- 메모된 자식(
React.memo)에onClick등 콜백을 props로 전달할 때 - 의존성이 드물게 변하고, 부모가 자주 렌더(다른 state 변경)되어도 자식 리렌더를 막고 싶을 때
- 효과/메모 등 deps 배열에 콜백을 넣어야 하는데 참조가 매 렌더마다 바뀌면 곤란할 때
2) 쓰지 말아야 할 때
- 콜백을 자식에 넘기지 않고 로컬에서만 쓰는 가벼운 핸들러(코드 복잡도만 증가)
- 의존성이 자주 바뀌어
useCallback이 매번 새 함수를 만들 수밖에 없는 경우 - 성능 문제가 관측되지 않았는데 습관적으로 일괄 적용(오버엔지니어링)
예제 1 — React.memo + useCallback으로 자식 리렌더 억제
부모에 count와 text 두 상태가 있고, 자식 목록은 count를 증가시키는 버튼만 가집니다. text만 변경되어도 자식이 매번 리렌더되는 문제를 useCallback으로 해결합니다.
import React, { memo, useCallback, useState } from 'react';
// 메모된 자식: 동일 props면 리렌더 생략
const Row = memo(function Row({ item, onInc }) {
console.log('Row render:', item.id); // 개발자도구에서 렌더 횟수 확인용
return (
<li style={{display:'flex', alignItems:'center', gap:8}}>
{item.label}
<button onClick={() => onInc(item.id)}>+1</button>
</li>
);
});
const seed = [
{ id: 1, label: '🍎 사과' },
{ id: 2, label: '🍌 바나나' },
{ id: 3, label: '🍒 체리' },
];
export default function MemoizedChildWithCallback() {
const [text, setText] = useState('');
const [counts, setCounts] = useState({ 1: 0, 2: 0, 3: 0 });
// ❌ 나쁜 예: 매 렌더마다 새 함수 → 자식이 매번 리렌더
// const onInc = (id) => setCounts((prev) => ({ ...prev, [id]: prev[id] + 1 }));
// ✅ 좋은 예: 참조 안정화 (deps가 바뀔 때만 동일 참조 유지)
const onInc = useCallback((id) => {
setCounts((prev) => ({ ...prev, [id]: prev[id] + 1 }));
}, []); // setState 업데이터는 안전하므로 deps 비움
return (
<section style={{border:'1px solid #ddd', padding:12, borderRadius:8, maxWidth:540}}>
<h3>MemoizedChildWithCallback (useCallback + React.memo)</h3>
<div style={{display:'flex', gap:8, marginBottom:8}}>
<input
placeholder="부모의 text (자식과 무관)"
value={text}
onChange={(e) => setText(e.target.value)}
style={{flex:1}}
/>
<span style={{color:'#64748b'}}>text 길이: {text.length}</span>
</div>
<ul>
{seed.map((it) => (
<Row key={it.id} item={it} onInc={onInc} />
))}
</ul>
<hr />
<p>현재 카운트: 🍎 {counts[1]} / 🍌 {counts[2]} / 🍒 {counts[3]}</p>
<p style={{color:'#64748b'}}>
입력창에 타이핑해도 Row가 찍히지 않으면(콘솔 로그 확인) 최적화 성공!</p>
</section>
);
}▼ 실행 화면 설명: 입력창에 타이핑해도 자식 Row들이 리렌더되지 않습니다(콘솔 로그로 확인). 각 행의 버튼을 눌렀을 때만 해당 행과 부모 상태가 갱신됩니다.

예제 2 — Stale Closure(오래된 값) 재현과 해결
의존성을 빈 배열로 둔 콜백에서 오래된 상태를 캡처해 버그가 나는 경우를 재현합니다. 해결책은 함수형 업데이트를 쓰거나, 정확한 deps를 지정하는 것입니다.
import React, { useCallback, useEffect, useState } from 'react';
export default function StaleClosureFix() {
const [count, setCount] = useState(0);
// ❌ 나쁜 예: count를 참조하지만 deps가 [] → count=0만 본 채로 굳어버림
// const tick = useCallback(() => setCount(count + 1), []);
// ✅ 해결 1: 함수형 업데이트(이전 값을 인자로 수신)
const tick = useCallback(() => {
setCount((c) => c + 1);
}, []);
// ✅ 해결 2: 정확한 deps 지정 (대신 매번 새 콜백이 됨)
// const tick = useCallback(() => setCount(count + 1), [count]);
useEffect(() => {
const id = setInterval(tick, 500);
return () => clearInterval(id);
}, [tick]);
return (
<section style={{border:'1px solid #ddd', padding:12, borderRadius:8, maxWidth:420}}>
<h3>StaleClosureFix (useCallback)</h3>
<p>0.5초마다 카운트 업: <strong>{count}</strong></p>
<p style={{color:'#64748b'}}>함수형 업데이트를 쓰면 deps 없이도 최신 값을 안전하게 업데이트합니다.</p>
</section>
);
}▼ 실행 화면 설명: 카운트가 매 0.5초마다 정상적으로 증가합니다. 잘못된 버전([] deps + 직접 count 사용)은 1 이상 증가하지 않는 문제가 발생합니다.

3) 체크리스트 & 베스트 프랙티스
- 자식 메모 + 콜백 전달 구조일 때 우선 검토(
React.memo와 시너지) - 함수형 업데이트로 deps 최소화:
setState(prev => ...) - 콜백에서 참조하는 값은 정확히 deps에 포함(누락 시 stale)
- 불필요 남용 금지: 로컬 핸들러나 자주 바뀌는 deps엔 실익이 적음
- useMemo와 구분: useMemo는 “값” 메모화, useCallback은 “함수 참조” 메모화
참고 링크
함께 보면 좋은 게시글
- 리액트 useMemo 제대로 쓰기: 언제 이득이고 언제 오버엔지니어링일까
- 리액트 props 사용법: 부모에서 자식으로 데이터 전달하기
- 리액트 state 관리: useState 기본 사용법과 동작 원리
- React 이벤트 처리 방법: onClick·onChange·onSubmit 예제
- 리액트 컴포넌트 반복: map과 key로 리스트 렌더링 최적화
이 글이 도움이 되셨다면 공유 부탁 드립니다.



