성능 최적화 15편 - React.memo, useMemo, useCallback으로 불필요한 리렌더링 줄이기
지금까지 14편에 걸쳐 백엔드 관점의 최적화를 다뤄왔습니다. 하지만 아무리 서버 응답이 빨라져도, 화면이 버벅이면 사용자는 "느리다"고 느낍니다. 이번 글부터는 방향을 바꿔서 프론트엔드 렌더링 최적화를 다뤄보겠습니다. 첫 편은 React에서 가장 흔하게 마주치는 문제, 불필요한 리렌더링을 줄이는 방법입니다.
1. 리렌더링이 문제가 되는 이유
React는 상태(state)나 props가 바뀌면 컴포넌트를 다시 렌더링합니다. 문제는 부모 컴포넌트가 리렌더링되면, 자식 컴포넌트도 props가 바뀌지 않았는데도 함께 리렌더링된다는 점입니다.
function ProductList({ products }) {
const [keyword, setKeyword] = useState('');
return (
<div>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
검색어 입력창에 한 글자씩 입력할 때마다 keyword 상태가 바뀌고, ProductList가 리렌더링됩니다. 그런데 products 배열은 전혀 바뀌지 않았는데도, 화면에 그려진 모든 ProductCard가 함께 다시 렌더링됩니다. 상품이 100개라면, 글자 하나 입력할 때마다 100개의 카드가 불필요하게 다시 계산되는 셈입니다.
2. React.memo로 컴포넌트 리렌더링 막기
React.memo는 props가 실제로 바뀌지 않았다면, 부모가 리렌더링되어도 해당 컴포넌트는 리렌더링을 건너뛰게 해줍니다.
const ProductCard = React.memo(function ProductCard({ product }) {
console.log(`렌더링: ${product.name}`);
return (
<div className="card">
<h3>{product.name}</h3>
<p>{product.price.toLocaleString()}원</p>
</div>
);
});
이제 keyword가 바뀌어도 product props 자체는 변하지 않았으므로, ProductCard는 리렌더링되지 않습니다.
3. React.memo가 안 먹히는 함정 - 매번 새로 생성되는 함수와 객체
React.memo는 props를 얕은 비교(shallow compare)로 확인합니다. 문제는 부모 컴포넌트가 자식에게 함수나 객체를 props로 넘길 때, 매 렌더링마다 새로운 참조가 생성된다는 점입니다.
function ProductList({ products }) {
const [keyword, setKeyword] = useState('');
return (
<div>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
{products.map((product) => (
<ProductCard
key={product.id}
product={product}
onClick={() => addToCart(product.id)} // 매 렌더링마다 새 함수 생성
/>
))}
</div>
);
}
onClick={() => addToCart(product.id)}는 렌더링될 때마다 새로운 함수 객체로 만들어지기 때문에, React.memo가 "props가 바뀌었다"고 판단해서 리렌더링을 막지 못합니다.
4. useCallback으로 함수 참조 고정하기
function ProductList({ products }) {
const [keyword, setKeyword] = useState('');
const handleAddToCart = useCallback((productId) => {
addToCart(productId);
}, []); // 의존성이 없으므로 최초 한 번만 생성
return (
<div>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
{products.map((product) => (
<ProductCard key={product.id} product={product} onClick={handleAddToCart} />
))}
</div>
);
}
useCallback으로 감싸면, 의존성 배열([])이 바뀌지 않는 한 함수의 참조가 계속 동일하게 유지됩니다. 그래야 React.memo가 "props가 그대로다"라고 정확히 판단할 수 있습니다.
5. useMemo로 무거운 계산 결과 캐싱하기
함수뿐 아니라, 렌더링마다 반복해서 계산하기엔 비용이 큰 값도 문제가 됩니다.
function ProductList({ products, keyword }) {
// keyword가 바뀔 때마다, products 전체를 매번 새로 필터링/정렬
const filteredProducts = products
.filter((p) => p.name.includes(keyword))
.sort((a, b) => b.price - a.price);
return (
<div>
{filteredProducts.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
function ProductList({ products, keyword }) {
const filteredProducts = useMemo(() => {
return products
.filter((p) => p.name.includes(keyword))
.sort((a, b) => b.price - a.price);
}, [products, keyword]); // products나 keyword가 바뀔 때만 다시 계산
return (
<div>
{filteredProducts.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
useMemo는 의존성 배열에 지정한 값이 바뀌지 않으면, 이전에 계산해둔 결과를 그대로 재사용합니다. products나 keyword와 무관한 다른 상태(예: 모달 열림 여부)가 바뀌어 컴포넌트가 리렌더링되더라도, 필터링/정렬 연산은 다시 실행되지 않습니다.
6. 언제 무엇을 써야 할까
| 상황 | 사용할 것 |
|---|---|
| 자식 컴포넌트 자체의 불필요한 리렌더링 방지 | React.memo |
| 자식에게 넘기는 함수의 참조를 고정 | useCallback |
| 계산 비용이 큰 값(필터링, 정렬, 집계)을 재사용 | useMemo |
7. 과도한 최적화는 오히려 독이 될 수 있다
useMemo, useCallback을 모든 곳에 습관적으로 붙이는 것은 권장되지 않습니다. 이 훅들도 의존성 배열을 비교하는 비용이 들기 때문에, 연산 자체가 가벼운 값에 적용하면 오히려 손해입니다.
// 굳이 필요 없는 경우 - 단순 문자열 연결에 useMemo를 쓸 필요는 없음
const title = useMemo(() => `${product.name} 상세`, [product.name]);
React 공식 문서에서도 다음 기준을 권장합니다.
- 렌더링되는 리스트 항목 수가 많고 (수십~수백 개)
- 각 항목의 렌더링 비용이 크거나
- 리렌더링이 눈에 띄게 잦은 경우
이런 상황에서만 선택적으로 적용하고, 실제로 효과가 있는지는 React DevTools의 Profiler 탭으로 렌더링 횟수와 소요 시간을 확인하며 판단하는 것이 정확합니다.
8. 정리
- 부모 컴포넌트가 리렌더링되면 자식도 함께 리렌더링되는 것이 기본 동작이며, 이는 리스트가 클수록 부담이 커진다
React.memo로 자식 컴포넌트의 불필요한 리렌더링을 막을 수 있지만, props로 넘기는 함수나 객체가 매번 새로 생성되면 무력화된다useCallback으로 함수 참조를,useMemo로 계산 비용이 큰 값을 고정해야React.memo가 제 역할을 한다- 모든 곳에 습관적으로 적용하기보다, Profiler로 실제 병목을 확인하고 필요한 곳에만 선택적으로 적용한다
백엔드에서 쿼리 횟수를 줄이는 것(1~2편)과, 프론트엔드에서 리렌더링 횟수를 줄이는 것은 사실 같은 사고방식입니다. 불필요한 작업을 얼마나 줄이느냐가 결국 성능 최적화의 핵심이라는 점 입니다.
'성능 최적화' 카테고리의 다른 글
| 성능 최적화 STEP 17 - 이미지 최적화 (0) | 2026.07.14 |
|---|---|
| 성능 최적화 STEP 16 - 초기 로딩 속도 개선하기 (1) | 2026.07.13 |
| 성능 최적화 STEP 14 - 캐시 스탬피드 방지하기 (0) | 2026.07.10 |
| 성능 최적화 STEP 13 - JVM GC 튜닝 (0) | 2026.07.10 |
| 성능 최적화 STEP 12 - No Offset (0) | 2026.07.09 |