이전 Chapter에서는 대시보드 페이지를 동적으로 만드는 데 성공했지만, 데이터 Fetch 속도가 느린 경우, 애플리케이션의 성능에 어떤 영향을 미칠 수 있는가에 대해 논의했다.

이번 코스에서는, 느린 데이터 요청이 있을 때 사용자 경험을 어떻게 개선할 수 있는지에 대해 살펴보도록 하자.

1. 기존 SSR

  • 기존 SSR의 flow를 생각해보면 아래와 같다.
    1. 백엔드 서버(API)로부터 Data를 가져온다.
    2. 프론트엔드 서버(Next.js)에서 HTML을 렌더링한다.
    3. 클라이언트(브라우저)에서 HTML를 받는다.
    4. 클라이언트(브라우저)는 자바스크립트(bundle.js)를 다운로드 한 후, 상호작용성있는 웹을 만든다.
  • 이는, 위의 과정이 모두 끝나기 전까지는, 사용자는 페이지와 상호작용을 할 수 없다는 뜻이다.
    - Next.js에서는, 이 문제를 해결하기 위해 어떠한 기술을 도입했는데, 그것이 Streaming이다.

 

2. Streaming

  • Next.js에서는 Streaming경로를 더 작은 "청크(chunk)"로 나누어 서버에서 클라이언트로 점진적으로 스트리밍하는 데이터 전송 기술이라고 소개하고 있다.
  • 좀 더 쉽게 말하면, HTML을 이미지처럼 작게 나누어, 모든 데이터가 로드되기 전에 준비된 컴포넌트는 미리 완성해 상호작용할 수 있게 해주는 기술을 Streaming이라고 한다.
    • 이 기술을 사용해 우선순위가 높은 컴포넌트를 먼저 작동하게 해 줄 수 있다.
  • 정리하면, Streaming은 느린 데이터의 요청이 전체 페이지를 차단하는 것을 방지할 수 있도록 고안된 기술이다. 이를 통해 사용자는 모든 데이터가 로드될 때 까지 기다리지 않고, 페이지의 일부를 보고 상호작용할 수 있게 된다.
    • 아래는 각 컴포넌트가 Hydrating되기까지의 시간(TTL: 페이지 요청 후, 페이지 상호작용이 가능해지기까지 걸린 시간)을 한 눈에 확인할 수 있도록 제시된 이미지이다.
  • 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> 사용

 

3. Streaming a whole page with loading.tsx

  • /app/dashboard 디렉터리 경로에, 새 파일인 loading.tsx를 만들어보자.
    • <Loading/>컴포넌트는 간단한 로딩 화면을 렌더링하며, 페이지가 로딩될 때 표시된다.
export default function Loading() {
  return <div>Loading...</div>;
}
  • 여기서는 몇 가지 사항이 이루어지고 있다.
    • loading.tsx는 Suspense를 기반으로 한 Next.js의 특수 파일로, 페이지 콘텐츠가 로드되는 동안 대체 UI를 보여줄 수 있게 한다.
    • <SideNav>컴포넌트는 정적이므로 즉시 표시되며, 사용자는 동적 콘텐츠가 로드되는 동안 <SideNav>와 상호 작용할 수 있다.
      • 사용자는 페이지가 완전히 로드되기를 기다릴 필요가 없으며, 페이지를 떠나기 위해서도 기다릴 필요가 없다. (이를 interruptable navigation이라고 부른다.)
      • 이는 사용자 경험을 개선하기 위해 더 많은 작업을 수행할 수 있도록 하며, "Loading..." 텍스트 대신 Loading Skeleton을 쉽게 표시할 수 있도록 한다.

 

4. Adding loading skeletons

  • 지금부터 만들 <LoadingSkeleton/> 컴포넌트는 UI의 간소화된 버전을 표현해준다. 많은 웹사이트가 사용자에게 콘텐츠를 로딩 중임을 나타내기 위해 대체 UI로서 사용 중이다.
  • loading.tsx에 포함된 모든 UI는 정적 파일의 일부로 포함되어 먼저 전송되며, 그 다음 나머지 동적 콘텐츠는 서버에서 클라이언트로 스트리밍된다.
    • 만약, 사용자가 콘텐츠의 로딩을 기다리면서 하얀 백지 화면만 보게 된다면 어떻게 될까? 페이지 동작이 아예 멈춘 것인지, 혹은 로딩이 오래 걸리는 것인지에 대한 판단을 하기 힘들 것이다. (UX 문제 발생)
    • Skeleton UI를 사용하면, 별도 UI인 스켈레톤 컴포넌트를 보여줌으로써 사용자에게 서비스가 원활히 작동중임을 알릴 수 있고, 이탈을 방지하는 효과 또한 얻을 수 있다.
  • /app/dashboard/loading.tsx 파일을 사전에 구성된 <DashboardSkeleton/> 컴포넌트를 호출하는 방식으로, 아래와 같이 코드를 변경해주면 된다.
import DashboardSkeleton from "../ui/skeletons";

export default function Loading() {
  return <DashboardSkeleton />;
}
  • 콘텐츠 로딩 중일 때 표시되는 Skeleton UI 표시 결과는 아래 이미지와 같다

 

5. Fixed the loading Skeleton bug with route groups (경로 그룹을 사용한 스켈레톤 버그 수정)

  • 현재 loading.tsx파일에 추가한 대시보드 Skeleton UI는 송장 페이지 및 고객 페이지에도 적용된다.

    • 이 오류는 파일 시스템 상, /app/dashboard/loading.tsx/app/dashboard/invoices/page.tsx/app/dashboard/customers/page.tsx보다 높은 수준이기 때문에 발생한다.
  • 이 오류는, 경로 그룹(Route Groups)을 통해 해결할 수 있다.

    • Route Groups: 디렉터리 에서 app 하위에 중첩된 폴더는 일반적으로 URL 경로에 매핑된다. 그러나 폴더가 경로의 URL 경로에 포함되지 않도록 폴더를 경로 그룹 으로 표시할 수 있다.
    • 이를 통해 URL 경로 구조에 영향을 주지 않고, 경로 세그먼트와 프로젝트 파일을 논리적인 그룹으로 구성할 수 있다.
      • Route Groups를 활용하는 방법 중, URL에 영향을 주지않고 경로를 구성하기 위해서는, 관련 경로를 함께 유지하는 그룹을 묶어낼 디렉터리명을 소괄호() 를 포함하여 생성하면 된다.
  • /app/dashboard 경로 하위에 (overview)라는 폴더를 생성한 후, page.tsxloading.tsx를 아래 이미지와 같이 새로 추가한 폴더 하위로 이동시키면 된다.
    • 이제, 이제 loading.tsx파일에 표시되는 UI는 대시보드 개요 페이지에만 적용되게 된다.

※ 정리

  1. 위 이미지와 같은 방법으로 경로 그룹을 사용하면 URL 경로 구조에 영향을 주지 않고 파일을 논리적 그룹으로 구성할 수 있다. 

  2. 소괄호를 사용하여 새 폴더를 생성하면 ()이름이 포함된 디렉터리는 URL 경로에 포함되지 않는다. 결과적으로 파일 경로 상으로는/dashboard/(overview)/page.tsx위치에서 구성된 내용을 URL 상 /dashboard에서 확인할 수 있게 된다.

  3. 로딩 파일인 loading.tsx에서는 대시보드 개요 페이지에만 적용되도록 경로 그룹을 사용하고 있다. 그러나 경로 그룹을 사용하여 애플리케이션을 Section(예: (marketing)경로 및 (shop)경로)별로 분리하거나, 대규모 애플리케이션의 경우 별로 분리할 수도 있다.

 

6. Streaming a Component

  • 지금까지는 전체 페이지를 스트리밍하는 방법에 대해 알아보았다.
    • 데이터 요청이 이루어지고 있는 중이라면, 전체 페이지가 loading.tsx UI로 보여진다.
    • React Suspense는 원하는 Component에 대해 더욱 세분화된 스트리밍을 진행할 수 있도록 한다.
  • Suspense를 사용하면, 일부 조건(데이터 로드)을 충족할 때 까지, 응용 프로그램 일부의 렌더링 부분을 지연시킬 수 있다.
    • Suspense로 동적 컴포넌트를 감쌀 수 있으며, 동적 컴포넌트가 로드되는 동안 보여줄 대체 컴포넌트를 전달할 수 있다.
  • 우리는 코스 7에서 실습했던 fetchReevenue() 함수 내에, setTimeout()를 사용하여 의도적으로 데이터 요청시간을 늘였던 적이 있다.
    • 이는 전체 페이지의 성능을 저하시킨다. 그래서 우리는 해당 함수 내에서 요청이 진행되는 동안 loading.tsx가 화면에 출력될 수 있게끔 실습을 진행했었다.
    • 이제, 페이지 전체를 차단하는 대신 Suspense를 사용하여 데이터 요청이 오래걸리는 컴포넌트만 스트리밍하고 페이지의 나머지 UI는 즉시 표시할 수 있다.
  • Suspense 실습을 진행하기 위해, 먼저 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()이 수행되기 전의 화면이다.
  • 이미지 1
  • 이미지 2
  • 이전 코드와 현재 코드의 차이를 아래 주석에서 다시 확인해보자
		{/**
         * 이전 코드: Suspense가 없기 때문에, 컴포넌트 별 스트리밍이 이루어지지 않음.
         * 즉, 페이지 전체가 차단되고, fetchLatestInvoices(), fetchCardData(), fetchRevenue()가 모두 수행되어야 화면을 볼 수 있다.
         */}
        <RevenueChart />

        {/**
         * 변경 코드: Suspense가 있기 때문에, 컴포넌트 별 스트리밍이 이루어짐.
         * 페이지 전체가 차단되지 않고, fetchLatestInvoices(), fetchCardData()가 수행되면 페이지를 확인할 수 있다.
         * children props 내에서 별도로 데이터를 요청하는 함수인 fetchRevenue()가 모두 수행되기 전에는 해당 컴포넌트 출력 영역에 fallback 컴포넌트가 표시되며, 모두 수행되고 나면 children props로 전달한 컴포넌트 화면을 볼 수 있다.
         */}
        <Suspense fallback={<RevenueChartSkeleton />}>
          <RevenueChart />
        </Suspense>

 

Pratice Code

  • /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>
  );
}

 

7. Grouping Component: <Card/>

  • 이제, <Card/> 컴포넌트를 SuspenseWrapping하는 것이 남았다. 각 개별 카드에 대한 데이터를 가져와 처리할 수 있지만, 이렇게 하면 각 카드가 로드될 때 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"
      />
    </>
  );
}

// ...
  • 결과

 

8. Deciding where to place your Suspense boundaries (Suspense 경계를 어디에 놓을지에 대한 결정)

  • Suspense 경계를 어디에 놓을지 결정하는 것은 다음과 같은 몇 가지 사항에 달려 있다:
    1. 페이지가 스트리밍되는 동안 사용자가 페이지를 어떻게 경험하길 원하는가? (사용자 요구사항 등에서 결정).
    2. 어떤 콘텐츠를 우선순위로 두길 원하는가?
    3. 컴포넌트가 데이터 Fetch에 의존하는가에 대한 여부

위의 내용은 상황에 따라 달라질 수 있기 때문에 정답이 없다.

  • 해당 코스에서는 먼저, 전체 페이지를 loading.tsx에서 했던 것처럼 스트리밍했다. 그러나 컴포넌트 중 하나가 데이터를 느리게 가져오는 경우 로딩 시간이 길어질 수 있기 때문에, 모든 컴포넌트를 개별적으로 스트리밍했다. 또한 <Card/> 컴포넌트의 popping 효과를 우려하여, Wrapping 컴포넌트를 만들었다
  • Suspense 경계를 어디에 놓을지는 애플리케이션에 따라 다를 것이다.
    • 일반적으로 데이터를 필요로 하는 컴포넌트에 데이터를 불러오는 메소드를 이동시키고, 해당 컴포넌트를 Suspense로 래핑하는 것이 좋은 실천 방법이다.
    • 만약, 애플리케이션이 필요로 하는 사항이 위와 다르다면 Section 또는 전체 페이지를 스트리밍하는 것도 크게 문제되지 않는다.
profile
프론트엔드 입문 개발자입니다.

0개의 댓글