icon
10장 : React 성능 최적화 기초

대규모 리스트 렌더링 최적화

이번에는 지금까지 배운 내용을 총체적으로 활용하여 실제 시나리오에서 성능 최적화를 적용하는 실습을 진행하겠습니다. 이 실습을 통해 여러분은 React Developer Tools의 Profiler를 사용하여 성능 문제를 진단하고, 적절한 최적화 기법을 적용하여 문제를 해결하는 경험을 하게 될 것입니다.


실습 목표

  1. 프로젝트 설정: 기본적인 리액트 프로젝트를 준비합니다.
  2. 비최적화된 컴포넌트 구현: 의도적으로 불필요한 리렌더링이 발생하는 컴포넌트들을 구성합니다.
  3. 성능 진단: React Developer Tools의 Profiler 탭을 사용하여 불필요한 리렌더링을 식별합니다.
  4. 최적화 적용: React.memo, useCallback, useMemo를 사용하여 성능 문제를 해결합니다.
  5. 성능 재진단: 최적화 적용 후 Profiler 탭을 다시 사용하여 개선된 성능을 확인합니다.

시나리오: 동적인 목록 필터링 및 아이템 표시

우리는 사용자 입력에 따라 필터링되는 상품 목록을 렌더링하는 애플리케이션을 만들 것입니다. 이 시나리오에서 다음과 같은 구성 요소를 가집니다:

  • App 컴포넌트 (부모): 전체 앱 상태를 관리하고 하위 컴포넌트에 값을 전달합니다.
    • globalCounter 상태: 앱 전반의 클릭 수를 나타내는 상태 (최적화되지 않은 리렌더링 유발용).
    • filterTerm 상태: 상품 목록을 필터링하는 데 사용되는 검색어.
  • ProductList 컴포넌트 (자식): 상품 데이터를 받아 필터링하고 ProductItem들을 렌더링합니다.
    • 초기에는 모든 상품을 받지만, filterTerm에 따라 필터링된 상품 목록을 계산하여 ProductItem에 전달.
  • ProductItem 컴포넌트 (가장 하위 자식): 개별 상품 정보를 표시합니다.
    • 클릭 시 해당 상품의 이름을 alert하는 기능 포함.

초기 문제점 (의도적)

  1. App 컴포넌트의 globalCounter가 변경될 때마다 ProductListProductItem들이 불필요하게 리렌더링됩니다.
  2. ProductList 내에서 필터링된 상품 목록을 계산하는 로직이 매 렌더링마다 다시 실행됩니다.
  3. ProductItem에 전달되는 onItemClick 함수가 매번 재생성되어 ProductItem의 불필요한 리렌더링을 유발합니다.

프로젝트 준비

기존 리액트 프로젝트를 사용하거나, create-react-app으로 새로 생성하여 시작합니다.

src/
├── App.js
├── index.css (기본 스타일)
├── components/
│   ├── ProductList.js
│   └── ProductItem.js

초기 구현 (비최적화 버전)

먼저 의도적으로 성능 문제가 있는 코드를 작성합니다.

src/components/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
// 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
// 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 탭을 확인합니다.

  1. Components 탭 확인

    • "Highlight updates when components render." 옵션을 켜세요 (컴포넌트 주변에 테두리가 깜빡임).
    • 글로벌 카운터 증가 버튼을 클릭할 때마다 App, ProductList, 그리고 모든 ProductItem들이 함께 깜빡이는 것을 확인합니다. 이는 globalCounterProductListProductItem과 직접적인 관련이 없음에도 불구하고 불필요하게 리렌더링되고 있다는 것을 의미합니다.
  2. Profiler 탭 사용

    • Profiler 탭으로 이동합니다.
    • Record 버튼을 클릭하여 프로파일링을 시작합니다.
    • 몇 번 글로벌 카운터 증가 버튼을 클릭합니다.
    • Stop 버튼을 클릭하여 프로파일링을 종료합니다.
    • 결과 분석
      • 왼쪽 상단의 커밋 목록에서 막대들의 높이와 색깔을 확인합니다.
      • FlamegraphRanked 차트를 살펴보세요.
      • ProductListProductItem들이 많은 시간을 차지하고 있거나, 불필요하게 여러 번 렌더링되고 있음을 확인할 수 있을 것입니다.
      • ProductListProductItem을 클릭한 후, 오른쪽 패널의 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
// 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는 다음과 같이 최적화합니다.

  1. 필터링 로직: filterTerm이 변경될 때만 필터링된 상품 목록을 다시 계산하도록 useMemo를 사용합니다.
  2. 클릭 핸들러: ProductItem에 전달되는 handleItemClick 함수가 매번 재생성되지 않도록 useCallback을 사용합니다.
  3. 컴포넌트 자체: ProductList 컴포넌트가 filterTerm 프롭스가 변경될 때만 리렌더링되도록 React.memo로 감쌉니다.
src/components/ProductList.js
// 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
// 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 탭을 다시 확인합니다.

  1. Components 탭 확인

    • "Highlight updates when components render." 옵션을 켜세요.
    • 글로벌 카운터 증가 버튼을 클릭합니다.
    • 이제 App 컴포넌트만 깜빡이고, ProductListProductItem들은 더 이상 깜빡이지 않는 것을 확인할 수 있을 것입니다! 이는 불필요한 리렌더링이 성공적으로 방지되었음을 의미합니다.
    • 상품 검색 입력창에 텍스트를 입력해 보세요. filterTerm이 변경될 때 ProductList가 깜빡이고, 필터링된 상품 목록이 변경될 때만 해당 ProductItem들이 깜빡이는 것을 볼 수 있습니다.
  2. Profiler 탭 사용

    • Profiler 탭으로 이동하여 Record 버튼을 클릭합니다.
    • 몇 번 글로벌 카운터 증가 버튼을 클릭합니다.
    • Stop 버튼을 클릭하여 프로파일링을 종료합니다.
    • 결과 분석
      • 왼쪽 상단의 커밋 목록에서 막대들의 높이와 색깔을 확인합니다. 이전보다 훨씬 짧고 초록색인 막대들이 보일 것입니다.
      • FlamegraphRanked 차트에서 ProductListProductItem이 차지하는 비중이 현저히 줄어들거나, 아예 나타나지 않을 것입니다 (부모만 렌더링된 커밋의 경우).
      • ProductList를 클릭한 후 Component Chart에서 "Why did this render?"를 확인하면, 이제 filterTerm이 변경될 때만 렌더링된다고 표시될 것입니다.
      • ProductItem을 클릭하면 productonItemClick 프롭스가 변경될 때만 렌더링된다고 표시될 것입니다. (대부분의 경우 product 데이터가 바뀌거나 필터링으로 인해 재배열될 때만 렌더링됩니다.)

이 실습을 통해 React 애플리케이션의 성능 문제를 진단하고, React.memo, useCallback, useMemo를 사용하여 불필요한 리렌더링을 방지하는 실질적인 방법을 체득하셨기를 바랍니다.


10장 5절 "실습: React.memo, useCallback, useMemo를 이용한 성능 최적화"는 여기까지입니다. 이 실습을 통해 여러분은 다음과 같은 중요한 리액트 성능 최적화 기술을 습득했습니다:

  • React Developer Tools의 Profiler를 이용한 성능 진단 능력
  • React.memo를 사용하여 컴포넌트 리렌더링 제어
  • useCallback을 사용하여 함수 프롭스 재생성 방지
  • useMemo를 사용하여 고비용 계산 결과 및 객체/배열 프롭스 재생성 방지
  • 이 세 가지 훅을 함께 사용하여 시너지 효과 창출

성능 최적화는 리액트 개발의 핵심적인 부분이며, 사용자 경험을 크게 향상시킬 수 있는 중요한 기술입니다. 하지만 항상 "측정하고 최적화하라" 는 원칙을 잊지 마세요. 불필요한 최적화는 코드의 복잡성만 증가시킬 수 있습니다.

이것으로 10장 "React 성능 최적화 기초"를 마칩니다. 다음 장에서는 리액트의 고급 기능이나 다른 중요한 주제로 넘어가겠습니다.