성능 최적화 — React.memo, 코드 분할, Profiler
React 성능 최적화 기초
React는 기본적으로 부모가 리렌더링되면 모든 자식 컴포넌트도 리렌더링합니다. 대부분의 경우 충분히 빠르지만, 무거운 컴포넌트가 있다면 최적화가 필요합니다.
리렌더링이 발생하는 경우
- 컴포넌트의 state가 변경될 때
- 컴포넌트의 props가 변경될 때
- 부모 컴포넌트가 리렌더링될 때
- 구독한 Context 값이 변경될 때
React.memo
props가 변경되지 않으면 리렌더링을 건너뜁니다. 얕은 비교(shallow comparison)를 사용합니다.
import { memo, useState } from 'react';
// ✅ memo로 감싼 컴포넌트 — props가 같으면 리렌더링 안 함
const ExpensiveChart = memo(function ExpensiveChart({ data, title }) {
console.log('ExpensiveChart 렌더링');
// 비용이 큰 차트 렌더링 로직...
return (
<div>
<h2>{title}</h2>
<canvas>{/* 차트 */}</canvas>
</div>
);
});
function Dashboard() {
const [count, setCount] = useState(0);
const chartData = [10, 20, 30, 40]; // ❌ 매 렌더링마다 새 배열
return (
<div>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
{/* count가 바뀌어도 ExpensiveChart는 리렌더링 안 됨 */}
{/* 단, chartData가 새 배열이라 실제로는 리렌더됨 → useMemo 필요 */}
<ExpensiveChart data={chartData} title="판매 현황" />
</div>
);
}
memo + useMemo + useCallback 조합
import { memo, useState, useMemo, useCallback } from 'react';
const ProductCard = memo(function ProductCard({ product, onAddToCart }) {
console.log(`ProductCard ${product.id} 렌더링`);
return (
<div>
<h3>{product.name}</h3>
<button onClick={() => onAddToCart(product.id)}>담기</button>
</div>
);
});
function ProductList({ allProducts, category }) {
const [cartItems, setCartItems] = useState([]);
// useMemo: 필터링 결과 캐싱
const filteredProducts = useMemo(
() => allProducts.filter(p => p.category === category),
[allProducts, category]
);
// useCallback: 함수 참조 안정화
const handleAddToCart = useCallback((productId) => {
setCartItems(prev => [...prev, productId]);
}, []);
return (
<ul>
{filteredProducts.map(product => (
<ProductCard
key={product.id}
product={product}
onAddToCart={handleAddToCart} // 항상 같은 참조
/>
))}
</ul>
);
}
커스텀 비교 함수
const Article = memo(
function Article({ article }) {
return <div>{article.title}</div>;
},
// 두 번째 인자: 이전/다음 props 비교 함수
// true 반환 → 렌더링 건너뜀
(prevProps, nextProps) =>
prevProps.article.id === nextProps.article.id &&
prevProps.article.updatedAt === nextProps.article.updatedAt
);
코드 분할 (Code Splitting)
번들을 여러 청크로 나눠 초기 로딩 속도를 개선합니다.
React.lazy + Suspense
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
// 동적 import — 해당 라우트 접근 시에만 로드
const HomePage = lazy(() => import('./pages/HomePage'));
const ProductPage = lazy(() => import('./pages/ProductPage'));
const AdminPage = lazy(() => import('./pages/AdminPage'));
const HeavyChartLibrary = lazy(() => import('./components/HeavyChart'));
function App() {
return (
<Suspense fallback={<div className="page-loader">로딩 중...</div>}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/products" element={<ProductPage />} />
<Route path="/admin" element={<AdminPage />} />
</Routes>
</Suspense>
);
}
Suspense 경계 설계
// ✅ 적절한 위치에 여러 Suspense 경계 배치
function App() {
return (
<div>
<Header /> {/* 항상 빠르게 표시 */}
<Suspense fallback={<MainContentSkeleton />}>
<MainContent /> {/* 메인 콘텐츠 로딩 중 스켈레톤 표시 */}
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar /> {/* 사이드바는 별도 로딩 */}
</Suspense>
</div>
);
}
컴포넌트 수준 지연 로딩
function ProductDetail({ id }) {
const [showReviews, setShowReviews] = useState(false);
// 리뷰 섹션은 필요할 때만 로드
const ReviewSection = lazy(() => import('./ReviewSection'));
return (
<div>
<ProductInfo id={id} />
<button onClick={() => setShowReviews(true)}>리뷰 보기</button>
{showReviews && (
<Suspense fallback={<p>리뷰 로딩 중...</p>}>
<ReviewSection productId={id} />
</Suspense>
)}
</div>
);
}
React Profiler로 성능 측정
프로파일러 API
import { Profiler } from 'react';
function onRenderCallback(
id, // "id" prop
phase, // "mount" | "update" | "nested-update"
actualDuration, // 렌더링에 걸린 시간 (ms)
baseDuration, // 최적화 없이 걸릴 시간 추정 (ms)
startTime, // 렌더링 시작 타임스탬프
commitTime // 커밋 타임스탬프
) {
// 성능 로깅
if (actualDuration > 16) { // 60fps 기준 16ms 초과
console.warn(`${id} 느린 렌더링: ${actualDuration.toFixed(2)}ms`);
}
}
function App() {
return (
<Profiler id="ProductList" onRender={onRenderCallback}>
<ProductList />
</Profiler>
);
}
React DevTools Profiler
브라우저 DevTools의 Profiler 탭에서:
- "Record" 클릭 후 인터랙션 수행
- "Stop" 클릭
- 각 컴포넌트의 렌더링 시간 확인
- "Why did this render?" 로 리렌더 원인 파악
리렌더 최소화 패턴
상태 위치 최적화
// ❌ 불필요하게 높은 곳에 상태 위치
function App() {
const [inputValue, setInputValue] = useState(''); // App 전체 리렌더
return (
<div>
<Header /> {/* 불필요한 리렌더 */}
<Sidebar /> {/* 불필요한 리렌더 */}
<input value={inputValue} onChange={e => setInputValue(e.target.value)} />
<SearchResults query={inputValue} />
</div>
);
}
// ✅ 상태를 사용하는 컴포넌트로 내리기 (State Colocation)
function SearchSection() {
const [inputValue, setInputValue] = useState('');
return (
<div>
<input value={inputValue} onChange={e => setInputValue(e.target.value)} />
<SearchResults query={inputValue} />
</div>
);
}
function App() {
return (
<div>
<Header /> {/* 리렌더 없음 */}
<Sidebar /> {/* 리렌더 없음 */}
<SearchSection />
</div>
);
}
컨텐츠 끌어올리기 패턴
// ✅ children을 이용해 리렌더 격리
function SlowParent({ children }) {
const [count, setCount] = useState(0);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(c => c + 1)}>+</button>
{children} {/* count 변경 시 children은 리렌더 안 됨 */}
</div>
);
}
function App() {
return (
<SlowParent>
<ExpensiveComponent /> {/* SlowParent 리렌더와 무관 */}
</SlowParent>
);
}
가상화 (Virtualization)
수천 개의 항목을 렌더링할 때 화면에 보이는 항목만 실제로 렌더링합니다.
npm install react-window
import { FixedSizeList, VariableSizeList } from 'react-window';
const ITEM_HEIGHT = 60;
function Row({ index, style, data }) {
const item = data[index];
return (
<div style={style} className="list-item">
<strong>{item.name}</strong>
<span>{item.email}</span>
</div>
);
}
function VirtualizedUserList({ users }) {
return (
<FixedSizeList
height={600} // 컨테이너 높이 (px)
itemCount={users.length}
itemSize={ITEM_HEIGHT} // 각 항목 높이
itemData={users}
width="100%"
>
{Row}
</FixedSizeList>
);
}
// 가변 높이 항목
function VariableList({ items }) {
const getItemSize = (index) => items[index].expanded ? 120 : 60;
return (
<VariableSizeList
height={600}
itemCount={items.length}
itemSize={getItemSize}
width="100%"
>
{({ index, style }) => (
<div style={style}>{items[index].title}</div>
)}
</VariableSizeList>
);
}
실전 예제: 최적화된 무한 스크롤 목록
import { memo, useCallback } from 'react';
import { FixedSizeList } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';
import { useInfiniteQuery } from '@tanstack/react-query';
const PAGE_SIZE = 20;
const ITEM_HEIGHT = 72;
// memo로 항목 컴포넌트 최적화
const ListItem = memo(function ListItem({ style, user }) {
if (!user) return <div style={style} className="skeleton" />;
return (
<div style={style} className="user-row">
<img src={user.avatar} alt="" width={40} height={40} />
<div>
<strong>{user.name}</strong>
<p>{user.email}</p>
</div>
</div>
);
});
function OptimizedUserList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['users', 'infinite'],
queryFn: ({ pageParam = 0 }) =>
fetch(`/api/users?offset=${pageParam}&limit=${PAGE_SIZE}`).then(r => r.json()),
getNextPageParam: (last, all) =>
last.length === PAGE_SIZE ? all.flat().length : undefined,
initialPageParam: 0,
});
const users = data?.pages.flat() ?? [];
const itemCount = hasNextPage ? users.length + 1 : users.length;
const isItemLoaded = useCallback(
(index) => !hasNextPage || index < users.length,
[hasNextPage, users.length]
);
const loadMoreItems = useCallback(
() => !isFetchingNextPage ? fetchNextPage() : Promise.resolve(),
[isFetchingNextPage, fetchNextPage]
);
return (
<InfiniteLoader
isItemLoaded={isItemLoaded}
itemCount={itemCount}
loadMoreItems={loadMoreItems}
>
{({ onItemsRendered, ref }) => (
<FixedSizeList
ref={ref}
height={600}
itemCount={itemCount}
itemSize={ITEM_HEIGHT}
onItemsRendered={onItemsRendered}
width="100%"
>
{({ index, style }) => (
<ListItem style={style} user={users[index]} />
)}
</FixedSizeList>
)}
</InfiniteLoader>
);
}
고수 팁
1. 최적화 전 측정이 먼저
최적화는 측정 없이 하면 오히려 코드만 복잡해집니다. React DevTools Profiler 또는 console.time으로 병목을 먼저 찾으세요.
2. React.memo 남용 금지
메모이제이션 자체에도 비용(비교 연산)이 있습니다. 다음 경우에만 사용하세요.
- props가 자주 바뀌지 않는 컴포넌트
- 렌더링이 실제로 느린 컴포넌트
3. React 19 컴파일러를 기다리기
React 19 Compiler가 안정화되면 memo, useMemo, useCallback을 수동으로 작성할 필요가 크게 줄어듭니다.