React 19에서 강화된 Suspense의 고급 패턴, 스트리밍 SSR, 중첩 Suspense 전략, 배칭 동작, Partial Pre-rendering을 다룹니다.
4장에서 use() API가 Suspense와 통합되는 방식을, 5장에서 새로운 훅들과 함께 사용하는 패턴을 살펴보았습니다. 이번 장에서는 Suspense 자체를 깊이 파고들어, React 19에서 달라진 동작, 고급 패턴, 스트리밍 SSR, 그리고 최신 기능인 Partial Pre-rendering까지 다룹니다.
| 버전 | Suspense 기능 |
|---|---|
| React 16.6 | React.lazy() 코드 스플리팅 전용 |
| React 18 | 데이터 페칭, 서버 사이드 스트리밍 지원 |
| React 19 | 즉시 커밋, 프리워밍, RSC 통합 |
| React 19.2 | SSR 배칭, Partial Pre-rendering |
React 18에서는 Suspense fallback이 표시될 때 약간의 지연이 있었습니다. 이는 짧은 네트워크 지연으로 인한 불필요한 로딩 UI 깜빡임을 방지하기 위한 의도였습니다. 하지만 이 동작이 예측하기 어렵고 오히려 사용자 경험을 해치는 경우가 있었습니다.
React 19에서는 Suspense fallback이 즉시 커밋됩니다. 컴포넌트가 suspend하면 곧바로 fallback이 표시됩니다.
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
{/* 이 컴포넌트가 suspend하면 PageSkeleton이 즉시 표시됨 */}
<AsyncPage />
</Suspense>
);
}React 19에서는 컴포넌트가 suspend할 때, 해당 Suspense 경계의 형제(sibling) 컴포넌트들의 lazy 요청을 미리 시작합니다.
function Dashboard() {
return (
<Suspense fallback={<DashboardSkeleton />}>
<SlowDataPanel /> {/* 이것이 suspend하면... */}
<LazyChart /> {/* 이것의 코드 로딩을 미리 시작 */}
<LazyNotifications /> {/* 이것의 코드 로딩도 미리 시작 */}
</Suspense>
);
}
const LazyChart = lazy(() => import('./Chart'));
const LazyNotifications = lazy(() => import('./Notifications'));SlowDataPanel이 데이터를 기다리는 동안, React는 LazyChart와 LazyNotifications의 JavaScript 번들을 미리 다운로드합니다. 모든 준비가 완료되면 한 번에 표시됩니다.
function Page() {
return (
<Suspense fallback={<FullPageSpinner />}>
<Header /> {/* 빠르게 로드됨 */}
<MainContent /> {/* 빠르게 로드됨 */}
<SlowSidebar /> {/* 느리게 로드됨 */}
<SlowComments /> {/* 느리게 로드됨 */}
</Suspense>
);
}이 패턴에서는 SlowSidebar나 SlowComments 중 하나라도 로딩 중이면 전체 페이지가 스피너로 대체됩니다. Header와 MainContent가 이미 준비되어 있어도 표시되지 않습니다.
function Page() {
return (
<div>
{/* 핵심 콘텐츠는 외부 Suspense로 */}
<Suspense fallback={<HeaderSkeleton />}>
<Header />
</Suspense>
<Suspense fallback={<ContentSkeleton />}>
<MainContent />
</Suspense>
{/* 부가 콘텐츠는 별도 Suspense로 */}
<aside>
<Suspense fallback={<SidebarSkeleton />}>
<SlowSidebar />
</Suspense>
</aside>
<Suspense fallback={<CommentsSkeleton />}>
<SlowComments />
</Suspense>
</div>
);
}각 섹션이 독립적으로 로드되므로, 빠르게 준비된 부분부터 순차적으로 표시됩니다.
Suspense를 중첩하면 점진적 로딩(Progressive Loading) UI를 구현할 수 있습니다.
function ArticlePage() {
return (
<Suspense fallback={<ArticlePageSkeleton />}>
{/* 1단계: 전체 페이지 구조가 로드됨 */}
<ArticleLayout>
<ArticleHeader />
<ArticleBody />
{/* 2단계: 본문 로드 후 댓글 영역 로드 시작 */}
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
{/* 3단계: 댓글 로드 후 추천 글 로드 시작 */}
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations />
</Suspense>
</Suspense>
</ArticleLayout>
</Suspense>
);
}사용자는 세 단계에 걸쳐 콘텐츠가 채워지는 것을 봅니다. 각 단계에서 해당 영역의 skeleton이 실제 콘텐츠로 교체됩니다.
전통적 SSR은 모든 데이터를 가져온 후 전체 HTML을 한 번에 전송합니다.
스트리밍 SSR은 준비된 부분부터 점진적으로 전송합니다.
Suspense 경계가 스트리밍의 단위가 됩니다. 각 Suspense 경계는 독립적인 HTML 청크로 전송됩니다.
// app/articles/[id]/page.tsx
async function ArticlePage({ params }: { params: { id: string } }) {
const article = await getArticle(params.id);
return (
<div>
{/* 이 부분은 즉시 전송 */}
<h1>{article.title}</h1>
<p>{article.content}</p>
{/* 이 부분은 데이터 준비되면 스트리밍 */}
<Suspense fallback={<div>댓글 로딩 중...</div>}>
<Comments articleId={params.id} />
</Suspense>
{/* 이 부분도 독립적으로 스트리밍 */}
<Suspense fallback={<div>관련 글 로딩 중...</div>}>
<RelatedArticles articleId={params.id} />
</Suspense>
</div>
);
}
async function Comments({ articleId }: { articleId: string }) {
// 이 await가 완료되면 HTML 청크로 스트리밍됨
const comments = await getComments(articleId);
return (
<ul>
{comments.map(c => <li key={c.id}>{c.text}</li>)}
</ul>
);
}스트리밍 SSR에서는 하이드레이션도 점진적으로 수행됩니다.
function Page() {
return (
<div>
{/* 1순위: 사용자가 상호작용하는 영역을 먼저 하이드레이션 */}
<Suspense fallback={<NavSkeleton />}>
<Navigation />
</Suspense>
<main>
{/* 2순위: 메인 콘텐츠 */}
<Suspense fallback={<ContentSkeleton />}>
<Content />
</Suspense>
{/* 3순위: 하단 영역은 나중에 하이드레이션 */}
<Suspense fallback={<FooterSkeleton />}>
<InteractiveFooter />
</Suspense>
</main>
</div>
);
}사용자가 아직 하이드레이션되지 않은 영역을 클릭하면, React는 해당 영역의 하이드레이션을 우선적으로 처리합니다.
React 19.2에서는 서버 렌더링된 Suspense 경계들이 비슷한 시점에 해결되면 함께 드러나는(reveal) 배칭 동작이 추가되었습니다.
function Dashboard() {
return (
<div className="grid grid-cols-3 gap-4">
<Suspense fallback={<CardSkeleton />}>
<StatsCard /> {/* 200ms 후 준비 */}
</Suspense>
<Suspense fallback={<CardSkeleton />}>
<ChartCard /> {/* 220ms 후 준비 */}
</Suspense>
<Suspense fallback={<CardSkeleton />}>
<ActivityCard /> {/* 250ms 후 준비 */}
</Suspense>
</div>
);
}세 카드가 비슷한 시점(200~250ms)에 준비되면, React는 이들을 모아서 한 번에 표시합니다. 하나씩 따로따로 나타나는 것보다 시각적으로 안정적입니다.
배칭에는 LCP(Largest Contentful Paint) 보호 휴리스틱이 포함되어 있습니다. 페이지 로딩이 2.5초에 근접하면 배칭을 중단하고 준비된 콘텐츠부터 표시합니다. 이는 Core Web Vitals 지표를 보호하기 위한 장치입니다.
function Skeleton({ className }: { className?: string }) {
return (
<div
className={cn(
'animate-pulse rounded bg-gray-200 dark:bg-gray-700',
className
)}
/>
);
}
function ArticleSkeleton() {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-3/4" /> {/* 제목 */}
<Skeleton className="h-4 w-1/4" /> {/* 날짜 */}
<div className="space-y-2">
<Skeleton className="h-4 w-full" /> {/* 본문 줄 */}
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
</div>
);
}
function CommentsSkeleton() {
return (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex gap-3">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-1/4" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
))}
</div>
);
}Next.js App Router에서는 loading.tsx 파일이 자동으로 Suspense 경계를 생성합니다.
function ArticlesLoading() {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">글 목록</h1>
<div className="grid gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<ArticleSkeleton key={i} />
))}
</div>
</div>
);
}
export default ArticlesLoading;이 파일은 내부적으로 다음과 동일하게 동작합니다.
<Suspense fallback={<ArticlesLoading />}>
<ArticlesPage />
</Suspense>React 19.2에서 도입된 Partial Pre-rendering은 정적 셸과 동적 콘텐츠를 분리하여 최적의 성능을 달성하는 기법입니다.
// 이 부분은 빌드 타임에 정적으로 렌더링됨
async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id);
return (
<div>
{/* 정적 영역: CDN 캐시 가능 */}
<header>
<Navigation />
<Breadcrumb product={product} />
</header>
<main>
<h1>{product.name}</h1>
<ProductImages images={product.images} />
<ProductDescription description={product.description} />
{/* 동적 영역: 요청 시점에 스트리밍 */}
<Suspense fallback={<PriceSkeleton />}>
<DynamicPrice productId={product.id} />
</Suspense>
<Suspense fallback={<StockSkeleton />}>
<StockStatus productId={product.id} />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<RecentReviews productId={product.id} />
</Suspense>
</main>
</div>
);
}| 지표 | 전통적 SSR | 스트리밍 SSR | PPR |
|---|---|---|---|
| TTFB | 느림 (모든 데이터 대기) | 빠름 | 매우 빠름 (CDN) |
| FCP | 느림 | 빠름 | 매우 빠름 |
| LCP | 보통 | 보통 | 빠름 |
| TTI | 느림 | 보통 | 빠름 |
| 캐싱 | 어려움 | 어려움 | 정적 부분 캐싱 가능 |
React DevTools의 Profiler 탭에서 Suspense 경계의 fallback 표시 시간을 확인할 수 있습니다. 어떤 컴포넌트가 suspend하고 있는지, 얼마나 오래 fallback이 표시되는지를 파악하여 성능을 최적화합니다.
React 19.2에서는 Chrome DevTools의 Performance 탭에 React 전용 트랙이 추가되었습니다. 이 트랙에서 다음을 확인할 수 있습니다.
다음 장에서는 React Compiler의 동작 원리와 적용 방법을 살펴봅니다.
이 글이 도움이 되셨나요?
React Compiler의 동작 원리, HIR 기반 분석, 자동 메모이제이션, 설치와 설정, ESLint 통합, 실전 적용 전략을 다룹니다.
React 19의 새로운 훅 3종을 심층 분석합니다. 폼 상태 관리, 제출 상태 추적, 낙관적 UI 업데이트의 실전 패턴을 다룹니다.
React 19의 DX 개선사항을 다룹니다. ref를 일반 props로 전달하는 방법, 컴포넌트 내 메타데이터 태그, 리소스 프리로딩 API를 살펴봅니다.