리액트 useMemo 제대로 쓰기: 언제 이득이고 언제 오버엔지니어링일까
useMemo는 비싼 계산 결과나 파생 데이터를 의존성 배열(deps)이 변할 때만 다시 계산하도록 메모화하는 훅입니다. 모든 계산에 붙이면 성능이 좋아지는 게 아니라, 연산 비용이 높거나 참조 동일성 유지가 중요한 구간에서만 이득입니다. 이 글에서는 선택 기준과 실전 예제 2개로 핵심을 정리합니다.
1) 언제 useMemo를 쓸까?
- 비용 높은 계산: 큰 데이터 필터·정렬·집계, 재귀 계산 등.
- 참조 동일성 유지: 메모된 값(예: 파생 배열/객체)을 자식 메모 컴포넌트(
React.memo)에 내려줄 때. - 불필요 재계산 억제: 화면의 다른 상태가 바뀔 때마다 매번 다시 계산되는 것을 막고 싶을 때.
2) 쓰지 말아야 할 때
- 계산이 매우 가볍고, 렌더 빈도도 낮은 경우(코드 복잡도만 증가).
- 의존성 배열을 부정확하게 관리해 오히려 오래된 값(stale)을 사용할 위험이 있는 경우.
- 불변성 없이 원본 배열을
sort로 직접 바꾸는 등(버그 유발).
예제 1 — 비싼 계산 메모화(피보나치)
입력 n에 대한 피보나치 값을 일부러 비효율적인 방식으로 계산해(재귀) 연산 비용을 키우고, useMemo로 n이 바뀔 때만 재계산하도록 합니다. 라벨만 변경 시 재계산이 일어나지 않아야 합니다.
import React, { useMemo, useState } from 'react';
function heavyFib(n) {
if (n < 2) return n;
return heavyFib(n - 1) + heavyFib(n - 2); // 일부러 O(2^n)
}
export default function ExpensiveCalcMemo() {
const [n, setN] = useState(35);
const [label, setLabel] = useState('메모 데모');
const fib = useMemo(() => heavyFib(n), [n]);
return (
<section style={{border:'1px solid #ddd', padding:12, borderRadius:8, maxWidth:520}}>
<h3>ExpensiveCalcMemo (useMemo)</h3>
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap:8}}>
<label>
n:
<input
type="number"
value={n}
onChange={(e)=>setN(Number(e.target.value)||0)}
style={{marginLeft:6, width:100}}
/>
</label>
<label>
라벨:
<input
value={label}
onChange={(e)=>setLabel(e.target.value)}
style={{marginLeft:6}}
/>
</label>
</div>
<p style={{marginTop:8}}>fib({n}) = <strong>{fib}</strong></p>
<p style={{color:'#64748b'}}>라벨만 바꿔도 fib 재계산은 일어나지 않습니다.</p>
</section>
);
}▼ 실행 화면 설명: n을 바꾸면 결과가 다시 계산되어 숫자가 바뀝니다. 라벨 입력만 바꾸면 계산은 캐시된 값으로 유지됩니다.

예제 2 — 파생 리스트 메모화 + React.memo와 시너지
큰 데이터에서 검색/최소 점수 필터 후 정렬하는 파생 리스트를 만들고, 그 결과를 useMemo로 캐시합니다. 각 행(Row)은 React.memo로 감싸 불필요한 재렌더를 줄입니다.
import React, { memo, useMemo, useState } from 'react';
const Row = memo(function Row({ item }) {
return <li>{item.name} — {item.score}</li>;
});
const makeData = () =>
Array.from({length: 2000}).map((_, i) => ({
id: i+1,
name: `User ${i+1}`,
score: Math.floor(Math.random()*1000),
}));
export default function MemoizedList() {
const [data] = useState(makeData); // 최초 한 번 생성
const [query, setQuery] = useState('');
const [min, setMin] = useState(500);
const filteredSorted = useMemo(() => {
const q = query.toLowerCase();
// 원본 불변성 유지: filter로 새 배열 생성 후, 정렬은 복사본에 수행
const f = data.filter(d => d.score >= min && d.name.toLowerCase().includes(q));
return [...f].sort((a,b) => b.score - a.score).slice(0, 50);
}, [data, query, min]);
return (
<section style={{border:'1px solid #ddd', padding:12, borderRadius:8}}>
<h3>MemoizedList (useMemo + React.memo)</h3>
<div style={{display:'flex', gap:8}}>
<input
placeholder="이름 검색"
value={query}
onChange={(e)=>setQuery(e.target.value)}
/>
<input
type="number"
value={min}
onChange={(e)=>setMin(Number(e.target.value)||0)}
style={{width:120}}
/>
</div>
<ul style={{marginTop:8}}>
{filteredSorted.map(item => <Row key={item.id} item={item} />)}
</ul>
<p style={{color:'#64748b'}}>검색/최소점수 변경 때만 파생 리스트를 재계산합니다.</p>
</section>
);
}▼ 실행 화면 설명: 검색어/최소 점수를 바꿀 때만 필터·정렬이 다시 계산됩니다. 다른 상태가 바뀌어도 행 렌더링은 억제됩니다.

3) 체크리스트 & 베스트 프랙티스
- 비용 판단: 필터·정렬·집계처럼 O(n log n) 이상이며 빈번히 호출되는가?
- 의존성 정확도: deps에 쓰인 모든 값 포함(누락 시 stale). 객체/배열은 생성 위치를 고정하거나
useMemo/useCallback으로 참조를 안정화. - 불변성: 원본 데이터 변형 금지(특히
sort는 얕은 복사 후 정렬). - 과도한 남용 금지: 아주 가벼운 계산엔 쓰지 말 것(코드만 복잡).
- React.memo와 조합: 부모가 파생 값의 참조를 안정적으로 유지하면 자식 메모가 효과적.
참고 링크
함께 보면 좋은 게시글
- 리액트 useReducer vs useState: 언제 쓰고 어떻게 마이그레이션할까
- 리액트 state 관리: useState 기본 사용법과 동작 원리
- 리액트 useEffect 사용법: 의존성 배열과 생명주기 이해
- 리액트 컴포넌트 반복: map과 key로 리스트 렌더링 최적화
- 리액트 props 사용법: 부모에서 자식으로 데이터 전달하기
이 글이 도움이 되셨다면 공유 부탁 드립니다.



