대규모 리스트 렌더링 최적화
이번에는 지금까지 배운 내용을 종합적으로 활용해 실제 시나리오에서 성능 최적화를 적용하는 실습을 진행하겠습니다. 이 실습을 통해 React Developer Tools의 Profiler로 성능 문제를 진단하고, 적절한 최적화 기법으로 문제를 해결하는 경험을 하게 될 것입니다.
실습 목표: 성능 병목 측정과 교정
이번 실습은 성능 이슈를 먼저 측정한 뒤 교정하는 케이스 기반 방식으로 진행합니다. 즉, 최적화를 바로 적용하지 않고 Profiler에서 불필요 리렌더링 원인을 먼저 식별합니다.
프로젝트 설정: 기본적인 리액트 프로젝트를 준비합니다.
비최적화된 컴포넌트 구현: 의도적으로 불필요한 리렌더링이 발생하는 컴포넌트들을 구성합니다.
성능 진단: React Developer Tools의 Profiler 탭을 사용하여 불필요한 리렌더링을 식별합니다.
최적화 적용: React.memo, useCallback, useMemo를 사용하여 성능 문제를 해결합니다.
성능 재진단: 최적화 적용 후 Profiler 탭을 다시 사용하여 개선된 성능을 확인합니다.
시나리오: 동적인 목록 필터링 및 아이템 표시
우리는 사용자 입력에 따라 필터링되는 상품 목록을 렌더링하는 애플리케이션을 만들 것입니다. 이 시나리오에서 다음과 같은 구성 요소를 가집니다.
App컴포넌트 (부모): 전체 앱 상태를 관리하고 하위 컴포넌트에 값을 전달합니다.globalCounter상태: 앱 전반의 클릭 수를 나타내는 상태 (최적화되지 않은 리렌더링 유발용).filterTerm상태: 상품 목록을 필터링하는 데 사용되는 검색어.
ProductList컴포넌트 (자식): 상품 데이터를 받아 필터링하고ProductItem들을 렌더링합니다.- 초기에는 모든 상품을 받지만,
filterTerm에 따라 필터링된 상품 목록을 계산하여ProductItem에 전달.
- 초기에는 모든 상품을 받지만,
ProductItem컴포넌트 (가장 하위 자식): 개별 상품 정보를 표시합니다.- 클릭 시 해당 상품의 이름을
alert하는 기능 포함.
- 클릭 시 해당 상품의 이름을
App 컴포넌트의 globalCounter가 변경될 때마다 ProductList와 ProductItem들이 불필요하게 리렌더링됩니다.
ProductList 내에서 필터링된 상품 목록을 계산하는 로직이 매 렌더링마다 다시 실행됩니다.
ProductItem에 전달되는 onItemClick 함수가 매번 재생성되어 ProductItem의 불필요한 리렌더링을 유발합니다.
준비 단계: 최적화 적용 기준 수립
준비 단계에서는 최적화 선택 기준을 분명히 둡니다. React.memo는 프롭스가 안정적인 하위 컴포넌트에, useCallback은 함수 참조 안정화가 필요한 경우에, useMemo는 계산 비용이 높은 필터링/파생 데이터에만 적용합니다.
기존 리액트 프로젝트를 사용하거나, Vite 기반으로 새 프로젝트를 생성하여 시작합니다.
초기 구현 (비최적화 버전)
먼저 의도적으로 성능 문제가 있는 코드를 작성합니다.
src/components/ProductItem.js (비최적화 버전)
import React from 'react';
function ProductItem({ product, onItemClick }) {
// 이 로그로 컴포넌트의 렌더링 시점을 확인합니다.
console.log(`ProductItem [${product.name}] 렌더링됨`);
return (
<li
style={{
padding: '10px',
margin: '5px 0',
backgroundColor: '#f9f9f9',
border: '1px solid #eee',
borderRadius: '4px',
cursor: 'pointer'
}}
onClick={() => onItemClick(product.name)}
>
{product.name} - ${product.price}
</li>
);
}
export default ProductItem;src/components/ProductList.js (비최적화 버전)
import React from 'react';
import ProductItem from './ProductItem';
const ALL_PRODUCTS = [
{ id: 1, name: 'Laptop', price: 1200 },
{ id: 2, name: 'Mouse', price: 25 },
{ id: 3, name: 'Keyboard', price: 75 },
{ id: 4, name: 'Monitor', price: 300 },
{ id: 5, name: 'Webcam', price: 50 },
{ id: 6, name: 'Microphone', price: 80 },
{ id: 7, name: 'Headphones', price: 150 },
{ id: 8, name: 'USB Drive', price: 15 },
];
function ProductList({ filterTerm }) {
console.log('ProductList 렌더링됨'); // 렌더링 시점 확인
// 🚨 1. 필터링 로직: 매 렌더링마다 이 계산이 다시 실행됨
const filteredProducts = ALL_PRODUCTS.filter(product =>
product.name.toLowerCase().includes(filterTerm.toLowerCase())
);
// 🚨 2. 클릭 핸들러: 매 렌더링마다 이 함수가 재생성됨
const handleItemClick = (productName) => {
alert(`Clicked: ${productName}`);
};
return (
<div style={{ padding: '20px', border: '1px solid #ccc', borderRadius: '8px', marginBottom: '20px', backgroundColor: '#fff' }}>
<h3>상품 목록</h3>
<ul style={{ listStyle: 'none', padding: 0 }}>
{filteredProducts.map(product => (
// 🚨 ProductItem에 handleItemClick 함수가 매번 새로운 참조로 전달됨
<ProductItem key={product.id} product={product} onItemClick={handleItemClick} />
))}
</ul>
</div>
);
}
export default ProductList;src/App.js (비최적화 버전)
import React, { useState } from 'react';
import './index.css'; // 기본 스타일 임포트 (Chapter 9에서 사용한 스타일 사용)
import ProductList from './components/ProductList';
function App() {
const [globalCounter, setGlobalCounter] = useState(0); // 다른 상태 (불필요한 리렌더링 유발용)
const [filterTerm, setFilterTerm] = useState(''); // 상품 목록 필터링 용도
console.log('App 컴포넌트 렌더링됨');
return (
<div className="main-content">
<h1 style={{ textAlign: 'center', color: 'var(--header-color)' }}>성능 최적화 실습</h1>
<div style={{ marginBottom: '20px', padding: '15px', border: '1px solid #ddd', borderRadius: '8px', backgroundColor: '#fff' }}>
<h2>앱 전체 상태 제어</h2>
<p>글로벌 카운터: {globalCounter}</p>
<button onClick={() => setGlobalCounter(prev => prev + 1)} className="button">
글로벌 카운터 증가 (App 리렌더링)
</button>
</div>
<div style={{ marginBottom: '20px', padding: '15px', border: '1px solid #ddd', borderRadius: '8px', backgroundColor: '#fff' }}>
<h2>상품 필터링</h2>
<input
type="text"
placeholder="상품 검색..."
value={filterTerm}
onChange={(e) => setFilterTerm(e.target.value)}
style={{ width: '100%', padding: '10px', border: '1px solid #ccc', borderRadius: '4px', boxSizing: 'border-box' }}
/>
</div>
{/* ProductList 컴포넌트 */}
<ProductList filterTerm={filterTerm} />
</div>
);
}
export default App;성능 진단
애플리케이션을 실행하고(npm run dev 또는 yarn dev), 브라우저 개발자 도구(F12)를 열어 Components 탭과 Profiler 탭을 확인합니다.
Components 탭 확인- Highlight updates when components render. 옵션을 켜세요 (컴포넌트 주변에 테두리가 깜빡임).
글로벌 카운터 증가버튼을 클릭할 때마다App,ProductList, 그리고 모든ProductItem들이 함께 깜빡이는 것을 확인합니다. 이는globalCounter가ProductList나ProductItem과 직접적인 관련이 없음에도 불구하고 불필요하게 리렌더링되고 있다는 것을 의미합니다.
Profiler 탭 사용Profiler탭으로 이동합니다.Record버튼을 클릭하여 프로파일링을 시작합니다.-
몇 번
글로벌 카운터 증가버튼을 클릭합니다. Stop버튼을 클릭하여 프로파일링을 종료합니다.-
결과 분석
- 왼쪽 상단의 커밋 목록에서 막대들의 높이와 색깔을 확인합니다.
Flamegraph나Ranked차트를 살펴보세요.ProductList와ProductItem들이 많은 시간을 차지하고 있거나, 불필요하게 여러 번 렌더링되고 있음을 확인할 수 있을 것입니다.ProductList나ProductItem을 클릭한 후, 오른쪽 패널의Component Chart에서 Why did this render? 섹션을 확인해 보세요. 아마도 Parent re-rendered나 Props changed (특히 함수 프롭스의 경우)와 같은 이유를 볼 수 있을 것입니다.
이 진단 결과는 우리가 의도했던 문제점들을 명확하게 보여줄 것입니다.
최적화 적용
이제 React.memo, useCallback, useMemo를 사용하여 위에서 진단한 성능 문제들을 해결해 보겠습니다.
src/components/ProductItem.js (최적화 버전)
ProductItem은 받는 프롭스(product, onItemClick)가 변경되지 않으면 리렌더링할 필요가 없습니다. 따라서 React.memo로 감쌉니다.
import React from 'react';
// ✅ React.memo로 감싸서 프롭스가 변경될 때만 리렌더링되도록 최적화
const ProductItem = React.memo(function ProductItem({ product, onItemClick }) {
console.log(`ProductItem [${product.name}] 렌더링됨 (Memoized)`);
return (
<li
style={{
padding: '10px',
margin: '5px 0',
backgroundColor: '#f9f9f9',
border: '1px solid #eee',
borderRadius: '4px',
cursor: 'pointer'
}}
onClick={() => onItemClick(product.name)}
>
{product.name} - ${product.price}
</li>
);
});
export default ProductItem;src/components/ProductList.js (최적화 버전)
ProductList는 다음과 같이 최적화합니다.
필터링 로직: filterTerm이 변경될 때만 필터링된 상품 목록을 다시 계산하도록 useMemo를 사용합니다.
클릭 핸들러: ProductItem에 전달되는 handleItemClick 함수가 매번 재생성되지 않도록 useCallback을 사용합니다.
컴포넌트 자체: ProductList 컴포넌트가 filterTerm 프롭스가 변경될 때만 리렌더링되도록 React.memo로 감쌉니다.
import React, { useMemo, useCallback } from 'react'; // useMemo, useCallback 임포트
import ProductItem from './ProductItem';
const ALL_PRODUCTS = [
{ id: 1, name: 'Laptop', price: 1200 },
{ id: 2, name: 'Mouse', price: 25 },
{ id: 3, name: 'Keyboard', price: 75 },
{ id: 4, name: 'Monitor', price: 300 },
{ id: 5, name: 'Webcam', price: 50 },
{ id: 6, name: 'Microphone', price: 80 },
{ id: 7, name: 'Headphones', price: 150 },
{ id: 8, name: 'USB Drive', price: 15 },
];
// ✅ React.memo로 감싸서 filterTerm이 변경될 때만 리렌더링되도록 최적화
const ProductList = React.memo(function ProductList({ filterTerm }) {
console.log('ProductList 렌더링됨 (Memoized)'); // 렌더링 시점 확인
// ✅ 1. 필터링 로직: filterTerm이 변경될 때만 다시 계산하도록 useMemo 사용
const filteredProducts = useMemo(() => {
console.log('상품 필터링 계산됨'); // 계산 시점 확인
return ALL_PRODUCTS.filter(product =>
product.name.toLowerCase().includes(filterTerm.toLowerCase())
);
}, [filterTerm]); // filterTerm이 변경될 때만 재계산
// ✅ 2. 클릭 핸들러: useCallback을 사용하여 함수 재생성 방지
const handleItemClick = useCallback((productName) => {
alert(`Clicked: ${productName}`);
}, []); // 의존성 배열이 비어있으므로 컴포넌트 마운트 시 한 번만 생성됨 (productName은 인자로 받음)
return (
<div style={{ padding: '20px', border: '1px solid #ccc', borderRadius: '8px', marginBottom: '20px', backgroundColor: '#fff' }}>
<h3>상품 목록</h3>
<ul style={{ listStyle: 'none', padding: 0 }}>
{filteredProducts.map(product => (
// ✅ ProductItem은 이제 메모이제이션된 함수 프롭스를 받으므로 불필요한 리렌더링이 줄어듦
<ProductItem key={product.id} product={product} onItemClick={handleItemClick} />
))}
</ul>
</div>
);
});
export default ProductList;src/App.js (최적화 확인 버전)
App.js는 그대로 둡니다. 최적화는 하위 컴포넌트에서 이루어져야 합니다.
import React, { useState } from 'react';
import './index.css';
import ProductList from './components/ProductList';
function App() {
const [globalCounter, setGlobalCounter] = useState(0);
const [filterTerm, setFilterTerm] = useState('');
console.log('App 컴포넌트 렌더링됨');
return (
<div className="main-content">
<h1 style={{ textAlign: 'center', color: 'var(--header-color)' }}>성능 최적화 실습</h1>
<div style={{ marginBottom: '20px', padding: '15px', border: '1px solid #ddd', borderRadius: '8px', backgroundColor: '#fff' }}>
<h2>앱 전체 상태 제어</h2>
<p>글로벌 카운터: {globalCounter}</p>
<button onClick={() => setGlobalCounter(prev => prev + 1)} className="button">
글로벌 카운터 증가 (App 리렌더링)
</button>
</div>
<div style={{ marginBottom: '20px', padding: '15px', border: '1px solid #ddd', borderRadius: '8px', backgroundColor: '#fff' }}>
<h2>상품 필터링</h2>
<input
type="text"
placeholder="상품 검색..."
value={filterTerm}
onChange={(e) => setFilterTerm(e.target.value)}
style={{ width: '100%', padding: '10px', border: '1px solid #ccc', borderRadius: '4px', boxSizing: 'border-box' }}
/>
</div>
<ProductList filterTerm={filterTerm} />
</div>
);
}
export default App;검증 순서 및 확인 사항: 전후 성능 비교
실습 검증은 비최적화 상태 측정 -> 최적화 적용 -> 동일 입력으로 재측정 순서로 진행합니다. 동일한 입력 조건(카운터 클릭 횟수, 필터 입력 길이)을 유지해야 개선 폭을 정확하게 비교할 수 있습니다.
비최적화 기준값 수집: Profiler에서 globalCounter 클릭과 검색어 입력 시 커밋 시간/렌더링 컴포넌트를 기록합니다.
최적화 적용 후 재측정: React.memo, useCallback, useMemo를 적용한 뒤 동일 동작을 반복하고, 커밋 길이와 렌더링 범위를 비교합니다.
회귀 확인: 최적화 이후에도 검색 결과 정확성과 클릭 동작(아이템 클릭 알림)이 유지되는지 함께 확인합니다.
성능 재진단 및 결과 확인
최적화된 코드를 저장하고 애플리케이션을 다시 실행한 후, React Developer Tools의 Components 탭과 Profiler 탭을 다시 확인합니다.
Components 탭 확인- Highlight updates when components render. 옵션을 켜세요.
글로벌 카운터 증가버튼을 클릭합니다.- 이제
App컴포넌트만 깜빡이고,ProductList및ProductItem들은 더 이상 깜빡이지 않는 것을 확인할 수 있을 것입니다! 이는 불필요한 리렌더링이 성공적으로 방지되었음을 의미합니다. 상품 검색입력창에 텍스트를 입력해 보세요.filterTerm이 변경될 때ProductList가 깜빡이고, 필터링된 상품 목록이 변경될 때만 해당ProductItem들이 깜빡이는 것을 볼 수 있습니다.
Profiler 탭 사용Profiler탭으로 이동하여Record버튼을 클릭합니다.-
몇 번
글로벌 카운터 증가버튼을 클릭합니다. Stop버튼을 클릭하여 프로파일링을 종료합니다.-
결과 분석
- 왼쪽 상단의 커밋 목록에서 막대들의 높이와 색깔을 확인합니다. 이전보다 훨씬 짧고 초록색인 막대들이 보일 것입니다.
Flamegraph나Ranked차트에서ProductList나ProductItem이 차지하는 비중이 현저히 줄어들거나, 아예 나타나지 않을 것입니다 (부모만 렌더링된 커밋의 경우).ProductList를 클릭한 후Component Chart에서 Why did this render?를 확인하면, 이제filterTerm이 변경될 때만 렌더링된다고 표시될 것입니다.ProductItem을 클릭하면product나onItemClick프롭스가 변경될 때만 렌더링된다고 표시될 것입니다. (대부분의 경우product데이터가 바뀌거나 필터링으로 인해 재배열될 때만 렌더링됩니다.)
이 실습을 통해 React 애플리케이션의 성능 문제를 진단하고, React.memo, useCallback, useMemo를 사용하여 불필요한 리렌더링을 방지하는 실질적인 방법을 체득하셨기를 바랍니다.
React.lazy + Suspense 실전 적용 기준
리렌더링 최적화와 별개로, 초기 번들 크기 자체가 큰 경우에는 코드 스플리팅이 필요합니다. 이때 React.lazy와 Suspense를 사용해 당장 필요 없는 화면을 지연 로드할 수 있습니다.
import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
const ReportPage = lazy(() => import('./pages/ReportPage'));
export default function App() {
return (
<BrowserRouter>
<Suspense fallback={<p>화면을 불러오는 중...</p>}>
<Routes>
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/reports" element={<ReportPage />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}- 라우트 전환 시점에만 필요한 대형 페이지/차트/에디터
- 관리자 화면처럼 일부 사용자만 접근하는 기능
- 초기 로딩 성능(LCP)을 줄여야 하는 경우
- 첫 화면에서 반드시 즉시 보여야 하는 핵심 컴포넌트
- 너무 작은 컴포넌트 단위까지 과도하게 쪼개 네트워크 요청만 늘어나는 경우
- 폴백 UI 설계 없이 도입해 로딩 깜빡임이 UX를 해치는 경우
10장 5절 "실습: React.memo, useCallback, useMemo를 이용한 성능 최적화"는 여기까지입니다. 이 실습을 통해 여러분은 다음과 같은 중요한 리액트 성능 최적화 기술을 습득했습니다.
- React Developer Tools의
Profiler를 이용한 성능 진단 능력 React.memo를 사용하여 컴포넌트 리렌더링 제어useCallback을 사용하여 함수 프롭스 재생성 방지useMemo를 사용하여 고비용 계산 결과 및 객체/배열 프롭스 재생성 방지- 이 세 가지 훅을 함께 사용하여 시너지 효과 창출
성능 최적화는 리액트 개발의 핵심적인 부분이며, 사용자 경험을 크게 향상시킬 수 있는 중요한 기술입니다. 하지만 항상 측정하고 최적화하라는 원칙을 잊지 마세요. 불필요한 최적화는 코드의 복잡성만 증가시킬 수 있습니다.
이것으로 10장 React 성능 최적화 기초를 마칩니다. 다음 장(11장)에서는 프로젝트 배포와 학습 로드맵을 중심으로 실무 적용 범위를 확장하겠습니다.