✨React Suspense는 왜 등장했을까?

송연지·2025년 11월 27일

— 비동기 때문에 흔들리던 UI를 React가 직접 통제하기 시작한 순간

프론트엔드 개발을 하다 보면 자연스럽게 하나의 고민이 생깁니다.

“왜 로딩 UI가 이렇게 많지?”

“왜 페이지 이동할 때 깜빡이지?”

“데이터 패칭 실패하면 화면이 바로 죽는 이유가 뭘까?”

“컴포넌트마다 isLoading, error를 계속 만들어야 하는 게 맞는 걸까?”

React는 분명 “UI를 선언적으로 그리는 라이브러리”인데,

정작 데이터 패칭·로딩·에러·코드 스플리팅처럼 실제 애플리케이션에 반드시 필요한 비동기 흐름은 React가 직접 관리하지 않았습니다.

그 결과 애플리케이션 구조는 점점 복잡해졌습니다.

  • 로딩 UI가 곳곳에 흩어지고
  • 에러 처리 방식은 개발자마다 다르고
  • 코드 스플리팅은 가능하지만 “대기 UI”는 직접 만들어야 하고
  • 렌더링 중 깜빡임(Flash)이 생기고
  • 데이터가 늦게 도착하면 빈 화면이 잠깐 노출되고…

React 팀은 어느 순간 이렇게 결론을 내립니다.

“비동기는 UI의 일부이므로, UI 프레임워크인 React가 이걸 직접 통제해야 한다.”

그 결론의 첫 번째 산물이 바로 Suspense입니다.

그리고 나머지 기술들은 이 Suspense라는 개념을 중심으로 확장된 애들입니다.

이 글에서는 기술 이름만 나열하지 않고,

  • ✔ 왜 등장했는지 (발생 배경)
  • ✔ 어떤 구조적 문제를 해결했는지 (개념)
  • ✔ 내부적으로 어떻게 작동하는지 (메커니즘)
  • ✔ 언제 쓰는지 (사용 시점 예시)

이 네 가지 축으로 길게 정리해보겠습니다.


1. 🎯 React의 근본적 한계: “UI는 동기적이다”라는 오래된 가정

React는 초기에 “상태 → UI”라는 동기적 렌더링 모델을 기반으로 설계됐습니다.

즉, 렌더링 시점에는 모든 데이터가 이미 존재한다고 가정한 거죠.

하지만 실제 애플리케이션은 다음처럼 비동기 투성이입니다.

  • API에서 데이터 가져오기
  • Lazy 로딩된 컴포넌트 불러오기
  • 사용자 인터랙션에 따른 추가 데이터 조회
  • SSR/CSR 간 데이터 교환
  • 리소스 로딩(이미지, 폰트, 스크립트 등)

이 비동기 흐름을 React는 “외부 상태”나 “사용자 정의 로직”으로 해결하게 만들었습니다.

그 때문에 애플리케이션 구조는 자연스럽게 이렇게 변해갔습니다.

  • ❌ 컴포넌트마다 isLoading, error를 도배
  • ❌ 데이터 도착 전 UI가 잠깐 깨져 보임
  • ❌ 코드 스플리팅했는데 로딩 UI는 직접 구현
  • ❌ 에러 발생 시 화면 전체가 죽어버림
  • ❌ 렌더링이 비동기로 인해 뒤틀리는 현상(깜빡임, 빈 화면)

이건 React의 “버그”라기보다는,

애초에 React가 비동기를 렌더링 모델 안에 포함시키지 않았기 때문입니다.

그러니 React는 이런 질문을 하게 됩니다.

“비동기를 UI 렌더링의 일부로 만들 수 없을까?”

Suspense는 바로 그 질문에 대한 첫 번째 대답입니다.


2. 🍱 React.lazy — 코드 스플리팅을 “React의 비동기”로 만들다

2-1. 코드 스플리팅의 등장 배경

프론트엔드 앱이 커지면서 JS 번들은 점점 커졌고,

SPA 특성상 안 쓰는 페이지 JS까지 처음에 다 들고 오는 문제가 생겼습니다.

그래서 Webpack / Vite 같은 도구가 코드 스플리팅을 제공하기 시작했죠.

하지만 이 상태에서는 이런 문제가 남아 있었습니다.

“번들은 쪼개지는데, 그게 로딩 중인지 아닌지 React는 모른 척한다.”

그래서 예전에는 이런 코드가 많았습니다.

// (예전에 흔히 보던 패턴 – 직접 상태 관리)
function LazySettingsWrapper() {
  const [Settings, setSettings] = React.useState<React.ComponentType | null>(null);

  React.useEffect(() => {
    import('./Settings').then(mod => {
      setSettings(() => mod.default);
    });
  }, []);

  if (!Settings) {
    return <div>설정 화면 불러오는 중...</div>;
  }

  return <Settings />;
}
  • 모듈 로딩 상태를 직접 관리해야 하고
  • 로딩 UI도 매번 직접 짜야 하고
  • 에러 처리도 따로 해야 합니다.

React는 이걸 “React가 직접 지원해야 하는 영역”이라고 보고,

React.lazy를 도입합니다.

const Settings = React.lazy(() => import('./Settings'));

이 한 줄은 단순히 “동적 import”가 아닙니다.

내부적으로는 렌더링 중에 Promise를 던지는 컴포넌트가 됩니다.

즉, 렌더링 중에 이 컴포넌트를 만나면 흐름이 이렇게 바뀝니다.

렌더링 → Promise throw → “대기 필요”

근데 아직 React는 이 Promise를 받을 줄 모릅니다.

이 Promise를 받아서, “잠깐 이 UI 대신 다른 걸 보여주자”를 처리해주는 애가 바로 Suspense입니다.

2-2. React.lazy + Suspense 기본 사용 예시

import React, { Suspense } from 'react';

const SettingsPage = React.lazy(() => import('./SettingsPage'));

export function App() {
  return (
    <Suspense fallback={<div>설정 화면 불러오는 중...</div>}>
      <SettingsPage />
    </Suspense>
  );
}
  • SettingsPage는 아직 안 받아온 상태일 수 있고
  • 그 동안 Suspense가 fallback을 렌더링해줍니다.

여기서 이미 “비동기 로딩을 UI 렌더링의 일부로 넣는다”는 개념이 시작됩니다.


3. 🎬 Suspense — 렌더링 중 비동기 “대기”를 정식 기능으로 넣은 기술

Suspense를 잘 모르면 흔히 이렇게 생각하기 쉽습니다.

“아, 로딩 스피너 보여주는 컴포넌트지?”

절반은 맞고, 절반은 틀렸습니다.

Suspense의 본질은 “렌더링 중 발생한 비동기를 React가 직접 처리하는 것”입니다.

3-1. Suspense는 DOM을 어떻게 다룰까?

중요한 포인트는 이겁니다.

Suspense는 DOM을 직접 조작하지 않고,

“렌더링 과정을 제어”합니다.

대략적인 흐름은 이렇습니다.

  1. Suspense 아래에 있는 컴포넌트가 Promise를 던짐
    • 예) React.lazy, React Query suspense: true, 커스텀 리소스 래퍼 등
  2. React는 지금 진행 중인 렌더링을 중단
  3. 가장 가까운 Suspense Boundary를 찾음
  4. 그 Boundary에 정의된 fallback UI를 대신 렌더링
  5. Promise가 resolve되면
    • React가 다시 원래 렌더링을 이어서 수행
    • 기존 fallback DOM은 사라지고, 실제 UI로 자연스럽게 교체

즉, “fallback → 실제 UI”로 부드럽게 전환되는 전체 과정을

React 렌더링 엔진이 직접 관리하게 됩니다.

3-2. Suspense를 쓸 때 체감되는 효과

이 방식 덕분에:

  • 로딩 상태가 컴포넌트 깊은 곳에 흩어지지 않고
  • *“어디까지를 하나의 로딩 단위로 볼 건지”**를 Boundary로 명확하게 자를 수 있고
  • 화면이 깜빡이거나, 잠깐 빈 영역이 보이는 문제를 줄일 수 있습니다.

예를 들어 이런 식입니다.

// 대시보드 전체를 하나의 Suspense boundary로 감싸는 예시
function Dashboard() {
  return (
    <Suspense fallback={<div>대시보드 로딩 중...</div>}>
      <UserSummary />   {/* 내부에서 데이터 패칭 */}
      <ActivityChart /> {/* 내부에서 데이터 패칭 */}
      <NotificationList /> {/* 내부에서 데이터 패칭 */}
    </Suspense>
  );
}

각 컴포넌트가 알아서 비동기를 던지고,

Suspense가 그 전체를 하나의 “로딩 화면”으로 묶어줍니다.

좀 더 세분화하고 싶다면, 이런 식도 가능합니다.

function Dashboard() {
  return (
    <div>
      <Suspense fallback={<div>프로필 불러오는 중...</div>}>
        <UserSummary />
      </Suspense>

      <Suspense fallback={<div>활동 차트 로딩 중...</div>}>
        <ActivityChart />
      </Suspense>
    </div>
  );
}

Boundary를 어떻게 나눌지에 따라

“사용자가 어디까지를 하나의 화면으로 인식할지”를 설계할 수 있습니다.


4. 🛡 ErrorBoundary — Suspense와 100% 맞물려 돌아가는 에러 복구 메커니즘

여기까지 보면 Suspense는 “대기(loading)”만 해결합니다.

그러면 “실패(error)”는 누가 처리할까요?

대답: Suspense가 아니라, ErrorBoundary입니다.

4-1. ErrorBoundary의 동작 흐름

React는 렌더링 중 에러가 발생하면 아래와 같이 동작합니다.

  1. 렌더링 중 Error가 throw
  2. React는 렌더링을 중단
  3. 가장 가까운 <ErrorBoundary>를 찾음
  4. 그 Boundary의 fallback UI를 렌더링하여 안전하게 복구

Suspense = “기다림” 담당

ErrorBoundary = “실패” 담당

둘이 세트라고 보면 됩니다.

4-2. ErrorBoundary 예시

// 클래스형 컴포넌트로만 공식 지원
class RootErrorBoundary extends React.Component<
  { children: React.ReactNode },
  { hasError: boolean }
> {
  constructor(props: any) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  componentDidCatch(error: any, info: any) {
    console.error('[ErrorBoundary]', error, info);
  }

  render() {
    if (this.state.hasError) {
      return <div>문제가 발생했습니다. 잠시 후 다시 시도해 주세요.</div>;
    }
    return this.props.children;
  }
}

// Suspense와 함께 사용
function App() {
  return (
    <RootErrorBoundary>
      <Suspense fallback={<div>전체 앱 로딩 중...</div>}>
        <MainRouter />
      </Suspense>
    </RootErrorBoundary>
  );
}
  • 비동기 로딩 중에는 Suspense가 처리
  • 비동기 실패(또는 동기 에러)는 ErrorBoundary가 처리

이렇게 되면:

  • API 하나 터졌다고 전체 앱이 흰 화면이 되는 걸 막을 수 있고
  • “특정 영역만 에러 UI로 교체” 같은 UX를 설계할 수 있습니다.

5. 🔗 React Query + Suspense — 데이터 패칭을 React 렌더링 모델에 완전히 편입

React Query를 쓸 때 가장 익숙한 패턴은 이거일 겁니다.

const { data, isLoading, isError } = useQuery({
  queryKey: ['user', userId],
  queryFn: fetchUser,
});

if (isLoading) return <div>로딩 중...</div>;
if (isError) return <div>에러…</div>;

이 방식의 단점은:

  • 컴포넌트마다 로딩/에러 조건문이 반복되고
  • 로딩 UI가 페이지 전역에서 제각각 만들어지고
  • 중첩 컴포넌트 구조에서는 가독성이 급격히 떨어진다는 점입니다.

React 18 이후, React Query는 suspense: true를 지원하면서 완전히 다른 그림이 나옵니다.

// React Query + Suspense 사용
const { data } = useQuery({
  queryKey: ['user', userId],
  queryFn: fetchUser,
  suspense: true,  // 핵심
});

이제 내부적으로는 이렇게 동작합니다.

  • 로딩 중 → Promise를 던짐 → Suspense에서 fallback 렌더
  • 실패 → Error를 던짐 → ErrorBoundary에서 복구
  • 성공 → 정상적으로 데이터 사용

예시를 한 번에 정리하면:

function UserProfile() {
  const { data: user } = useQuery({
    queryKey: ['user', 1],
    queryFn: fetchUser,
    suspense: true,
  });

  return <div>{user.name}님 안녕하세요.</div>;
}

function App() {
  return (
    <RootErrorBoundary>
      <Suspense fallback={<div>사용자 정보를 불러오는 중입니다...</div>}>
        <UserProfile />
      </Suspense>
    </RootErrorBoundary>
  );
}

여기서 포인트는:

  • UserProfile 내부에는 isLoading, isError가 사라지고
  • 로딩/에러 처리는 “위에서 한 번에” 잡아줄 수 있다는 점입니다.

실제 프로젝트가 커지면, 이 구조가 생각보다 엄청 시원해집니다.


6. 🌊 Server Components + Streaming — Suspense가 만들어낸 SSR 진화

Next.js 13 이후 도입된 Server Components / Streaming도 사실 Suspense 위에 서 있는 기능입니다.

아이디어는 단순합니다.

“서버에서 준비된 부분만 먼저 보내고,

아직 안 된 부분은 Suspense fallback으로 대체해서 보낸 다음,

준비되면 해당 부분만 교체하자.”

6-1. 아주 간단한 예시 느낌

// app/page.tsx (Next.js 13+ 예시 느낌)
import { Suspense } from 'react';
import UserFeed from './UserFeed';

export default function Page() {
  return (
    <><h1>대시보드</h1>
      <Suspense fallback={<div>피드 로딩 중...</div>}>
        {/* 서버에서 데이터 패칭 후 스트리밍 */}
        <UserFeed />
      </Suspense>
    </>
  );
}

실제로는 서버가:

  1. <h1>대시보드</h1>fallback을 먼저 내려보내고
  2. UserFeed 데이터가 준비되면
  3. 해당 영역만 치환하는 방식으로 동작합니다.

이 구조 덕분에:

  • 초기 렌더링 체감 속도가 빨라지고
  • 네트워크 지연이 덜 답답해지고
  • JS 번들 크기도 줄어들고
  • UX가 훨씬 자연스러워집니다.

이 모든 게 가능한 이유가 바로 “부분적으로 UI를 대체하고 다시 복구한다”는 Suspense 모델이 존재하기 때문입니다.


7. 🎛 결국 이 기술들은 하나의 흐름이다

정리해보면:

기술해결하려는 문제Suspense와의 관계
React.lazy코드 스플리팅 시 “로딩 중” 상태 처리Promise를 던져서 Suspense가 받도록 함
Suspense비동기 렌더링 전체를 React가 직접 제어모든 비동기 흐름의 중심 엔진
ErrorBoundary비동기/동기 에러를 안전하게 복구Suspense가 못 잡는 “실패”를 담당
React Query + Suspense데이터 패칭 로딩/에러를 렌더링 모델로 통합Promise/Error를 던져 Suspense/EB에 위임
Server Components/StreamingSSR에서 부분 렌더링/스트리밍Suspense를 기반으로 부분적인 UI 교체

즉, 이 기술들은 따로 떨어진 기능 목록이 아니라

“React 렌더링 모델의 진화 과정에서 등장한 한 계보”에 가깝습니다.

공통된 철학은 항상 하나입니다.

“UI와 비동기를, React가 직접 통제하겠다.”


8. 🌱 Suspense를 제대로 쓰기 위한 사전 조건

Suspense는 강력하지만, “그냥 아무 데나 막 꽂아 넣으면 좋은 기능”은 아닙니다.

개인적으로는 아래 같은 상황에서 특히 잘 맞는다고 느꼈습니다.

  • ✔ 비동기 로직이 컴포넌트 곳곳에 섞여 있을 때
  • ✔ 로딩/에러 UI를 일관되게 관리하고 싶을 때
  • ✔ 페이지/기능 단위 코드 스플리팅을 적용하고 싶을 때
  • ✔ 여러 API를 병렬로 호출하면서도 깔끔한 UI 흐름을 만들고 싶을 때
  • ✔ Next.js처럼 SSR/Streaming까지 염두에 둔 구조를 만들고 싶을 때

이럴 때 “Suspense + ErrorBoundary + (React Query or lazy)” 조합이 진짜 빛을 발합니다.


🎉 마지막 요약

Suspense는 “로딩 스피너 보여주는 컴포넌트”가 아닙니다.

Suspense는 React가 비동기 UI 전체를 스스로 통제하기 시작한 첫 번째 기술입니다.

비동기 → 대기 → 복구 → 렌더링

이 전체 사이클을 React 내부 엔진이 책임지는 구조로 바꾸면서,

  • 깜빡임 없는 UI
  • 일관된 로딩/에러 처리
  • 코드 스플리팅의 자연스러운 적용
  • SSR Streaming 같은 차세대 기능

이런 것들이 한 줄로 이어지기 시작했습니다.

개인적으로는,

“Suspense를 이해하는 순간

‘React 생태계 전체가 왜 이런 방향으로 진화하고 있는지’가

훨씬 명확하게 보인다”

라는 느낌이 들었습니다.

profile
프론트엔드 개발쟈!!

0개의 댓글