React 성능 최적화 핵심 전략
React 애플리케이션의 렌더링 성능을 향상시키는 실전 최적화 기법을 다룹니다. 메모이제이션, 코드 분할, 가상화 등 핵심 전략을 알아봅니다.
React 성능 최적화 핵심 전략
React는 Virtual DOM을 통해 효율적인 UI 업데이트를 제공하지만, 애플리케이션이 복잡해지면 성능 문제가 발생할 수 있습니다. 이 글에서는 React 애플리케이션의 성능을 체계적으로 개선하는 방법을 실전 예제와 함께 알아보겠습니다.
성능 문제의 원인 이해하기
React의 성능 문제는 대부분 불필요한 리렌더링에서 발생합니다. React 컴포넌트는 다음 세 가지 경우에 리렌더링됩니다.
- 상태(state)가 변경될 때: setState 또는 useState의 setter 함수가 호출될 때
- 부모 컴포넌트가 리렌더링될 때: props가 변경되지 않았더라도 부모가 리렌더링되면 자식도 리렌더링됩니다
- 컨텍스트(context) 값이 변경될 때: useContext로 구독 중인 컨텍스트 값이 바뀔 때
이 원리를 이해하면, 최적화의 핵심이 "불필요한 리렌더링을 줄이는 것"임을 알 수 있습니다.
React DevTools Profiler 활용
성능 최적화를 시작하기 전에, 먼저 어디서 병목이 발생하는지 측정해야 합니다. React DevTools의 Profiler는 이를 위한 필수 도구입니다.
Profiler 사용 방법
- 브라우저에 React DevTools 확장 프로그램을 설치합니다.
- DevTools에서 "Profiler" 탭을 선택합니다.
- 녹화 버튼을 클릭하고 UI 조작을 수행합니다.
- 녹화를 중지하면 각 컴포넌트의 렌더링 시간과 횟수를 확인할 수 있습니다.
성능 측정 시 주의사항
- 반드시 프로덕션 빌드에서 측정하세요. 개발 모드에서는 추가적인 검사로 인해 성능이 느립니다.
React.StrictMode는 개발 모드에서 컴포넌트를 두 번 렌더링하므로, 성능 측정 시 일시적으로 비활성화할 수 있습니다.- 여러 번 측정하여 평균값을 사용하세요. 단일 측정값은 신뢰하기 어렵습니다.
React.memo로 불필요한 리렌더링 방지
React.memo는 컴포넌트의 props가 변경되지 않으면 리렌더링을 건너뛰는 고차 컴포넌트(HOC)입니다.
기본 사용법
import { memo } from 'react';
interface UserCardProps {
name: string;
email: string;
avatar: string;
}
const UserCard = memo(function UserCard({ name, email, avatar }: UserCardProps) {
console.log('UserCard 렌더링:', name);
return (
<div className="user-card">
<img src={avatar} alt={name} />
<h3>{name}</h3>
<p>{email}</p>
</div>
);
});
export default UserCard;
React.memo 사용 시 주의사항
React.memo는 얕은 비교(shallow comparison)를 수행합니다. 따라서 객체나 배열, 함수를 props로 전달할 때는 참조가 유지되도록 주의해야 합니다.
// 문제: 매 렌더링마다 새 객체가 생성되어 memo가 무용지물
function ParentComponent() {
return (
<UserCard
name="홍길동"
email="hong@example.com"
style={{ color: 'blue' }} // 매번 새 객체 생성
onClick={() => console.log('clicked')} // 매번 새 함수 생성
/>
);
}
// 해결: useMemo와 useCallback 사용
function ParentComponent() {
const style = useMemo(() => ({ color: 'blue' }), []);
const handleClick = useCallback(() => console.log('clicked'), []);
return (
<UserCard
name="홍길동"
email="hong@example.com"
style={style}
onClick={handleClick}
/>
);
}
React.memo를 사용해야 하는 경우
모든 컴포넌트에 React.memo를 적용하는 것은 좋지 않습니다. 메모이제이션 자체에도 비용이 있기 때문입니다. 다음과 같은 경우에 사용하세요.
- 리스트 아이템 컴포넌트 (자주 리렌더링되는 목록의 각 항목)
- 렌더링 비용이 높은 컴포넌트 (차트, 지도, 복잡한 계산이 포함된 UI)
- 부모가 자주 리렌더링되지만 자식의 props는 거의 변하지 않는 경우
useMemo와 useCallback 올바르게 사용하기
useMemo와 useCallback은 값과 함수를 메모이제이션하여 불필요한 재계산이나 참조 변경을 방지합니다.
useMemo: 비용이 큰 계산 메모이제이션
import { useMemo, useState } from 'react';
function ProductList({ products, searchTerm }: ProductListProps) {
// 필터링과 정렬에 비용이 큰 경우 useMemo 적용
const filteredProducts = useMemo(() => {
console.log('제품 필터링 실행');
return products
.filter(p => p.name.toLowerCase().includes(searchTerm.toLowerCase()))
.sort((a, b) => a.price - b.price);
}, [products, searchTerm]);
return (
<ul>
{filteredProducts.map(product => (
<ProductItem key={product.id} product={product} />
))}
</ul>
);
}
useCallback: 함수 참조 유지
import { useCallback, useState } from 'react';
function TodoApp() {
const [todos, setTodos] = useState<Todo[]>([]);
// 함수 참조를 유지하여 자식 컴포넌트의 불필요한 리렌더링 방지
const addTodo = useCallback((text: string) => {
setTodos(prev => [...prev, { id: Date.now(), text, completed: false }]);
}, []);
const toggleTodo = useCallback((id: number) => {
setTodos(prev =>
prev.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
}, []);
const deleteTodo = useCallback((id: number) => {
setTodos(prev => prev.filter(todo => todo.id !== id));
}, []);
return (
<div>
<TodoInput onAdd={addTodo} />
<TodoList
todos={todos}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
</div>
);
}
주의: 과도한 메모이제이션 피하기
메모이제이션은 항상 이점이 있는 것은 아닙니다. 다음과 같은 경우에는 불필요합니다.
- 원시 타입(문자열, 숫자, 불린) 계산은 대부분 충분히 빠릅니다.
- 의존성 배열이 자주 변경되면 메모이제이션의 효과가 없습니다.
- 컴포넌트 자체가 가볍고 리렌더링 비용이 낮다면 오히려 오버헤드가 됩니다.
코드 분할(Code Splitting)
코드 분할은 애플리케이션의 JavaScript 번들을 작은 청크로 나누어 필요할 때만 로드하는 기법입니다. 초기 로딩 시간을 크게 줄일 수 있습니다.
React.lazy와 Suspense
import { lazy, Suspense } from 'react';
// 동적 임포트로 코드 분할
const DashboardChart = lazy(() => import('./components/DashboardChart'));
const UserProfile = lazy(() => import('./components/UserProfile'));
const Settings = lazy(() => import('./components/Settings'));
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/dashboard" element={<DashboardChart />} />
<Route path="/profile" element={<UserProfile />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
라우트 기반 코드 분할
페이지 단위로 코드를 분할하는 것이 가장 효과적입니다. 사용자가 특정 페이지에 접근할 때만 해당 코드가 로드됩니다.
// Next.js에서는 파일 기반 라우팅으로 자동 코드 분할 적용
// 추가적으로 dynamic import를 사용할 수 있음
import dynamic from 'next/dynamic';
const HeavyEditor = dynamic(() => import('./components/HeavyEditor'), {
loading: () => <p>에디터를 로딩 중입니다...</p>,
ssr: false, // 서버 사이드 렌더링 비활성화 (브라우저 전용 컴포넌트)
});
리스트 가상화(Virtualization)
수천 개의 아이템이 포함된 긴 리스트를 렌더링할 때, 모든 아이템을 DOM에 렌더링하면 성능이 크게 저하됩니다. 가상화는 현재 화면에 보이는 아이템만 렌더링하여 이 문제를 해결합니다.
react-window 사용
import { FixedSizeList as List } from 'react-window';
interface RowProps {
index: number;
style: React.CSSProperties;
}
function VirtualizedList({ items }: { items: string[] }) {
const Row = ({ index, style }: RowProps) => (
<div style={style} className="list-item">
{items[index]}
</div>
);
return (
<List
height={600}
itemCount={items.length}
itemSize={50}
width="100%"
>
{Row}
</List>
);
}
가상화 적용 기준
일반적으로 리스트 아이템이 100개 이상이면 가상화를 고려해야 합니다. 다음과 같은 경우에 특히 효과적입니다.
- 데이터 테이블 (수백~수천 행)
- 무한 스크롤 피드
- 대용량 셀렉트 드롭다운
- 파일 탐색기 트리 뷰
상태 관리 최적화
상태 관리 방식에 따라 렌더링 성능이 크게 달라질 수 있습니다.
상태를 최대한 지역적으로 관리하기
전역 상태를 최소화하고, 상태를 필요한 곳에 가까이 두세요. 상태가 변경될 때 영향받는 컴포넌트 범위를 줄이는 것이 핵심입니다.
// 나쁜 예: 모달 열림 상태를 전역 상태에 저장
// → 모달이 열리고 닫힐 때마다 전체 앱이 리렌더링될 수 있음
// 좋은 예: 모달 상태를 해당 컴포넌트 내부에서 관리
function ProductPage() {
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<div>
<ProductList />
<button onClick={() => setIsModalOpen(true)}>상품 추가</button>
{isModalOpen && (
<AddProductModal onClose={() => setIsModalOpen(false)} />
)}
</div>
);
}
Context 최적화
React Context는 편리하지만, 값이 변경되면 해당 Context를 구독하는 모든 컴포넌트가 리렌더링됩니다.
// 나쁜 예: 하나의 큰 Context에 모든 상태를 넣기
const AppContext = createContext({
user: null,
theme: 'light',
language: 'ko',
notifications: [],
// ... 모든 전역 상태
});
// 좋은 예: Context를 도메인별로 분리하기
const UserContext = createContext<UserState | null>(null);
const ThemeContext = createContext<ThemeState>({ theme: 'light' });
const NotificationContext = createContext<NotificationState>({ items: [] });
이미지 최적화
이미지는 웹 페이지 용량의 상당 부분을 차지하므로, 이미지 최적화는 성능에 큰 영향을 미칩니다.
Next.js Image 컴포넌트 활용
import Image from 'next/image';
function ProductCard({ product }: ProductCardProps) {
return (
<div className="product-card">
<Image
src={product.imageUrl}
alt={product.name}
width={300}
height={200}
placeholder="blur"
blurDataURL={product.blurHash}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
loading="lazy"
/>
<h3>{product.name}</h3>
<p>{product.price}원</p>
</div>
);
}
이미지 최적화 체크리스트
- WebP 또는 AVIF 같은 차세대 포맷을 사용합니다.
srcset과sizes속성으로 반응형 이미지를 제공합니다.- 뷰포트 밖의 이미지는 지연 로딩(lazy loading)합니다.
- 이미지 치수를 명시하여 CLS(Cumulative Layout Shift)를 방지합니다.
- CDN을 통해 이미지를 제공하고, 적절한 캐시 헤더를 설정합니다.
번들 크기 분석과 최적화
번들 크기가 크면 초기 로딩 시간이 길어집니다. 정기적으로 번들을 분석하고 최적화해야 합니다.
번들 분석 도구
# Next.js 프로젝트에서 번들 분석
npm install @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// ... 기타 설정
});
번들 크기 줄이는 방법
- 트리 쉐이킹 확인: 사용하지 않는 코드가 번들에 포함되지 않도록 ES Module import를 사용합니다.
- 큰 라이브러리 대체: moment.js 대신 dayjs를, lodash 대신 개별 함수 import를 사용합니다.
- 동적 임포트: 모든 페이지에서 필요하지 않은 라이브러리는 동적으로 로드합니다.
- 서드파티 라이브러리 검토: bundlephobia.com에서 라이브러리의 번들 크기를 확인합니다.
마무리
React 성능 최적화는 "측정 → 분석 → 개선 → 재측정"의 순환 과정입니다. 추측이 아닌 실측 데이터를 기반으로 최적화해야 합니다. React DevTools Profiler, Lighthouse, Web Vitals 등의 도구를 적극 활용하세요.
가장 중요한 원칙은 성급한 최적화를 피하는 것입니다. 먼저 올바르게 동작하는 코드를 작성하고, 성능 문제가 실제로 발생한 곳을 측정하여 그 부분만 최적화하세요. 모든 곳에 React.memo와 useMemo를 적용하는 것은 코드 복잡성만 높일 뿐 실질적인 성능 개선으로 이어지지 않을 수 있습니다.
꾸준한 성능 모니터링과 사용자 경험 중심의 최적화가 진정한 성능 향상의 열쇠입니다. Core Web Vitals 지표를 정기적으로 점검하면서 사용자에게 빠르고 부드러운 경험을 제공하는 것을 목표로 삼으시길 바랍니다.
관련 게시글
JavaScript 비동기 프로그래밍 완벽 가이드
JavaScript의 비동기 프로그래밍을 콜백부터 Promise, async/await, 이벤트 루프, 에러 처리, 동시성 패턴까지 단계별로 완벽하게 정리합니다.
웹 성능 최적화 완벽 가이드: Core Web Vitals
Core Web Vitals를 중심으로 웹 성능을 측정하고 개선하는 방법을 알아봅니다. LCP, FID, CLS 지표와 실전 최적화 기법을 다룹니다.
Tailwind CSS 실전 활용법: 모던 웹 스타일링
Tailwind CSS를 활용한 효율적인 웹 스타일링 방법을 알아봅니다. 유틸리티 퍼스트 철학부터 반응형 디자인, 다크 모드, 컴포넌트 패턴까지 다룹니다.