← 목록으로 돌아가기

React Error Boundary와 Suspense 실전 패턴: 에러·로딩 상태를 선언적으로 다루는 컴포넌트 설계

React

React Error Boundary Suspense fallback declarative component patterns

에러는 피하는 것이 아니라 설계하는 것이다

React 애플리케이션을 운영하다 보면 반드시 마주치는 순간이 있습니다. 서드파티 API가 갑자기 응답을 거부하고, 예상치 못한 형태의 데이터가 렌더 함수 안으로 흘러들어 오고, 네트워크가 불안정한 모바일 환경에서 Suspense로 감싼 컴포넌트가 영영 해소되지 않는 상황입니다. 우리 팀이 MAU 30만 규모의 여행 예약 플랫폼을 운영하면서 가장 뼈아프게 배운 것은 바로 이것이었습니다. 에러는 try/catch로 모두 잡아낼 수 있다는 낙관, 그리고 로딩 상태는 isLoading 불리언 하나로 충분하다는 착각이 프로덕션 장애의 온상이 된다는 사실입니다.

React는 렌더 트리 안에서 발생하는 예외를 try/catch로 잡을 수 없습니다. 이 문제에 대한 React 팀의 해법이 Error Boundary이고, 데이터 비동기 로딩의 선언적 처리 해법이 Suspense입니다.


1. Error Boundary가 클래스 컴포넌트로 남은 이유

2026년 현재 React 생태계의 압도적 주류는 함수형 컴포넌트입니다. 하지만 Error Boundary는 단 하나의 예외로 남아 있습니다.

React 공식 문서는 이 이유를 명확하게 설명합니다. Error Boundary를 구현하려면 getDerivedStateFromError 또는 componentDidCatch를 정의해야 하는데, 이 두 메서드는 클래스 컴포넌트의 생명주기 API에만 존재합니다.

React 팀이 함수형 Error Boundary를 제공하지 않는 이유는 구현 철학과 맞닿아 있습니다. 렌더 단계에서 던져진 예외를 포착하려면 React 내부의 재귀적 렌더 루프가 특정 컴포넌트 경계에서 예외를 가로채는 메커니즘이 필요합니다. 실무에서는 react-error-boundary 라이브러리가 이 번거로움을 추상화해 줍니다.


2. getDerivedStateFromError와 componentDidCatch 동작

getDerivedStateFromError는 정적 메서드로, 렌더 단계에서 자식 트리가 예외를 던졌을 때 호출됩니다. 이 메서드는 새 state를 반환해야 하며, 그 state를 바탕으로 fallback UI를 렌더링합니다. 사이드 이펙트(로깅, API 호출 등)를 일으켜서는 안 됩니다.

componentDidCatch는 커밋 단계 이후에 호출됩니다. 사이드 이펙트를 실행하기 적합한 위치이므로 에러 리포팅 서비스 호출은 여기서 이루어집니다.

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

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

interface ErrorBoundaryProps {
  fallback: (props: { error: Error; reset: () => void }) => ReactNode;
  onError?: (error: Error, info: ErrorInfo) => void;
  children: ReactNode;
}

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

  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, info: ErrorInfo) {
    this.props.onError?.(error, info);
  }

  handleReset = () => {
    this.setState({ hasError: false, error: null });
  };

  render() {
    if (this.state.hasError && this.state.error) {
      return this.props.fallback({
        error: this.state.error,
        reset: this.handleReset,
      });
    }
    return this.props.children;
  }
}

export default ErrorBoundary;

Error Boundary는 이벤트 핸들러 안에서 발생한 에러를 잡지 못합니다. onClick, onChange 같은 이벤트 핸들러는 React 렌더 사이클 바깥에서 실행되므로, 그 안의 오류는 try/catch로 직접 처리하거나 useErrorBoundary 훅으로 위임해야 합니다.


3. react-error-boundary 라이브러리 활용

react-error-boundary는 Error Boundary의 모범 구현을 훅과 함께 제공합니다.

'use client';

import { ErrorBoundary, useErrorBoundary } from 'react-error-boundary';
import { Suspense, useState } from 'react';

function ApiErrorFallback({ error, resetErrorBoundary }: {
  error: Error;
  resetErrorBoundary: () => void;
}) {
  return (
    <div role="alert" className="error-container">
      <p className="error-title">데이터를 불러오는 데 실패했습니다.</p>
      <p className="error-detail">{error.message}</p>
      <button onClick={resetErrorBoundary} className="retry-btn">
        다시 시도
      </button>
    </div>
  );
}

function DataFetcher({ resourceId }: { resourceId: string }) {
  const { showBoundary } = useErrorBoundary();

  const handleFetch = async () => {
    try {
      const res = await fetch(`/api/resources/${resourceId}`);
      if (!res.ok) throw new Error(`HTTP ${res.status}: 리소스 로딩 실패`);
    } catch (err) {
      showBoundary(err);
    }
  };

  return <button onClick={handleFetch}>데이터 로드</button>;
}

export default function ResourcePage() {
  const [resourceId, setResourceId] = useState('item-001');

  return (
    <ErrorBoundary
      FallbackComponent={ApiErrorFallback}
      resetKeys={[resourceId]}
      onError={(error, info) => {
        console.error('[ErrorBoundary]', error, info.componentStack);
      }}
    >
      <Suspense fallback={<div>로딩 중...</div>}>
        <DataFetcher resourceId={resourceId} />
      </Suspense>
    </ErrorBoundary>
  );
}

useErrorBoundaryshowBoundary 함수는 이벤트 핸들러 내부의 에러를 가장 가까운 Error Boundary로 전파하는 다리 역할을 합니다.


4. Suspense와 함께 쓰는 fallback 계층 설계

Suspense와 Error Boundary는 서로 다른 문제를 선언적으로 해결합니다. Suspense는 "아직 준비되지 않은 상태"를, Error Boundary는 "실패한 상태"를 처리합니다.

배치 전략장점단점적합한 상황
최상위 단일 경계구현 단순부분 실패가 전체 UI를 대체MVP, 단순 CRUD 앱
피처 단위 경계모듈 격리각 피처마다 fallback UI 필요대시보드, 위젯 기반
데이터 패칭 단위 경계가장 세밀한 복구코드 복잡도 급증독립적 데이터 소스

Error Boundary와 Suspense의 중첩 순서도 중요합니다. <ErrorBoundary> 안에 <Suspense>를 배치하면 Suspense가 해소되지 못하고 에러를 던진 경우 Error Boundary가 처리합니다. 일반적으로는 <ErrorBoundary>가 바깥에 오는 구조가 올바릅니다.


5. RSC 환경에서의 에러 처리 차이

React Server Components 환경에서 에러 처리는 클라이언트 환경과 근본적으로 다른 점이 있습니다. 서버 컴포넌트는 서버에서 실행되는 async 함수이기 때문에, 클라이언트 사이드의 Error Boundary가 서버 컴포넌트 안에서 발생한 예외를 직접 포착할 수 없습니다.

서버 컴포넌트에서 fetch가 실패하거나 DB 쿼리가 예외를 던졌을 때, React는 해당 컴포넌트의 렌더링을 실패로 표시하고 그 실패 신호를 클라이언트로 스트리밍합니다.

서버 컴포넌트에서 발생한 에러는 프로덕션 환경에서 클라이언트로 상세 메시지가 전달되지 않습니다. 보안상의 이유로 React가 에러 메시지를 비워버리기 때문에, 에러 리포팅은 반드시 서버 측에서(예: Next.js의 instrumentation.ts) 처리해야 합니다.


6. 에러 리포팅 통합(Sentry, OpenTelemetry)

import * as Sentry from '@sentry/nextjs';
import { ErrorBoundary } from 'react-error-boundary';

function logErrorToSentry(error: Error, info: ErrorInfo) {
  Sentry.withScope((scope) => {
    scope.setContext('componentStack', {
      stack: info.componentStack,
    });
    scope.setLevel('fatal');
    Sentry.captureException(error);
  });
}

export function AppErrorBoundary({ featureName, children }: {
  featureName: string;
  children: ReactNode;
}) {
  return (
    <ErrorBoundary
      FallbackComponent={({ error, resetErrorBoundary }) => (
        <div role="alert" className="p-4 bg-red-50 rounded-lg">
          <h2 className="font-semibold text-red-800">{featureName} 로딩 실패</h2>
          <p className="text-sm text-red-600 mt-1">{error.message}</p>
          <button onClick={resetErrorBoundary}>다시 시도</button>
        </div>
      )}
      onError={(error, info) => {
        logErrorToSentry(error, info);
      }}
    >
      {children}
    </ErrorBoundary>
  );
}

featureName prop을 통해 어떤 기능 영역에서 에러가 발생했는지 Sentry 이벤트에 명확하게 태깅합니다.


7. reset 전략과 사용자 UX 설계

에러의 성격에 따라 reset 전략을 달리해야 합니다. 일시적 네트워크 오류(503, 504)는 자동 재시도 또는 수동 재시도 버튼이 유효합니다. 인증 만료(401)는 로그인 페이지 리다이렉트가 올바른 복구입니다.

function TabErrorFallback({ error, resetErrorBoundary }) {
  const isAuthError = error.message.includes('401') || error.message.includes('Unauthorized');

  if (isAuthError) {
    return (
      <div role="alert" className="p-4">
        <p>세션이 만료되었습니다.</p>
        <a href="/login" className="btn-primary">로그인 페이지로</a>
      </div>
    );
  }

  return (
    <div role="alert" className="p-4">
      <p>탭 콘텐츠를 불러오지 못했습니다: {error.message}</p>
      <button onClick={resetErrorBoundary}>다시 시도</button>
    </div>
  );
}

export function TabbedDashboard() {
  const [activeTab, setActiveTab] = useState<TabId>('overview');

  return (
    <div>
      <nav>
        {(['overview', 'analytics', 'settings'] as TabId[]).map((tab) => (
          <button key={tab} onClick={() => setActiveTab(tab)}>{tab}</button>
        ))}
      </nav>

      <ErrorBoundary FallbackComponent={TabErrorFallback} resetKeys={[activeTab]}>
        <Suspense fallback={<div className="skeleton-loader">로딩 중...</div>}>
          <TabContent tabId={activeTab} />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

8. 부분 실패 허용 컴포넌트 트리

서버 컴포넌트에서는 try/catch로 에러를 흡수하고 기본 UI를 반환하는 방식이 가장 단순합니다.

async function RecommendationWidget() {
  let recommendations: Recommendation[] = [];

  try {
    recommendations = await fetchRecommendations({ limit: 5, timeout: 3000 });
  } catch (error) {
    console.error('[RecommendationWidget] fetch failed:', error);
  }

  if (recommendations.length === 0) {
    return (
      <aside className="widget widget--empty">
        <p>추천 콘텐츠를 준비 중입니다.</p>
      </aside>
    );
  }

  return (
    <aside className="widget">
      <h3>추천 항목</h3>
      <ul>
        {recommendations.map((item) => (
          <li key={item.id}>{item.title}</li>
        ))}
      </ul>
    </aside>
  );
}

export default async function DashboardPage() {
  return (
    <main className="dashboard-layout">
      <ErrorBoundary fallback={<CoreContentError />}>
        <Suspense fallback={<CoreContentSkeleton />}>
          <CoreContent />
        </Suspense>
      </ErrorBoundary>

      <Suspense fallback={<WidgetSkeleton />}>
        <RecommendationWidget />
      </Suspense>
    </main>
  );
}

9. 테스트 전략: React Testing Library + ErrorBoundary

import { render, screen, fireEvent } from '@testing-library/react';
import { ErrorBoundary } from 'react-error-boundary';

function ThrowOnRender({ shouldThrow }: { shouldThrow: boolean }) {
  if (shouldThrow) throw new Error('테스트용 렌더 에러');
  return <div>정상 렌더링</div>;
}

describe('ErrorBoundary', () => {
  let consoleError: jest.SpyInstance;

  beforeEach(() => {
    consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
  });

  afterEach(() => {
    consoleError.mockRestore();
  });

  it('자식 컴포넌트가 에러를 던지면 fallback UI를 표시한다', () => {
    render(
      <ErrorBoundary FallbackComponent={({ error }) => <p role="alert">{error.message}</p>}>
        <ThrowOnRender shouldThrow={true} />
      </ErrorBoundary>
    );

    expect(screen.getByRole('alert')).toBeInTheDocument();
    expect(screen.getByText(/테스트용 렌더 에러/)).toBeInTheDocument();
  });
});

10. Next.js App Router의 error.tsx와의 관계

Next.js App Router는 파일 시스템 기반의 에러 처리를 제공합니다. 라우트 세그먼트 폴더 안에 error.tsx를 두면, 해당 세그먼트와 그 자식 세그먼트에서 발생하는 에러를 자동으로 처리하는 Error Boundary가 생성됩니다.

'use client';

import { useEffect } from 'react';
import * as Sentry from '@sentry/nextjs';

export default function DashboardError({ error, reset }: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    Sentry.captureException(error, {
      extra: { digest: error.digest },
    });
  }, [error]);

  return (
    <section className="error-page">
      <h1>대시보드를 불러오지 못했습니다</h1>
      <p>오류 코드: {error.digest ?? '알 수 없음'}</p>
      <div className="actions">
        <button onClick={reset}>다시 시도</button>
        <a href="/">홈으로 이동</a>
      </div>
    </section>
  );
}

error.tsx는 라우트 세그먼트 전체의 에러를 처리합니다. 반면 직접 배치한 ErrorBoundary는 컴포넌트 단위의 에러를 처리합니다.

실무에서는 error.tsx로 페이지 레벨 안전망을 구성하고, 컴포넌트 레벨에서는 react-error-boundary로 세밀한 에러 경계를 추가로 설정하는 이중 방어선 전략이 권장됩니다. React의 useTransition과 Concurrent 렌더링과 결합하면, 페이지 전환 중 에러가 발생하더라도 이전 페이지 UI를 유지하면서 에러를 처리하는 더욱 매끄러운 패턴을 구성할 수 있습니다.


결론

  • 에러 경계 이중화: error.tsx로 라우트 레벨 안전망을, react-error-boundary로 컴포넌트 레벨 세밀한 경계를 이중으로 구성한다.
  • reset 전략 명시: 모든 Error Boundary에는 재시도 버튼 또는 resetKeys 기반 자동 초기화 전략을 반드시 정의한다.
  • 에러 리포팅 서버/클라이언트 분리: 클라이언트 에러는 onError 콜백에서, 서버 컴포넌트 에러는 instrumentation.ts에서 별도로 리포팅한다.
  • 부분 실패 허용 범위 결정: 핵심 기능은 Error Boundary로 사용자에게 명확히 알리고, 부가 기능은 서버에서 에러를 흡수해 빈 상태로 렌더링하는 전략을 합의한다.
  • Error Boundary 테스트 필수화: 의도적으로 에러를 던지는 테스트 컴포넌트로 Error Boundary의 fallback UI, 재시도 흐름, onError 콜백 호출을 모두 테스트한다.