뒤로가기
React Suspense 실전 사용 가이드

November 1, 2023

reactfrontend

로딩 처리 코드가 프로젝트 전체에 흩어져 있었다. 어떤 컴포넌트는 isLoading 상태를 직접 관리하고, 어떤 컴포넌트는 TanStack Query의 isLoading을 쓰고, 또 어떤 컴포넌트는 로딩 상태를 아예 처리하지 않아서 데이터가 올 때까지 빈 화면이 보였다. 로딩 스피너의 디자인도 제각각이었다.

Suspense를 도입하면서 이 혼란이 정리됐다. Suspense는 로딩 상태를 "어디서 보여줄지"를 컴포넌트 트리 구조로 선언하게 해준다. 명령적으로 if (isLoading) return <Spinner />를 쓰는 대신, 선언적으로 로딩 경계를 설정하는 거다.

기본 개념#

Suspense의 핵심은 간단하다. 자식 컴포넌트가 "아직 준비가 안 됐다"고 알리면, 가장 가까운 Suspense boundary가 fallback을 보여준다.

tsx
import { Suspense } from 'react';

function DashboardPage() {
  return (
    <div className="dashboard">
      <h1>대시보드</h1>
      <Suspense fallback={<SkeletonChart />}>
        <SalesChart />
      </Suspense>
      <Suspense fallback={<SkeletonTable />}>
        <RecentOrders />
      </Suspense>
    </div>
  );
}

SalesChart가 데이터를 가져오는 동안 SkeletonChart가 보이고, RecentOrders가 데이터를 가져오는 동안 SkeletonTable이 보인다. 두 컴포넌트는 독립적이라서 하나가 먼저 로드되면 그것만 먼저 보인다.

이전 방식과 비교해보자.

tsx
// Suspense 없이
function DashboardPage() {
  const { data: sales, isLoading: salesLoading } = useQuery(salesOptions);
  const { data: orders, isLoading: ordersLoading } = useQuery(ordersOptions);

  return (
    <div className="dashboard">
      <h1>대시보드</h1>
      {salesLoading ? <SkeletonChart /> : <SalesChart data={sales} />}
      {ordersLoading ? <SkeletonTable /> : <RecentOrders data={orders} />}
    </div>
  );
}

문제가 몇 가지 있다. 로딩 상태를 이 컴포넌트에서 직접 관리해야 한다. 데이터 fetching 로직과 로딩 UI 로직이 같은 레벨에 섞여 있다. 그리고 이 컴포넌트가 세 번째 데이터도 가져와야 한다면? 네 번째라면? isLoading 변수가 계속 늘어난다.

Code Splitting with lazy#

React.lazy와 Suspense의 조합은 이미 많이 알려져 있다. 코드 스플리팅을 위한 가장 기본적인 패턴이다.

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

// 동적 import
const AdminPanel = lazy(() => import('./AdminPanel'));
const UserSettings = lazy(() => import('./UserSettings'));
const Analytics = lazy(() => import('./Analytics'));

function App() {
  return (
    <Routes>
      <Route
        path="/admin"
        element={
          <Suspense fallback={<PageSkeleton />}>
            <AdminPanel />
          </Suspense>
        }
      />
      <Route
        path="/settings"
        element={
          <Suspense fallback={<PageSkeleton />}>
            <UserSettings />
          </Suspense>
        }
      />
      <Route
        path="/analytics"
        element={
          <Suspense fallback={<PageSkeleton />}>
            <Analytics />
          </Suspense>
        }
      />
    </Routes>
  );
}

각 라우트의 컴포넌트가 필요할 때만 로드된다. 초기 번들 사이즈를 줄이는 가장 쉬운 방법이다.

모달처럼 사용자 인터랙션으로 열리는 무거운 컴포넌트도 lazy로 분리하면 좋다.

tsx
const HeavyEditor = lazy(() => import('./HeavyEditor'));

function PostPage() {
  const [isEditing, setIsEditing] = useState(false);

  return (
    <div>
      <PostContent />
      <button onClick={()=> setIsEditing(true)}>수정</button>
      {isEditing && (
        <Suspense fallback={<div className="editor-skeleton" />}>
          <HeavyEditor />
        </Suspense>
      )}
    </div>
  );
}

Data Fetching with Suspense#

이쪽이 더 흥미로운 영역이다. 데이터 페칭 라이브러리가 Suspense를 지원하면, 컴포넌트 안에서 로딩 상태를 신경 쓸 필요가 없어진다.

TanStack Query v5에서는 useSuspenseQuery를 제공한다.

tsx
import { useSuspenseQuery } from '@tanstack/react-query';

// 이 컴포넌트는 로딩 상태를 처리하지 않는다
function UserProfile({ userId }: { userId: string }) {
  const { data: user } = useSuspenseQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  // data는 항상 존재한다 (undefined가 아님)
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

// 로딩 UI는 부모에서 Suspense로 처리
function ProfilePage({ userId }: { userId: string }) {
  return (
    <Suspense fallback={<ProfileSkeleton />}>
      <UserProfile userId={userId} />
    </Suspense>
  );
}

useSuspenseQuery를 쓰면 dataundefined일 가능성이 없다. TypeScript에서도 data의 타입이 User | undefined가 아니라 User로 좁혀진다. null 체크를 안 해도 된다는 게 꽤 편하다.

Suspense 경계 설계#

Suspense를 어디에 두느냐에 따라 사용자 경험이 완전히 달라진다.

tsx
// 패턴 1: 세밀한 경계
function Dashboard() {
  return (
    <div className="grid grid-cols-3 gap-4">
      <Suspense fallback={<SkeletonCard />}>
        <RevenueCard />
      </Suspense>
      <Suspense fallback={<SkeletonCard />}>
        <UsersCard />
      </Suspense>
      <Suspense fallback={<SkeletonCard />}>
        <OrdersCard />
      </Suspense>
    </div>
  );
}

// 패턴 2: 하나의 경계
function Dashboard() {
  return (
    <Suspense fallback={<DashboardSkeleton />}>
      <div className="grid grid-cols-3 gap-4">
        <RevenueCard />
        <UsersCard />
        <OrdersCard />
      </div>
    </Suspense>
  );
}

패턴 1은 각 카드가 독립적으로 로드된다. 빠른 것부터 먼저 보인다. 데이터 소스가 다르고 응답 시간이 다를 때 좋다.

패턴 2는 세 카드가 전부 준비될 때까지 스켈레톤을 보여준다. 한꺼번에 나타나야 자연스러운 UI일 때 좋다.

상황에 따라 다르다. 나는 보통 이렇게 판단한다.

  • 독립적인 데이터 소스를 쓰는 영역 → 개별 Suspense
  • 같은 데이터에 의존하는 관련 영역 → 하나의 Suspense
  • 페이지 전체가 하나의 데이터에 의존 → 페이지 레벨 Suspense

ErrorBoundary와 함께#

Suspense가 로딩을 처리한다면, ErrorBoundary는 에러를 처리한다.

tsx
import { ErrorBoundary } from 'react-error-boundary';

function Dashboard() {
  return (
    <div className="grid grid-cols-3 gap-4">
      <ErrorBoundary fallback={<ErrorCard message="매출 데이터 로드 실패" />}>
        <Suspense fallback={<SkeletonCard />}>
          <RevenueCard />
        </Suspense>
      </ErrorBoundary>

      <ErrorBoundary fallback={<ErrorCard message="사용자 데이터 로드 실패" />}>
        <Suspense fallback={<SkeletonCard />}>
          <UsersCard />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

ErrorBoundary와 Suspense를 함께 두면, 한 영역의 에러가 전체 페이지를 죽이지 않는다. 매출 데이터 API가 500을 반환해도 사용자 데이터는 정상적으로 보인다.

이 패턴을 반복해서 쓰게 되니까, 래퍼 컴포넌트를 만들었다.

tsx
function AsyncBoundary({
  children,
  pendingFallback,
  errorFallback,
}: {
  children: React.ReactNode;
  pendingFallback: React.ReactNode;
  errorFallback: React.ReactNode;
}) {
  return (
    <ErrorBoundary fallback={errorFallback}>
      <Suspense fallback={pendingFallback}>
        {children}
      </Suspense>
    </ErrorBoundary>
  );
}

// 사용
<AsyncBoundary
  pendingFallback={<SkeletonCard />}
  errorFallback={<ErrorCard />}
>
  <RevenueCard />
</AsyncBoundary>

주의할 점#

Suspense 안에서 useEffect로 데이터를 페치하면 Suspense가 동작하지 않는다. Suspense는 렌더링 중에 Promise를 throw하는 방식으로 동작한다. useEffect는 렌더링 이후에 실행되기 때문에 Suspense가 감지할 수 없다. useSuspenseQuery 같은 Suspense를 지원하는 라이브러리를 써야 한다.

Suspense fallback이 너무 자주 보이면 오히려 UX가 나빠진다. 페이지 이동할 때마다 전체가 스켈레톤으로 바뀌면 사용자 입장에서는 느리게 느껴진다. useTransition과 함께 쓰면 이전 UI를 유지하면서 백그라운드에서 새 데이터를 로드할 수 있다.

Suspense를 쓰기 시작하면 컴포넌트의 역할이 명확해진다. 데이터를 가져오는 컴포넌트는 데이터를 가져오고 화면을 그리는 데만 집중한다. 로딩이 어떻게 보이는지는 Suspense boundary가 결정한다. 에러가 어떻게 보이는지는 ErrorBoundary가 결정한다. 관심사가 분리되는 게 코드에서 느껴진다. 한번 이 방식에 익숙해지면 if (isLoading) 패턴으로 돌아가기가 싫어진다.