🎯 학습 목표
- React 컴포넌트가 리렌더링되는 세 가지 조건을 이해한다
- React.memo, useMemo, useCallback을 언제 쓰는지 정확히 구분한다
- React DevTools Profiler로 성능 병목을 찾는 방법을 익힌다
- 코드 스플리팅(lazy + Suspense)으로 초기 로드를 최적화한다
- 가상화(virtualization)가 필요한 상황을 안다
📖 핵심 개념 1 — 리렌더링 조건
최적화 전에 React가 언제 리렌더링하는지 정확히 알아야 합니다.
// 리렌더링 발생 조건 3가지:
// 1. 컴포넌트 자신의 state가 변경될 때
// 2. 부모 컴포넌트가 리렌더링될 때 (자식도 자동으로 리렌더링)
// 3. Context 값이 변경될 때 (해당 Context를 구독하는 컴포넌트)
function Parent() {
const [count, setCount] = useState(0);
console.log('Parent 렌더링');
return (
<div>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
{/* Parent가 리렌더링될 때마다 Child도 리렌더링됨 */}
<Child name="고정된 이름" />
</div>
);
}
function Child({ name }) {
console.log('Child 렌더링'); // Parent 리렌더링마다 출력됨
return <p>{name}</p>;
}
대부분의 경우 리렌더링은 매우 빠르고 문제가 없습니다. 최적화는 실제 성능 문제가 확인된 후에 적용하세요. 패널에서 “측정 없이 최적화하는 것은 조기 최적화(Premature Optimization)이며 오히려 코드를 복잡하게 만든다”고 강조했습니다.
📖 핵심 개념 2 — React.memo
import { memo, useState } from 'react';
// React.memo: props가 변경되지 않으면 리렌더링 건너뜀
const ExpensiveChild = memo(function ExpensiveChild({ data, onSelect }) {
console.log('ExpensiveChild 렌더링');
// 복잡한 계산이나 큰 리스트 렌더링
return (
<ul>
{data.map(item => (
<li key={item.id} onClick={() => onSelect(item)}>{item.name}</li>
))}
</ul>
);
});
function Parent() {
const [count, setCount] = useState(0);
const [items] = useState([/* 대량 데이터 */]);
// ⚠️ 문제: 렌더링마다 새 함수 참조 → memo가 항상 실패
// const handleSelect = (item) => console.log(item); // 매번 새 참조
// ✅ useCallback으로 함수 참조 유지
const handleSelect = useCallback((item) => {
console.log(item);
}, []); // 의존성 없음 → 항상 같은 참조
return (
<div>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
{/* count 변경 시 Parent는 리렌더링되지만 ExpensiveChild는 건너뜀 */}
<ExpensiveChild data={items} onSelect={handleSelect} />
</div>
);
}
React.memo가 오히려 해로운 경우
// ❌ memo가 불필요하거나 해로운 경우
const SimpleText = memo(function SimpleText({ text }) {
return <p>{text}</p>; // 너무 단순한 컴포넌트 — memo 비용이 더 큼
});
// Props로 객체/배열/함수를 받는데 useCallback/useMemo 없이 사용
const AlwaysRerender = memo(function AlwaysRerender({ config }) {
return <div>{config.title}</div>;
});
function Parent() {
return (
// 렌더링마다 새 객체 → memo가 항상 실패 (memo 비용만 추가)
<AlwaysRerender config={{ title: '제목' }} />
);
}
📖 핵심 개념 3 — useMemo vs useCallback
import { useMemo, useCallback, useState } from 'react';
function SearchResults({ products, searchQuery, onSelect }) {
// useMemo: 값(계산 결과)을 메모이제이션
// searchQuery나 products가 바뀔 때만 재계산
const filteredProducts = useMemo(() => {
console.log('필터링 계산 중...'); // 실제로 비용이 큰 작업일 때만 사용
return products.filter(p =>
p.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [products, searchQuery]);
// useMemo: 파생 값 계산
const totalPrice = useMemo(() =>
filteredProducts.reduce((sum, p) => sum + p.price, 0),
[filteredProducts]
);
// useCallback: 함수 참조를 메모이제이션
// memo로 감싼 자식 컴포넌트에 전달할 때 유용
const handleSelect = useCallback((productId) => {
onSelect(productId);
// onSelect가 변하면 이 함수도 재생성되어야 함
}, [onSelect]);
return (
<div>
<p>총 {filteredProducts.length}개 / 합계 {totalPrice.toLocaleString()}원</p>
{filteredProducts.map(p => (
<ProductCard key={p.id} product={p} onSelect={handleSelect} />
))}
</div>
);
}
// 핵심 차이:
// useMemo(() => computeValue(), [deps]) → 값을 반환
// useCallback(() => doSomething(), [deps]) → 함수를 반환
// useCallback(fn, deps) === useMemo(() => fn, deps)
📖 핵심 개념 4 — React DevTools Profiler
성능 최적화의 첫 단계는 측정입니다. React DevTools의 Profiler 탭을 사용합니다.
- Chrome 확장에서 React DevTools 설치
- 개발 모드에서 앱 실행 후 DevTools 열기
- Profiler 탭 선택 → 녹화 버튼(⏺) 클릭
- 느린 동작 수행
- 녹화 중지 → Flamegraph 분석
// Flamegraph 읽는 법:
// - 회색: 렌더링되지 않음 (memo로 최적화됨)
// - 색깔 있음: 렌더링됨
// - 색이 진할수록 렌더링 시간이 김
// - 막대 너비: 렌더링 시간 (넓을수록 느림)
// Profiler로 발견할 수 있는 문제:
// 1. 불필요한 리렌더링 (부모 리렌더링 시 자식도 항상 재렌더링)
// 2. 특정 컴포넌트의 렌더링 시간이 비정상적으로 긴 경우
// 3. State 변경 시 예상보다 많은 컴포넌트가 리렌더링되는 경우
📖 핵심 개념 5 — 코드 스플리팅과 가상화
// 코드 스플리팅 — 초기 번들 크기 줄이기
import { lazy, Suspense } from 'react';
// 각 페이지를 필요할 때 로드 (동적 import)
const HeavyDashboard = lazy(() => import('./HeavyDashboard'));
const ReportPage = lazy(() => import('./ReportPage'));
function App() {
return (
<Suspense fallback={<div className="loading-spinner">로딩 중...</div>}>
<Routes>
<Route path="/dashboard" element={<HeavyDashboard />} />
<Route path="/reports" element={<ReportPage />} />
</Routes>
</Suspense>
);
}
// 가상화 — 대량 리스트 렌더링 최적화
// npm install react-window
import { FixedSizeList } from 'react-window';
function VirtualizedList({ items }) {
// 10,000개 항목도 화면에 보이는 것만 렌더링
const Row = ({ index, style }) => (
<div style={style}> {/* style은 react-window가 제공하는 위치/크기 */}
{items[index].name}
</div>
);
return (
<FixedSizeList
height={500} // 리스트 컨테이너 높이
itemCount={items.length}
itemSize={50} // 각 항목 높이
width="100%"
>
{Row}
</FixedSizeList>
);
}
// 가상화가 필요한 기준: 100개 이상의 항목이 한 화면에 렌더링될 때
⚠️ 흔한 실수 (よくあるミス)
- 측정 없이 최적화: Profiler로 실제 병목을 확인하기 전에 memo/useMemo/useCallback을 남발하면 코드만 복잡해집니다.
- 모든 함수에 useCallback: useCallback 자체도 비용이 있습니다. memo로 감싼 자식에 전달하지 않는 함수에는 useCallback이 불필요합니다.
- useMemo의 빈 의존성 배열:
useMemo(() => compute(value), [])에서 value가 변해도 재계산 안 됨 — 의존성을 정확히 넣어야 합니다. - 개발 모드에서 성능 측정: React 개발 모드는 프로덕션보다 훨씬 느립니다. 성능 측정은 반드시
npm run build후 프로덕션 빌드로 해야 합니다.
💡 실무 팁
- 최적화 순서: 알고리즘 복잡도 개선 → 코드 스플리팅 → 가상화 → memo/useMemo/useCallback 순으로 접근하세요. 대부분 앞의 방법으로 해결됩니다.
- Lighthouse 활용: Chrome DevTools의 Lighthouse 탭으로 Core Web Vitals(LCP, FID, CLS)를 측정하면 실제 사용자 경험에 영향을 주는 성능 문제를 찾을 수 있습니다.
- 이미지 최적화: React 앱에서 이미지는 가장 큰 성능 병목 중 하나입니다. lazy loading(
loading="lazy"), WebP 포맷, 적절한 크기 조절을 적용하세요. - 번들 분석:
npm run build후vite-bundle-visualizer로 번들 크기를 분석하면 불필요하게 큰 라이브러리를 발견할 수 있습니다.