2025-09-22 23:37

  • React Suspense(서스펜스)는 비동기 데이터 로딩을 선언적으로 관리하여 로딩 상태 UI를 쉽게 구현하게 해주는 기능이다.
  • Error Boundary(에러 바운더리)는 자식 컴포넌트 트리에서 발생하는 자바스크립트 에러를 포착하여 전체 앱의 중단을 막고 대체 UI를 보여주는 컴포넌트다.
  • 이 두 가지를 함께 사용하면 데이터 로딩 중 발생할 수 있는 에러까지 우아하게 처리하여 견고하고 사용자 친화적인 React 애플리케이션을 만들 수 있다.

React Suspense와 Error Boundary 완벽 정복 핸드북

오늘날 웹 애플리케이션은 사용자에게 끊김 없는 경험을 제공하기 위해 수많은 비동기 데이터 통신에 의존한다. 하지만 이러한 비동기 로직은 애플리케이션의 복잡도를 높이고, 로딩 상태와 에러 처리를 까다롭게 만드는 주범이 되기도 한다. React는 이러한 문제를 해결하기 위해 Suspense와 Error Boundary라는 강력한 두 가지 도구를 제공한다.

이 핸드북에서는 비동기 시대의 필수품, React Suspense와 Error Boundary의 탄생 배경부터 구조, 사용법, 그리고 함께 활용하여 사용자 경험을 극대화하는 심화 전략까지 모든 것을 상세히 다룬다. 단순히 API 사용법을 나열하는 것을 넘어, ‘왜’ 이 기능들이 필요하게 되었는지 근본적인 이유를 파고들어 React의 철학을 더 깊이 이해하도록 돕는다.

1. 만들어진 이유 React의 비동기 처리와 에러 핸들링의 역사

Suspense와 Error Boundary의 필요성을 이해하려면, 이들이 등장하기 전 개발자들이 어떤 어려움을 겪었는지 살펴봐야 한다.

1.1. 로딩 상태 지옥 (Loading State Hell)

초창기 React에서 비동기 데이터를 가져오는 로직은 주로 컴포넌트의 생명주기 메서드(주로 componentDidMount) 내에서 처리되었다. 이 방식은 간단한 시나리오에서는 잘 동작했지만, 여러 비동기 요청이 얽히고설키기 시작하면 금세 ‘로딩 상태 지옥’에 빠지게 만들었다.

개발자는 다음과 같은 상태들을 수동으로 관리해야 했다.

  • isLoading: 데이터 요청 시작 여부
  • data: 요청 성공 시 데이터
  • error: 요청 실패 시 에러 객체

컴포넌트는 isLoading 상태에 따라 로딩 스피너를 보여주고, error 상태에 따라 에러 메시지를 보여주는 등 복잡한 조건부 렌더링 로직으로 가득 찼다. 이는 코드를 장황하게 만들고, 상태 관리의 복잡성을 가중시켰다. 여러 컴포넌트가 동일한 데이터를 필요로 할 경우, 각 컴포넌트에서 개별적으로 로딩 및 에러 상태를 관리해야 하는 중복 문제도 발생했다.

이러한 문제를 해결하기 위해 상태 관리 라이브러리(Redux, MobX 등)가 등장했지만, 여전히 비동기 로직 자체의 복잡성은 개발자의 몫으로 남았다. React 팀은 이 문제를 라이브러리가 아닌 React 코어 차원에서 더 선언적으로 해결할 방법을 고민하기 시작했고, 그 결과물이 바로 Suspense다.

1.2. 하나의 에러가 전체를 무너뜨리다

전통적인 웹 애플리케이션에서 특정 부분의 자바스크립트 에러는 해당 기능의 동작을 멈추게 할 뿐, 전체 애플리케이션을 다운시키는 경우는 드물었다. 하지만 단일 페이지 애플리케이션(SPA)인 React에서는 이야기가 다르다.

React는 컴포넌트 트리 구조로 이루어져 있다. 만약 특정 컴포넌트에서 렌더링 도중 에러가 발생하면, 이 에러는 부모 컴포넌트로 전파된다. 이 에러를 적절히 처리하지 못하면 결국 전체 React 애플리케이션이 멈춰버리고, 사용자에게는 아무것도 보이지 않는 ‘흰 화면(White Screen of Death)‘이 나타난다.

이는 사용자 경험에 치명적이다. 단지 댓글 컴포넌트 하나에서 발생한 에러 때문에 전체 페이지가 동작하지 않는 것은 매우 불합리하다. 개발자들은 try...catch 구문으로 에러를 잡으려 시도했지만, try...catch는 렌더링 메서드 내부의 명령형 코드 에러는 잡을 수 있지만, 선언적인 컴포넌트 렌더링 과정에서 발생하는 에러는 잡아낼 수 없었다.

이러한 문제를 해결하기 위해, 컴포넌트 트리 일부에서 발생한 에러가 전체 앱을 중단시키는 것을 방지하고, 에러가 발생했을 때 사용자에게 적절한 대체 UI를 보여줄 수 있는 메커니즘이 필요했다. 이것이 바로 Error Boundary가 탄생한 배경이다.

2. Suspense 심층 분석 데이터가 준비될 때까지 기다리는 기술

Suspense는 “일시 중단”이라는 이름에서 알 수 있듯이, 컴포넌트 렌더링을 “일시 중단”하고, 특정 작업(주로 데이터 로딩)이 완료될 때까지 기다렸다가 렌더링을 재개하는 기능이다. 이를 통해 비동기 데이터 로딩을 마치 동기 코드처럼 선언적으로 작성할 수 있게 된다.

2.1. Suspense의 구조와 작동 원리

Suspense는 Suspense 컴포넌트와 데이터 소스를 읽는 자식 컴포넌트의 조합으로 동작한다.

import React, { Suspense } from 'react';
import MyComponentThatFetchesData from './MyComponent';
 
function App() {
  return (
    <div>
      <h1>My App</h1>
      <Suspense fallback={<p>Loading...</p>}>
        <MyComponentThatFetchesData />
      </Suspense>
    </div>
  );
}

핵심 작동 메커니즘:

  1. Promise 던지기 (Throwing a Promise): MyComponentThatFetchesData 컴포넌트가 렌더링되는 동안, 아직 데이터가 준비되지 않았다면 컴포넌트는 일반적인 값이 아닌 Promise를 던진다(throw). 이는 일반적인 에러를 던지는 것과는 다르며, Suspense에게 “아직 렌더링할 준비가 안 됐으니 기다려줘!”라는 신호를 보내는 것과 같다.
  2. Suspense의 포착: 부모 트리에서 가장 가까운 Suspense 컴포넌트가 이 던져진 Promise를 포착한다.
  3. Fallback UI 렌더링: Suspense는 Promise가 포착되는 즉시, 자식 컴포넌트 대신 자신이 fallback prop으로 받은 UI(위 예제에서는 <p>Loading...</p>)를 화면에 렌더링한다.
  4. 렌더링 재개: Suspense는 던져진 Promise가 resolve(성공적으로 완료)되기를 기다린다. Promise가 resolve되면, React는 이전에 렌더링을 “일시 중단”했던 MyComponentThatFetchesData 컴포넌트의 렌더링을 다시 시도한다. 이때는 데이터가 준비되었으므로, 정상적으로 컴포넌트가 렌더링된다.

이러한 메커니즘 덕분에 개발자는 더 이상 isLoading과 같은 상태를 직접 관리할 필요가 없다. 데이터가 준비되지 않았을 때의 처리는 전적으로 React와 Suspense에 위임하고, 오직 데이터가 준비되었을 때의 UI에만 집중하면 된다.

2.2. Suspense와 함께 사용하는 데이터 로딩 라이브러리

Suspense가 Promise를 던지는 방식으로 동작한다고 해서, fetch API를 직접 호출하고 Promise를 던지는 코드를 작성하는 것은 권장되지 않는다. 이 과정에는 캐싱, 중복 요청 방지 등 복잡한 처리가 필요하기 때문이다.

대신, Suspense와 호환되는 데이터 로딩 라이브러리를 사용하는 것이 일반적이다. 이러한 라이브러리들은 내부적으로 Suspense 메커니즘을 지원하여 개발자가 편리하게 사용할 수 있도록 돕는다.

라이브러리특징사용 예시
React-Query (TanStack Query)서버 상태 관리의 사실상 표준. 캐싱, 재시도, 포커스 시 재요청 등 강력한 기능 제공.useQuery 훅의 suspense: true 옵션 사용
SWRVercel에서 만든 경량 데이터 로딩 라이브러리. stale-while-revalidate 전략을 통해 빠른 UI 반응성 제공.useSWR 훅의 suspense: true 옵션 사용
RelayFacebook(Meta)에서 만든 GraphQL 클라이언트. Suspense와 긴밀하게 통합되어 있다.프레임워크 자체적으로 Suspense 지원
React.lazyReact에 내장된 기능으로, 코드 스플리팅(Code Splitting)에 사용된다. 컴포넌트 코드를 비동기적으로 로딩할 때 Suspense와 함께 사용.const OtherComponent = React.lazy(() => import('./OtherComponent'));

2.3. Suspense 사용법 및 고급 활용

기본 사용법 (코드 스플리팅)

React.lazy와 함께 사용하는 것은 Suspense의 가장 기본적인 활용 사례다. 초기 로딩 속도를 개선하기 위해 당장 필요하지 않은 컴포넌트의 로드를 지연시킬 수 있다.

import React, { Suspense, lazy } from 'react';
 
const MarkdownPreview = lazy(() => import('./MarkdownPreview.js'));
 
function App() {
  return (
    <div>
      <h1>My App</h1>
      <Suspense fallback={<div>Loading component...</div>}>
        <MarkdownPreview />
      </Suspense>
    </div>
  );
}

데이터 로딩과 함께 사용 (React-Query 예시)

import { Suspense } from 'react';
import { useQuery } from '@tanstack/react-query';
 
const fetchTodos = async () => {
  const res = await fetch('https://api.example.com/todos');
  return res.json();
};
 
function Todos() {
  // suspense: true 옵션으로 Suspense 모드 활성화
  const { data } = useQuery(['todos'], fetchTodos, { suspense: true });
 
  return (
    <ul>
      {data.map(todo => <li key={todo.id}>{todo.title}</li>)}
    </ul>
  );
}
 
function App() {
  return (
    <Suspense fallback={<h1>Loading todos...</h1>}>
      <Todos />
    </Suspense>
  );
}

이 코드에서 Todos 컴포넌트는 더 이상 로딩 상태를 관리하지 않는다. useQuery가 데이터를 가져오는 동안 내부적으로 Promise를 던지고, Suspense가 이를 처리한다. 코드가 훨씬 간결하고 선언적으로 바뀐 것을 확인할 수 있다.

여러 Suspense 컴포넌트 중첩하기

Suspense는 중첩하여 사용할 수 있다. 이를 통해 페이지의 각 부분에 대해 더 세분화된 로딩 상태를 보여줄 수 있다.

<Suspense fallback={<p>Loading profile...</p>}>
  <ProfileDetails />
  <Suspense fallback={<p>Loading posts...</p>}>
    <ProfilePosts />
  </Suspense>
</Suspense>

위 예제에서는 먼저 프로필 상세 정보가 로딩되고, 그동안 전체적으로 “Loading profile…” 메시지가 보인다. 프로필 상세 정보 로딩이 완료되면 해당 UI가 나타나고, 이어서 게시물 목록이 로딩되는 동안에는 “Loading posts…” 메시지가 보이게 된다.

3. Error Boundary 심층 분석 깨지지 않는 앱을 만드는 방어막

Error Boundary는 자식 컴포넌트 트리에서 발생하는 자바스크립트 에러를 포착하여 앱 전체가 다운되는 것을 막고, 에러 발생 시 보여줄 대체 UI(Fallback UI)를 렌더링하는 클래스 컴포넌트다.

3.1. Error Boundary의 구조와 작동 원리

Error Boundary는 반드시 클래스 컴포넌트여야 하며, 다음 두 생명주기 메서드 중 하나 또는 둘 모두를 정의해야 한다.

  • static getDerivedStateFromError(error): 에러가 발생했을 때 대체 UI를 렌더링하기 위해 state를 업데이트하는 데 사용된다. 반드시 업데이트된 state 객체를 반환하거나, 아무것도 업데이트하지 않으려면 null을 반환해야 한다.
  • componentDidCatch(error, errorInfo): 발생한 에러와 에러 정보를 로깅하는 데 사용된다. (예: Sentry, LogRocket 같은 에러 리포팅 서비스로 전송)

핵심 작동 메커니즘:

  1. 에러 발생: 자식 컴포넌트 트리(Error Boundary 자신은 제외)의 렌더링 과정, 생명주기 메서드, 또는 생성자에서 자바스크립트 에러가 발생한다.
  2. 포착 및 상태 업데이트: 가장 가까운 상위 Error Boundary의 getDerivedStateFromError가 호출된다. 이 메서드는 에러를 인자로 받아 state를 업데이트할 기회를 얻는다. 일반적으로 hasError: true와 같은 상태를 설정한다.
  3. 대체 UI 렌더링: state가 업데이트되면 Error Boundary 컴포넌트는 리렌더링된다. 이때 render 메서드에서 업데이트된 state(예: this.state.hasError)를 확인하고, true이면 미리 준비된 대체 UI를 반환한다. false이면 자식 컴포넌트(this.props.children)를 그대로 렌더링한다.
  4. 에러 로깅: componentDidCatch가 호출되어 에러 리포팅 서비스에 에러 정보를 보낼 수 있다.

3.2. Error Boundary가 포착할 수 있는 에러와 없는 에러

Error Boundary는 모든 에러를 포착할 수 있는 만능 도구가 아니다. 포착 범위에는 명확한 한계가 존재한다.

포착 가능한 에러포착 불가능한 에러
렌더링 중 발생하는 에러이벤트 핸들러 내부의 에러 (try...catch 사용 필요)
생명주기 메서드 내부의 에러비동기 코드 (setTimeout, requestAnimationFrame 콜백 등)
생성자(constructor) 내부의 에러서버 사이드 렌더링(SSR)에서 발생하는 에러
Error Boundary 자신에게서 발생하는 에러

이벤트 핸들러 내의 에러를 포착할 수 없는 이유는, 이벤트 핸들러가 React의 렌더링 사이클과 직접적인 관련이 없기 때문이다. 이벤트가 발생했을 때 React는 무엇을 렌더링해야 할지 알지 못하므로, Error Boundary가 동작할 수 없다. 따라서 이벤트 핸들러 내에서는 전통적인 try...catch 구문을 사용해야 한다.

3.3. Error Boundary 사용법 및 모범 사례

기본 Error Boundary 컴포넌트 만들기

import React, { Component } from 'react';
 
class ErrorBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }
 
  static getDerivedStateFromError(error) {
    // 다음 렌더링에서 폴백 UI가 보이도록 상태를 업데이트합니다.
    return { hasError: true };
  }
 
  componentDidCatch(error, errorInfo) {
    // 에러 리포팅 서비스에 에러를 기록할 수 있습니다.
    logErrorToMyService(error, errorInfo);
  }
 
  render() {
    if (this.state.hasError) {
      // 폴백 UI를 렌더링할 수 있습니다.
      return <h1>Something went wrong.</h1>;
    }
 
    return this.props.children;
  }
}

애플리케이션에 적용하기

Error Boundary는 애플리케이션의 어느 곳에나 배치할 수 있다.

  • 최상위 레벨: 전체 앱을 감싸서 예상치 못한 에러에 대한 포괄적인 처리를 할 수 있다.
  • 페이지 레벨: 각 페이지 라우트 컴포넌트를 감싸서 특정 페이지의 에러가 다른 페이지에 영향을 주지 않도록 격리할 수 있다.
  • 컴포넌트 레벨: 독립적인 위젯이나 중요한 기능 단위(예: 댓글 목록, 사이드바)를 감싸서 세분화된 에러 처리를 할 수 있다.
<ErrorBoundary>
  <MyProfilePage />
</ErrorBoundary>
 
// 또는 더 세분화하여
<Layout>
  <ErrorBoundary>
    <Sidebar />
  </ErrorBoundary>
  <ErrorBoundary>
    <MainContent />
  </ErrorBoundary>
</Layout>

어디에 배치할지는 애플리케이션의 구조와 요구사항에 따라 결정해야 한다. 중요한 점은 에러의 영향 범위를 최소화하는 것이다.

최근에는 react-error-boundary와 같은 라이브러리를 사용하면 클래스 컴포넌트를 직접 작성하지 않고도 훅(hook) 기반으로 더 간편하게 Error Boundary를 구현할 수 있다.

4. Suspense와 Error Boundary의 시너지 비동기 에러 처리의 완성

Suspense와 Error Boundary는 각각 로딩 상태와 에러를 처리하는 강력한 도구이지만, 이 둘을 함께 사용하면 비동기 작업에서 발생할 수 있는 거의 모든 시나리오에 우아하게 대응할 수 있다.

Suspense는 Promise가 reject(실패)될 때 이를 포착하여 에러처럼 상위로 전파한다. 이 전파된 에러는 Error Boundary에 의해 포착될 수 있다. 즉, 데이터 로딩 과정에서 발생한 에러를 Suspense가 Error Boundary로 던져주는 환상적인 연계 플레이가 가능하다.

4.1. 결합 패턴

가장 일반적인 패턴은 Suspense 컴포넌트를 Error Boundary 컴포넌트로 감싸는 것이다.

import React, { Suspense } from 'react';
import ErrorBoundary from './ErrorBoundary';
import MyDataComponent from './MyDataComponent';
 
function App() {
  return (
    <div>
      <h1>My App</h1>
      <ErrorBoundary fallback={<h2>Could not fetch data.</h2>}>
        <Suspense fallback={<h1>Loading...</h1>}>
          <MyDataComponent />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

작동 시나리오:

  1. 로딩 중: MyDataComponent가 데이터를 가져오는 동안 Promise를 던진다. Suspense가 이를 포착하고 fallback으로 <h1>Loading...</h1>을 보여준다.
  2. 로딩 성공: Promise가 resolve되면 SuspenseMyDataComponent를 렌더링한다.
  3. 로딩 실패: 데이터 요청에 실패하여 Promise가 reject된다. Suspense는 이 reject된 Promise를 에러로 간주하고 상위로 다시 던진다.
  4. 에러 포착: ErrorBoundary가 이 에러를 포착한다. getDerivedStateFromError가 호출되고 hasError 상태가 true가 된다.
  5. 에러 UI 렌더링: ErrorBoundary는 자신의 fallback<h2>Could not fetch data.</h2>를 렌더링한다.

이 패턴을 통해 개발자는 로딩 상태와 에러 상태를 분리하여 훨씬 더 깔끔하고 직관적인 코드를 작성할 수 있다. 더 이상 isLoading, error 상태를 수동으로 관리하고 복잡한 삼항 연산자로 UI를 분기할 필요가 없다.

4.2. 재시도(Retry) 기능 구현하기

react-error-boundary 라이브러리를 사용하면 Error Boundary에 재시도 기능을 손쉽게 추가할 수 있다. 이는 일시적인 네트워크 문제 등으로 데이터 로딩에 실패했을 때 사용자에게 재시도할 기회를 제공하여 사용자 경험을 크게 향상시킨다.

react-query와 함께 사용하면 쿼리를 초기화하는 것만으로 간단하게 재시도 로직을 구현할 수 있다.

import { Suspense } from 'react';
import { useQuery, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';
 
// ... (fetchPosts, queryClient 설정)
 
function Posts() {
  const { data } = useQuery(['posts'], fetchPosts, { suspense: true });
  // ...
}
 
function App() {
  const { reset } = useQueryErrorResetBoundary();
 
  return (
    <ErrorBoundary
      onReset={reset}
      fallbackRender={({ resetErrorBoundary }) => (
        <div>
          There was an error!
          <button onClick={resetErrorBoundary}>Try again</button>
        </div>
      )}
    >
      <Suspense fallback={<h1>Loading posts...</h1>}>
        <Posts />
      </Suspense>
    </ErrorBoundary>
  );
}

이처럼 Suspense와 Error Boundary, 그리고 이들을 지원하는 라이브러리를 함께 활용하면, 선언적이고 견고하며 사용자 친화적인 비동기 데이터 처리 로직을 완성할 수 있다.

5. 결론 미래의 React를 위한 준비

Suspense와 Error Boundary는 단순히 코드를 깔끔하게 만드는 유틸리티를 넘어, React가 지향하는 애플리케이션 개발의 미래를 보여주는 핵심적인 기능이다. 이들은 개발자가 ‘무엇을 보여줄 것인가’에만 집중하고, ‘어떻게, 언제 보여줄 것인가’에 대한 복잡한 고민은 React에 위임하도록 돕는다.

앞으로 서버 컴포넌트(Server Components)와 같은 새로운 패러다임이 도입되면서 Suspense의 역할은 더욱 중요해질 것이다. 지금 Suspense와 Error Boundary의 원리를 깊이 이해하고 능숙하게 활용하는 능력은, 더 나은 React 애플리케이션을 구축하고 미래의 변화에 유연하게 대응하기 위한 필수적인 역량이 될 것이다. 이 핸드북이 그 여정에 훌륭한 가이드가 되기를 바란다.