2026. 8. 21. 14:32ㆍReact
useMemo와 useCallback, 언제 써야 할까?
리액트를 쓰다 보면 언젠가 반드시 마주치는 질문이 있다.
"이 값, useMemo로 감싸야 하나?", "이 함수, useCallback 안 쓰면 안 되나?"
둘 다 "메모이제이션(memoization)"이라는 같은 개념을 사용하지만, 감싸는 대상이 다르고 쓰는 이유도 조금씩 다르다.
이번 글에서는 두 훅을 언제 쓰고, 언제 안 써도 되는지를 실제 예시와 함께 정리해보았다.
먼저, 왜 이런 훅이 필요할까
리액트 컴포넌트는 state나 props가 바뀌면 다시 렌더링이 된다.
문제는 컴포넌트 함수가 다시 실행될 때마다, 그 안에서 만든 배열, 객체, 함수도 매번 새로 생성된다는 점이다.
function Parent() {
const [count, setCount] = useState(0);
// 렌더링될 때마다 새로운 배열, 새로운 함수가 만들어짐
const list = [1, 2, 3].map((n) => n * 2);
const handleClick = () => console.log('clicked');
return <Child list={list} onClick={handleClick} />;
}
list와 handleClick은 내용은 똑같아도 매 렌더링마다 "다른 객체"로 취급된다.
자바스크립트에서 {} === {}가 false인 것과 같은 원리로 이게 문제가 되는 상황은 크게 두 가지이다.
- 계산 비용이 큰 작업을 매번 다시 하는 경우 — 배열을 필터링하거나 정렬하는 로직이 무겁다면, 렌더링마다 다시 계산하는 게 낭비일 수 있다.
- 참조가 바뀌었다는 이유만으로 불필요한 재렌더링이나 재실행이 일어나는 경우 — React.memo로 감싼 자식 컴포넌트, 혹은 useEffect의 의존성 배열이 대표적이다.
useMemo는 값을, useCallback은 함수를 캐싱해서 이 문제를 다룬다.
useMemo: 값을 재계산하지 않고 재사용하기
사용 예시 1 — 무거운 계산 캐싱
function ProductList({ products, keyword }) {
const filteredProducts = useMemo(() => {
return products.filter((p) => p.name.includes(keyword));
}, [products, keyword]);
return (
<ul>
{filteredProducts.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
상품 개수가 수천 개고 필터링 로직이 무겁다면, products나 keyword가 바뀔 때만 다시 계산하는 게 합리적이다.
관련 없는 state(예: 모달 열림 여부)가 바뀌어서 이 컴포넌트가 리렌더링되더라도, 필터링은 다시 돌지 않는다.
사용 예시 2 — 참조 동일성이 필요한 값
function SearchPage({ rawFilters }) {
const options = useMemo(
() => ({ category: rawFilters.category, sort: rawFilters.sort }),
[rawFilters.category, rawFilters.sort]
);
useEffect(() => {
fetchResults(options);
}, [options]); // options가 매번 새 객체면 이 effect가 매 렌더링마다 실행됨
// ...
}
useEffect나 다른 훅의 의존성 배열에 객체·배열을 넣을 때,
그 값이 매번 새로 만들어지면 의존성이 "바뀐 것"으로 판정되어 의도치 않게 매번 실행된다.
이럴 때 useMemo로 참조를 고정해주면 해결된다.
여기서 의문을 가지게 되는 건,
그럼 useState에 그냥 저장을하고 useEffect을 호출해서 상태 변경과 UI를 감당하면 되지 않아?
1. 흔한 실수 — props에서 파생된 값을 굳이 state로 복제하기
useMemo를 잘 쓰려면, "왜 이 값을 useState+useEffect 조합으로 만들면 안 되는가"를 이해하는 게 도움이 된다.
다음처럼 props로 받은 값을 필터링해서 별도 state로 저장하는 패턴을 종종 보게 된다.
// ❌ 흔히 하는 실수: props에서 파생된 값을 state + effect로 동기화
function ProductList({ products, keyword }) {
const [filtered, setFiltered] = useState([]);
const [isModalOpen, setModalOpen] = useState(false);
useEffect(() => {
setFiltered(products.filter((p) => p.name.includes(keyword)));
}, [products, keyword]);
// ...
}
먼저 짚어야 할 오해가 하나 있다.
isModalOpen처럼 관련 없는 state가 바뀌어서 이 컴포넌트가 리렌더링돼도, filtered는 저절로 사라지거나 재계산되지 않는다.
리액트의 state는 컴포넌트 함수가 다시 호출되는 것과 별개로 파이버(fiber)에 저장되어 있기 때문에, 관련 없는 리렌더링에는 영향을 받지 않는다. 이 부분은 useMemo와 동일하다.
문제는 products나 keyword가 실제로 바뀌었을 때 일어난다. 이 패턴은 다음과 같은 사이클을 거친다.
- props 변경으로 리렌더링 발생 → 이 시점엔 아직 effect가 실행되기 전이라 filtered는 이전 값 그대로
- 화면이 오래된 값으로 한 번 그려짐 (1차 페인트)
- 페인트가 끝난 뒤 useEffect가 실행되고, 그 안에서 setFiltered 호출
- state가 바뀌었으니 다시 리렌더링 → 화면이 새 값으로 다시 그려짐 (2차 페인트)
반면 useMemo를 쓰면 이렇게 된다.
// ✅ useMemo: 렌더링과 계산이 같은 사이클 안에서 끝남
function ProductList({ products, keyword }) {
const [isModalOpen, setModalOpen] = useState(false);
const filtered = useMemo(
() => products.filter((p) => p.name.includes(keyword)),
[products, keyword]
);
// ...
}
props가 바뀌면 리액트는 어차피 컴포넌트 함수를 다시 실행한다.
다만 그 실행 도중에 useMemo의 계산도 같이 끝나버리기 때문에, 화면은 처음부터 최신 값을 반영한 채로 한 번만 그려진다.
여기서 정확히 짚어야 할 부분은, "렌더링이 아예 일어나지 않는다"가 아니라 "렌더링과 계산이 같은 렌더 사이클 안에서 동시에 끝나기 때문에 추가 렌더 사이클이 필요 없다" 는 점이다.
useState+useEffect 조합은 이 계산이 렌더링 이후로 밀려나기 때문에 사이클이 한 번 더 도는 것.

정리비교
| 방식 | 관련 없는 state 변경 시 재계산? | products/keyword 실제 변경 시 |
| useMemo(() => ..., [products, keyword]) | ❌ 안 됨 | 렌더링 중 바로 계산, 추가 리렌더링 없음 |
| useState(products) + 그대로 유지 | ❌ 안 됨 | props가 바뀌어도 자동 갱신 안 됨 (수동 동기화 필요) |
| useState(products) + useEffect로 동기화 | ❌ 안 됨 | 계산은 되지만, 오래된 값이 먼저 그려졌다가 다시 리렌더링 |
정리하면, props가 유일한 진실의 원천(source of truth)이 되고 useMemo는 거기서 매번 값을 계산하되
비용이 클 때만 캐싱해주는 역할을 한다.
파생 가능한 값을 굳이 별도 state로 복제해서 effect로 동기화할 필요가 없다는 게 리액트에서 흔히 말하는 "state는 최소화하고, 파생되는 값은 파생시켜라"는 원칙의 핵심이다
참고로 useState(expensiveCalc(products))처럼 초기값 자리에 함수 호출을 직접 넣으면, 그 계산은 리렌더링마다 실행되고 버려진다 (리액트는 최초 렌더링 이후엔 이 값을 무시하고 저장해둔 state를 쓴다).
최초 한 번만 계산하고 싶다면 useState(() => expensiveCalc(products))처럼 함수를 넘기는 지연 초기화(lazy initializer)를 써야 한다.
1. 초기값을 함수 호출로 넘기는 경우
// ⚠️ 이렇게 쓰면 문제가 있다
const [productsState] = useState(expensiveTransform(products));
useState(expensiveTransform(products))라고 쓰면, expensiveTransform(products)는 자바스크립트 함수 호출 인자이기 때문에 컴포넌트가 리렌더링될 때마다 실행된다.
리액트는 첫 렌더링 이후에는 이 값을 무시하고 저장해둔 state를 쓰지만, 그 계산 자체는 매번 실행되고 버려진다.
isModalOpen이 바뀌어서 리렌더링될 때도 expensiveTransform은 계속 호출된다.
이걸 피하려면 lazy initializer를 쓴다.
// ✅ 함수를 넘기면 최초 렌더링에서만 실행된다
const [productsState] = useState(() => expensiveTransform(products));
useCallback: 함수의 참조를 고정하기
useCallback은 사실 useMemo의 특수한 형태이다.
useCallback(fn, deps)는 useMemo(() => fn, deps)와 동작이 같다. 다만 함수를 감싸는 용도로 워낙 자주 쓰이다 보니 별도 API로 분리된 것뿐이다.
사용 예시 1 — React.memo로 감싼 자식에게 콜백을 넘길 때
const ExpensiveChild = React.memo(function ExpensiveChild({ onSelect }) {
console.log('ExpensiveChild 렌더링');
// 내부에 무거운 렌더링 로직이 있다고 가정
return <button onClick={() => onSelect(1)}>선택</button>;
});
function Parent() {
const [count, setCount] = useState(0);
const handleSelect = useCallback((id) => {
console.log('선택된 id:', id);
}, []); // 의존성이 없으니 한 번만 생성
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>count: {count}</button>
<ExpensiveChild onSelect={handleSelect} />
</div>
);
}
여기서 핵심은 ExpensiveChild가 React.memo로 감싸져 있다는 점이다. React.memo는 props가 이전과 "같으면" 리렌더링을 건너뛴다. 그런데 handleSelect를 그냥 일반 함수로 선언하면 Parent가 리렌더링될 때마다 새 함수가 생성되어 React.memo의 props 비교가 항상 "다름"으로 판정되고, 결국 React.memo가 있으나 마나 한 상태가 된다.
useCallback으로 함수 참조를 고정해야 React.memo가 실제로 효과를 발휘한다.
사용 예시 2 — 커스텀 훅이나 useEffect의 의존성으로 쓰일 때
function useWindowResize(onResize) {
useEffect(() => {
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [onResize]); // onResize가 매번 바뀌면 매 렌더링마다 리스너를 등록/해제
}
function App() {
const handleResize = useCallback(() => {
console.log(window.innerWidth);
}, []);
useWindowResize(handleResize);
// ...
}
이벤트 리스너 등록·해제처럼 "함수 참조가 바뀔 때마다 부수효과가 다시 실행되는" 구조라면, useCallback으로 불필요한 재등록을 막아줄 수 있다.
안 써도 되는 경우들
여기가 사실 이 글에서 가장 중요한 부분이다.
useMemo/useCallback은 공짜가 아니다. 캐시를 유지하기 위한 메모리, 의존성 배열을 매 렌더링마다 비교하는 연산 비용이 든다.
그리고 무엇보다 코드를 읽기 어렵게 만드므로 아래 상황에서는 굳이 쓸 필요가 없다.
1. 계산이 가볍다면
// 이 정도 계산에 useMemo는 과하다
const fullName = useMemo(() => `${first} ${last}`, [first, last]);
// 그냥 이렇게 써도 충분하다
const fullName = `${first} ${last}`;
문자열 결합, 간단한 산술, 짧은 배열의 map/filter 정도는 리렌더링될 때마다 다시 계산해도 사람이 체감할 수 있는 성능 차이가 거의 없다.오히려 useMemo의 의존성 비교 비용이 더 클 수도 있다.
2. React.memo로 감싸지 않은 컴포넌트에 넘기는 콜백
function RegularChild({ onClick }) {
return <button onClick={onClick}>클릭</button>;
}
function Parent() {
// RegularChild가 React.memo로 감싸져 있지 않다면
// 어차피 Parent가 리렌더링될 때 RegularChild도 같이 리렌더링된다.
// useCallback으로 참조를 고정해도 얻는 게 없다.
const handleClick = () => console.log('clicked');
return <RegularChild onClick={handleClick} />;
}
useCallback의 존재 이유는 "참조가 바뀌면 불이익이 있는 곳"에 넘겨줄 때이다. 자식이 React.memo로 감싸져 있지 않다면, 애초에 부모가 리렌더링될 때 자식도 리렌더링되는 게 정상 동작이라 함수 참조를 고정해도 얻는 이득이 없다.
3. state로 관리해도 되는 값을 억지로 useMemo로 흉내 내는 경우
// 이렇게 하기보다
const doubled = useMemo(() => count * 2, [count]);
// 파생값이 정말 단순하면 그냥 렌더링 중에 계산
const doubled = count * 2;
파생 상태(derived state)는 대부분 렌더링 중에 그냥 계산해도 된다.
useMemo는 "재계산 비용이 실제로 문제가 될 때"를 위한 도구지, 모든 파생값에 습관적으로 씌우는 패턴이 아니다.
4. 아직 성능 문제를 겪지 않았다면
가장 흔한 실수가 "혹시 모르니까" 미리 최적화하는 것이다.
리액트 팀도 공식 문서에서 이 점을 강조하는데, 프로파일링 없이 감으로 useMemo/useCallback을 여기저기 붙이면 코드만 복잡해지고 실제 성능 이득은 없는 경우가 많다.
React DevTools의 Profiler로 실제 병목을 확인한 뒤에 최적화하는 게 순서에 맞다.
5. 참고: React Compiler가 있다면 상황이 또 다르다
2025년 10월에 정식 출시된 React Compiler는 빌드 타임에 코드를 분석해서 필요한 메모이제이션을 자동으로 삽입해준다.
Next.js 16 등 최신 툴체인에서 이 컴파일러를 활성화했다면, 대부분의 useMemo/useCallback은 더 이상 수동으로 작성할 필요가 없다.
다만 아래와 같은 경우엔 여전히 수동 처리가 필요합니다.
- 서드파티 라이브러리가 함수/객체의 참조 동일성 자체를 API 계약으로 요구하는 경우 (예: 외부 이벤트 시스템에 콜백을 등록할 때)
- try/catch 안에 있는 등 컴파일러가 제어 흐름을 완전히 분석하기 어려운 코드
- 아직 컴파일러를 도입하지 않은 기존 프로젝트, 혹은 컴파일러를 지원하지 않는 빌드 환경
즉 컴파일러가 있는 프로젝트라면 "일단 자연스럽게 코드를 작성하고, 실제로 참조 동일성이 계약 조건인 경우에만 수동으로 훅을 추가한다"는 방향으로 접근하는 게 맞다.
정리하면
| 상황 | useMemo/useCallback 필요? |
| 배열 필터링/정렬 등 계산 비용이 큰 로직 | ✅ 필요 |
| React.memo로 감싼 자식에게 함수를 props로 전달 | ✅ 필요 (useCallback) |
| useEffect 등의 의존성 배열에 객체/함수를 넣어야 할 때 | ✅ 필요 |
| props에서 파생되는 값을 state+effect로 동기화하려는 경우 | ✅ useMemo로 대체 권장 |
| 문자열 결합, 간단한 산술 등 가벼운 계산 | ❌ 불필요 |
| 일반 컴포넌트(React.memo 미적용)에 넘기는 콜백 | ❌ 불필요 |
| 프로파일링 없이 "혹시 몰라서" 미리 감싸는 경우 | ❌ 불필요 |
| React Compiler를 도입한 프로젝트의 일반적인 파생값/콜백 | ❌ 대부분 불필요 |
결국 핵심 기준은 하나이다.
"이 값/함수의 참조(혹은 재계산 비용)가 바뀌는 게 실제로 문제를 일으키는가?"
이 질문에 명확히 "그렇다"고 답할 수 있을 때만 useMemo/useCallback을 쓰는 게, 코드도 깔끔하고 성능도 챙기는 길이다.
'React' 카테고리의 다른 글
| [React] React에서 화살표 함수 역할 (0) | 2026.01.06 |
|---|---|
| [Next.js] Zustand 상태 관리 사용하기 (0) | 2025.10.24 |
| [Next.js] Next.js 시작하기 (nextJs 설치 / 클라이언트렌더링, 서버렌더링 - use client / page / layout / url설정 / url:key값 설정) (1) | 2025.04.17 |
| [React] Proxy서버 이용 + 사이드서버(Node.js) 이용하기 (0) | 2024.04.08 |
| [React] Webpack 깃허브 배포하기 (0) | 2024.02.22 |