이전 Chapter에서는 대시보드 페이지를 동적으로 만드는 데 성공했지만, 데이터 Fetch 속도가 느린 경우 애플리케이션의 성능에 어떤 영향을 미칠 수 있는지 논의했습니다.
이번 코스에서는 느린 데이터 요청이 있을 때 사용자 경험을 어떻게 개선할 수 있는지에 대해 살펴보겠습니다.
기존 SSR의 flow를 생각해보면 다음과 같습니다:

문제점: 위의 과정이 모두 끝나기 전까지는 사용자가 페이지와 상호작용을 할 수 없습니다. Next.js에서는 이 문제를 해결하기 위해 Streaming 기술을 도입했습니다.
Next.js에서는 Streaming을 다음과 같이 정의합니다:
경로를 더 작은 "청크(chunk)"로 나누어 서버에서 클라이언트로 점진적으로 스트리밍하는 데이터 전송 기술


Hydrating은 Next.js 서버가 Pre-Rendering된 웹 페이지를 클라이언트에게 보낸 뒤, React가 bundling된 Javascript 코드들을 chunk 단위로 클라이언트에게 전송하고, 이러한 Javascript 코드들이 이전에 전송된 HTML DOM 요소 위로 리렌더링되는 과정에서 자기 자리를 찾아 매칭되는 과정입니다.
Hydrating을 통해 초기 로딩 시 클라이언트에서 즉시 상호작용이 가능하고, 이후에는 일반적인 React 애플리케이션처럼 동작할 수 있습니다.
Next.js에서 스트리밍을 구현하는 방법은 두 가지입니다:
loading.tsx 파일 사용<Suspense> 사용loading.tsx/app/dashboard 디렉터리에 loading.tsx 파일을 생성합니다:
export default function Loading() {
return <div>Loading...</div>;
}
loading.tsx는 Suspense를 기반으로 한 Next.js의 특수 파일입니다<SideNav> 같은 정적 컴포넌트는 즉시 표시되며, 사용자는 동적 콘텐츠가 로드되는 동안에도 상호작용할 수 있습니다
사용자 경험을 개선하기 위해 Loading Skeleton을 추가할 수 있습니다:
import DashboardSkeleton from "../ui/skeletons";
export default function Loading() {
return <DashboardSkeleton />;
}

현재 loading.tsx 파일의 대시보드 Skeleton UI가 송장 페이지와 고객 페이지에도 적용되는 문제가 발생합니다.
원인: /app/dashboard/loading.tsx가 /app/dashboard/invoices/page.tsx 및 /app/dashboard/customers/page.tsx보다 상위 레벨에 위치
Route Groups는 폴더를 경로의 URL 경로에 포함되지 않도록 표시할 수 있는 기능입니다.
()로 감싸서 생성/app/dashboard 하위에 (overview) 폴더를 생성하고, page.tsx와 loading.tsx를 이동:

()로 감싼 디렉터리는 URL 경로에 포함되지 않음/dashboard/(overview)/page.tsx → URL: /dashboard((marketing), (shop)) 또는 팀별로 분리 가능<Suspense>전체 페이지가 아닌 특정 컴포넌트만 스트리밍하고 싶을 때 React Suspense를 사용합니다.
export default async function Page() {
// const revenue = await fetchRevenue(); // 삭제
const latestInvoices = await fetchLatestInvoices();
// ...
}
import { Suspense } from 'react';
return (
<main>
{/* 기존 코드 */}
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
<Suspense fallback={<RevenueChartSkeleton />}>
<RevenueChart />
</Suspense>
<LatestInvoices latestInvoices={latestInvoices} />
</div>
</main>
);
import { fetchRevenue } from "@/app/lib/data";
export default async function RevenueChart() {
const chartHeight = 350;
const revenue = await fetchRevenue(); // 추가
const { yAxisLabels, topLabel } = generateYAxis(revenue);
if (!revenue || revenue.length === 0) {
return <p className="mt-4 text-gray-400">No data available.</p>;
}
// 컴포넌트 렌더링 로직...
}
이전 코드 (Suspense 없음):
// 페이지 전체가 차단되고, 모든 데이터 fetch가 완료되어야 화면 표시
<RevenueChart />
변경 코드 (Suspense 적용):
// 컴포넌트별 스트리밍으로 페이지 전체 차단 없음
// fetchRevenue() 완료 전까지 fallback 컴포넌트 표시
<Suspense fallback={<RevenueChartSkeleton />}>
<RevenueChart />
</Suspense>
1단계: fetchLatestInvoices(), fetchCardData() 진행 중

2단계: 위 함수들 완료 후, fetchRevenue() 수행 전

<Card/> 최적화각 개별 카드에 대한 데이터를 따로 가져오면 Popping 효과(화면 요소가 갑자기 바뀌거나 나타나는 효과)가 발생하여 사용자에게 시각적으로 거슬릴 수 있습니다.
카드들을 그룹화하여 종합된 지연 효과를 만들어 정적인 <SideNav/>가 먼저 표시되고 그 다음에 카드 데이터가 표시되도록 합니다.
import { Suspense } from "react";
import {
CardSkeleton,
LatestInvoicesSkeleton,
RevenueChartSkeleton,
} from "@/app/ui/skeletons";
import CardWrapper from "@/app/ui/dashboard/cards";
export default async function Page() {
return (
<main>
<h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
Dashboard
</h1>
{/* 카드들을 그룹화하여 Suspense로 감싸기 */}
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<Suspense fallback={<CardSkeleton />}>
<CardWrapper />
</Suspense>
</div>
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
<Suspense fallback={<RevenueChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<LatestInvoicesSkeleton />}>
<LatestInvoices />
</Suspense>
</div>
</main>
);
}
export default async function CardWrapper() {
const {
numberOfCustomers,
numberOfInvoices,
totalPaidInvoices,
totalPendingInvoices,
} = await fetchCardData();
return (
<>
<Card title="Collected" value={totalPaidInvoices} type="collected" />
<Card title="Pending" value={totalPendingInvoices} type="pending" />
<Card title="Total Invoices" value={numberOfInvoices} type="invoices" />
<Card
title="Total Customers"
value={numberOfCustomers}
type="customers"
/>
</>
);
}

Suspense 경계를 어디에 놓을지 결정할 때 고려해야 할 사항들:
loading.tsx로 스트리밍<Card/> 컴포넌트의 popping 효과 방지를 위한 그룹화Next.js의 Streaming 기능을 통해 사용자 경험을 크게 개선할 수 있습니다. 핵심은 데이터 로딩 상태를 세밀하게 관리하고, 사용자가 기다리는 시간을 최소화하면서도 시각적으로 안정적인 인터페이스를 제공하는 것입니다.
loading.tsx로 전체 페이지 스트리밍<Suspense>로 세밀한 제어이러한 기법들을 적절히 조합하여 사용자가 만족할 수 있는 웹 애플리케이션을 구축할 수 있습니다.