컴포넌트 렌더링 도중 Promise를 throw한다? 에러도 아닌데 throw를 한다니, 이것이 어떻게 동작하는 걸까요?

Suspense는 React의 가장 독특한 메커니즘 중 하나입니다. "아직 준비되지 않은 것"이 있을 때 fallback UI를 보여주는 선언적 방법인데, 그 내부에서는 Promise를 throw하는 다소 파격적인 방식으로 동작합니다.

Suspense의 기본 사용

JSX
import { Suspense, lazy } from 'react';

const HeavyComponent = lazy(() => import('./HeavyComponent'));

function App() {
  return (
    <Suspense fallback={<div>로딩 중...</div>}>
      <HeavyComponent />
    </Suspense>
  );
}

HeavyComponent가 아직 로드되지 않았을 때 "로딩 중..."이 표시되고, 로드가 완료되면 컴포넌트가 렌더링됩니다. 간단해 보이지만, 내부 동작은 상당히 흥미롭습니다.

throw Promise — 핵심 메커니즘

일반적인 컴포넌트 렌더링

PLAINTEXT
React: "Component를 렌더링해줘"
Component: <div>안녕하세요</div> 반환
React: DOM에 반영

Suspense가 있을 때

PLAINTEXT
React: "Component를 렌더링해줘"
Component: Promise를 throw! (아직 데이터가 없어요)
Suspense: Promise를 catch → fallback 표시
...
Promise가 resolve됨
React: "Component를 다시 렌더링해줘"
Component: <div>데이터 로드 완료</div> 반환
React: DOM에 반영

의사 코드로 이해하기

JSX
// React.lazy의 내부 동작 (간략화)
function lazy(importFn) {
  let status = 'pending';
  let result;

  const modulePromise = importFn().then(
    (module) => {
      status = 'fulfilled';
      result = module.default;
    },
    (error) => {
      status = 'rejected';
      result = error;
    }
  );

  return function LazyComponent(props) {
    if (status === 'pending') {
      throw modulePromise;  // Promise를 throw!
    }
    if (status === 'rejected') {
      throw result;  // 에러를 throw → ErrorBoundary가 잡음
    }
    // 로드 완료 → 실제 컴포넌트 렌더링
    return React.createElement(result, props);
  };
}

Suspense의 catch 동작 (간략화)

JSX
// Suspense의 내부 동작 (간략화)
class Suspense {
  render() {
    try {
      return this.props.children;
    } catch (thrown) {
      if (thrown instanceof Promise) {
        // Promise를 잡았다 → fallback 표시
        this.showFallback();
        thrown.then(() => {
          // Promise가 resolve되면 다시 렌더링 시도
          this.retryRender();
        });
      } else {
        // 일반 에러 → 상위로 전파 (ErrorBoundary가 처리)
        throw thrown;
      }
    }
  }
}

코드 스플리팅에서의 Suspense

React.lazy는 Suspense의 가장 일반적인 사용 사례입니다.

JSX
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  );
}

동작 흐름:

  1. /dashboard에 접근합니다
  2. Dashboard 컴포넌트가 렌더링을 시도합니다
  3. 모듈이 아직 로드되지 않았으므로 Promise를 throw합니다
  4. Suspense가 Promise를 catch하고 <PageSkeleton />을 표시합니다
  5. import('./pages/Dashboard')가 완료됩니다
  6. React가 Dashboard를 다시 렌더링합니다 — 이번에는 성공합니다

데이터 페칭에서의 Suspense

일반 useEffect 방식 (Suspense 미사용)

JSX
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetchUser(userId).then((data) => {
      setUser(data);
      setLoading(false);
    });
  }, [userId]);

  if (loading) return <Skeleton />;
  return <div>{user.name}</div>;
}

각 컴포넌트가 자체적으로 로딩 상태를 관리합니다.

Suspense 방식

JSX
// Suspense 지원 라이브러리 사용 (React Query 예시)
function UserProfile({ userId }) {
  // suspense: true 옵션으로 Suspense 연동
  const { data: user } = useSuspenseQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  // loading 체크 불필요 — Suspense가 처리
  return <div>{user.name}</div>;
}

// 사용하는 쪽에서 Suspense로 감쌈
<Suspense fallback={<ProfileSkeleton />}>
  <UserProfile userId={1} />
</Suspense>

로딩 상태 관리가 컴포넌트에서 빠져나와 Suspense 바운더리로 이동합니다.

주의: 직접 구현은 비권장

JSX
// 이런 패턴은 프레임워크/라이브러리 없이 직접 만들지 마세요
function createResource(promise) {
  let status = 'pending';
  let result;
  const suspender = promise.then(
    (data) => { status = 'success'; result = data; },
    (err) => { status = 'error'; result = err; }
  );

  return {
    read() {
      if (status === 'pending') throw suspender;
      if (status === 'error') throw result;
      return result;
    }
  };
}

React 팀은 이런 low-level 패턴을 직접 사용하지 말고, React Query, SWR, Relay 등 Suspense를 지원하는 라이브러리를 사용할 것을 권장합니다.

중첩 Suspense

Suspense를 중첩하여 각 영역의 로딩을 독립적으로 처리할 수 있습니다.

JSX
function DashboardPage() {
  return (
    <Suspense fallback={<FullPageSkeleton />}>
      {/* 전체 페이지 로딩 */}
      <Header />

      <div className="content">
        <Suspense fallback={<ChartSkeleton />}>
          {/* 차트만 로딩 */}
          <ChartSection />
        </Suspense>

        <Suspense fallback={<TableSkeleton />}>
          {/* 테이블만 로딩 */}
          <DataTable />
        </Suspense>

        <Suspense fallback={<SidebarSkeleton />}>
          {/* 사이드바만 로딩 */}
          <Sidebar />
        </Suspense>
      </div>
    </Suspense>
  );
}

중첩 Suspense의 동작

  1. 처음에는 모든 데이터가 없으므로 가장 바깥 <FullPageSkeleton />이 표시될 수 있습니다
  2. Header가 로드되면 레이아웃이 표시됩니다
  3. 차트, 테이블, 사이드바는 각각 독립적으로 로딩됩니다
  4. 차트가 먼저 로드되면 차트만 표시되고, 나머지는 여전히 스켈레톤입니다

이렇게 하면 점진적 로딩(Progressive Loading) 경험을 제공합니다.

Suspense와 ErrorBoundary 조합

JSX
function DataSection() {
  return (
    <ErrorBoundary FallbackComponent={ErrorUI}>
      <Suspense fallback={<Skeleton />}>
        <DataComponent />
      </Suspense>
    </ErrorBoundary>
  );
}
  • 데이터가 ** 로딩 중 **이면 → Suspense의 fallback (Skeleton)
  • 데이터 로딩이 ** 실패 **하면 → ErrorBoundary의 fallback (ErrorUI)
  • 데이터가 ** 성공 **하면 → DataComponent 렌더링

Suspense의 한계

useEffect 안의 fetch는 동작하지 않음

JSX
// 이 방식은 Suspense와 동작하지 않습니다
function MyComponent() {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetch('/api/data').then(res => res.json()).then(setData);
  }, []);

  // useEffect는 렌더링 후에 실행되므로 Promise를 throw할 수 없음
}

Suspense는 ** 렌더링 도중** throw된 Promise만 감지합니다. useEffect는 렌더링 ** 후에** 실행되므로 Suspense 메커니즘과 맞지 않습니다.

지원되는 방식

  • React.lazy (코드 스플리팅)
  • Suspense를 지원하는 데이터 페칭 라이브러리 (React Query, SWR, Relay)
  • React Server Components
  • use() Hook (React 19+)

React 19의 use() Hook

JSX
// React 19+
import { use, Suspense } from 'react';

function UserProfile({ userPromise }) {
  // use()는 Promise가 resolve될 때까지 Suspense를 트리거
  const user = use(userPromise);

  return <div>{user.name}</div>;
}

function App() {
  const userPromise = fetchUser(1); // Promise 생성

  return (
    <Suspense fallback={<Skeleton />}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  );
}

use()는 Promise, Context 등을 읽는 새로운 Hook으로, Suspense와 자연스럽게 통합됩니다.

정리

Suspense의 핵심은 "Promise를 throw하여 로딩 상태를 선언적으로 처리하는 것"입니다.

  • throw Promise: 컴포넌트가 아직 준비되지 않은 리소스가 있으면 Promise를 throw합니다
  • Suspense catch: 가장 가까운 Suspense 바운더리가 Promise를 catch하고 fallback을 표시합니다
  • ** 코드 스플리팅 **: React.lazy는 Suspense의 가장 기본적인 사용 사례입니다
  • ** 데이터 페칭 **: React Query 등 Suspense 지원 라이브러리와 함께 사용합니다
  • ** 중첩 Suspense**: 영역별 독립적인 로딩 처리로 점진적 로딩을 구현합니다
  • **ErrorBoundary와 조합 **: 로딩과 에러를 분리하여 처리합니다

주의할 점

Suspense를 지원하지 않는 라이브러리에서 Promise를 직접 throw

React의 내부 프로토콜을 직접 구현하면 버전 업데이트 시 깨질 수 있습니다. TanStack Query, SWR 등 공식적으로 Suspense를 지원하는 라이브러리를 사용하는 것이 안전합니다.

fallback이 너무 잦게 나타나는 UX 문제

빠른 네트워크에서 fallback이 깜빡이듯 나타나면 오히려 사용자 경험이 나빠집니다. startTransition과 함께 사용하면 이전 UI를 유지하면서 새 데이터를 로드할 수 있습니다.

Suspense는 "로딩 상태 관리"를 컴포넌트 내부에서 바운더리 레벨로 끌어올려, 더 선언적이고 일관된 UX를 만들 수 있게 합니다.

댓글 로딩 중...