본문으로 건너뛰기

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

React34분 읽기본문 16,949

핵심 요약

이 글에서 확인할 내용

에러는 피하는 것이 아니라 설계하는 것이다 React 애플리케이션을 운영하다 보면 반드시 마주치는 순간이 있습니다. 서드파티 API가 갑자기 응답을 거부하고, 예상치 못한 형태의 데이터가 렌더 함수 안으로 흘러들어 오고, 네트워크가 불안정한 모바일 환경에서 Suspense로 감싼 컴포넌트가 영영 해소되지 않는 상황입니다.

  • 1. Error Boundary가 클래스 컴포넌트로 남은 이유
  • 2. getDerivedStateFromError와 componentDidCatch 동작
  • 3. react-error-boundary 라이브러리 활용

React Error Boundary Suspense fallback declarative component patterns

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

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

우리가 컴포넌트를 설계할 때 무의식적으로 가정하는 것은 "데이터가 이미 여기 있다"는 성공 경로 하나뿐입니다. 그러나 실제 화면이 살아가는 시간의 대부분은 성공이 아니라 그 주변에 있습니다. 데이터가 아직 도착하지 않은 대기(pending) 상태, 요청이 무너진 실패(error) 상태, 결과가 비어 있는 빈(empty) 상태가 그것입니다. 명령형 방식에서는 이 세 가지 상태를 컴포넌트마다 isLoading, error, data.length === 0 같은 지역 변수로 일일이 분기합니다. 컴포넌트가 늘어날수록 같은 분기가 복제되고, 상태 조합은 기하급수적으로 폭발하며, 정작 정말로 처리해야 할 실패 경로는 아무도 테스트하지 않는 사각지대로 남습니다.

Error Boundary와 Suspense가 제안하는 발상의 전환은 이 지점에 있습니다. "실패했을 때 무엇을 보여줄지"와 "아직 준비되지 않았을 때 무엇을 보여줄지"를 각 컴포넌트가 개별적으로 짊어지는 대신, 트리 위쪽의 경계(boundary) 에 한 번 선언해 두고 하위 컴포넌트는 오직 성공 경로만 기술하게 만드는 것입니다. 실패와 대기는 예외적 사건이 아니라 UI가 반드시 렌더링해야 할 정식 상태이며, 그것을 컴포넌트 로직에서 분리해 트리 구조로 위임하는 것이 선언적 에러·로딩 설계의 핵심입니다. 이렇게 상태를 트리로 끌어올리면 컴포넌트 본체는 놀랄 만큼 단순해지고, 전역 상태 관리 계층이 로딩·에러 플래그로 오염되는 일도 줄어듭니다. 상태를 어디에 둘 것인지에 대한 관점은 모던 React 상태 관리 생태계 분석에서 다룬 원칙과도 곧장 맞닿아 있습니다.

React는 렌더 트리 안에서 발생하는 예외를 try/catch로 잡을 수 없습니다. 이 문제에 대한 React 팀의 해법이 Error Boundary이고, 데이터 비동기 로딩의 선언적 처리 해법이 Suspense입니다. 이 글에서는 두 메커니즘의 내부 동작, 포착 범위와 한계, 조합 방법, 그리고 스트리밍 SSR과 fallback UX까지 실무 운영 관점에서 차례로 파고듭니다.


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

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

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

React 팀이 함수형 Error Boundary를 제공하지 않는 이유는 구현 철학과 맞닿아 있습니다. 렌더 단계에서 던져진 예외를 포착하려면 React 내부의 재귀적 렌더 루프가 특정 컴포넌트 경계에서 예외를 가로채는 메커니즘이 필요합니다. 이 가로채기는 훅처럼 함수 호출 순서에 의존하는 방식으로는 표현하기 어렵습니다. 훅은 정상적으로 렌더가 완료되는 흐름을 전제로 설계된 반면, 에러 포착은 그 렌더 자체가 도중에 무너지는 상황을 다뤄야 하기 때문입니다. React 19에 이르러서도 이 구조는 바뀌지 않았고, 앞으로도 클래스 기반으로 유지될 가능성이 높습니다. 실무에서는 이 클래스 보일러플레이트를 매번 작성하지 않도록 react-error-boundary 라이브러리가 표준 구현을 훅과 함께 추상화해 줍니다.


2. getDerivedStateFromError와 componentDidCatch 동작

React의 렌더링은 크게 두 국면으로 나뉩니다. 순수 계산으로 다음 UI를 결정하는 렌더 단계(render phase) 와, 그 결과를 실제 DOM에 반영하고 부수 효과를 실행하는 커밋 단계(commit phase) 입니다. Error Boundary의 두 메서드는 정확히 이 두 국면에 하나씩 대응합니다.

getDerivedStateFromError는 정적 메서드로, 렌더 단계에서 자식 트리가 예외를 던졌을 때 호출됩니다. 이 메서드는 새 state를 반환해야 하며, 그 state를 바탕으로 fallback UI를 렌더링합니다. 렌더 단계에서 실행되므로 반드시 순수해야 하고, 사이드 이펙트(로깅, API 호출, 라우팅 등)를 일으켜서는 안 됩니다. React가 이 단계의 작업을 중단하거나 다시 실행할 수 있기 때문에, 여기서 부수 효과를 내면 중복 호출되거나 화면과 어긋난 로그가 남습니다.

componentDidCatch는 커밋 단계 이후에 호출됩니다. 이미 화면 반영이 끝난 뒤이므로 사이드 이펙트를 실행하기에 안전한 위치이며, 에러 리포팅 서비스 호출이나 로컬 로깅은 바로 여기서 이루어집니다. 이 메서드는 두 번째 인자로 componentStack을 담은 정보 객체를 받는데, 이 스택은 자바스크립트 기본 error.stack과 달리 "어떤 컴포넌트 트리 경로에서 터졌는가"를 알려 주므로 원인 추적에 결정적입니다.

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, onSubmit 같은 핸들러는 React의 렌더 사이클 바깥, 브라우저 이벤트 루프가 호출하는 별도의 콜 스택에서 실행됩니다. React가 렌더를 try/catch로 감싸 두었더라도 그 스택은 이미 빠져나온 뒤이므로 예외를 가로챌 방법이 없습니다. 따라서 핸들러 안의 오류는 직접 try/catch로 처리하거나, 아래에서 소개할 useErrorBoundaryshowBoundary로 가장 가까운 경계에 명시적으로 위임해야 합니다.

둘째, 비동기 코드입니다. setTimeout 콜백, Promise.then 이후, await 지점을 넘어간 코드는 모두 원래의 렌더 스택을 떠난 뒤 실행됩니다. 이 예외들은 Error Boundary가 아니라 브라우저의 window.onerrorunhandledrejection으로 흘러갑니다. "데이터 패칭이 실패했는데 fallback이 안 뜬다"는 신고의 대부분이 여기서 비롯됩니다. 비동기 실패를 선언적 경계로 흘려보내려면 그 실패를 다시 렌더 단계의 예외로 변환해 주는 다리가 필요하며, 그 역할을 하는 것이 바로 Suspense(대기)와 showBoundary(실패)입니다.

셋째, 서버 사이드 렌더링 중의 에러와 넷째, Error Boundary 자기 자신이 던진 에러입니다. 후자의 경우 예외는 더 상위의 Error Boundary로 전파됩니다. 그래서 fallback UI는 절대 무거운 로직을 담아서는 안 됩니다. 실패를 보여주려던 화면이 다시 실패하면 사용자는 상위 경계의 더 투박한 화면으로 떨어지고, 최악의 경우 어떤 경계도 잡지 못해 트리 전체가 언마운트되어 백지 화면만 남습니다.

React 19에서는 루트 레벨에서 에러 흐름을 관측하는 옵션이 추가되어 이 그림을 보완합니다. createRoothydrateRoot에 넘길 수 있는 onCaughtError는 Error Boundary가 잡은 에러를, onUncaughtError는 어떤 경계도 잡지 못한 에러를, onRecoverableError는 하이드레이션 불일치처럼 React가 자동으로 복구한 에러를 각각 콜백으로 흘려보냅니다. 경계별 onError가 지역 리포팅이라면, 이 루트 옵션들은 앱 전체의 에러를 한곳으로 모으는 마지막 그물망 역할을 합니다.


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

react-error-boundary는 Error Boundary의 모범 구현을 훅과 함께 제공합니다. 직접 클래스를 작성할 때 빠뜨리기 쉬운 resetKeys, onReset, 그리고 이벤트·비동기 에러를 경계로 밀어 넣는 useErrorBoundary까지 표준화되어 있어, 대부분의 프로젝트에서 굳이 바퀴를 다시 발명할 이유가 없습니다.

'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로 전파하는 다리 역할을 합니다. 내부적으로는 넘겨받은 에러를 다음 렌더에서 다시 던지도록 예약해, 비동기·이벤트 예외를 React가 포착 가능한 렌더 단계 예외로 되돌려 놓는 방식입니다. 덕분에 데이터 패칭 라이브러리를 직접 쓰지 않는 수동 fetch 코드에서도 실패를 동일한 경계로 일관되게 모을 수 있습니다.

fallback을 지정하는 방법은 세 가지입니다. 정적 요소를 넘기는 fallback, 렌더 함수를 넘기는 fallbackRender, 그리고 컴포넌트를 넘기는 FallbackComponent입니다. 셋 중 FallbackComponent가 가장 재사용성이 높은데, errorresetErrorBoundary를 props로 표준화해 받기 때문입니다. 여기서 resetKeys는 특히 중요합니다. 배열에 담긴 값 중 하나라도 이전 렌더와 달라지면 경계가 자동으로 초기화되어, 사용자가 "다시 시도" 버튼을 누르지 않아도 입력이 바뀌는 순간 스스로 회복을 시도합니다. 다만 resetKeys를 매 렌더마다 새로 생성되는 객체나 배열로 채우면 무한 초기화 루프에 빠지므로, 원시 값이나 안정적인 참조만 넣어야 합니다.


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

Suspense와 Error Boundary는 서로 다른 문제를 선언적으로 해결합니다. Suspense는 "아직 준비되지 않은 상태"를, Error Boundary는 "실패한 상태"를 처리합니다. 둘을 함께 두면 성공·대기·실패라는 세 가지 상태가 하나의 트리 안에서 모두 선언적으로 표현됩니다.

Suspense의 동작 원리는 의외로 단순합니다. 어떤 컴포넌트가 렌더 도중 "나는 아직 준비되지 않았다"는 신호로 Promise를 던지면, React는 그 예외를 위로 전파하다가 가장 가까운 <Suspense> 경계에서 멈추고 fallback을 대신 렌더링합니다. 그 Promise가 해소되면 React가 자동으로 해당 서브트리를 다시 렌더링해 실제 내용으로 교체합니다. React 19에서는 use() 훅이 이 규약을 공식화해, use(promise) 한 줄로 컴포넌트를 대기 상태로 진입시킬 수 있습니다. 개발자가 throw promise를 직접 다룰 일은 거의 없고, 데이터 패칭 라이브러리나 프레임워크가 이 규약 위에서 로딩을 처리합니다.

여기서 중요한 통찰은, Suspense가 던지는 것은 결국 렌더 단계의 신호라는 점입니다. 앞서 Error Boundary가 비동기 실패를 놓친다고 했는데, Suspense는 비동기 "대기"를 렌더 단계 신호로 번역해 주는 장치이고, 같은 원리로 데이터 라이브러리는 비동기 "실패"를 렌더 단계 예외로 번역해 Error Boundary가 잡게 만듭니다. 그래서 두 경계는 늘 짝을 이뤄 배치됩니다.

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

Error Boundary와 Suspense의 중첩 순서도 중요합니다. <ErrorBoundary> 안에 <Suspense>를 배치하면 Suspense가 해소되지 못하고 에러를 던진 경우 Error Boundary가 처리합니다. 일반적으로는 <ErrorBoundary>가 바깥에 오는 구조가 올바릅니다. 순서를 뒤집어 <Suspense>가 바깥에 오면, 데이터 로딩이 실패했을 때 Error Boundary가 그 실패를 잡더라도 상위 Suspense의 fallback과 하위 에러 fallback이 어색하게 겹치거나, 로딩 화면이 사라지지 않는 것처럼 보이는 UX 결함이 생깁니다. 원칙은 하나입니다. "실패는 대기를 감싸고, 대기는 실제 내용을 감싼다."

한편 대기 중 상태가 갱신될 때의 동작은 트랜지션과 결합해 크게 달라집니다. 이미 실제 내용을 보여주던 Suspense 경계에서, 사용자의 상호작용으로 새 데이터를 다시 불러와야 할 때 그대로 다시 fallback을 띄우면 화면이 껌뻑입니다. 이런 갱신을 startTransition으로 감싸면 React는 이전 내용을 화면에 유지한 채 뒤에서 새 내용을 준비하고, 준비가 끝나면 조용히 교체합니다. 이 세밀한 우선순위 제어는 useTransition과 Concurrent 렌더링에서 다루는 스케줄러 동작과 직접 연결됩니다.


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

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

서버 컴포넌트에서 fetch가 실패하거나 DB 쿼리가 예외를 던졌을 때, React는 해당 컴포넌트의 렌더링을 실패로 표시하고 그 실패 신호를 클라이언트로 스트리밍합니다. 클라이언트는 이 신호를 받아 가장 가까운 Error Boundary(App Router라면 error.tsx)의 fallback으로 그 자리를 채웁니다. 즉 실제 예외는 서버에서 발생하지만, 그것을 화면에 반영하는 경계는 클라이언트에 있는 셈입니다. 이 서버-클라이언트 경계를 넘나드는 직렬화의 원리는 RSC 아키텍처의 내부 동작에서 더 깊게 다룹니다.

서버 컴포넌트에서 발생한 에러는 프로덕션 환경에서 클라이언트로 상세 메시지가 전달되지 않습니다. 보안상의 이유로 React가 에러 메시지를 비워버리기 때문에, 에러 리포팅은 반드시 서버 측에서(예: Next.js의 instrumentation.ts) 처리해야 합니다. 대신 React는 각 서버 에러에 짧은 해시인 digest를 부여해 클라이언트로 전달합니다. 사용자에게는 이 digest만 노출하고, 같은 값을 서버 로그와 대조하면 상세 스택을 유출하지 않고도 특정 사용자의 특정 실패를 정확히 짚어낼 수 있습니다. 서버 에러의 원문 메시지를 그대로 화면에 뿌리려는 시도는 프로덕션에서 빈 문자열만 얻을 뿐 아니라, 개발 환경에서는 내부 경로나 쿼리가 노출되는 보안 사고로 이어질 수 있습니다.


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 이벤트에 명확하게 태깅합니다. 이렇게 경계마다 맥락을 붙여 두면, 대시보드에서 "결제 위젯에서만 특정 배포 이후 에러가 급증했다"는 식의 판단을 즉시 내릴 수 있습니다. 태깅이 없으면 모든 에러가 익명의 자바스크립트 예외 한 덩어리로 뭉쳐, 원인 영역을 좁히는 데만 반나절이 걸립니다.

리포팅을 실전에 올릴 때는 몇 가지 운영 원칙이 필요합니다. 첫째, 샘플링입니다. 인기 페이지에서 한 번의 배포 실수가 초당 수천 건의 동일 에러를 만들어 낼 수 있으므로, 동일 지문(fingerprint)의 에러는 묶고 전송량에는 상한을 둬야 리포팅 비용과 노이즈를 함께 통제할 수 있습니다. 둘째, 개인정보 스크러빙입니다. error.messagecomponentStack에 사용자 입력값, 토큰, 이메일이 섞여 들어가는 경우가 흔하므로 전송 전에 민감 정보를 마스킹해야 합니다. 셋째, componentStack의 활용입니다. 자바스크립트 기본 스택은 번들러가 뒤섞어 놓지만, React가 제공하는 컴포넌트 스택은 소스맵 없이도 트리 경로를 사람이 읽을 수 있게 보여 주므로, 두 스택을 함께 보내면 재현이 어려운 버그의 위치를 훨씬 빠르게 좁힙니다. OpenTelemetry를 쓴다면 이 에러 이벤트를 분산 트레이스의 span과 연결해, "어떤 요청이 어떤 dependency를 거치다 렌더에서 무너졌는가"를 한 화면에서 추적할 수 있습니다.


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

에러의 성격에 따라 reset 전략을 달리해야 합니다. 일시적 네트워크 오류(503, 504)는 자동 재시도 또는 수동 재시도 버튼이 유효합니다. 인증 만료(401)는 로그인 페이지 리다이렉트가 올바른 복구입니다. 반대로 400이나 422처럼 요청 자체가 잘못된 경우는 아무리 다시 시도해도 같은 결과이므로, "다시 시도" 버튼을 노출하는 것이 오히려 사용자를 기만하는 UX가 됩니다. 실패를 무조건 하나의 화면으로 뭉뚱그리지 말고, 상태 코드와 에러 종류에 따라 회복 경로를 분기해야 하는 이유입니다.

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>
  );
}

reset을 설계할 때 가장 경계해야 할 함정은 무한 재시도 루프입니다. "다시 시도"가 곧바로 같은 실패를 다시 유발하는 경우, 사용자는 껌뻑이는 화면 앞에서 무력해지고 서버는 회복 중인 dependency를 계속 두드려 장애를 증폭시킵니다. 그래서 자동 재시도에는 반드시 횟수 상한과 지수 백오프(exponential backoff), 그리고 매 시도 사이를 흩뜨리는 지터(jitter)를 곁들여야 합니다. 몇 차례 실패가 누적되면 자동 재시도를 멈추고 "잠시 후 다시 시도해 주세요" 같은 정적 안내로 전환해, 회복 불가능한 상황에서 사용자와 서버 모두를 쉬게 해 주는 것이 옳습니다. 재시도의 예의에 관한 더 일반적인 원칙은 API 회복탄력성 설계와 동일한 사고방식을 따릅니다.


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

핵심 콘텐츠와 부가 콘텐츠는 실패를 다루는 방식이 달라야 합니다. 결제나 예약 정보 같은 핵심은 실패를 사용자에게 분명히 알리고 재시도 경로를 제공해야 하지만, 추천 위젯이나 배너 같은 부가 요소는 조용히 사라지거나 빈 상태로 대체되는 편이 낫습니다. 부가 기능 하나의 실패가 화면 전체를 에러 페이지로 끌고 내려가는 것만큼 나쁜 UX도 드뭅니다. 서버 컴포넌트에서는 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>
  );
}

여기서 두 가지 실패 처리 방식이 공존한다는 점에 주목해야 합니다. 핵심 콘텐츠는 ErrorBoundary로 감싸 실패를 명시적으로 노출하고, 추천 위젯은 서버에서 try/catch로 실패를 흡수해 빈 상태로 조용히 렌더링합니다. 이 선택은 순전히 제품 판단의 문제입니다. "이 요소가 없으면 페이지가 무의미해지는가, 아니면 없어도 나머지가 충분히 유용한가"를 기준으로, 각 서브트리를 핵심부가로 분류하고 실패 전략을 명문화해 두는 것이 부분 실패 허용 설계의 출발점입니다. 이 합의가 없으면 개발자마다 제각각 판단해, 어떤 위젯은 전체를 무너뜨리고 어떤 위젯은 조용히 사라지는 일관성 없는 화면이 됩니다.


본문 중간 핵심 흐름을 시각화한 React 일러스트

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();
  });
});

Error Boundary 테스트에는 특유의 잔가지가 있습니다. React는 개발 모드에서 잡힌 에러를 그대로 console.error로도 출력하기 때문에, 테스트 러너의 로그가 붉은 에러로 뒤덮여 실제 실패와 구분하기 어려워집니다. 위 코드처럼 console.error를 스파이로 감싸 억제하되, 테스트가 끝나면 반드시 원복해 다른 테스트의 에러를 삼키지 않도록 해야 합니다. 검증 대상은 단순히 "fallback이 떴는가"에 그치면 안 됩니다. 재시도 버튼을 눌렀을 때 정상 화면으로 돌아오는 회복 흐름, resetKeys가 바뀌었을 때 경계가 스스로 초기화되는지, 그리고 onError 콜백이 정확한 에러 객체와 함께 호출되는지까지 함께 검증해야 리포팅이 조용히 끊기는 사고를 막을 수 있습니다. 한편 이벤트 핸들러나 비동기 실패는 렌더 예외가 아니므로, 그 테스트는 showBoundary 경로를 태워야 하며 단순히 컴포넌트를 렌더하는 것만으로는 재현되지 않는다는 점도 기억해야 합니다.


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같은 세그먼트의 layout.tsx에서 발생한 에러는 잡지 못합니다. 레이아웃은 에러 경계보다 바깥에 위치하기 때문입니다. 레이아웃까지 무너지는 최악의 상황이나 루트에서 발생하는 에러를 받아 내려면, 앱 최상단에 global-error.tsx를 두어 <html><body>째로 대체하는 최후의 안전망을 마련해야 합니다. 또한 error.tsx는 반드시 클라이언트 컴포넌트여야 하는데, 사용자가 누르는 reset 함수가 클라이언트 상호작용이기 때문입니다.

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


11. 스트리밍 SSR과 Suspense: 서버에서 UI를 흘려보낸다

Suspense가 진가를 발휘하는 무대는 클라이언트만이 아닙니다. React 18 이후의 서버 렌더링은 renderToPipeableStream(Node)과 renderToReadableStream(웹/엣지 런타임)을 통해 HTML을 한 번에 완성해 내려보내는 대신 조각으로 흘려보내는 스트리밍 방식을 지원합니다. 이 스트리밍의 이음매가 바로 Suspense 경계입니다.

동작은 이렇습니다. 서버는 느린 데이터에 의존하지 않는 페이지의 뼈대(shell)를 먼저 즉시 전송합니다. 데이터가 필요한 부분은 <Suspense>fallback(대개 스켈레톤)으로 채워 함께 내려보내고, 그 안의 실제 콘텐츠는 서버에서 데이터가 준비되는 즉시 후속 청크로 흘려보냅니다. 이 청크에는 준비된 HTML과, 앞서 심어 둔 fallback 자리를 실제 내용으로 바꿔치기하는 짧은 인라인 스크립트가 함께 담깁니다. 그 결과 전체 데이터가 준비되기를 기다리지 않고도 사용자는 첫 화면을 훨씬 빨리 보게 되어 TTFB와 체감 로딩이 크게 개선됩니다. 하나의 느린 API가 페이지 전체의 응답을 볼모로 잡던 워터폴이 끊기는 것입니다.

여기에 더해 React는 선택적 하이드레이션(selective hydration) 을 수행합니다. 스트리밍으로 도착한 각 Suspense 경계는 다른 경계와 독립적으로 하이드레이션되며, 사용자가 특정 영역을 클릭하면 React가 그 영역의 하이드레이션을 우선순위로 끌어올려 먼저 상호작용 가능하게 만듭니다. 전체 자바스크립트가 로드되고 하이드레이션이 끝날 때까지 화면이 통째로 멈춰 있던 과거의 SSR과 근본적으로 다른 지점입니다. 이 스트리밍과 부분 렌더링의 이점은 RSC 아키텍처Next.js 15의 서버 컴포넌트 진화를 관통하는 공통의 설계 철학이기도 합니다.

에러와 스트리밍이 만나는 지점에는 미묘한 함정이 있습니다. 셸이 아직 전송되기 전, 즉 응답 상태 코드가 확정되기 전에 에러가 나면 서버는 정상적으로 500 상태와 에러 페이지를 내려보낼 수 있습니다. 그러나 셸이 이미 200으로 전송된 뒤 스트리밍 도중에 에러가 나면, HTTP 상태 코드는 되돌릴 수 없습니다. 이때 React는 해당 Suspense 경계를 클라이언트에서 에러 처리하도록 신호를 보내고, App Router는 이를 가장 가까운 error.tsx로 연결합니다. 스트리밍 환경에서 "상태 코드는 200인데 화면 일부가 에러 fallback"인 상황이 정상적으로 발생할 수 있다는 사실을 관측 지표에 반영해 두지 않으면, 모니터링이 이 부분 실패를 놓치게 됩니다.


12. fallback UX와 레이아웃 시프트(CLS) 방지

fallback은 "로딩 중"을 알리는 임시 화면이지만, 잘못 만들면 그 자체가 사용자 경험을 해칩니다. 가장 흔한 문제가 레이아웃 시프트(Cumulative Layout Shift, CLS) 입니다. 화면 중앙에 작은 스피너 하나만 띄웠다가 실제 콘텐츠가 도착하는 순간 높이가 갑자기 늘어나면, 그 아래에 있던 요소들이 아래로 밀려 내려가며 사용자가 누르려던 버튼이 순식간에 위치를 바꿉니다. 오조작을 유발하고 Core Web Vitals 점수를 떨어뜨리는 전형적인 나쁜 패턴입니다.

이를 막는 원칙은 하나입니다. fallback은 실제 콘텐츠와 같은 공간을 미리 차지해야 합니다. 그래서 단순 스피너보다 최종 레이아웃의 형태를 본뜬 스켈레톤이 우월합니다. 스켈레톤에 min-height, aspect-ratio, 고정 너비를 부여해 실제 콘텐츠가 들어올 자리를 정확히 예약해 두면, 콘텐츠가 도착해도 주변 요소가 흔들리지 않습니다. 에러 fallback 역시 마찬가지 원칙을 따라야 합니다. 로딩 스켈레톤과 성공 화면은 크기를 맞췄는데 에러 화면만 유독 작으면, 실패 순간에 또 한 번의 레이아웃 시프트가 발생합니다.

두 번째 함정은 로딩 상태의 깜빡임(flash of loading state) 입니다. 데이터가 50~100밀리초 만에 도착하는 빠른 응답에서, 스켈레톤이 아주 잠깐 나타났다 사라지면 화면이 번쩍이며 오히려 더 느리고 불안정하게 느껴집니다. 완화책은 두 갈래입니다. 첫째, 스피너나 스켈레톤을 일정 지연 뒤에야 나타나게 해(예: CSS 애니메이션 지연) 빠른 응답에서는 아예 로딩 화면을 건너뛰게 만드는 방법입니다. 둘째, 이미 내용을 보여주던 화면을 갱신할 때는 startTransition이나 useDeferredValue로 이전 내용을 유지한 채 새 데이터를 준비해, fallback으로 되돌아가는 껌뻑임 자체를 없애는 방법입니다. React는 스트리밍으로 도착하는 Suspense 콘텐츠의 노출 속도를 내부적으로 약간 조절(throttle)해 여러 조각이 동시다발적으로 튀어나오며 화면을 어지럽히는 현상도 완화합니다.

마지막으로 접근성을 잊지 말아야 합니다. 로딩 영역에는 aria-busy나 적절한 라이브 리전 속성을 부여해 스크린 리더 사용자에게도 "지금 콘텐츠가 준비되는 중"임을 전달하고, 에러 fallback에는 원문 코드처럼 role="alert"를 붙여 실패가 즉시 안내되도록 해야 합니다. 화려한 스켈레톤보다 중요한 것은, 모든 사용자가 현재 화면이 어떤 상태인지 오해 없이 인지할 수 있게 만드는 일입니다.


13. 경계를 어디에 둘 것인가: 세분화와 전역의 균형

경계 배치는 결국 트레이드오프의 문제입니다. 경계를 최상위에 딱 하나만 두면 구현은 단순하지만, 화면 한구석의 사소한 실패가 페이지 전체를 에러 화면으로 대체합니다. 반대로 데이터를 부르는 컴포넌트마다 경계를 촘촘히 두면 부분 실패 격리는 완벽해지지만, 스켈레톤과 에러 UI가 파편처럼 흩어져 화면이 산만해지고 코드 복잡도가 치솟습니다. 정답은 양 극단이 아니라 그 사이 어딘가에 있으며, 그 위치는 제품의 정보 구조가 결정합니다.

실무에서 검증된 출발점은 의미 있는 UI 블록 단위로 경계를 나누는 것입니다. 대시보드라면 위젯 하나가, 상세 페이지라면 핵심 정보 영역과 리뷰 영역과 추천 영역이 각각 하나의 경계가 됩니다. 이렇게 나누면 각 블록은 독립적으로 로딩되고 독립적으로 실패하며, 사용자는 준비된 부분부터 즉시 소비할 수 있습니다. 이때 앞서 강조한 핵심·부가 분류가 다시 등장합니다. 핵심 블록의 실패는 명확한 에러와 재시도로, 부가 블록의 실패는 조용한 빈 상태로 처리하도록 경계마다 정책을 달리 부여하는 것입니다.

계층 구조로 보면 세 겹의 방어선이 자연스럽습니다. 가장 바깥에는 global-error.tsx로 앱 전체가 무너지는 최악을 받아 내고, 그 안쪽에는 라우트 세그먼트별 error.tsx로 페이지 단위 안전망을 두며, 가장 안쪽에는 UI 블록 단위의 react-error-boundary로 세밀한 격리를 둡니다. 안쪽 경계가 먼저 실패를 흡수하고, 그것이 감당하지 못하는 실패만 바깥으로 전파되는 구조입니다. 이 계층이 명확하면 "이 실패는 어느 경계가 잡아야 하는가"라는 질문에 팀 전체가 같은 답을 내놓을 수 있습니다.


14. 언제 Error Boundary와 Suspense를 쓰지 말아야 하는가

강력한 도구일수록 오용의 여지도 큽니다. Error Boundary는 예상 가능한 흐름 제어에 쓰는 것이 아닙니다. 폼 검증 실패, 빈 검색 결과, 권한 없는 사용자에게 보이는 안내처럼 정상적인 사용자 흐름의 일부인 상태를 에러로 던져 경계에서 잡는 것은 잘못된 설계입니다. 이런 상태는 예외가 아니라 데이터이므로, 값으로 반환하고 조건부 렌더링으로 다루는 편이 훨씬 명료하고 테스트하기 쉽습니다. 예외 메커니즘을 흐름 제어로 전용하면 코드의 의도가 흐려지고, 진짜 예외적 실패가 흔한 상태 사이에 묻혀 버립니다.

Suspense 역시 만능이 아닙니다. Suspense는 데이터 라이브러리나 프레임워크가 그 규약을 지원할 때 비로소 동작하며, 임의의 비동기 함수를 감싸면 저절로 로딩이 처리되는 마법이 아닙니다. 규약을 지원하지 않는 낡은 코드에 Suspense를 씌우려다 캐시를 잘못 다루면, 매 렌더마다 새 Promise를 던져 영원히 fallback에서 벗어나지 못하는 무한 로딩에 빠지기 쉽습니다. 또한 아주 빠르게 끝나는 데이터나 이미 캐시된 데이터에까지 Suspense 경계를 두르는 것은 앞서 본 깜빡임만 유발할 뿐 실익이 없습니다. 이벤트 핸들러 안에서 잠깐 도는 로컬 로딩 상태는 예전처럼 useState 불리언으로 다루는 편이 오히려 단순하고 정확합니다. 요컨대 선언적 경계는 "여러 컴포넌트가 공유하는 대기·실패 상태를 트리 수준으로 끌어올릴 가치가 있을 때" 빛나는 도구이지, 모든 로딩과 조건 분기를 대체하려는 순간 과잉 설계가 됩니다.


15. 자주 묻는 질문

Q. 왜 데이터 패칭 실패가 Error Boundary에 안 잡히나요?
fetch 자체는 비동기이고, await 이후의 코드는 렌더 스택을 떠난 뒤 실행되기 때문입니다. Error Boundary는 렌더 단계에서 동기적으로 던져진 예외만 잡습니다. 실패를 경계로 보내려면 데이터 라이브러리가 그 실패를 렌더 예외로 다시 던지게 하거나, 이벤트 핸들러라면 showBoundary로 명시적으로 위임해야 합니다.

Q. 하나의 경계로 Error와 Suspense를 동시에 처리할 수 있나요?
아니요. 둘은 다른 상태를 다루는 별개의 경계입니다. 다만 <ErrorBoundary><Suspense>를 감싸면 실패와 대기를 한 지점에서 함께 선언할 수 있고, 이것이 표준 패턴입니다. 순서는 "실패가 대기를 감싼다"를 지켜야 합니다.

Q. 재시도 버튼을 눌렀는데 같은 에러가 반복됩니다.
에러의 원인이 일시적이지 않은데 무한 재시도를 허용했기 때문입니다. 상태 코드로 재시도 가능 여부를 먼저 분기하고, 자동 재시도에는 횟수 상한과 백오프를 두며, 회복 불가능하다고 판단되면 재시도 버튼 대신 정적 안내로 전환해야 합니다.

Q. 개발 모드에서는 fallback 대신 에러 오버레이가 뜹니다.
Next.js 개발 서버와 React 개발 모드는 디버깅을 돕기 위해 잡힌 에러도 오버레이로 함께 보여 줍니다. Error Boundary가 동작하지 않는 것이 아니며, 오버레이를 닫으면 아래에 fallback이 정상적으로 렌더링되어 있습니다. 최종 동작은 프로덕션 빌드로 확인해야 합니다.


정리

  • 에러 경계 이중화: error.tsx로 라우트 레벨 안전망을, react-error-boundary로 컴포넌트 레벨 세밀한 경계를 이중으로 구성하고, 최악에 대비해 global-error.tsx까지 세 겹의 방어선을 둔다.
  • 포착 범위를 정확히 안다: Error Boundary는 렌더·생명주기·생성자의 동기 예외만 잡는다. 이벤트 핸들러와 비동기 실패는 try/catchshowBoundary로, 대기는 Suspense로 렌더 단계 신호로 번역해 경계에 흘려보낸다.
  • reset 전략 명시: 모든 Error Boundary에는 재시도 버튼 또는 resetKeys 기반 자동 초기화 전략을 정의하되, 무한 재시도를 막는 백오프와 상한을 함께 둔다.
  • 에러 리포팅 서버/클라이언트 분리: 클라이언트 에러는 onError 콜백에서, 서버 컴포넌트 에러는 instrumentation.ts에서 digest를 매개로 리포팅하고, 샘플링과 개인정보 스크러빙을 기본으로 건다.
  • 부분 실패 허용 범위 결정: 각 서브트리를 핵심과 부가로 분류해, 핵심은 명확한 에러로 알리고 부가는 서버에서 흡수해 빈 상태로 렌더링하는 전략을 팀 차원에서 합의한다.
  • fallback은 공간을 예약한다: 스켈레톤에 크기를 부여해 레이아웃 시프트를 막고, 빠른 응답의 깜빡임은 지연 노출과 트랜지션으로 완화하며, 접근성 속성으로 상태를 모두에게 전달한다.
  • 스트리밍을 활용한다: Suspense 경계를 스트리밍 SSR의 이음매로 삼아 셸을 먼저 내려보내고, 선택적 하이드레이션으로 상호작용 우선순위를 끌어올려 체감 성능을 개선한다.
  • Error Boundary 테스트 필수화: 의도적으로 에러를 던지는 테스트 컴포넌트로 fallback UI, 재시도 흐름, resetKeys 초기화, onError 콜백 호출을 모두 검증한다.