이전 Chapter에서는 대시보드 페이지를 동적으로 만드는 데 성공했지만, 데이터 Fetch 속도가 느린 경우, 애플리케이션의 성능에 어떤 영향을 미칠 수 있는가에 대해 논의했다.
이번 코스에서는, 느린 데이터 요청이 있을 때 사용자 경험을 어떻게 개선할 수 있는지에 대해 살펴보도록 하자.
Next.js에서 제공하는 Streaming은 각 컴포넌트에서 필요로 하는 TTL이 지나고 나면, 컴포넌트 별로 상호작용이 가능하도록 한다.
Hydrating: Next.js 서버는 Pre-Rendering된 웹 페이지를 클라이언트에게 보낸 뒤, 바로 React가 bundling된 Javascript 코드들을 chunk 단위로 클라이언트에게 전송한다. 그리고 이러한 Javascript 코드들이 이전에 전송된 HTML DOM 요소 위로 리렌더링 되는 과정 속에서 자기 자리를 찾아 매칭되는 과정이 Hydrating이다.
Hydrating을 통해 초기 로딩 시 클라이언트에서 즉시 상호작용이 가능하고, 이후에는 일반적인 React 애플리케이션처럼 동작할 수 있다.
Next.js에서 스트리밍을 구현하는 방법에는 두 가지가 있다.
1. 페이지 수준에서loading.tsx
파일 사용
2. 특정 컴포넌트에서<Suspense>
사용
loading.tsx
/app/dashboard
디렉터리 경로에, 새 파일인 loading.tsx
를 만들어보자.<Loading/>
컴포넌트는 간단한 로딩 화면을 렌더링하며, 페이지가 로딩될 때 표시된다.export default function Loading() {
return <div>Loading...</div>;
}
loading.tsx
는 Suspense를 기반으로 한 Next.js의 특수 파일로, 페이지 콘텐츠가 로드되는 동안 대체 UI를 보여줄 수 있게 한다.<SideNav>
컴포넌트는 정적이므로 즉시 표시되며, 사용자는 동적 콘텐츠가 로드되는 동안 <SideNav>
와 상호 작용할 수 있다.
<LoadingSkeleton/>
컴포넌트는 UI의 간소화된 버전을 표현해준다. 많은 웹사이트가 사용자에게 콘텐츠를 로딩 중임을 나타내기 위해 대체 UI로서 사용 중이다. loading.tsx
에 포함된 모든 UI는 정적 파일의 일부로 포함되어 먼저 전송되며, 그 다음 나머지 동적 콘텐츠는 서버에서 클라이언트로 스트리밍된다./app/dashboard/loading.tsx
파일을 사전에 구성된 <DashboardSkeleton/>
컴포넌트를 호출하는 방식으로, 아래와 같이 코드를 변경해주면 된다.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)을 통해 해결할 수 있다.
app
하위에 중첩된 폴더는 일반적으로 URL 경로에 매핑된다. 그러나 폴더가 경로의 URL 경로에 포함되지 않도록 폴더를 경로 그룹 으로 표시할 수 있다./app/dashboard
경로 하위에 (overview)
라는 폴더를 생성한 후, page.tsx
와 loading.tsx
를 아래 이미지와 같이 새로 추가한 폴더 하위로 이동시키면 된다.loading.tsx
파일에 표시되는 UI는 대시보드 개요 페이지에만 적용되게 된다.위 이미지와 같은 방법으로 경로 그룹을 사용하면 URL 경로 구조에 영향을 주지 않고 파일을 논리적 그룹으로 구성할 수 있다.
소괄호를 사용하여 새 폴더를 생성하면 ()
이름이 포함된 디렉터리는 URL 경로에 포함되지 않는다. 결과적으로 파일 경로 상으로는/dashboard/(overview)/page.tsx
위치에서 구성된 내용을 URL 상 /dashboard
에서 확인할 수 있게 된다.
로딩 파일인 loading.tsx
에서는 대시보드 개요 페이지에만 적용되도록 경로 그룹을 사용하고 있다. 그러나 경로 그룹을 사용하여 애플리케이션을 Section(예: (marketing)경로 및 (shop)경로)
별로 분리하거나, 대규모 애플리케이션의 경우 팀별로 분리할 수도 있다.
loading.tsx
UI로 보여진다.fetchReevenue()
함수 내에, setTimeout()
를 사용하여 의도적으로 데이터 요청시간을 늘였던 적이 있다.loading.tsx
가 화면에 출력될 수 있게끔 실습을 진행했었다.fetchRevenue()
함수를 호출하는 /app/dashboard/(overview)/page.tsx
파일의 호출부를 제거해주어야 한다.export default async function Page() {
// const revenue = await fetchRevenue(); // delete line
const latestInvoices = await fetchLatestInvoices();
// ...
}
<Suspense>
컴포넌트를 React에서 import 한 후, <RevenueChart/>
에 children
으로 전달해야 한다. 그 코드는 아래와 같다.fallback
이라는 props는 해당 컴포넌트 내에서 데이터를 요청하는 것과 같은 특정 동작이 이루어지는 동안 대체적으로 표시할 컴포넌트를 전달하는 속성이다.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">
{/* 이전 코드 */}
{/* <RevenueChart revenue={revenue} /> */}
{/* 새 코드 */}
<Suspense fallback={<RevenueChartSkeleton />}>
<RevenueChart />
</Suspense>
<LatestInvoices latestInvoices={latestInvoices} />
</div>
</main>
);
<RevenueChart/>
컴포넌트 내에서 자체적으로 데이터를 가져오도록 구성 요소를 업데이트 해야 한다.import { generateYAxis } from "@/app/lib/utils";
import { CalendarIcon } from "@heroicons/react/24/outline";
import { lusitana } from "../font";
import { Revenue } from "@/app/lib/definitions";
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>;
}
return (
<div className="w-full md:col-span-4">
<h2 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
Recent Revenue
</h2>
<div className="rounded-xl bg-gray-50 p-4">
<div className="sm:grid-cols-13 mt-0 grid grid-cols-12 items-end gap-2 rounded-md bg-white p-4 md:gap-4">
<div
className="mb-6 hidden flex-col justify-between text-sm text-gray-400 sm:flex"
style={{ height: `${chartHeight}px` }}
>
{yAxisLabels.map((label) => (
<p key={label}>{label}</p>
))}
</div>
{revenue.map((month) => (
<div key={month.month} className="flex flex-col items-center gap-2">
<div
className="w-full rounded-md bg-blue-300"
style={{
height: `${(chartHeight / topLabel) * month.revenue}px`,
}}
></div>
<p className="-rotate-90 text-sm text-gray-400 sm:rotate-0">
{month.month}
</p>
</div>
))}
</div>
<div className="flex items-center pb-2 pt-6">
<CalendarIcon className="h-5 w-5 text-gray-500" />
<h3 className="ml-2 text-sm text-gray-500 ">Last 12 months</h3>
</div>
</div>
</div>
);
}
fetchLatestInvoices()
, fetchCardData()
데이터 요청이 이루어지면 즉시 전체 페이지가 표시되고, <RevenueChart/>
내에서 fetchRevenue()
가 수행되는 동안에는 fallback
컴포넌트인 <RevenueChartSkeleton/>
가 출력된다.children props
내에서 데이터를 요청하는 fetchRevenue()
함수 실행이 완료되면 <RevenueChart/>
컴포넌트의 내용을 확인할 수 있다. fetchLatestInvoices()
, fetchCardData()
이 진행되고 있을 때의 화면이며, 두 번째 이미지는 fetchLatestInvoices()
, fetchCardData()
이 완료된 후, fetchRevenue()
이 수행되기 전의 화면이다. {/**
* 이전 코드: Suspense가 없기 때문에, 컴포넌트 별 스트리밍이 이루어지지 않음.
* 즉, 페이지 전체가 차단되고, fetchLatestInvoices(), fetchCardData(), fetchRevenue()가 모두 수행되어야 화면을 볼 수 있다.
*/}
<RevenueChart />
{/**
* 변경 코드: Suspense가 있기 때문에, 컴포넌트 별 스트리밍이 이루어짐.
* 페이지 전체가 차단되지 않고, fetchLatestInvoices(), fetchCardData()가 수행되면 페이지를 확인할 수 있다.
* children props 내에서 별도로 데이터를 요청하는 함수인 fetchRevenue()가 모두 수행되기 전에는 해당 컴포넌트 출력 영역에 fallback 컴포넌트가 표시되며, 모두 수행되고 나면 children props로 전달한 컴포넌트 화면을 볼 수 있다.
*/}
<Suspense fallback={<RevenueChartSkeleton />}>
<RevenueChart />
</Suspense>
/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/font";
import {
fetchCardData,
fetchLatestInvoices,
fetchRevenue,
} from "../../lib/data";
import { Suspense } from "react";
import {
LatestInvoicesSkeleton,
RevenueChartSkeleton,
} from "@/app/ui/skeletons";
export default async function Page() {
// 추가
const {
numberOfCustomers,
numberOfInvoices,
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">
<Suspense fallback={<RevenueChartSkeleton />}>
<RevenueChart />
</Suspense>
{/*RSC를 React.Suspense와 함께 사용한다면 **모든 데이터를 기다릴 필요 없이 먼저 그릴 수 있는 부분을 반영하여 뷰를 로드한 뒤, data fetch가 완료되면 그 결과가 즉각적으로 스트림에 반영*/}
<Suspense fallback={<LatestInvoicesSkeleton />}>
<LatestInvoices />
</Suspense>
</div>
</main>
);
}
/app/ui/dashboard/latest-invoices.tsx
import { ArrowPathIcon } from "@heroicons/react/24/outline";
import clsx from "clsx";
import Image from "next/image";
import { lusitana } from "../font";
import { LatestInvoice } from "@/app/lib/definitions";
import { fetchLatestInvoices } from "@/app/lib/data";
export default async function LatestInvoices() {
const latestInvoices = await fetchLatestInvoices();
return (
<div className="flex w-full flex-col md:col-span-4 lg:col-span-4">
<h2 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
Latest Invoices
</h2>
<div className="flex grow flex-col justify-between rounded-xl bg-gray-50 p-4">
{/* NOTE: comment in this code when you get to this point in the course */}
<div className="bg-white px-6">
{latestInvoices.map((invoice, i) => {
return (
<div
key={invoice.id}
className={clsx(
"flex flex-row items-center justify-between py-4",
{
"border-t": i !== 0,
}
)}
>
<div className="flex items-center">
<Image
src={invoice.image_url}
// alt option은 이제 필수가 되었으니, 꼭 입력해야 함
alt={"invoice_img"}
className="mr-4 rounded-full"
width={32}
height={32}
/>
<div className="min-w-0">
<p className="truncate text-sm font-semibold md:text-base">
{invoice.name}
</p>
<p className="hidden text-sm text-gray-500 sm:block">
{invoice.email}
</p>
</div>
</div>
<p
className={`${lusitana.className} truncate text-sm font-medium md:text-base`}
>
{invoice.amount}
</p>
</div>
);
})}
</div>
<div className="flex items-center pb-2 pt-6">
<ArrowPathIcon className="h-5 w-5 text-gray-500" />
<h3 className="ml-2 text-sm text-gray-500 ">Updated just now</h3>
</div>
</div>
</div>
);
}
<Card/>
이제, <Card/>
컴포넌트를 Suspense로 Wrapping하는 것이 남았다. 각 개별 카드에 대한 데이터를 가져와 처리할 수 있지만, 이렇게 하면 각 카드가 로드될 때 Popping 효과(화면 요소가 갑자기 바뀌거나 나타나는 효과)가 발생할 수 있는데, 이는 사용자에게 시각적으로 거슬리는 효과를 줄 수 있다.
이 문제를 해결하기 위해, 카드를 그룹화하여 종합된 지연 효과를 만들 수 있다. 이렇게 하면 정적인 <SideNav/>
가 먼저 표시되고 그 다음에 카드 데이터가 표시된다.
/app/dashboard/(overview)/page.tsx
파일에서 아래 과정을 거치면 된다.
1. <Card/>
컴포넌트를 삭제한다.
2. fetchCardData()
컴포넌트를 삭제한다.
3. <CardWrapper/>
라는 새로운 Wrapper 컴포넌트를 import한다.
4. <CardsSkeleton />
이라는 새로운 스켈레톤 컴포넌트를 import한다.
5. <CardWrapper />
를 Suspense로 Wrapping한다.
/app/dashboard/(overview)/page.tsx
import RevenueChart from "@/app/ui/dashboard/revenue-chart";
import LatestInvoices from "@/app/ui/dashboard/latest-invoices";
import { lusitana } from "@/app/ui/font";
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>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{/* add Suspense, CardSkeleton, Wrapping */}
<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>
);
}
/app/ui/dashboard/cards.tsx
// ...
export default async function CardWrapper() {
// 추가
const {
numberOfCustomers,
numberOfInvoices,
totalPaidInvoices,
totalPendingInvoices,
} = await fetchCardData();
return (
<>
{/* NOTE: comment in this code when you get to this point in the course */}
<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"
/>
</>
);
}
// ...
위의 내용은 상황에 따라 달라질 수 있기 때문에 정답이 없다.
loading.tsx
에서 했던 것처럼 스트리밍했다. 그러나 컴포넌트 중 하나가 데이터를 느리게 가져오는 경우 로딩 시간이 길어질 수 있기 때문에, 모든 컴포넌트를 개별적으로 스트리밍했다. 또한 <Card/>
컴포넌트의 popping 효과를 우려하여, Wrapping 컴포넌트를 만들었다