Next.js의 스트리밍 SSR 동작 원리를 살펴봅니다. loading.tsx, Suspense 경계, 스켈레톤 설계, 프로그레시브 렌더링 전략과 성능 지표 영향을 다룹니다.
6장에서 Cache Components를 다루면서 Suspense와의 결합을 언급했습니다. 이번 장에서는 스트리밍 SSR의 동작 원리를 깊이 파고들고, Next.js에서 로딩 UI를 설계하는 전략을 체계적으로 살펴봅니다.
전통적 SSR에서는 서버가 페이지 전체의 HTML을 생성한 뒤 클라이언트에 전송합니다. 이 방식에는 근본적인 문제가 있습니다.
모든 데이터가 준비될 때까지 클라이언트는 빈 화면을 봅니다. 헤더, 사이드바 같은 빠르게 준비되는 부분도 느린 데이터 요청이 완료될 때까지 기다려야 합니다. 가장 느린 데이터 소스가 전체 페이지의 TTFB(Time to First Byte)를 결정합니다.
스트리밍 SSR은 서버가 HTML을 점진적으로 전송합니다. 준비된 부분부터 먼저 보내고, 나머지는 준비되는 대로 추가로 스트리밍합니다.
핵심: 각 섹션의 데이터가 독립적으로 준비되며, 준비된 순서대로 화면에 나타납니다.
| 지표 | 전통적 SSR | 스트리밍 SSR |
|---|---|---|
| TTFB | 모든 데이터 완료 후 | 셸 즉시 전송 |
| FCP | 전체 HTML 수신 후 | 셸 수신 즉시 |
| LCP | 전체 HTML 수신 후 | 주요 콘텐츠 스트리밍 시 |
| TTI | 전체 Hydration 후 | 점진적 Hydration |
스트리밍 SSR은 TTFB와 FCP를 극적으로 개선합니다. 사용자는 전체 데이터가 준비되기 전에 의미 있는 콘텐츠를 볼 수 있습니다.
Next.js App Router에서 loading.tsx 파일을 생성하면, 해당 라우트 세그먼트에 자동으로 Suspense 경계가 래핑됩니다.
app/
dashboard/
loading.tsx ← 자동 Suspense fallback
page.tsx
layout.tsx
export default function DashboardLoading() {
return (
<div className="animate-pulse space-y-4 p-6">
<div className="h-8 w-1/3 rounded bg-gray-200 dark:bg-gray-700" />
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="h-32 rounded-lg bg-gray-200 dark:bg-gray-700"
/>
))}
</div>
<div className="h-64 rounded-lg bg-gray-200 dark:bg-gray-700" />
</div>
);
}이 파일은 Next.js가 내부적으로 다음과 같이 변환합니다.
import Loading from './loading';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<Suspense fallback={<Loading />}>
{children}
</Suspense>
);
}loading.tsx는 해당 세그먼트의 page.tsx에만 적용됩니다. 같은 레이아웃을 공유하는 다른 세그먼트에는 영향을 주지 않습니다. 레이아웃 자체는 loading.tsx로 래핑되지 않으므로, 레이아웃의 데이터 로딩 상태를 처리하려면 별도의 Suspense를 사용해야 합니다.
loading.tsx는 편리하지만 한 가지 한계가 있습니다. 페이지 전체를 하나의 로딩 상태로 처리한다는 점입니다. 페이지 내에 빠른 섹션과 느린 섹션이 섞여 있으면, 모든 것이 로딩 상태로 표시됩니다.
이 한계를 극복하려면 세분화된 Suspense 경계가 필요합니다.
페이지 내부에서 각 섹션을 독립적인 Suspense로 감싸면, 섹션별로 독립적인 스트리밍이 가능합니다.
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">대시보드</h1>
{/* 통계 카드: 빠르게 로드됨 */}
<Suspense fallback={<StatsSkeleton />}>
<StatsCards />
</Suspense>
<div className="grid grid-cols-12 gap-6">
{/* 차트: 중간 속도 */}
<div className="col-span-8">
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
</div>
{/* 최근 활동: 느리게 로드됨 */}
<div className="col-span-4">
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
</div>
{/* 테이블: 가장 느림 */}
<Suspense fallback={<TableSkeleton rows={10} />}>
<OrdersTable />
</Suspense>
</div>
);
}async function StatsCards() {
const stats = await getStats(); // ~100ms
return (
<div className="grid grid-cols-4 gap-4">
{stats.map(stat => (
<div key={stat.label} className="rounded-lg border p-4">
<p className="text-sm text-gray-500">{stat.label}</p>
<p className="text-2xl font-bold">{stat.value}</p>
</div>
))}
</div>
);
}async function RevenueChart() {
const data = await getRevenueData(); // ~500ms
return (
<div className="rounded-lg border p-6">
<h2 className="text-lg font-semibold">매출 추이</h2>
<Chart data={data} />
</div>
);
}이 구조에서 각 섹션은 데이터가 준비되는 순서대로 화면에 나타납니다. StatsCards가 100ms 만에 준비되면 즉시 표시되고, RevenueChart는 500ms 후, OrdersTable은 1초 후에 각각 스트리밍됩니다.
효과적인 스켈레톤은 실제 콘텐츠의 레이아웃을 충실히 반영합니다. 이를 레이아웃 시프트(Layout Shift)를 방지하는 핵심 전략이라고 합니다.
import { cn } from '@/lib/utils';
interface SkeletonProps {
className?: string;
}
export function Skeleton({ className }: SkeletonProps) {
return (
<div
className={cn(
'animate-pulse rounded bg-gray-200 dark:bg-gray-700',
className
)}
/>
);
}
export function SkeletonText({
lines = 3,
className,
}: {
lines?: number;
className?: string;
}) {
return (
<div className={cn('space-y-2', className)}>
{Array.from({ length: lines }).map((_, i) => (
<Skeleton
key={i}
className={cn(
'h-4',
i === lines - 1 ? 'w-2/3' : 'w-full'
)}
/>
))}
</div>
);
}import { Skeleton } from '@/components/Skeleton';
export function StatsSkeleton() {
return (
<div className="grid grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-lg border p-4">
<Skeleton className="mb-2 h-4 w-20" />
<Skeleton className="h-8 w-16" />
</div>
))}
</div>
);
}import { Skeleton } from '@/components/Skeleton';
export function TableSkeleton({ rows = 5 }: { rows?: number }) {
return (
<div className="rounded-lg border">
{/* 테이블 헤더 */}
<div className="flex gap-4 border-b p-4">
<Skeleton className="h-4 w-1/4" />
<Skeleton className="h-4 w-1/4" />
<Skeleton className="h-4 w-1/4" />
<Skeleton className="h-4 w-1/4" />
</div>
{/* 테이블 로우 */}
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex gap-4 border-b p-4 last:border-b-0">
<Skeleton className="h-4 w-1/4" />
<Skeleton className="h-4 w-1/4" />
<Skeleton className="h-4 w-1/4" />
<Skeleton className="h-4 w-1/4" />
</div>
))}
</div>
);
}스켈레톤의 크기는 실제 콘텐츠와 최대한 일치시켜야 합니다. 스켈레톤과 실제 콘텐츠의 높이 차이가 CLS(Cumulative Layout Shift)를 발생시키고, 이는 사용자 경험과 Core Web Vitals 점수에 부정적 영향을 줍니다.
스켈레톤에 적절한 접근성 속성을 추가합니다.
export function SkeletonWithA11y({ label }: { label: string }) {
return (
<div role="status" aria-label={`${label} 로딩 중`}>
<Skeleton className="h-32 w-full" />
<span className="sr-only">{label} 로딩 중</span>
</div>
);
}모든 섹션의 중요도가 같지는 않습니다. 사용자에게 가장 중요한 콘텐츠가 먼저 표시되도록 설계합니다.
export default function ProductPage({ params }: PageProps) {
return (
<div>
{/* 핵심 정보: Suspense 없이 즉시 렌더링 */}
<ProductHeader />
{/* 중요: 첫 번째로 스트리밍 */}
<Suspense fallback={<PriceSkeleton />}>
<ProductPrice />
</Suspense>
{/* 보통: 두 번째로 스트리밍 */}
<Suspense fallback={<DescriptionSkeleton />}>
<ProductDescription />
</Suspense>
{/* 낮음: 마지막으로 스트리밍 */}
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews />
</Suspense>
</div>
);
}Suspense 경계를 중첩하여 더 세밀한 스트리밍 제어가 가능합니다.
export default function BlogPage() {
return (
<Suspense fallback={<PageSkeleton />}>
<BlogContent />
</Suspense>
);
}
async function BlogContent() {
const posts = await getPosts(); // 500ms
return (
<div>
<PostList posts={posts} />
{/* 게시물이 먼저 표시된 후, 사이드바가 스트리밍됨 */}
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
</div>
);
}
async function Sidebar() {
const trending = await getTrending(); // 1500ms
return <TrendingList items={trending} />;
}이 패턴에서 BlogContent의 게시물 목록이 먼저 스트리밍되고, Sidebar는 그 이후에 독립적으로 스트리밍됩니다.
loading.tsx를 페이지 수준의 fallback으로, Suspense를 세부 섹션의 fallback으로 사용하는 조합 전략입니다.
app/
dashboard/
loading.tsx ← 페이지 전환 시 즉시 표시
page.tsx ← 내부에 세분화된 Suspense
// 간단한 전체 페이지 스켈레톤
export default function Loading() {
return <DashboardSkeleton />;
}// 세분화된 Suspense로 점진적 로딩
export default function DashboardPage() {
return (
<div>
<Suspense fallback={<HeaderSkeleton />}>
<DashboardHeader />
</Suspense>
<Suspense fallback={<ContentSkeleton />}>
<DashboardContent />
</Suspense>
</div>
);
}loading.tsx는 페이지 탐색(네비게이션) 시에 즉시 표시되어 탐색이 즉각적으로 느껴지게 합니다. 페이지가 로드된 후에는 내부 Suspense 경계가 개별 섹션의 로딩 상태를 관리합니다.
스트리밍 중 특정 섹션에서 에러가 발생하면, 해당 섹션만 에러 상태로 표시되고 나머지는 정상적으로 렌더링됩니다. 이는 error.tsx와 Error Boundary의 조합으로 구현합니다.
'use client';
export function SectionError({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div className="rounded-lg border border-red-200 bg-red-50 p-4
dark:border-red-800 dark:bg-red-900/20">
<p className="text-sm text-red-600 dark:text-red-400">
이 섹션을 불러오는 중 문제가 발생했습니다.
</p>
<button
onClick={reset}
className="mt-2 text-sm font-medium text-red-600
hover:underline dark:text-red-400"
>
다시 시도
</button>
</div>
);
}import { ErrorBoundary } from 'react-error-boundary';
export default function DashboardPage() {
return (
<div>
<ErrorBoundary fallback={<SectionError />}>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
</ErrorBoundary>
<ErrorBoundary fallback={<SectionError />}>
<Suspense fallback={<TableSkeleton />}>
<OrdersTable />
</Suspense>
</ErrorBoundary>
</div>
);
}스트리밍 SSR을 최대한 활용하기 위한 체크리스트입니다.
독립적인 데이터 요청: 각 Suspense 경계 내의 컴포넌트는 독립적으로 데이터를 요청해야 합니다. 데이터 간 의존성이 있으면 워터폴이 발생합니다.
적절한 Suspense 세분화: 너무 많은 Suspense 경계는 시각적 혼란을 줍니다. 논리적 섹션 단위로 묶는 것이 좋습니다.
스켈레톤 크기 일치: 스켈레톤과 실제 콘텐츠의 크기가 다르면 레이아웃 시프트가 발생합니다.
모션 설정 존중: prefers-reduced-motion 미디어 쿼리를 확인하여 애니메이션을 비활성화합니다.
@media (prefers-reduced-motion: reduce) {
.animate-pulse {
animation: none;
}
}loading.tsx는 라우트 세그먼트 전체에 자동으로 Suspense를 래핑합니다. 페이지 탐색 시 즉시 로딩 UI를 표시합니다.다음 장에서는 Server Actions와 폼 처리 고급 패턴을 다룹니다. 데이터를 가져오는 것에서 데이터를 변경하는 것으로 초점을 옮겨, 서버와 클라이언트의 상호작용을 완성합니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
Next.js의 Server Actions와 폼 처리를 다룹니다. next/form, useActionState, 보안, revalidation 통합, after() API, 고급 뮤테이션 패턴을 살펴봅니다.
Next.js 16의 Cache Components 시스템을 다룹니다. 'use cache' 디렉티브, cacheLife 프로필, cacheTag 무효화, 세 가지 캐시 변형을 살펴봅니다.
Next.js의 차세대 번들러 Turbopack을 다룹니다. Rust 기반 아키텍처, 성능 벤치마크, FS 캐싱, Webpack 마이그레이션, 설정 방법을 살펴봅니다.