이전 장에서는 대시보드 페이지를 동적으로 만들었지만 느린 데이터 가져오기가 애플리케이션 성능에 어떤 영향을 미칠 수 있는지 논의했습니다. 데이터 요청이 느린 경우 사용자 경험을 개선할 수 있는 방법을 살펴보겠습니다.
loading.tsx와 Suspense를 사용하여 스트리밍을 구현하는 방법.스트리밍은 경로를 더 작은 "chunks"로 나누고 준비가 되면 서버에서 클라이언트로 점진적으로 스트리밍할 수 있는 데이터 전송 기술입니다.

스트리밍하면 느린 데이터 요청이 전체 페이지를 차단하는 것을 방지할 수 있습니다. 이를 통해 사용자는 UI가 사용자에게 표시되기 전에 모든 데이터가 로드될 때까지 기다리지 않고 페이지의 일부를 보고 상호 작용할 수 있습니다.

스트리밍은 각 구성 요소가 덩어리(chunk)로 간주될 수 있으므로 React의 구성 요소 모델과 잘 작동합니다.
Next.js에서 스트리밍을 구현하는 방법에는 두 가지가 있습니다.
loading.tsx 파일을 사용합니다.<Suspense>를 사용합니다.이것이 어떻게 작동하는지 봅시다.
loading.tsx로 전체 페이지 스트리밍/app/dashboard 폴더에서 loading.tsx이라는 새 파일을 만듭니다.
// /app/dashboard/loading.tsx
export default function Loading() {
return <div>Loading...</div>;
}
http://localhost:3000/dashboard 새로 고침, 이제 다음이 표시됩니다.

여기서는 몇 가지 일이 일어나고 있습니다.
loading.tsx는 Suspense를 기반으로 구축된 특별한 Next.js 파일로, 페이지 콘텐츠가 로드되는 동안 대체 UI로 표시할 폴백 UI를 생성할 수 있습니다.<SideNav>는 정적이므로, 즉시 표시됩니다. 사용자는 동적 콘텐츠가 로드되는 동안 <SideNav>와 상호 작용할 수 있습니다.축하합니다. 방금 스트리밍을 구현했습니다. 하지만 우리는 사용자 경험을 개선하기 위해 더 많은 일을 할 수 있습니다. Loading… 텍스트 대신 로딩 뼈대를 보여드리겠습니다.
로딩 스켈레톤은 UI의 단순화된 버전입니다. 많은 웹사이트에서는 이를 placeholder(또는 fallback)로 사용하여 사용자에게 콘텐츠가 로드 중임을 나타냅니다. loading.tsx에 내장된 모든 UI는 정적 파일의 일부로 포함되어 먼저 전송됩니다. 그런 다음 나머지 동적 콘텐츠가 서버에서 클라이언트로 스트리밍됩니다.
loading.tsx 파일에서 <DashboardSkeleton>이라는 새 구성 요소를 가져옵니다.
import DashboardSkeleton from '@/app/ui/skeletons'; // DashboardSkeleton 불러오기
export default function Loading() {
return <DashboardSkeleton />; // 여기 추가
}
그런 다음 http://localhost:3000/dashboard 를 새로고침합니다. 이제 다음이 표시됩니다.

현재 로딩 뼈대는 송장 및 고객 페이지에도 적용됩니다.
loading.tsx는 파일 시스템에서 ./invoices/page.tsx, /customers/page.tsx
보다 높은 수준이므로 해당 페이지에도 적용 됩니다
Route Groups를 사용하여 이를 변경할 수 있습니다. 대시보드 폴더 안에 라는 새 폴더 /(overview)를 만듭니다. 그런 다음 loading.tsx 및 page.tsx파일을 폴더 내부로 이동하십시오.

이제 loading.tsx 파일은 대시보드 개요 페이지에만 적용됩니다.
경로 그룹을 사용하면 URL 경로 구조에 영향을 주지 않고 파일을 논리적 그룹으로 구성할 수 있습니다. 괄호를 사용하여 새 폴더를 생성하면 () 이름이 URL 경로에 포함되지 않습니다. 그래서 /dashboard/(overview)/page.tsx는 /dashboard가 됩니다.
여기에서는 loading.tsx를 대시보드 개요(overview) 페이지에만 적용되도록 경로 그룹을 사용하고 있습니다. 그러나 경로 그룹을 사용하여 애플리케이션을 섹션(예: (marketing) 경로 및 (shop) 경로)으로 분리하거나 대규모 애플리케이션의 경우 팀별로 분리할 수도 있습니다.
지금까지는 전체 페이지를 스트리밍하고 있습니다. 하지만 그 대신 React Suspense를 사용하면 더욱 세분화되고 특정 구성 요소를 스트리밍할 수 있습니다.
Suspense를 사용하면 일부 조건이 충족될 때까지(예: 데이터 로드) 애플리케이션의 렌더링 부분을 연기할 수 있습니다. Suspense에서 동적 구성 요소를 래핑할 수 있습니다. 그런 다음 동적 구성 요소가 로드되는 동안 표시할 대체 구성 요소를 전달합니다.
느린 데이터 요청을 기억하신다면, fetchRevenue()는 전체 페이지의 속도를 늦추는 요청입니다. 페이지를 차단하는 대신 Suspense를 사용하여 이 구성 요소만 스트리밍하고 페이지 UI의 나머지 부분을 즉시 표시할 수 있습니다.
이렇게 하려면 가져온 데이터를 구성 요소로 이동해야 합니다. 코드를 업데이트하여 어떻게 보이는지 살펴보겠습니다.
/dashboard/(overview)/page.tsx에서 fetchRevenue()의 모든 인스턴스와 해당 데이터를 삭제합니다.
import { Card } from '@/app/ui/dashboard/cards';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { lusitana } from '@/app/ui/fonts';
import { fetchLatestInvoices, fetchCardData } from '@/app/lib/data'; // fetchRevenue 지우기
export default async function Page() {
const revenue = await fetchRevenue // 이 줄 삭제
const latestInvoices = await fetchLatestInvoices();
const {
numberOfInvoices,
numberOfCustomers,
totalPaidInvoices,
totalPendingInvoices,
} = await fetchCardData();
return (
// ...
);
}
그런 다음 React에서 <Suspense>를 가져와서 <RevenueChart />에 감쌀 수 있습니다. <RevenueChartSkeleton>라는 대체(fallback) 구성 요소를 전달할 수 있습니다.
// /app/dashboard/(overview)/page.tsx
import { Card } from '@/app/ui/dashboard/cards';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { lusitana } from '@/app/ui/fonts';
import { fetchLatestInvoices, fetchCardData } from '@/app/lib/data';
import { Suspense } from 'react'; // Suspense 불러오기
import { RevenueChartSkeleton } from '@/app/ui/skeletons'; // fallback 불러오기
export default async function Page() {
const latestInvoices = await fetchLatestInvoices();
const {
numberOfInvoices,
numberOfCustomers,
totalPaidInvoices,
totalPendingInvoices,
} = await fetchCardData();
return (
<main>
<h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
Dashboard
</h1>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<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"
/>
</div>
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
{/* fallback 전달 */}
<Suspense fallback={<RevenueChartSkeleton />}>
<RevenueChart />
</Suspense>
<LatestInvoices latestInvoices={latestInvoices} />
</div>
</main>
);
}
마지막으로, 자체 데이터를 가져오도록 <RevenueChart> 구성 요소를 업데이트하고 전달된 prop을 제거합니다.
// /app/ui/dashboard/revenue-chart.tsx
import { generateYAxis } from '@/app/lib/utils';
import { CalendarIcon } from '@heroicons/react/24/outline';
import { lusitana } from '@/app/ui/fonts';
import { fetchRevenue } from '@/app/lib/data'; // fetchRevenue 불러오기
// ...
export default async function RevenueChart() { // 구성 요소를 비동기화하고, prop 제거하기
const revenue = await fetchRevenue(); // 구성 요소 내부에서 데이터 가져오기
const chartHeight = 350;
const { yAxisLabels, topLabel } = generateYAxis(revenue);
if (!revenue || revenue.length === 0) {
return <p className="mt-4 text-gray-400">No data available.</p>;
}
return (
// ...
);
}
이제 페이지를 새로고침하면 대시보드 정보가 거의 즉시 표시되고 <RevenueChart>에 대한 대체 뼈대가 표시됩니다.

<LatestInvoices> 스트리밍이제 당신 차례입니다. <LatestInvoices> 구성 요소를 스트리밍하여 방금 배운 내용을 연습해보세요.
페이지에서 fetchLatestInvoices()를 <LatestInvoices> 구성 요소 아래로 이동시킵니다. <LatestInvoicesSkeleton>라는 폴백을 사용하여 <Suspense> 범위 내로 구성 요소를 래핑합니다.
좋아요. 거의 다 왔습니다. 이제 Suspense에서 <Card> 구성 요소를 래핑해야 합니다. 각 개별 카드에 대한 데이터를 가져올 수 있지만 이로 인해 카드가 로드될 때 팝업 효과가 발생할 수 있으며 이는 사용자에게 시각적으로 불편할 수 있습니다.
그렇다면 이 문제를 어떻게 해결하시겠습니까?
더 많은 시차 효과를 생성하려면 래퍼 구성 요소를 사용하여 카드를 그룹화할 수 있습니다. 즉, 정적인 <SideNav/>가 먼저 표시되고 그다음에 카드 등이 표시됩니다.
page.tsx 파일에서:
1. <Card> 구성 요소를 삭제하세요.
2. fetchCardData() 함수를 삭제합니다.
3. <CardWrapper />라는 새 래퍼 구성 요소를 가져옵니다.
4. <CardsSkeleton />라는 새 뼈대 구성 요소를 가져옵니다.
5. <CardWrapper /> 서스펜스로 감싸세요.
import CardWrapper from '@/app/ui/dashboard/cards'; // 래퍼 불러오기
// ...
import {
RevenueChartSkeleton,
LatestInvoicesSkeleton,
CardsSkeleton, // 여기 추가
} from '@/app/ui/skeletons';
export default async function Page() {
return (
<main>
<h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
Dashboard
</h1>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{/* 구성 요소 래핑 */}
<Suspense fallback={<CardsSkeleton />}>
<CardWrapper />
</Suspense>
</div>
// ...
</main>
);
}
그런 다음 /app/ui/dashboard/cards.tsx 파일로 이동하여 fetchCardData() 함수를 가져온 다음 <CardWrapper/> 구성 요소 내에서 호출합니다. 이 구성요소에서 필요한 코드의 주석 처리를 제거했는지 확인하세요.
// ...
import { fetchCardData } from '@/app/lib/data'; // 함수 불러오기
// ...
export default async function CardWrapper() {
// 변수 호출
const {
numberOfInvoices,
numberOfCustomers,
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... 에서 했던 것처럼 전체 페이지를 스트리밍할 수 있지만 구성 요소 중 하나의 데이터 가져오기 속도가 느린 경우 로드 시간이 길어질 수 있습니다.서스펜스 영역을 배치하는 위치는 애플리케이션에 따라 달라집니다. 일반적으로 데이터 가져오기를 필요한 구성 요소로 이동한 다음 Suspense에서 해당 구성 요소를 래핑하는 것이 좋습니다. 그러나 애플리케이션에 필요한 경우 섹션이나 전체 페이지를 스트리밍하는 데엔 아무런 문제가 없습니다.
Suspense를 실험해보고 가장 효과적인 것이 무엇인지 알아보는 것을 두려워하지 마십시오. Suspense는 보다 즐거운 사용자 경험을 만드는 데 도움이 될 수 있는 강력한 API입니다.
스트리밍 및 서버 구성 요소는 궁극적으로 최종 사용자 경험을 개선한다는 목표를 가지고 데이터 가져오기 및 로드 상태를 처리하는 새로운 방법을 제공합니다.
다음 장에서는 스트리밍을 염두에 두고 구축된 새로운 Next.js 렌더링 모델인 부분 사전 렌더링에 대해 알아봅니다.