React 실무 정리: Hooks 심화, Fiber 아키텍처, Suspense·에러 처리, Context, 성능 최적화

이 글의 핵심

React는 UI를 상태의 함수로 보고, 상태가 바뀌면 Fiber가 작업을 쪼개 비교한 뒤 필요한 DOM만 바꿉니다. 이 구조를 알면 Context 값 하나 때문에 트리 전체가 다시 렌더링되는 이유나 React 17에서 18로 올릴 때 배칭 동작이 달라지는 이유가 보이고, 메모이제이션과 리스트 가상화를 언제 적용할지도 판단할 수 있습니다.

React 소개

React는 Meta(구 Facebook)에서 개발한 선언적 UI 라이브러리로, 2013년 오픈소스로 공개된 이후 프론트엔드 개발의 사실상 표준이 되었습니다. React의 핵심 철학은 “UI는 상태(state)의 함수”라는 개념으로, 데이터가 변경되면 UI가 자동으로 업데이트되는 선언적 패러다임을 제공합니다.

React의 핵심 특징

컴포넌트 기반 아키텍처
React는 UI를 독립적이고 재사용 가능한 컴포넌트로 분리합니다. 각 컴포넌트는 자체 상태와 로직을 가지며, 이를 조합하여 복잡한 사용자 인터페이스를 구축할 수 있습니다.

가상 DOM (Virtual DOM)
React는 실제 DOM의 가벼운 복사본인 가상 DOM을 메모리에 유지합니다. 상태가 변경되면 가상 DOM에서 먼저 변경사항을 계산하고, 실제 DOM에는 최소한의 변경만 적용하여 성능을 최적화합니다.

단방향 데이터 흐름
데이터는 부모 컴포넌트에서 자식 컴포넌트로 props를 통해 전달됩니다. 이러한 단방향 흐름은 데이터의 출처를 명확하게 하고 디버깅을 용이하게 만듭니다.

React가 해결하는 문제

전통적인 DOM 조작 방식에서는 상태 변화에 따라 어떤 DOM 요소를 어떻게 업데이트할지 명령형으로 작성해야 했습니다. 이는 코드의 복잡도를 높이고 버그 발생 가능성을 증가시켰습니다. React는 선언적 접근 방식을 통해 “무엇을 렌더링할지”만 정의하면, “어떻게 렌더링할지”는 React가 최적화하여 처리합니다.

개발 환경 설정

Vite를 사용한 React 프로젝트 생성

Vite는 빠른 개발 서버와 최적화된 빌드를 제공하는 차세대 프론트엔드 빌드 도구입니다. TypeScript를 기본으로 지원하며, React 개발에 이상적입니다.

# Vite를 사용하여 새 React TypeScript 프로젝트 생성
npm create vite@latest my-app -- --template react-ts

# 프로젝트 디렉토리로 이동
cd my-app

# 의존성 설치
npm install

# 개발 서버 실행 (기본 포트 5173)
npm run dev

React 18+ 진입점 설정

React 18에서는 createRoot API를 사용하여 동시성 기능을 활성화합니다. StrictMode는 개발 환경에서만 활성화되어 잠재적인 문제를 조기에 발견하는 데 도움을 줍니다.

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';

// React 18의 createRoot를 사용하여 동시성 기능 활성화
const root = createRoot(document.getElementById('root')!);

root.render(
  <StrictMode>
    <App />
  </StrictMode>
);

StrictMode의 역할

  • 안전하지 않은 생명주기 메서드 경고
  • 레거시 문자열 ref 사용 경고
  • 예상치 못한 부작용 감지를 위한 이중 렌더링 (개발 환경에서만)
  • 더 이상 사용되지 않는 API 사용 경고

주의: StrictMode는 프로덕션 빌드에서 제거되므로 성능에 영향을 주지 않습니다.

React Hooks 심화

useState와 useEffect의 실무 패턴

React Hooks는 함수 컴포넌트에서 상태와 생명주기 기능을 사용할 수 있게 해주는 핵심 API입니다.

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

// 사용자 데이터 타입 정의
type User = { 
  id: string; 
  name: string;
  email: string;
};

export function UserPanel({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  const [reloadKey, setReloadKey] = useState(0); // Retry 시 effect를 다시 실행하기 위한 값

  // useEffect를 사용한 데이터 페칭과 정리(cleanup)
  useEffect(() => {
    // 경쟁 조건(race condition) 방지를 위한 플래그
    let cancelled = false;
    
    const fetchUser = async () => {
      setLoading(true);
      setError(null);
      
      try {
        const res = await fetch(`/api/users/${userId}`);
        
        if (!res.ok) {
          throw new Error(`HTTP ${res.status}: ${res.statusText}`);
        }
        
        const data: User = await res.json();
        
        // 컴포넌트가 언마운트되었거나 userId가 변경된 경우
        // 오래된 응답으로 상태를 업데이트하지 않음
        if (!cancelled) {
          setUser(data);
        }
      } catch (err) {
        if (!cancelled) {
          setError(err instanceof Error ? err : new Error('Unknown error'));
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    };
    
    fetchUser();
    
    // cleanup 함수: 컴포넌트 언마운트 또는 userId 변경 시 실행
    return () => {
      cancelled = true;
    };
  }, [userId, reloadKey]); // userId 변경 또는 Retry 시 effect 재실행

  // 문법 예시일 뿐, 이 정도 계산은 실제로는 useMemo 없이 써도 됨
  const title = useMemo(() => {
    if (loading) return 'Loading user…';
    if (error) return 'Error loading user';
    return user ? `Hello, ${user.name}` : 'No user';
  }, [user, loading, error]);

  // 함수 참조를 안정적으로 유지 (자식 컴포넌트 리렌더 방지)
  const onRetry = useCallback(() => {
    setReloadKey((k) => k + 1);
  }, []);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message} <button onClick={onRetry}>Retry</button></div>;
  if (!user) return <div>No user found</div>;

  return (
    <section aria-label={title}>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </section>
  );
}

useMemo와 useCallback 사용 지침

useMemo를 사용해야 하는 경우:

  1. 계산 비용이 높은 연산 결과를 캐싱할 때
  2. 자식 컴포넌트에 참조 동일성이 필요한 객체/배열을 전달할 때
  3. 의존성 배열이 자주 변경되지 않을 때

useCallback을 사용해야 하는 경우:

  1. 자식 컴포넌트가 React.memo로 감싸져 있고, props로 함수를 받을 때
  2. 함수가 다른 Hook의 의존성 배열에 포함될 때
  3. 이벤트 리스너를 등록/해제할 때 함수 참조가 안정적이어야 할 때

주의사항: 무분별한 메모이제이션은 오히려 성능을 저하시킬 수 있습니다. React DevTools Profiler로 측정 후 필요한 곳에만 적용하세요.

위 UserPanel에서 cancelled 플래그가 하는 일을 짚어 둘 필요가 있습니다. userId가 1에서 2로 빠르게 바뀌면 두 요청이 동시에 날아가고, 네트워크 상황에 따라 1번 응답이 2번보다 늦게 도착할 수 있습니다. 플래그가 없으면 늦게 온 1번 사용자 정보가 화면을 덮어써, 주소창은 2번인데 화면은 1번인 버그가 됩니다. cleanup에서 플래그를 세우면 이전 effect의 응답은 무시됩니다. 다만 이 방식은 요청 자체를 취소하지는 않으므로, 대역폭까지 아끼려면 AbortController를 만들어 fetch에 signal로 넘기고 cleanup에서 abort()를 호출합니다. 이때 취소된 요청은 AbortError로 reject되므로 catch에서 이 에러를 사용자 에러로 표시하지 않도록 걸러야 합니다.

개발 모드에서 이 컴포넌트를 처음 띄우면 네트워크 탭에 같은 요청이 두 번 찍히는 것을 보게 됩니다. React 18의 StrictMode가 마운트 직후 effect를 한 번 정리했다가 다시 실행해 cleanup 누락을 드러내려는 의도적인 동작이며, 프로덕션 빌드에서는 한 번만 실행됩니다. 이 동작을 없애려고 StrictMode를 끄거나 useRef로 “이미 실행했음” 플래그를 두는 경우가 많은데, 그렇게 하면 정작 cleanup 버그를 숨기게 됩니다. 요청이 두 번 나가도 결과가 올바르게 나오도록 effect를 짜는 것이 StrictMode가 요구하는 기준입니다.

React Fiber 아키텍처 이해

Fiber란 무엇인가?

Fiber는 React 16에서 도입된 새로운 조정(reconciliation) 엔진의 내부 구현입니다. Fiber의 주요 목표는 렌더링 작업을 작은 단위로 분할하여 우선순위를 부여하고, 필요시 중단·재개할 수 있도록 만드는 것입니다.

Fiber의 핵심 개념

작업 단위 (Unit of Work)
Fiber는 컴포넌트 트리의 각 노드를 연결 리스트로 표현합니다. 각 Fiber 노드는 다음 정보를 포함합니다:

  • child: 첫 번째 자식 노드
  • sibling: 다음 형제 노드
  • return: 부모 노드

이중 버퍼링 (Double Buffering)
React는 두 개의 Fiber 트리를 유지합니다:

  • current: 현재 화면에 렌더링된 트리
  • workInProgress: 다음 렌더링을 준비 중인 트리

작업이 완료되면 workInProgress가 current가 되며, 이를 통해 화면 깜박임 없이 업데이트를 적용할 수 있습니다.

렌더 단계와 커밋 단계

렌더 단계 (Render Phase)

  • 중단 가능하며 여러 번 실행될 수 있음
  • 부수 효과(side effects)가 없어야 함
  • 가상 DOM 비교 및 변경사항 계산
  • 비동기적으로 처리 가능

커밋 단계 (Commit Phase)

  • 중단 불가능하며 동기적으로 실행
  • 실제 DOM 업데이트
  • useLayoutEffect 실행
  • useEffect 스케줄링

동시성 렌더링 (Concurrent Rendering)

React 18에서 도입된 동시성 기능은 Fiber 아키텍처를 기반으로 합니다. 동시성 렌더링을 사용하면:

  1. 긴급한 업데이트와 전환 업데이트를 구분할 수 있음
  2. 렌더링 중에도 사용자 입력에 응답 가능
  3. CPU 집약적인 작업을 청크 단위로 분할하여 메인 스레드 블로킹 방지
import { useState, useTransition, useDeferredValue } from 'react';

export function SearchResults() {
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();
  const [results, setResults] = useState<string[]>([]);

  const handleSearch = (value: string) => {
    // 입력값 업데이트는 즉시 (긴급 업데이트)
    setQuery(value);

    // 검색 결과 업데이트는 낮은 우선순위로 처리 (전환 업데이트)
    startTransition(() => {
      // CPU 집약적인 필터링 작업
      const filtered = expensiveFilterOperation(value);
      setResults(filtered);
    });
  };

  return (
    <div>
      <input 
        value={query} 
        onChange={(e) => handleSearch(e.target.value)}
        placeholder="Search..."
      />
      {isPending && <div>Updating results...</div>}
      <ResultsList results={results} />
    </div>
  );
}

이 예제에는 흔히 오해하는 지점이 있습니다. startTransition에 넘긴 함수는 즉시, 동기적으로 실행됩니다. 그래서 expensiveFilterOperation(value) 계산 자체는 키 입력마다 메인 스레드를 그대로 붙잡고, 낮은 우선순위로 처리되는 것은 그 뒤에 일어나는 setResults로 인한 렌더링뿐입니다. 필터링 계산이 무겁다면 계산을 렌더 단계로 옮겨야 중단 가능한 작업이 됩니다. 아래 useDeferredValue 예제처럼 입력값만 상태로 두고, 결과는 지연된 값으로부터 렌더 중에 계산하는 구조가 그래서 더 자주 권장됩니다. 계산이 수백 밀리초 단위로 무겁다면 동시성 렌더링으로도 부족하므로 Web Worker로 옮기는 것을 검토해야 합니다.

useDeferredValue 활용:

export function FilteredList({ items }: { items: string[] }) {
  const [filter, setFilter] = useState('');
  
  // filter 값의 업데이트를 지연시켜 렌더링 우선순위를 낮춤
  const deferredFilter = useDeferredValue(filter);

  // deferredFilter를 사용하여 필터링
  const filtered = useMemo(
    () => items.filter(item => item.includes(deferredFilter)),
    [items, deferredFilter]
  );

  return (
    <div>
      <input 
        value={filter} 
        onChange={(e) => setFilter(e.target.value)}
      />
      {/* 필터링 중에도 입력은 즉각 반응 */}
      <ul>
        {filtered.map(item => <li key={item}>{item}</li>)}
      </ul>
    </div>
  );
}

자동 배칭과 flushSync

React 18에서 동작이 조용히 바뀐 부분이 자동 배칭(automatic batching) 입니다. React 17은 이벤트 핸들러 안의 setState만 묶어서 한 번 렌더링하고, setTimeout이나 fetch().then() 안에서 연달아 호출한 setState는 호출마다 따로 렌더링했습니다. createRoot로 마운트한 React 18은 어디서 호출하든 같은 틱의 업데이트를 한 번으로 묶습니다. 대부분은 렌더링 횟수가 줄어 이득이지만, “상태를 바꾼 직후 DOM을 읽어 스크롤 위치를 계산하는” 식의 코드는 아직 DOM이 갱신되지 않아 틀린 값을 읽게 됩니다. 이런 곳만 flushSync로 감싸 즉시 반영시키면 됩니다.

import { flushSync } from 'react-dom';

fetch('/api/items').then((items) => {
  setItems(items);      // React 18: 아래와 함께 한 번만 렌더링
  setLoading(false);
});

function addMessage(msg) {
  flushSync(() => setMessages((m) => [...m, msg]));  // DOM 즉시 갱신
  listRef.current.scrollTop = listRef.current.scrollHeight;
}

위의 useTransition을 처음 보면 “그냥 setTimeout으로 늦추면 되지 않나” 싶은데, 둘은 다릅니다. setTimeout은 일정 시간 뒤에 업데이트를 시작할 뿐이고 일단 시작된 무거운 렌더링은 끝까지 메인 스레드를 붙잡습니다. startTransition으로 표시한 업데이트는 렌더링 도중에도 더 급한 입력이 들어오면 React가 중단하고 입력부터 처리한 뒤 다시 시작하므로, 타이핑이 끊기지 않습니다. 기다릴 시간을 추측할 필요도 없습니다.

React 17에서 18로 올릴 때

패키지 버전만 올리면 기존 ReactDOM.render는 경고와 함께 React 17 방식으로 계속 동작하고(React 19에서는 ReactDOM.render 자체가 제거되어 에러가 납니다), 위에서 다룬 동시성 기능과 자동 배칭은 켜지지 않습니다. 진입점을 createRoot(SSR이라면 hydrateRoot)로 바꾸는 순간부터 18의 동작이 적용되므로, 그때 StrictMode에서 이펙트가 두 번 실행되며 드러나는 정리(cleanup) 누락이나 위의 배칭 변화로 인한 문제를 함께 점검하는 편이 좋습니다. TypeScript를 쓴다면 @types/react 18부터 React.FC가 children을 암묵적으로 포함하지 않아, children을 받는 컴포넌트는 props 타입에 children?: ReactNode를 직접 적어야 컴파일됩니다.

// Before (17)
ReactDOM.render(<App />, document.getElementById('root'));
// After (18)
createRoot(document.getElementById('root')!).render(<App />);
// SSR
hydrateRoot(document.getElementById('root')!, <App />);

// @types/react 18: children은 명시적으로
type CardProps = { title: string; children?: React.ReactNode };

에러 처리와 Suspense

Error Boundary 구현

Error Boundary는 자식 컴포넌트 트리에서 발생한 JavaScript 오류를 포착하고 대체 UI를 표시하는 컴포넌트입니다.

import { Component, ReactNode, ErrorInfo } from 'react';

interface Props {
  children: ReactNode;
  fallback?: ReactNode;
}

interface State {
  hasError: boolean;
  error: Error | null;
}

export class ErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error: Error): State {
    // 다음 렌더링에서 대체 UI를 표시하도록 상태 업데이트
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    // 에러 로깅 서비스에 에러 정보 전송
    console.error('Error caught by boundary:', error, errorInfo);
    // 예: Sentry.captureException(error, { extra: errorInfo });
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || (
        <div role="alert">
          <h2>Something went wrong</h2>
          <details>
            <summary>Error details</summary>
            <pre>{this.state.error?.message}</pre>
          </details>
          <button onClick={() => this.setState({ hasError: false, error: null })}>
            Try again
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

Error Boundary의 제한사항:

  • 이벤트 핸들러 내부의 에러는 포착하지 않음 (try-catch 사용 필요)
  • 비동기 코드 내부의 에러는 포착하지 않음
  • 서버 사이드 렌더링 중 발생한 에러는 포착하지 않음
  • Error Boundary 자체에서 발생한 에러는 포착하지 않음

Error Boundary는 아직도 클래스 컴포넌트로만 만들 수 있어서, 함수 컴포넌트 위주의 코드베이스에서는 react-error-boundary 라이브러리를 쓰는 경우가 많습니다. 이 라이브러리는 resetKeys로 특정 값이 바뀌면 에러 상태를 자동으로 초기화하는 기능을 제공합니다. 위 예제의 “Try again” 버튼처럼 상태만 되돌리면, 에러의 원인(잘못된 데이터 등)이 그대로라 곧바로 같은 에러가 다시 나는 경우가 많기 때문에, 재시도할 때 캐시된 데이터를 함께 무효화하는 설계가 필요합니다. 비동기 에러를 경계로 올리고 싶다면 이벤트 핸들러에서 잡은 에러를 state에 저장한 뒤 렌더 중에 throw하는 방법을 씁니다.

Suspense를 사용한 로딩 상태 관리

Suspense는 컴포넌트가 렌더링되기 전에 “기다려야” 할 때 대체 UI를 표시합니다.

import { Suspense, lazy } from 'react';

// 코드 분할을 위한 lazy loading
const HeavyComponent = lazy(() => import('./HeavyComponent'));

export function App() {
  return (
    <div>
      <h1>My App</h1>
      
      {/* 컴포넌트 로딩 중 fallback UI 표시 */}
      <Suspense fallback={<div>Loading component...</div>}>
        <HeavyComponent />
      </Suspense>
      
      {/* 중첩된 Suspense 경계 */}
      <Suspense fallback={<div>Loading data...</div>}>
        <DataFetchingComponent />
      </Suspense>
    </div>
  );
}

Suspense와 데이터 페칭:

React Query나 SWR 같은 라이브러리는 Suspense 모드를 지원합니다.

import { Suspense } from 'react';
import { useSuspenseQuery } from '@tanstack/react-query';

function UserProfile({ userId }: { userId: string }) {
  // TanStack Query v5: suspense 옵션 대신 useSuspenseQuery 사용
  const { data } = useSuspenseQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  // data는 항상 정의되어 있음 (로딩 중에는 Suspense가 처리)
  return <div>{data.name}</div>;
}

export function App() {
  return (
    <ErrorBoundary>
      <Suspense fallback={<div>Loading user...</div>}>
        <UserProfile userId="123" />
      </Suspense>
    </ErrorBoundary>
  );
}

Context API와 전역 상태 관리

Context의 적절한 사용

Context는 컴포넌트 트리 전체에 데이터를 전달하는 메커니즘을 제공합니다. Props drilling을 피하고 싶을 때 유용하지만, 남용하면 성능 문제를 일으킬 수 있습니다.

import { createContext, useContext, useState, ReactNode } from 'react';

// 테마 타입 정의
type Theme = 'light' | 'dark';

interface ThemeContextType {
  theme: Theme;
  toggleTheme: () => void;
}

// Context 생성 (기본값 제공)
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

// Provider 컴포넌트
export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>('light');

  const toggleTheme = () => {
    setTheme(prev => prev === 'light' ? 'dark' : 'light');
  };

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

// Custom Hook으로 Context 사용 간소화
export function useTheme() {
  const context = useContext(ThemeContext);
  
  if (context === undefined) {
    throw new Error('useTheme must be used within ThemeProvider');
  }
  
  return context;
}

// 사용 예시
function Button() {
  const { theme, toggleTheme } = useTheme();
  
  return (
    <button 
      onClick={toggleTheme}
      style={{ 
        background: theme === 'light' ? '#fff' : '#000',
        color: theme === 'light' ? '#000' : '#fff'
      }}
    >
      Toggle Theme
    </button>
  );
}

Context 최적화 전략

Context 값이 변경되면 모든 Consumer가 리렌더링됩니다. 이를 최적화하려면:

1. Context 분리:

// 자주 변경되는 상태와 그렇지 않은 상태를 분리
const ThemeContext = createContext<Theme>('light');
const ThemeUpdaterContext = createContext<() => void>(() => {});

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>('light');
  // useCallback이 없으면 매 렌더마다 새 함수가 되어 Updater 구독자도 리렌더됨
  const toggleTheme = useCallback(() => setTheme(prev => prev === 'light' ? 'dark' : 'light'), []);

  return (
    <ThemeContext.Provider value={theme}>
      <ThemeUpdaterContext.Provider value={toggleTheme}>
        {children}
      </ThemeUpdaterContext.Provider>
    </ThemeContext.Provider>
  );
}

Context를 둘로 나누는 이유는 Context에 “일부 필드만 구독”하는 기능이 없기 때문입니다. { theme, toggleTheme } 객체 하나를 넘기면, 토글 버튼처럼 함수만 쓰는 컴포넌트도 theme이 바뀔 때마다 다시 렌더됩니다. 값과 갱신 함수를 분리하면 버튼은 갱신 함수 Context만 구독하므로 테마가 바뀌어도 리렌더되지 않습니다. 단, 위처럼 갱신 함수의 참조가 고정되어 있어야 이 분리가 효과를 냅니다.

2. useMemo로 Context 값 메모이제이션:

export function DataProvider({ children }: { children: ReactNode }) {
  const [data, setData] = useState<Data>({});
  
  // Context 값을 메모이제이션하여 불필요한 리렌더링 방지
  const contextValue = useMemo(
    () => ({ data, setData }),
    [data]
  );

  return (
    <DataContext.Provider value={contextValue}>
      {children}
    </DataContext.Provider>
  );
}

성능 최적화 실무 패턴

React.memo를 사용한 컴포넌트 메모이제이션

import { memo } from 'react';

interface ListItemProps {
  id: string;
  title: string;
  onClick: (id: string) => void;
}

// props가 변경되지 않으면 리렌더링하지 않음
export const ListItem = memo(function ListItem({ id, title, onClick }: ListItemProps) {
  console.log('ListItem rendered:', id);
  
  return (
    <li onClick={() => onClick(id)}>
      {title}
    </li>
  );
});

// 커스텀 비교 함수 사용
export const ComplexListItem = memo(
  function ComplexListItem({ item }: { item: ComplexItem }) {
    return <div>{item.name}</div>;
  },
  // 커스텀 비교 함수: true를 반환하면 리렌더링 skip
  (prevProps, nextProps) => {
    return prevProps.item.id === nextProps.item.id &&
           prevProps.item.version === nextProps.item.version;
  }
);

가상화(Virtualization)를 통한 큰 리스트 최적화

수천 개 이상의 항목을 렌더링할 때는 가상화를 사용하여 뷰포트에 보이는 항목만 렌더링합니다.

import { FixedSizeList } from 'react-window';

interface Item {
  id: string;
  name: string;
}

export function VirtualizedList({ items }: { items: Item[] }) {
  const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
    <div style={style}>
      {items[index].name}
    </div>
  );

  return (
    <FixedSizeList
      height={600}          // 리스트 전체 높이
      itemCount={items.length}
      itemSize={50}         // 각 항목의 높이
      width="100%"
    >
      {Row}
    </FixedSizeList>
  );
}

코드 분할(Code Splitting)

번들 크기를 줄이고 초기 로딩 시간을 개선하기 위해 코드 분할을 사용합니다.

import { lazy, Suspense } from 'react';

// 동적 import로 코드 분할
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));

export function App() {
  return (
    <Suspense fallback={<div>Loading page...</div>}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  );
}

실무 트러블슈팅 가이드

일반적인 성능 문제와 해결 방법

문제 1: 과도한 리렌더링

원인: 상태가 변경될 때마다 전체 컴포넌트 트리가 리렌더링됨

해결책:

  • React DevTools Profiler로 리렌더링 원인 파악
  • React.memo, useMemo, useCallback 적절히 사용
  • 상태를 컴포넌트 트리의 적절한 위치에 배치
  • Context를 분리하여 불필요한 리렌더링 방지

문제 2: 메모리 누수

원인: useEffect의 cleanup 함수를 제대로 구현하지 않음

해결책:

useEffect(() => {
  const subscription = someAPI.subscribe(data => {
    setData(data);
  });

  // cleanup 함수로 구독 해제
  return () => {
    subscription.unsubscribe();
  };
}, []);

문제 3: 무한 렌더링 루프

원인: useEffect의 의존성 배열에 매 렌더링마다 새로 생성되는 값 포함

해결책:

// 잘못된 예
useEffect(() => {
  fetchData(params);
}, [params]); // params가 객체인 경우 매번 새로운 참조

// 올바른 예: 객체 대신 실제로 쓰는 원시 값에 의존
const { id, filter } = params;
useEffect(() => {
  fetchData({ id, filter });
}, [id, filter]);

effect 안에서 setState를 호출하고 그 state가 다시 의존성에 들어가 있으면, 무한 루프가 되면서 React가 Maximum update depth exceeded 경고를 냅니다. 이 메시지를 보면 의존성 배열에 “렌더마다 새로 만들어지는 객체·배열·함수”가 들어 있는지, effect가 자기 자신의 의존성을 갱신하고 있는지부터 확인하면 됩니다. 의존성을 억지로 빼서 경고를 끄면 오래된 값을 참조하는 스테일 클로저 버그가 생기므로, 원시 값으로 의존성을 좁히거나 함수형 업데이트(setX(prev => ...))로 state를 읽지 않게 바꾸는 것이 올바른 방향입니다.

디버깅 도구 활용

React DevTools

  • Components 탭: 컴포넌트 트리와 props/state 검사
  • Profiler 탭: 렌더링 성능 측정 및 병목 지점 파악

브라우저 Performance 탭

  • 실제 렌더링 시간과 스크립트 실행 시간 측정
  • 긴 작업(Long Tasks) 식별

같이 보면 좋은 글