icon

안동민 개발노트

10장 : React 성능 최적화 기초

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


이번에는 지금까지 배운 내용을 종합적으로 활용해 실제 시나리오에서 성능 최적화를 적용하는 실습을 진행하겠습니다. 이 실습을 통해 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가 변경될 때마다 ProductListProductItem들이 불필요하게 리렌더링됩니다.

ProductList 내에서 필터링된 상품 목록을 계산하는 로직이 매 렌더링마다 다시 실행됩니다.

ProductItem에 전달되는 onItemClick 함수가 매번 재생성되어 ProductItem의 불필요한 리렌더링을 유발합니다.


준비 단계: 최적화 적용 기준 수립

준비 단계에서는 최적화 선택 기준을 분명히 둡니다. React.memo는 프롭스가 안정적인 하위 컴포넌트에, useCallback은 함수 참조 안정화가 필요한 경우에, useMemo는 계산 비용이 높은 필터링/파생 데이터에만 적용합니다.

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

App.js
index.css # 기본 스타일
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 run dev 또는 yarn dev), 브라우저 개발자 도구(F12)를 열어 Components 탭과 Profiler 탭을 확인합니다.

Components 탭 확인
  • Highlight updates when components render. 옵션을 켜세요 (컴포넌트 주변에 테두리가 깜빡임).
  • 글로벌 카운터 증가 버튼을 클릭할 때마다 App, ProductList, 그리고 모든 ProductItem들이 함께 깜빡이는 것을 확인합니다. 이는 globalCounterProductListProductItem과 직접적인 관련이 없음에도 불구하고 불필요하게 리렌더링되고 있다는 것을 의미합니다.
Profiler 탭 사용
  • Profiler 탭으로 이동합니다.
  • Record 버튼을 클릭하여 프로파일링을 시작합니다.
  • 몇 번 글로벌 카운터 증가 버튼을 클릭합니다.
  • Stop 버튼을 클릭하여 프로파일링을 종료합니다.
  • 결과 분석
    • 왼쪽 상단의 커밋 목록에서 막대들의 높이와 색깔을 확인합니다.
    • FlamegraphRanked 차트를 살펴보세요.
    • ProductListProductItem들이 많은 시간을 차지하고 있거나, 불필요하게 여러 번 렌더링되고 있음을 확인할 수 있을 것입니다.
    • ProductListProductItem을 클릭한 후, 오른쪽 패널의 Component Chart에서 Why did this render? 섹션을 확인해 보세요. 아마도 Parent re-renderedProps 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;

검증 순서 및 확인 사항: 전후 성능 비교

실습 검증은 비최적화 상태 측정 -> 최적화 적용 -> 동일 입력으로 재측정 순서로 진행합니다. 동일한 입력 조건(카운터 클릭 횟수, 필터 입력 길이)을 유지해야 개선 폭을 정확하게 비교할 수 있습니다.

비최적화 기준값 수집: Profiler에서 globalCounter 클릭과 검색어 입력 시 커밋 시간/렌더링 컴포넌트를 기록합니다.

최적화 적용 후 재측정: React.memo, useCallback, useMemo를 적용한 뒤 동일 동작을 반복하고, 커밋 길이와 렌더링 범위를 비교합니다.

회귀 확인: 최적화 이후에도 검색 결과 정확성과 클릭 동작(아이템 클릭 알림)이 유지되는지 함께 확인합니다.


성능 재진단 및 결과 확인

최적화된 코드를 저장하고 애플리케이션을 다시 실행한 후, React Developer Tools의 Components 탭과 Profiler 탭을 다시 확인합니다.

Components 탭 확인
  • Highlight updates when components render. 옵션을 켜세요.
  • 글로벌 카운터 증가 버튼을 클릭합니다.
  • 이제 App 컴포넌트만 깜빡이고, ProductListProductItem들은 더 이상 깜빡이지 않는 것을 확인할 수 있을 것입니다! 이는 불필요한 리렌더링이 성공적으로 방지되었음을 의미합니다.
  • 상품 검색 입력창에 텍스트를 입력해 보세요. filterTerm이 변경될 때 ProductList가 깜빡이고, 필터링된 상품 목록이 변경될 때만 해당 ProductItem들이 깜빡이는 것을 볼 수 있습니다.
Profiler 탭 사용
  • Profiler 탭으로 이동하여 Record 버튼을 클릭합니다.
  • 몇 번 글로벌 카운터 증가 버튼을 클릭합니다.
  • Stop 버튼을 클릭하여 프로파일링을 종료합니다.
  • 결과 분석
    • 왼쪽 상단의 커밋 목록에서 막대들의 높이와 색깔을 확인합니다. 이전보다 훨씬 짧고 초록색인 막대들이 보일 것입니다.
    • FlamegraphRanked 차트에서 ProductListProductItem이 차지하는 비중이 현저히 줄어들거나, 아예 나타나지 않을 것입니다 (부모만 렌더링된 커밋의 경우).
    • ProductList를 클릭한 후 Component Chart에서 Why did this render?를 확인하면, 이제 filterTerm이 변경될 때만 렌더링된다고 표시될 것입니다.
    • ProductItem을 클릭하면 productonItemClick 프롭스가 변경될 때만 렌더링된다고 표시될 것입니다. (대부분의 경우 product 데이터가 바뀌거나 필터링으로 인해 재배열될 때만 렌더링됩니다.)

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


React.lazy + Suspense 실전 적용 기준

리렌더링 최적화와 별개로, 초기 번들 크기 자체가 큰 경우에는 코드 스플리팅이 필요합니다. 이때 React.lazySuspense를 사용해 당장 필요 없는 화면을 지연 로드할 수 있습니다.

src/App.tsx (라우트 단위 코드 스플리팅 예시)
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장)에서는 프로젝트 배포와 학습 로드맵을 중심으로 실무 적용 범위를 확장하겠습니다.

목차