대규모 리스트 렌더링 최적화
Profiler로 대규모 상품 목록의 필터·렌더링 병목을 측정하고 메모이제이션을 단계적으로 적용해 개선 효과를 검증합니다.
10장에서는 React Developer Tools로 성능을 측정하고, 불필요한 리렌더링의 원인을 찾아 React.memo, useCallback, useMemo로 줄이는 방법을 익혔습니다. 이 실습에서는 이 내용을 모아 실제와 비슷한 시나리오에 성능 최적화를 적용합니다. React Developer Tools의 Profiler로 성능 문제를 진단하고, 알맞은 최적화 기법으로 문제를 해결한 뒤 같은 조건으로 다시 측정하는 과정을 직접 해 봅니다.
MEASURE / APPLY / VERIFY
8개 상품 예제로 렌더 경계를 비교한다
같은 카운터 클릭과 검색어 입력을 전후에 반복한다. memo·useCallback·useMemo로 참조와 렌더 경계를 바꾼 뒤, 렌더링 범위·비용과 검색·클릭 기능을 함께 확인한다.
BEFORE / GLOBAL COUNTER
무관한 카운터도 목록으로 전파
초기 App의 globalCounter가 바뀌면 일반 ProductList와 ProductItem도 실행된다. 필터 계산과 handleItemClick 생성이 반복된다.
AFTER / GLOBAL COUNTER
filterTerm이 같으면 목록을 건너뛸 수 있다
ProductList를 memo로 감싼다. globalCounter는 목록 prop이 아니므로, filterTerm이 같으면 목록 렌더를 건너뛸 수 있다. App의 상태 위치는 본문에서 유지한다.
AFTER / SEARCH
검색은 목록을 갱신한다
filterTerm이 바뀌면 필터 결과를 다시 계산한다. 고정된 product 객체와 [] 콜백을 받는 기존 항목은 건너뛸 수 있다. key={product.id}가 유지돼도 제거 후 다시 나타난 항목은 새로 마운트된다.
EXTENSION / MEASURE FIRST
실습 결과와 추가 선택을 구분한다
본문 데이터는 8개다. 유의미한 시간 차이가 없을 수 있다. 상태 소유 범위 축소·viewport 주변 행의 가상화는 추가 측정 후 검토하며, 이 구현에 이미 적용됐다고 해석하지 않는다.
반복 렌더 비용, DOM·layout 비용, 초기 코드 로딩 비용을 따로 기록한다. 가상화는 화면 주변 행 수를 줄이는 선택이고 lazy·Suspense는 코드 지연 로딩을 다룬다. 이 그림 자체는 성능 개선을 측정한 결과가 아니다.
위 그림은 이 실습의 8개 상품 예제로 렌더링의 범위를 비교하는 순서를 보여 줍니다. 그림의 요약처럼 같은 카운터 클릭과 검색어 입력을 전후에 되풀이하고, memo와 useCallback, useMemo로 참조와 렌더링 범위를 바꾼 뒤, 렌더링 범위와 비용, 검색과 클릭 기능을 함께 확인합니다. 순서는 클릭과 검색 입력의 기준 기록, memo와 두 캐시 Hook의 적용, 렌더링 범위와 비용의 같은 조건 재기록, 검색 결과와 클릭 알림까지 포함한 기능과 이득의 확인이며, 마지막 단계는 다시 기준 기록으로 돌아갑니다. 그림의 캡션이 밝히듯 상태 이동과 가상화는 이 글에 적용한 결과가 아니라, 측정한 뒤에 고를 수 있는 확장 단계입니다.
그림 아래의 네 칸은 전후의 차이를 정리합니다. 처음의 App에서 globalCounter가 바뀌면 일반 컴포넌트인 ProductList와 ProductItem도 실행되어 필터 계산과 handleItemClick의 생성이 되풀이됩니다. 최적화한 뒤에는 ProductList를 memo로 감싸므로, globalCounter는 목록의 prop이 아니어서 filterTerm이 같으면 목록의 렌더링을 건너뛸 수 있으며, App의 상태 위치는 그대로 둡니다. 검색어를 바꾸면 filterTerm이 바뀌어 필터 결과를 다시 계산하지만, 고정된 product 객체와 [] 콜백을 받는 기존 항목은 건너뛸 수 있고, key={product.id}가 유지되어도 한 번 사라졌다 다시 나타난 항목은 새로 마운트됩니다. 마지막 칸은 데이터가 8개뿐이라 의미 있는 시간 차이가 없을 수 있고, 상태를 가진 범위를 줄이거나 화면 주변의 행만 렌더링하는 가상화는 추가로 측정한 뒤에 검토한다고 밝힙니다. 그림의 마지막 설명처럼 반복 렌더링의 비용, DOM과 레이아웃의 비용, 처음 코드를 불러오는 비용은 따로 기록하며, 이 그림 자체는 성능 개선을 측정한 결과가 아닙니다.
실습 목표: 성능 병목 측정과 교정
이 실습은 성능 문제를 먼저 측정한 뒤 고치는 사례 중심으로 진행합니다. 최적화를 곧바로 적용하지 않고, Profiler에서 불필요한 리렌더링의 원인을 먼저 찾습니다.
먼저 기본적인 React 프로젝트를 준비하고, 일부러 불필요한 리렌더링이 일어나는 컴포넌트들을 만듭니다. 그다음 React Developer Tools의 Profiler 탭으로 불필요한 리렌더링을 찾고, React.memo, useCallback, useMemo로 문제를 해결한 뒤, 다시 Profiler로 성능이 나아졌는지 확인합니다.
시나리오: 동적인 목록 필터링과 항목 표시
사용자의 입력에 따라 걸러지는 상품 목록을 렌더링하는 애플리케이션을 만듭니다. 부모인 App 컴포넌트는 앱 전체의 상태를 관리하고 하위 컴포넌트에 값을 넘기며, 앱 전반의 클릭 수를 나타내는 globalCounter 상태(최적화되지 않은 리렌더링을 일으키는 용도)와 상품 목록을 거르는 검색어인 filterTerm 상태를 가집니다. 자식인 ProductList는 상품 데이터를 filterTerm에 따라 걸러 ProductItem들을 렌더링하고, 가장 아래의 ProductItem은 상품 하나의 정보를 보여 주며 누르면 그 상품의 이름을 alert로 알립니다.
처음 코드에는 일부러 세 가지 문제를 넣습니다. App의 globalCounter가 바뀔 때마다 ProductList와 ProductItem들이 불필요하게 리렌더링되고, ProductList 안에서 걸러진 상품 목록을 계산하는 로직이 렌더링할 때마다 다시 실행되며, ProductItem에 넘기는 onItemClick 함수가 매번 새로 만들어져 ProductItem의 불필요한 리렌더링을 일으킵니다.
준비 단계: 최적화 적용 기준 세우기
준비 단계에서는 어떤 최적화를 어디에 쓸지 기준을 분명히 합니다. React.memo는 props가 안정적인 하위 컴포넌트에, useCallback은 함수 참조를 안정시켜야 할 때에, useMemo는 계산 비용이 높은 필터링이나 파생 데이터에만 적용합니다.
기존 React 프로젝트를 쓰거나, Vite로 새 프로젝트를 만들어 시작합니다. Vite의 기본 React 설정에서는 JSX가 들어 있는 파일을 .jsx로 저장하므로 아래의 파일 이름도 .jsx로 씁니다. 예제 데이터는 8개이며, 대규모 목록에서 성능이 나아진 것을 이미 측정한 결과는 아닙니다.
처음 구현 (최적화하지 않은 버전)
먼저 일부러 성능 문제가 있는 코드를 작성합니다. App은 목록과 관계없는 globalCounter와 검색어 filterTerm을 함께 가지고, ProductList는 검색어로 상품을 거른 뒤 항목마다 ProductItem을 그립니다. 문제가 되는 곳은 ProductList 주석에 1., 2.로 표시한 두 줄로, 렌더링마다 다시 실행되는 필터링과 렌더링마다 새로 만들어지는 클릭 함수입니다. 각 컴포넌트의 console.log는 언제 렌더링되는지 콘솔에서 세어 보려고 넣었습니다.
src/components/ProductItem.jsx (최적화하지 않은 버전)
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;ProductItem은 자기 상태 없이, 클릭되면 받은 onItemClick을 상품 이름과 함께 부르기만 합니다. 그래서 이 컴포넌트가 다시 렌더링되는 이유는 부모의 렌더링뿐이며, 그때마다 콘솔에 상품 이름이 들어간 로그가 한 줄씩 찍힙니다.
src/components/ProductList.jsx (최적화하지 않은 버전)
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;ProductList가 렌더링될 때마다 filter가 여덟 상품을 다시 훑고, handleItemClick도 새 함수로 다시 만들어집니다. 함수의 내용이 같아도 참조가 매번 달라지므로, 이 상태에서는 나중에 ProductItem을 React.memo로 감싸도 onItemClick이 바뀐 것으로 판단됩니다.
src/App.jsx (최적화하지 않은 버전)
import React, { useState } from 'react';
import './index.css'; // 기본 스타일 임포트 (8장 실습에서 사용한 스타일 사용)
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;최적화 전과 후의 파일은 두 버전으로 보관하고, 실제 프로젝트에서는 한 버전씩만 씁니다. ProductList와 ProductItem을 최적화 버전으로 바꿀 때 App은 그대로 둡니다. 새 React 프로젝트의 처음 화면에 App이 연결되어 있어야 하며, index.css가 아직 없다면 빈 파일을 만들어 import가 실패하지 않게 합니다. main-content와 button 클래스는 8장 실습의 index.css에 있던 스타일이며, 스타일이 없으면 모양은 달라도 상태와 상품 목록은 비교할 수 있습니다.
실행 직후 글로벌 카운터는 0이고 상품은 8개입니다. 검색어에 mouse를 입력하면 id 2인 Mouse와 가격 25만 보이고, 그 항목을 누르면 “Clicked: Mouse”가 나타납니다. 이 기능 확인을 통과한 뒤에 기준 기록을 시작합니다. 검색어를 비우고 카운터를 0으로 되돌리려고 새로고침한 다음 카운터만 세 번 누른 기록과, 다시 새로고침한 뒤 mouse를 입력한 기록을 따로 남기며, 전후 버전에서 입력 문자열과 클릭 수, 8개의 데이터, 빌드 모드, React Compiler의 사용 여부를 같게 고정합니다.
성능 진단
애플리케이션을 실행하고(npm run dev), 브라우저 개발자 도구(F12)를 열어 Components 탭과 Profiler 탭을 확인합니다.
먼저 Components 탭의 설정에서 “Highlight updates when components render.”를 켜면 렌더링된 컴포넌트 주변에 테두리가 깜빡입니다. 글로벌 카운터 증가 버튼을 누를 때마다 App과 ProductList, 모든 ProductItem이 함께 깜빡이는 것을 볼 수 있는데, 이는 globalCounter가 ProductList나 ProductItem과 직접 관련이 없는데도 불필요하게 리렌더링되고 있다는 뜻입니다.
다음으로 Profiler 탭에서 Record를 눌러 프로파일링을 시작하고, 글로벌 카운터 증가 버튼을 몇 번 누른 뒤 Stop을 눌러 끝냅니다. 오른쪽 위의 커밋 목록에서 막대의 높이와 색을 보고, Flamegraph나 Ranked 차트에서 ProductList와 ProductItem들이 시간을 차지하거나 여러 번 렌더링되는지 확인합니다. Profiler 설정에서 “Record why each component rendered while profiling.”을 켜 두었다면, 컴포넌트를 골랐을 때 오른쪽 패널의 “Why did this render?”에서 부모가 렌더링되었다거나 어떤 props(특히 함수 props)가 바뀌었다는 이유를 볼 수 있습니다.
이 코드는 렌더링 경로를 비교하기 위한 실습입니다. 실제 병목과 개선의 폭은 같은 데이터와 동작, 빌드 조건에서 측정해야 하며, 콘솔 로그가 찍힌다는 사실만으로 비용이 크다고 결론 내리지 않습니다.
최적화 적용
이제 React.memo, useCallback, useMemo로 위에서 진단한 문제들을 해결합니다. 고치는 순서는 아래쪽부터입니다. 먼저 ProductItem이 같은 props에서 렌더링을 건너뛸 수 있게 하고, 다음으로 ProductList가 넘기는 함수의 참조를 고정하며, 마지막으로 ProductList 자체도 filterTerm이 같으면 건너뛰게 합니다. React.memo는 props가 같을 때만 효과가 있으므로, 참조를 고정하는 도구가 함께 있어야 합니다.
src/components/ProductItem.jsx (최적화 버전)
ProductItem은 받는 props인 product와 onItemClick이 바뀌지 않으면 리렌더링할 필요가 없으므로 React.memo로 감쌉니다. 함수 본문은 바꾸지 않고 바깥만 감쌌으며, 콘솔 문구에 (Memoized)를 붙여 최적화 버전의 로그와 구분합니다.
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;React.memo는 이전 props와 새 props를 하나씩 Object.is로 비교합니다. product는 모듈 상수 배열 ALL_PRODUCTS의 같은 객체를 계속 가리키므로 같다고 판단되지만, onItemClick은 부모가 참조를 고정해 주어야 같다고 판단됩니다.
src/components/ProductList.jsx (최적화 버전)
ProductList는 세 곳을 고칩니다. filterTerm이 바뀔 때만 걸러진 상품 목록을 다시 계산하도록 필터링 로직에 useMemo를 쓰고, ProductItem에 넘기는 handleItemClick의 참조를 재사용하도록 useCallback을 쓰며, 컴포넌트 자체는 filterTerm prop이 바뀔 때만 리렌더링되도록 React.memo로 감쌉니다. handleItemClick은 상품 이름을 인자로 받고 컴포넌트의 state나 props를 읽지 않으므로 빈 의존성 배열로 충분합니다.
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;검색어가 그대로인 채 App만 다시 렌더링되면 ProductList의 memo가 렌더링 자체를 건너뜁니다. 검색어가 바뀌어 ProductList가 렌더링되면 handleItemClick은 첫 렌더링의 참조를 그대로 돌려주므로, 목록에 남아 있는 항목은 다시 렌더링하지 않아도 됩니다. 한편 이 구성에서는 ProductList가 filterTerm이 바뀔 때만 렌더링되므로, useMemo가 필터링을 건너뛸 기회는 많지 않습니다. ProductList에 검색어와 무관한 상태나 props가 더해져 렌더링이 늘어날 때 그 효과가 드러납니다.
src/App.jsx (최적화 확인 버전)
이 실습은 하위 컴포넌트의 참조와 렌더링 범위를 비교하므로 App.jsx의 코드는 그대로 둡니다. 아래 코드는 앞의 App.jsx에서 주석 몇 줄만 빠진 같은 코드이므로, 이미 만든 파일을 바꿀 필요는 없습니다. App은 여전히 카운터를 누를 때마다 다시 렌더링되며, 달라지는 것은 그 아래의 ProductList와 ProductItem이 그 렌더링을 건너뛰는지입니다.
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;검증 순서: 전후 성능 비교
실습 검증은 최적화하지 않은 상태의 측정, 최적화 적용, 같은 입력으로 다시 측정하는 순서로 진행합니다. 카운터를 누르는 횟수와 필터에 입력하는 문자열 같은 조건을 같게 유지해야 개선의 폭을 정확하게 비교할 수 있습니다. 도입 그림의 가상화와 상태를 가진 범위의 축소는 측정 결과에 따라 고르는 확장 단계이며, 이 글의 8개 상품 구현에는 적용하지 않았습니다.
먼저 최적화하지 않은 상태에서 Profiler로 글로벌 카운터 클릭과 검색어 입력의 커밋 시간과 렌더링된 컴포넌트를 기록합니다. 그다음 React.memo, useCallback, useMemo를 적용한 뒤 같은 동작을 되풀이해 커밋의 길이와 렌더링 범위를 비교하고, 최적화한 뒤에도 검색 결과가 정확한지와 항목 클릭 알림이 그대로 동작하는지 함께 확인합니다.
성능 재진단과 결과 확인
최적화한 코드를 저장하고 애플리케이션을 다시 실행한 뒤, React Developer Tools의 Components 탭과 Profiler 탭을 다시 확인합니다.
“Highlight updates when components render.”를 켠 채 글로벌 카운터 증가 버튼을 누르면, 이제 App만 깜빡이고 ProductList와 ProductItem들은 깜빡이지 않아야 합니다. 상품 검색 입력창에 글자를 입력하면 filterTerm이 바뀌므로 ProductList가 깜빡이며, 새로 마운트되는 항목과 계속 남아 있는 항목을 나누어 확인합니다. 남아 있는 항목은 product와 onItemClick의 참조가 같아 렌더링을 건너뛸 수 있습니다.
Profiler에서 같은 방식으로 글로벌 카운터 클릭을 기록하고, 같은 조건의 전후 기록에서 렌더링 시간과 범위를 비교합니다. 상품이 8개뿐이라 의미 있는 시간 차이는 없을 수 있지만, Flamegraph나 Ranked 차트에서 부모만 렌더링된 커밋에는 ProductList나 ProductItem이 렌더링된 것으로 나타나지 않아야 합니다. “Why did this render?”를 확인하면 ProductList는 filterTerm이 바뀔 때만, ProductItem은 product나 onItemClick이 바뀔 때만 렌더링되었다고 보여야 합니다. 같은 key와 props를 가진 기존 항목은 순서가 바뀌었다는 이유만으로 다시 렌더링해야 하는 것은 아니며, 제거되었다가 다시 나타난 항목은 새로 마운트됩니다. 실습 결과에는 실제 Profiler 기록과 기능 확인 결과를 남기세요. 이 글의 설명 자체는 측정 결과가 아닙니다.
React.lazy와 Suspense의 실전 적용 기준
리렌더링 최적화와 별개로, 처음 내려받는 번들 자체가 크다면 코드 분할(code splitting)이 필요합니다. 이때 React.lazy와 Suspense로 당장 필요 없는 화면을 나중에 불러올 수 있습니다.
import { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router';
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>
);
}이 라우트 예제는 목록 예제와 별도의 실험이며, React Router 사용법의 라우터 설정을 먼저 준비합니다. 세 파일에는 타입 표기가 없으므로, 이 글의 자바스크립트 템플릿에서는 src/App.jsx, src/pages/DashboardPage.jsx, src/pages/ReportPage.jsx로 저장해도 같은 코드가 그대로 동작합니다. 라우터는 6장과 같은 react-router 패키지를 쓰며, 이미 더 바깥에서 BrowserRouter로 감쌌다면 하나만 남기고 중첩하지 않습니다. import 경로에 맞는 페이지 파일이 없으면 불러오기가 실패하므로 다음 두 파일을 각각 저장합니다.
export default function DashboardPage() {
return <h1>Dashboard</h1>;
}export default function ReportPage() {
return <h1>Reports</h1>;
}서버의 기본 주소 /에는 이 Routes에서 맞는 경로가 없으므로 아무것도 보이지 않습니다. 먼저 /dashboard로 들어가 Dashboard를, 다음에 /reports로 들어가 Reports를 확인합니다. 각 페이지의 기본 내보내기가 lazy가 import 결과에서 읽는 컴포넌트입니다. 이 작은 페이지들은 곧바로 불러와져 fallback을 눈으로 보지 못할 수 있으며, fallback이 반드시 일정 시간 보여야 성공하는 것은 아닙니다. 개발 서버에서 보이는 동작만으로 운영 서버의 직접 경로 요청 처리나 실제 청크 크기의 감소를 검증했다고 말하지 않습니다.
lazy는 import 결과가 준비될 때까지 Suspense가 보여 줄 내용을 정하는 기능이며, 실패한 import를 성공 화면으로 바꾸는 오류 처리가 아닙니다. 불러오기의 실패를 처리하려면 에러 바운더리도 따로 설계합니다(React 문서의 lazy).
코드 분할은 라우트를 바꿀 때만 필요한 큰 페이지나 차트, 에디터, 관리자 화면처럼 일부 사용자만 쓰는 기능에 알맞습니다. 뷰포트 안에서 가장 큰 이미지나 동영상, 글 덩어리가 그려지는 시간인 LCP(Largest Contentful Paint)를 줄여야 할 때도 검토합니다. 반대로 첫 화면에 반드시 바로 보여야 하는 핵심 컴포넌트, 너무 작은 단위까지 쪼개 네트워크 요청만 늘어나는 경우, fallback UI를 설계하지 않고 도입해 로딩 깜빡임이 사용자 경험을 해치는 경우에는 피합니다. 대규모 리스트 실습을 마무리할 때는 렌더링 최적화와 처음 로딩의 최적화를 따로 기록해야 하며, memo 계열 도구는 반복 렌더링의 비용을 줄이고 lazy와 Suspense는 처음 내려받는 코드의 양을 줄입니다.
더 깊이: 한 번 실패한 지연 로딩과 배포 뒤의 오래된 청크
lazy에 넘긴 load 함수는 그 컴포넌트를 처음 렌더링하려 할 때 호출됩니다. React 문서의 lazy에 따르면 React는 돌려받은 Promise와 그 결과를 모두 캐시하므로 load를 두 번 부르지 않고, Promise가 거부되면 그 이유를 가장 가까운 에러 바운더리로 던집니다. 그래서 같은 lazy 컴포넌트를 다시 렌더링하게 하는 것만으로는 불러오기를 새로 시도하지 않으며, 에러 바운더리의 다시 시도 버튼이 같은 오류를 다시 보여 줄 수 있습니다. 같은 이유로 lazy는 컴포넌트 안이 아니라 모듈의 최상위에서 선언해야 하며, 컴포넌트 안에서 선언하면 렌더마다 새 컴포넌트가 되어 그 아래의 state가 초기화됩니다.
배포 환경에서는 이런 실패가 생각보다 자주 생길 수 있습니다. Vite 문서는 새로 배포할 때 호스팅 서비스가 이전 배포의 파일을 지우면, 배포 전에 페이지를 연 사용자가 동적 import에서 오류를 만날 수 있다고 설명합니다. Vite는 동적 import를 불러오지 못하면 vite:preloadError 이벤트를 보내므로, window에 이 이벤트의 리스너를 두고 페이지를 새로고침해 새 HTML과 새 청크를 받게 할 수 있습니다(Vite의 빌드 안내). 이때 HTML 파일에 Cache-Control: no-cache를 두지 않으면, 새로고침해도 이전 파일을 가리키는 오래된 HTML이 남을 수 있습니다.
정리
- 최적화는 기준 기록, 한 가지 변경, 같은 조건의 재기록, 기능과 이득의 확인 순서로 진행합니다.
globalCounter처럼 목록과 관계없는 상태가 바뀌어도 목록이 렌더링되는 문제는React.memo로 감싼ProductList가filterTerm이 같을 때 건너뛰게 해서 줄입니다.- 항목에 넘기는 클릭 처리 함수를
useCallback으로 재사용해야ProductItem의memo가 효과를 냅니다. 각product는 모듈 상수의 같은 객체라 그대로 같은 값으로 비교되고, 걸러진 목록의useMemo는 검색어가 그대로일 때 필터 계산을 다시 하지 않게 하는 장치입니다. - 작은 데이터에서는 시간 차이가 측정 오차보다 작을 수 있으므로, 기능의 정확성과 렌더링 범위를 함께 기록합니다.
React.lazy와Suspense는 반복 렌더링이 아니라 처음 내려받는 코드의 양을 줄이는 도구이며, 불러오기 실패는 에러 바운더리로 따로 처리합니다.
연습 문제
1. 최적화가 기능을 바꾸지 않았는지 확인하기
각 버전을 새로고침해 글로벌 카운터를 세 번 늘린 뒤 검색어에 대문자 MO를 입력합니다. 남는 상품의 id와 이름, 가격, 글로벌 카운터의 값을 적고, 남아 있는 Monitor를 누를 때의 안내도 예상해 보세요.
해설 보기
글로벌 카운터는 3이고, 검색 결과는 id 2인 Mouse(가격 25)와 id 4인 Monitor(가격 300)입니다. 이름과 검색어를 모두 소문자로 바꾸어 비교하므로 MO는 mo로 비교되며, Monitor를 누르면 “Clicked: Monitor”가 나와야 합니다.
두 버전의 결과는 같아야 하며, memo나 캐시를 없애도 검색 결과와 클릭 동작은 맞아야 합니다. 걸러진 목록도 ALL_PRODUCTS의 각 product 객체를 그대로 넘기고 id를 key로 쓰므로, 같은 상품이 계속 남아 있는 경우와 한 번 제거되었다 다시 마운트되는 경우를 구분해 확인합니다.
2. 가상의 측정 기록 해석하기
같은 앱의 가상 측정 기록이 다음과 같다고 가정합니다. 이 코드를 실제로 실행해서 얻은 숫자는 아닙니다. 같은 카운터 동작 세 번의 React 렌더링 비용이 변경 전 6, 8, 7ms, 변경 뒤 2, 3, 4ms이고, 상호작용 전체 시간의 평균은 전후 모두 120ms입니다. 각 렌더링 평균과 줄어든 비용을 구하고, 이 자료만으로 전체 대기 시간이 줄었다고 말할 수 있는지 판단해 보세요.
해설 보기
렌더링 비용의 평균은 변경 전 (6 + 8 + 7) ÷ 3 = 7ms, 변경 뒤 (2 + 3 + 4) ÷ 3 = 3ms로 4ms 줄었습니다. 하지만 상호작용 전체는 전후 모두 120ms이므로, 전체 대기 시간이 4ms 줄었다는 결론은 나오지 않습니다.
기능이 같다는 결과와 렌더링 구간의 비용 차이, 전체 반응 시간을 각각 기록합니다. 작은 데이터에서 무조건 최적화에 성공했다고 결론 내리거나, 개선을 보이려고 상품을 빠뜨리지 않습니다.
3. 차이가 측정 오차보다 작다면
8개 상품을 그대로 쓰면서 측정한 렌더링 비용의 차이가 측정 오차보다 작다면 어떤 결론을 남겨야 할까요? 데이터가 훨씬 커진다면 무엇을 더 검토해야 할까요?
해설 보기
차이가 측정 오차와 구분되지 않으면, 이 데이터에서는 성능 개선의 폭을 확인하지 못했다고 기록합니다. 렌더링 범위가 줄어든 것은 Profiler에서 확인할 수 있어도, 시간이 줄었다는 주장은 측정으로 뒷받침될 때만 합니다.
큰 데이터에서는 반복되는 필터 계산뿐 아니라 실제 DOM 행의 수와 레이아웃, 페인트도 비용이 될 수 있습니다. 화면 주변의 행만 렌더링하는 가상화는 별도의 구현과 스크롤, 키보드, 접근성 검수가 필요하며, 이 글의 8개 상품 코드에 이미 구현된 기능으로 세지 않습니다.
이것으로 10장을 마칩니다. 10장에서는 React Developer Tools로 상태의 흐름과 렌더링 비용을 측정하는 방법에서 시작해, 불필요한 리렌더링의 원인과 React.memo, useCallback, useMemo의 쓰임새, 동시성 Hook으로 갱신의 우선순위를 나누는 방법을 익히고, 상품 목록 실습으로 측정과 최적화, 재측정을 직접 해 보았습니다. 다음 장의 React 앱 빌드부터는 만든 앱을 빌드하고 배포하는 방법과 앞으로의 학습 경로를 다룹니다.