본문 바로가기
C.W.K.
Stream
Lesson 02 of 07 · published

Suspense — 로딩 상태가 사는 자리

~13 min · suspense, boundary, fallback

Level 0React 입문자
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
Suspense는 기다림을 잡는 경계야. 로딩 UI를 경계에 맡기면 데이터를 쓰는 컴포넌트는 성공 경로에 집중할 수 있어.

작동 원리

자식 컴포넌트가 use(somePendingPromise)로 대기 중인 프로미스를 읽으면 React는 해당 렌더링을 중단해. 트리를 거슬러 올라가 가장 가까운 Suspense 경계를 찾고 그 경계의 대체 UI를 보여 줘. 프로미스가 이행되면 중단됐던 부분을 다시 렌더링해 실제 콘텐츠로 바꿔.

경계의 크기

페이지 전체를 경계 하나로 감싸면 구현은 쉽지만 데이터 하나를 기다릴 때도 화면 전체가 spinner로 바뀌어. 사이드바와 메인 패널처럼 의미 있는 영역별로 나누면 준비된 부분은 그대로 사용할 수 있어. 반대로 목록의 모든 행을 따로 감싸면 구조와 로딩 UI가 지나치게 복잡해질 수 있으니, 사용자가 하나의 섹션으로 인식하는 범위를 기준으로 골라.

중첩된 경계

안쪽 경계는 자기 자식에서 발생한 중단을 먼저 처리하고, 안쪽에 경계가 없으면 바깥 경계가 맡아. 페이지 shell은 즉시 보여 주고 데이터 섹션만 나중에 채우는 구조를 이 중첩으로 만들 수 있어.

Transition과 함께 쓰기

검색이나 필터 변경이 새 데이터를 요구할 때 평범한 상태 업데이트를 하면 기존 화면이 곧바로 대체 UI로 바뀔 수 있어. startTransition으로 긴급하지 않은 업데이트라고 알려 주면 React가 새 화면을 준비하는 동안 기존 화면을 유지해 더 부드럽게 전환할 수 있어.

경계는 사용자가 인식하는 가장 작은 의미 단위에 둬. 너무 크면 기다리는 동안 화면을 많이 잃고, 너무 작으면 로딩 UI와 구조가 시끄러워져. 어느 영역을 독립적으로 계속 쓸 수 있어야 하는지가 기준이야.

경계의 크기가 로딩 경험을 정해

앱 전체를 Suspense 하나로 감싸면 작은 위젯 하나가 기다릴 때도 화면 전체가 대체 UI로 바뀔 수 있어. Sidebar, 본문, 보조 패널처럼 서로 독립적으로 준비될 수 있는 영역에 경계를 나누면 먼저 준비된 부분부터 보여 줄 수 있어.

중첩 경계는 가장 가까운 곳이 처리해

자식이 대기 중 프로미스를 읽으면 가장 가까운 Suspense가 중단 신호를 받아. 안쪽 경계가 없으면 바깥쪽으로 올라가고, 어떤 조상에도 없으면 정상적인 로딩 UI 대신 오류가 돼.

Fallback은 최종 화면과 비슷한 크기를 유지해 layout shift를 줄여. 오류는 Suspense가 아니라 오류 경계가 처리하므로 두 경계를 필요한 순서로 함께 배치해야 해.

Code

Suspense 경계를 나눠 배치한 페이지·tsx
import { Suspense } from "react";
import { ConversationList } from "./ConversationList";
import { ConversationDetail } from "./ConversationDetail";

function ChatPage({ activeId }: { activeId: string }) {
  return (
    <div className="flex h-screen">
      {/* Sidebar는 다른 영역과 별개로 준비돼. */}
      <aside className="w-64 border-r">
        <Suspense fallback={<SidebarSkeleton />}>
          <ConversationList />
        </Suspense>
      </aside>

      {/* 메인 패널도 별도의 경계에서 준비돼. */}
      <main className="flex-1">
        <Suspense fallback={<DetailSkeleton />}>
          <ConversationDetail id={activeId} />
        </Suspense>
      </main>
    </div>
  );
}

// 페이지 shell과 header, navigation은 즉시 렌더링돼.
// Sidebar와 메인 패널은 각 데이터가 준비되는 대로 채워져.
중첩 Suspense: 안쪽이 먼저 잡음·tsx
function Profile({ userId }: { userId: string }) {
  return (
    <Suspense fallback={<p>Loading profile…</p>}>
      <UserHeader userId={userId} />
      {/* 중첩 경계는 더 느린 데이터 영역만 별도 대체 UI로 감싸. */}
      <Suspense fallback={<p>Loading posts…</p>}>
        <UserPosts userId={userId} />
      </Suspense>
    </Suspense>
  );
}

// UserHeader가 준비되면 먼저 렌더링돼. UserPosts를 기다리는 동안에는
// 안쪽 경계의 대체 UI만 보이고 header는 그대로 유지돼.

External links

Exercise

각 pane이 다른 데이터 페치하는 2-pane 레이아웃 (sidebar + main) 빌드. Suspense 경계 배치해서 두 pane이 독립적으로 로드. 그 다음 일부러 fetch 하나 느리게 (3초 후 이행 하는 프로미스) 만들고 다른 pane이 안 기다리고 렌더하는지 확인. 메인 pane 안에 보조 widget 주변 중첩 Suspense 추가해서 계층 동작 봐.
Hint
느린 fetch는 진짜 fetch를 new Promise((r) => setTimeout(() => fetch(...).then(j => r(j)), 3000))으로 감싸. 빠른 pane은 즉시 나타나야. 느린 pane의 skeleton은 timeout까지 유지해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.