대규모 리스트 렌더링 최적화
이번에는 지금까지 배운 내용을 총체적으로 활용하여 실제 시나리오에서 성능 최적화를 적용하는 실습을 진행하겠습니다. 이 실습을 통해 여러분은 React Developer Tools의 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
의 불필요한 리렌더링을 유발합니다.
프로젝트 준비
기존 리액트 프로젝트를 사용하거나, create-react-app
으로 새로 생성하여 시작합니다.
src/
├── App.js
├── index.css (기본 스타일)
├── components/
│ ├── ProductList.js
│ └── ProductItem.js
초기 구현 (비최적화 버전)
먼저 의도적으로 성능 문제가 있는 코드를 작성합니다.
src/components/ProductItem.js
// 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
// 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
// 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 start
또는 yarn start
), 브라우저 개발자 도구(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
로 감쌉니다.
// src/components/ProductItem.js (최적화 버전)
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
로 감쌉니다.
// src/components/ProductList.js (최적화 버전)
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
는 그대로 둡니다. 최적화는 하위 컴포넌트에서 이루어져야 합니다.
// src/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;
성능 재진단 및 결과 확인
최적화된 코드를 저장하고 애플리케이션을 다시 실행한 후, 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
를 사용하여 불필요한 리렌더링을 방지하는 실질적인 방법을 체득하셨기를 바랍니다.
10장 5절 "실습: React.memo
, useCallback
, useMemo
를 이용한 성능 최적화"는 여기까지입니다. 이 실습을 통해 여러분은 다음과 같은 중요한 리액트 성능 최적화 기술을 습득했습니다:
- React Developer Tools의
Profiler
를 이용한 성능 진단 능력 React.memo
를 사용하여 컴포넌트 리렌더링 제어useCallback
을 사용하여 함수 프롭스 재생성 방지useMemo
를 사용하여 고비용 계산 결과 및 객체/배열 프롭스 재생성 방지- 이 세 가지 훅을 함께 사용하여 시너지 효과 창출
성능 최적화는 리액트 개발의 핵심적인 부분이며, 사용자 경험을 크게 향상시킬 수 있는 중요한 기술입니다. 하지만 항상 "측정하고 최적화하라" 는 원칙을 잊지 마세요. 불필요한 최적화는 코드의 복잡성만 증가시킬 수 있습니다.
이것으로 10장 "React 성능 최적화 기초"를 마칩니다. 다음 장에서는 리액트의 고급 기능이나 다른 중요한 주제로 넘어가겠습니다.