고급

React 강좌 12강 — 성능 최적화: React.memo, useMemo, useCallback

🎯 학습 목표

  • React 컴포넌트가 리렌더링되는 세 가지 조건을 이해한다
  • React.memo, useMemo, useCallback을 언제 쓰는지 정확히 구분한다
  • React DevTools Profiler로 성능 병목을 찾는 방법을 익힌다
  • 코드 스플리팅(lazy + Suspense)으로 초기 로드를 최적화한다
  • 가상화(virtualization)가 필요한 상황을 안다

📖 핵심 개념 1 — 리렌더링 조건

최적화 전에 React가 언제 리렌더링하는지 정확히 알아야 합니다.

// 리렌더링 발생 조건 3가지:
// 1. 컴포넌트 자신의 state가 변경될 때
// 2. 부모 컴포넌트가 리렌더링될 때 (자식도 자동으로 리렌더링)
// 3. Context 값이 변경될 때 (해당 Context를 구독하는 컴포넌트)

function Parent() {
  const [count, setCount] = useState(0);
  console.log('Parent 렌더링');

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
      {/* Parent가 리렌더링될 때마다 Child도 리렌더링됨 */}
      <Child name="고정된 이름" />
    </div>
  );
}

function Child({ name }) {
  console.log('Child 렌더링'); // Parent 리렌더링마다 출력됨
  return <p>{name}</p>;
}

대부분의 경우 리렌더링은 매우 빠르고 문제가 없습니다. 최적화는 실제 성능 문제가 확인된 후에 적용하세요. 패널에서 “측정 없이 최적화하는 것은 조기 최적화(Premature Optimization)이며 오히려 코드를 복잡하게 만든다”고 강조했습니다.

📖 핵심 개념 2 — React.memo

import { memo, useState } from 'react';

// React.memo: props가 변경되지 않으면 리렌더링 건너뜀
const ExpensiveChild = memo(function ExpensiveChild({ data, onSelect }) {
  console.log('ExpensiveChild 렌더링');
  // 복잡한 계산이나 큰 리스트 렌더링
  return (
    <ul>
      {data.map(item => (
        <li key={item.id} onClick={() => onSelect(item)}>{item.name}</li>
      ))}
    </ul>
  );
});

function Parent() {
  const [count, setCount] = useState(0);
  const [items] = useState([/* 대량 데이터 */]);

  // ⚠️ 문제: 렌더링마다 새 함수 참조 → memo가 항상 실패
  // const handleSelect = (item) => console.log(item); // 매번 새 참조

  // ✅ useCallback으로 함수 참조 유지
  const handleSelect = useCallback((item) => {
    console.log(item);
  }, []); // 의존성 없음 → 항상 같은 참조

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
      {/* count 변경 시 Parent는 리렌더링되지만 ExpensiveChild는 건너뜀 */}
      <ExpensiveChild data={items} onSelect={handleSelect} />
    </div>
  );
}

React.memo가 오히려 해로운 경우

// ❌ memo가 불필요하거나 해로운 경우
const SimpleText = memo(function SimpleText({ text }) {
  return <p>{text}</p>; // 너무 단순한 컴포넌트 — memo 비용이 더 큼
});

// Props로 객체/배열/함수를 받는데 useCallback/useMemo 없이 사용
const AlwaysRerender = memo(function AlwaysRerender({ config }) {
  return <div>{config.title}</div>;
});

function Parent() {
  return (
    // 렌더링마다 새 객체 → memo가 항상 실패 (memo 비용만 추가)
    <AlwaysRerender config={{ title: '제목' }} />
  );
}

📖 핵심 개념 3 — useMemo vs useCallback

import { useMemo, useCallback, useState } from 'react';

function SearchResults({ products, searchQuery, onSelect }) {
  // useMemo: 값(계산 결과)을 메모이제이션
  // searchQuery나 products가 바뀔 때만 재계산
  const filteredProducts = useMemo(() => {
    console.log('필터링 계산 중...'); // 실제로 비용이 큰 작업일 때만 사용
    return products.filter(p =>
      p.name.toLowerCase().includes(searchQuery.toLowerCase())
    );
  }, [products, searchQuery]);

  // useMemo: 파생 값 계산
  const totalPrice = useMemo(() =>
    filteredProducts.reduce((sum, p) => sum + p.price, 0),
    [filteredProducts]
  );

  // useCallback: 함수 참조를 메모이제이션
  // memo로 감싼 자식 컴포넌트에 전달할 때 유용
  const handleSelect = useCallback((productId) => {
    onSelect(productId);
    // onSelect가 변하면 이 함수도 재생성되어야 함
  }, [onSelect]);

  return (
    <div>
      <p>총 {filteredProducts.length}개 / 합계 {totalPrice.toLocaleString()}원</p>
      {filteredProducts.map(p => (
        <ProductCard key={p.id} product={p} onSelect={handleSelect} />
      ))}
    </div>
  );
}

// 핵심 차이:
// useMemo(() => computeValue(), [deps])  → 값을 반환
// useCallback(() => doSomething(), [deps]) → 함수를 반환
// useCallback(fn, deps) === useMemo(() => fn, deps)

📖 핵심 개념 4 — React DevTools Profiler

성능 최적화의 첫 단계는 측정입니다. React DevTools의 Profiler 탭을 사용합니다.

  1. Chrome 확장에서 React DevTools 설치
  2. 개발 모드에서 앱 실행 후 DevTools 열기
  3. Profiler 탭 선택 → 녹화 버튼(⏺) 클릭
  4. 느린 동작 수행
  5. 녹화 중지 → Flamegraph 분석
// Flamegraph 읽는 법:
// - 회색: 렌더링되지 않음 (memo로 최적화됨)
// - 색깔 있음: 렌더링됨
// - 색이 진할수록 렌더링 시간이 김
// - 막대 너비: 렌더링 시간 (넓을수록 느림)

// Profiler로 발견할 수 있는 문제:
// 1. 불필요한 리렌더링 (부모 리렌더링 시 자식도 항상 재렌더링)
// 2. 특정 컴포넌트의 렌더링 시간이 비정상적으로 긴 경우
// 3. State 변경 시 예상보다 많은 컴포넌트가 리렌더링되는 경우

📖 핵심 개념 5 — 코드 스플리팅과 가상화

// 코드 스플리팅 — 초기 번들 크기 줄이기
import { lazy, Suspense } from 'react';

// 각 페이지를 필요할 때 로드 (동적 import)
const HeavyDashboard = lazy(() => import('./HeavyDashboard'));
const ReportPage = lazy(() => import('./ReportPage'));

function App() {
  return (
    <Suspense fallback={<div className="loading-spinner">로딩 중...</div>}>
      <Routes>
        <Route path="/dashboard" element={<HeavyDashboard />} />
        <Route path="/reports" element={<ReportPage />} />
      </Routes>
    </Suspense>
  );
}
// 가상화 — 대량 리스트 렌더링 최적화
// npm install react-window

import { FixedSizeList } from 'react-window';

function VirtualizedList({ items }) {
  // 10,000개 항목도 화면에 보이는 것만 렌더링
  const Row = ({ index, style }) => (
    <div style={style}>  {/* style은 react-window가 제공하는 위치/크기 */}
      {items[index].name}
    </div>
  );

  return (
    <FixedSizeList
      height={500}       // 리스트 컨테이너 높이
      itemCount={items.length}
      itemSize={50}      // 각 항목 높이
      width="100%"
    >
      {Row}
    </FixedSizeList>
  );
}

// 가상화가 필요한 기준: 100개 이상의 항목이 한 화면에 렌더링될 때

⚠️ 흔한 실수 (よくあるミス)

  • 측정 없이 최적화: Profiler로 실제 병목을 확인하기 전에 memo/useMemo/useCallback을 남발하면 코드만 복잡해집니다.
  • 모든 함수에 useCallback: useCallback 자체도 비용이 있습니다. memo로 감싼 자식에 전달하지 않는 함수에는 useCallback이 불필요합니다.
  • useMemo의 빈 의존성 배열: useMemo(() => compute(value), [])에서 value가 변해도 재계산 안 됨 — 의존성을 정확히 넣어야 합니다.
  • 개발 모드에서 성능 측정: React 개발 모드는 프로덕션보다 훨씬 느립니다. 성능 측정은 반드시 npm run build 후 프로덕션 빌드로 해야 합니다.

💡 실무 팁

  • 최적화 순서: 알고리즘 복잡도 개선 → 코드 스플리팅 → 가상화 → memo/useMemo/useCallback 순으로 접근하세요. 대부분 앞의 방법으로 해결됩니다.
  • Lighthouse 활용: Chrome DevTools의 Lighthouse 탭으로 Core Web Vitals(LCP, FID, CLS)를 측정하면 실제 사용자 경험에 영향을 주는 성능 문제를 찾을 수 있습니다.
  • 이미지 최적화: React 앱에서 이미지는 가장 큰 성능 병목 중 하나입니다. lazy loading(loading="lazy"), WebP 포맷, 적절한 크기 조절을 적용하세요.
  • 번들 분석: npm run buildvite-bundle-visualizer로 번들 크기를 분석하면 불필요하게 큰 라이브러리를 발견할 수 있습니다.