코딩/Next.js

Next.js를 알아보자 - 공식문서 따라하기(7~9장)

김기지 2024. 5. 8. 11:47

7. Fetching Data

7-1. 데이터를 가져오는 방법 선택

💜 API 계층

API는 애플리케이션 코드와 데이터베이스 사이의 중간 계층으로 API를 사용할 수 있는 몇 가지 경우가 있다.

  • API를 제공하는 타사 서비스를 사용하는 경우
  • 클라이언트에서 데이터를 가져오는 경우 데이터베이스 비밀이 클라이언트에 노출되는 것을 방지하기 위해 서버에서 실행되는 API 계층이 있어야함

Next.js에서는 Route Handlers를 사용해 API 엔드포인트를 생성할 수 있다.

 

 

💜 데이터베이스 쿼리

풀스택 애플리케이션을 생성할 때 데이터베이스와 상호 작용하는 로직도 작성해야 한다.

Postgres와 같은 관계형 데이터베이스의 경우 SQL 또는 Prisma와 같은 ORM을 사용하여 이를 수행할 수 있다.

 

데이터베이스 쿼리를 작성해야 하는 몇 가지 경우가 있다.

  • API 엔드포인트를 생성할 때 데이터베이스와 상호 작용하는 로직을 작성해야 함
  • React Server 컴포넌트(서버에서 데이터 가져오기)를 사용하는 경우 API 계층을 건너뛰고 데이터베이스 비밀이 클라이언트에 노출될 위험 없이 데이터베이스를 직접 쿼리할 수 있음

 

💜 서버 컴포넌트를 사용해 데이터 가져오기

기본적으로 Next.js 애플리케이션은 React Server Components를 사용한다. 이를 사용하면 몇 가지 이점이 있다.

  • Promise를 지원하여 데이터 가져오기와 같은 비동기 작업을 위한 더 간단한 솔루션을 제공함
    • useEffect, useState 또는 데이터 패칭 라이브러리를 사용하지 않고 async/await 구문을 사용할 수 있음
  • 서버에서 실행되므로 비용이 많이 드는 데이터 가져오기 및 로직을 서버에 보관하고 결과만 클라이언트로 보낼 수 있음
  • 서버에서 실행되기 때문에 별도의 API 계층 없이 데이터베이스에 직접 쿼리할 수 있음

 

💜 SQL 사용

이 대시보드 프로젝트에서 Vercel Postgres SDK와 SQL을 사용해 데이터베이스 쿼리를 작성한다.

SQL을 사용하는 데에는 몇 가지 이유가 있다.

  • SQL은 관계형 데이터베이스를 쿼리하기 위한 업계 표준이다.(ex. ORM은 내부적으로 SQL을 생성함)
  • SQL에 대한 기본적인 이해가 있으면 관계형 데이터베이스의 기본 사항을 이해하는 데 도움이 되며 지식을 다른 도구에 적용할 수 있다.
  • SQL은 다목적이므로 특정 데이터를 가져오고 조작할 수 있다.
  • Vercel Postgres SDK는 SQL 주입에 대한 보호 기능을 제공한다.

/app/lib/data.ts로 가면 @vercel/postgre에서 sql 함수를 가져오는 것을 볼 수 있다.

import { sql } from '@vercel/postgres';

sql은 모든 서버 컴포넌트 내부에서 호출할 수 있다. 하지만 컴포넌트를 더 쉽게 탐색할 수 있도록 모든 데이터 쿼리를 data.ts 파일에 보관해 이를 컴포넌트로 가져올 수 있다.

 

 

 

7-2. 대시보드 개요 페이지의 데이터 패칭

/app/dashboard/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';
 
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">
        {/* <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}  /> */}
        {/* <LatestInvoices latestInvoices={latestInvoices} /> */}
      </div>
    </main>
  );
}

페이지는 비동기 컴포넌트로 데이터를 가져오기 위해 await를 사용할 수 있다.

데이터를 수신하는 3개의 컴포넌트가 있다 : <Card>, <RevenueChart>, <LatestInvoices> ← 현재는 오류를 방지하기 위해 주석처리

 

 

 

7-3. <RevenueChart />에 대한 데이터 패칭

data.ts로부터 fetchRevenue 함수를 가져오고 <RevenueChart /> 컴포넌트의 주석을 제거한다.

import { fetchRevenue } from '../app/lib/data';
 
export default async function Page() {
  const revenue = await fetchRevenue();
  return (
    <main>
    //...
      <div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
        <RevenueChart revenue={revenue} />
        {/* <LatestInvoices latestInvoices={latestInvoices} /> */}
      </div>
    </main>
  );
}

 

/app/ui/dashboard/revenue-chart.tsx로 가서 코드의 주석을 제거하면 revenue 데이터를 사용하는 차트를 볼 수 있다.

 

 

 

7-4. <LatestInvoices />에 대한 데이터 패칭

<LatesInvoices /> 컴포넌트의 경우 날짜별로 정렬된 최신 5개의 송장을 가져와야 한다.

JavaScript를 사용해 모든 송장을 가져와 정렬할 수 있다. 데이터가 작기 때문에 지금은 문제가 되지 않지만 애플리케이션이 커짐에 따라 각 요청에 대해 전송되는 데이터의 양과 이를 정렬하는 데 필요한 JavaScript가 크게 늘어날 수 있다.

 

메모리 내에서 최신 송장을 정렬하는 대신 SQL 쿼리를 사용해 마지막 5개의 송장만 가져올 수 있다.

예를 들어, data.ts에 작성된 SQL 쿼리가 있다.

// 날짜별로 정렬된 최근 5개의 송장 가져오기
const data = await sql<LatestInvoiceRaw>`
  SELECT invoices.amount, customers.name, customers.image_url, customers.email
  FROM invoices
  JOIN customers ON invoices.customer_id = customers.id
  ORDER BY invoices.date DESC
  LIMIT 5`;

 

Page에서 이 쿼리가 포함된 fetchLatestInvoices 함수를 가져오고 <LatestInvoices /> 컴포넌트의 주석을 제거한다.

import { fetchRevenue, fetchLatestInvoices } from '../lib/data';

export default async function Page() {
  const revenue = await fetchRevenue();
  const latestInvoices = await fetchLatestInvoices();
  return (
	// ...
      <div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
        <RevenueChart revenue={revenue} />
        <LatestInvoices latestInvoices={latestInvoices} />
      </div>
    </main>
  );
}

 

/app/ui/dashboard/latest-invoices에 위치한 <LatestInvoices /> 컴포넌트의 주석을 제거한다.

데이터베이스에서 마지막 5개만 반환되는 것을 볼 수 있다!

 

 

 

7-5. <Card> 에 대한 데이터 패칭

<Card>에는 다음 데이터가 표시된다.

  • 수집된 청구서의 총액
  • 보류 중인 송장 총액
  • 총 송장 수
  • 총 고객 수

이 데이터에 대한 SQL 쿼리가 작성된 함수를 보자!

const invoiceCountPromise = sql`SELECT COUNT(*) FROM invoices`;
const customerCountPromise = sql`SELECT COUNT(*) FROM customers`;
const invoiceStatusPromise = sql`SELECT
     SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS "paid",
     SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END) AS "pending"
     FROM invoices`;

 

data.ts에서 카드에 대한 쿼리를 가진 fetchCardData( ) 함수를 가져온다.

import { fetchRevenue, fetchLatestInvoices, fetchCardData } from '../lib/data';

 

여기서 필요한 데이터를 하나씩 꺼내고 주석처리 된 <Card>컴포넌트를 해제한다.

export default async function Page() {
  // ...
  const {
    numberOfCustomers,
    numberOfInvoices,
    totalPaidInvoices,
    totalPendingInvoices,
  } = await fetchCardData();
  return (
    <main>
    // ...
      <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>
	// ...
    </main>
  );
}

 

 

개요 페이지에 대한 모든 데이터를 가져왔다!

하지만 두 가지 사항을 알아둬야 한다.

  1. 데이터 요청이 의도치 않게 서로를 차단해 request waterfall 이 생성된다.
  2. 기본적으로 Next.js는 성능 향상을 위해 경로를 미리 렌더링하며 이를 정적 렌더링이라 한다. 따라서 데이터가 변경되면 대시보드에 반영되지 않는다.

 

7-6. request waterfall(요청 폭포수) 란?

이전 요청의 완료에 따라 달라지는 일련의 네트워크 요청을 나타낸다.

 

데이터 패칭의 경우 각 요청은 이전 요청이 데이터를 반환한 후에만 시작할 수 있다.

예를 들어, fetchLatestInvoices( )가 실행되기 전에 fetchRevenue( )가 실행될 때 까지 기다려야 한다.

이 패턴이 반드시 나쁜 것은 아니다. 다음 요청을 하기 전에 조건이 충족되기를 원하기 때문에 폭포수를 원하는 경우가 있을 수 있다. 

그러나 이 동작은 의도하지 않은 것일 수도 있으며 성능에 영향을 미칠 수도 있다.

 

 

 

7-7. 병렬 데이터 패칭

폭포수를 방지하는 일반적인 방법은 모든 데이터 요청을 동시에 병렬로 시작하는 것이다.

 

JavaScript에서는 Promise.all( ) 또는 Promise.allSettled( )를 사용할 수 있다. data.ts에 fetchCardData( )에서는 Promise.all( )을 사용하고 있다.

export async function fetchCardData() {
  try {
    const invoiceCountPromise = sql`SELECT COUNT(*) FROM invoices`;
    const customerCountPromise = sql`SELECT COUNT(*) FROM customers`;
    const invoiceStatusPromise = sql`SELECT
         SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS "paid",
         SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END) AS "pending"
         FROM invoices`;
 
    const data = await Promise.all([
      invoiceCountPromise,
      customerCountPromise,
      invoiceStatusPromise,
    ]);
    // ...
  }
}

 

이 패턴을 사용하면 다음을 수행할 수 있다.

  • 모든 데이터 패칭을 동시에 시작하면 성능이 향상될 수 있다.
  • 모든 라이브러리나 프레임워크에 적용할 수 있는 기본 JavaScript 패턴을 사용한다.

그러나 이 JavaScript 패턴에만 의존하면 하나의 데이터 요청이 다른 모든 모든 데이터 요청보다 느린 경우에는 해당 요청이 완료될 때까지 기다려야 한다.

 

 


8. 정적 및 동적 렌더링

8-1. 정적 렌더링이란?

정적 렌더링을 사용하면 빌드 시 또는 유효성 재검사 중에 데이터 가져오기 및 렌더링이 서버에서 발생한다.

그 다음 결과는 CDN(Content Delivery Network)에 배포되고 캐시될 수 있다.

 

유저가 애플리케이션을 방문할 때마다 캐시된 결과가 제공된다.

정적 렌더링에는 몇 가지 이점이 있다.

  • 더 빠른 웹사이트 - 사전 렌더링된 콘텐츠를 캐시하고 전 세계적으로 배포할 수 있다. 이를 통해 전 세계 사용자가 웹사이트 콘텐츠에 더욱 빠르고 안정적으로 액세스할 수 있다.
  • 서버 로드 감소 - 콘텐츠가 캐시되기 때문에 서버는 각 사용자 요청에 대해 콘텐츠를 동적으로 생성할 필요가 없다.
  • SEO - 사전 렌더링된 콘텐츠는 페이지가 로드될 때 이미 콘텐츠를 사용할 수 있으므로 검색 엔진 크롤러가 색인을 생성하기가 더 쉽다. 이를 통해 검색 엔진 순위가 향상될 수 있다.

 

따라서 정적 렌더링은 정적 블로그 게시물이나 제품 페이지와 같이 사용자 간에 공유되는 데이터나 데이터가 없는 UI에 유용하다. 정기적으로 업데이트되는 개인화된 데이터가 있는 대시보드에는 적합하지 않을 수 있다.

 

 

 

8-2. 동적 렌더링이란?

동적 렌더링을 사용하면 요청 시(유저가 페이지를 방문할 때) 각 사용자의 콘텐츠가 서버에서 렌더링된다.

동적 렌더링에는 몇 가지 이점이 있다.

  • 실시간 데이터 - 동적 렌더링을 통해 애플리케이션은 실시간 또는 자주 업데이트되는 데이터를 표시할 수 있다. 이는 데이터가 자주 변경되는 애플리케이션에 이상적이다.
  • 사용자별 콘텐츠 - 대시보드나 유저 프로필과 같은 개인화된 콘텐츠를 제공하고 사용자 상호 작용을 기반으로 데이터를 업데이트하는 것이 더 쉽다.
  • 요청 시간 정보 - 동적 렌더링을 사용하면 쿠키나 URL 검색 매개변수와 같이 요청 시간에만 알 수 있는 정보에 액세스할 수 있다.

 

 

 

8-3. 대시보드를 동적으로 만들기

기본적으로 @vercel/postgres는 자체 캐싱 의미 체계를 설정하지 않는다. 이를 통해 프레임워크는 자체 정적 및 동적 동작을 설정할 수 있다.

 

서버 컴포넌트 내부에서 호출되는 unstable_noStore라는 Next.js API 또는 데이터 패치 함수를 사용해 정적 렌더링을 거부할 수 있다. 

data.ts에서 next/cache로부터 unstable_noStore를 가져오고 이것을 데이터 패칭 함수의 최상위라고 부른다.

// ...
import { unstable_noStore as noStore } from 'next/cache';
 
export async function fetchRevenue() {
  // Add noStore() here to prevent the response from being cached.
  // This is equivalent to in fetch(..., {cache: 'no-store'}).
  noStore();
 
  // ...
}
 
export async function fetchLatestInvoices() {
  noStore();
  // ...
}
 
export async function fetchCardData() {
  noStore();
  // ...
}
 
export async function fetchFilteredInvoices(
  query: string,
  currentPage: number,
) {
  noStore();
  // ...
}
 
export async function fetchInvoicesPages(query: string) {
  noStore();
  // ...
}
 
export async function fetchFilteredCustomers(query: string) {
  noStore();
  // ...
}
 
export async function fetchInvoiceById(query: string) {
  noStore();
  // ...
}

 

 

 

8-4. 느린 데이터 패칭 시뮬레이션

하나의 데이터 요청이 다른 모든 데이터 요청보다 느리면 어떻게 되는지 시뮬레이션 해보자!

data.ts 파일에서 fetchRevenue( ) 안의 주석 처리 된 console.log와 setTimeout을 주석 해제한다.

export async function fetchRevenue() {
  try {
    // We artificially delay a response for demo purposes.
    // Don't do this in production :)
    console.log('Fetching revenue data...');
    await new Promise((resolve) => setTimeout(resolve, 3000));
 
    const data = await sql<Revenue>`SELECT * FROM revenue`;
 
    console.log('Data fetch completed after 3 seconds.');
 
    return data.rows;
  } catch (error) {
    console.error('Database Error:', error);
    throw new Error('Failed to fetch revenue data.');
  }
}

 

http://localhost:3000/dashboard/를 열면 페이지를 로드하는 데 시간이 오래 걸리고 터미널에는 메세지가 표시된다.

인위적인 3초 지연을 추가했고 그 결과 데이터를 가져오는 동안 전체 페이지가 차단된다.

 

여기서 알 수 있듯, 동적 렌더링을 사용하면 애플리케이션 속도는 가장 느린 데이터를 가져오는 속도만큼만 빨라진다.

 

 

 


9. 스트리밍

데이터 요청이 느린 경우 사용자 경험을 개선할 수 있는 방법을 살펴보자

 

9-1. 스트리밍이란?

스트리밍은 경로를 더 작은 "청크"로 나누고 준비가 되면 서버에서 클라이언트로 점진적으로 스트리밍할 수 있는 데이터 전송 기술이다.

스트리밍하면 느린 데이터 요청이 전체 페이지를 차단하는 것을 방지할 수 있다.

이를 통해 유저는 UI가 유저에게 표시되기 전에 모든 데이터가 로드될 때까지 기다리지 않고 페이지의 일부를 보고 상호 작용할 수 있다.

 

스트리밍은 각 컴포넌트가 덩어리로 간주될 수 있으므로 React의 컴포넌트 모델과 잘 작동한다.

Next.js에서 스트리밍을 구현하는 방법은 두 가지가 있다.

  1. 페이지 수준에서 loading.tsx 파일을 사용한다.
  2. 특정 컴포넌트의 경우 <Suspense>를 사용한다.

 

 

9-2. loading.tsx로 전체 페이지 스트리밍

/app/dashboard 폴더에서 loading.tsx 파일을 만든다.

export default function Loading() {
  return <div>Loading...</div>;
}

 

이제 3초 동안 로딩 페이지가 화면에 나타난다.

여기서는 몇 가지가 일어나고 있다.

  1. loading.tsx는 Suspense를 기반으로 구축된 특별한 Next.js파일로, 페이지 콘텐츠가 로드되는 동안 대체 UI로 표시할 폴백 UI를 생성할 수 있다.
  2. <SideNav>는 정적이므로 즉시 표시된다. 유저는 <SideNav> 동적 콘텐츠가 로드되는 동안 상호작용할 수 있다.
  3. 유저는 다른 페이지로 이동하기 전에 페이지 로드가 완료될 때까지 기다릴 필요가 없다. ←중단 가능한 탐색

 

💜 loading 뼈대 추가하기

loading 뼈대는 UI의 단순화된 버전이다.

많은 웹사이트에서는 이를 자리 표시자(또는 대체)로 사용하여 유저에게 콘텐츠가 로드 중임을 나타낸다. loading에 포함하는 모든 UI는 정적 파일의 일부로 포함되어 먼저 전송된다. 그 다음 나머지 동적 콘텐츠가 서버에서 클라이언트로 스트리밍된다.

 

loading 파일 내에서 <DashboardSkeleton> 컴포넌트를 가져온다.

import DashboardSkeleton from '@/app/ui/skeletons';
 
export default function Loading() {
  return <DashboardSkeleton />;
}

 

 

 

💜 경로 그룹의과 loading 뼈대 버그 수정

현재 로딩 뼈대는 송장 및 고객 페이지에도 적용된다.(단지 빠르게 지나갈 뿐)

loading.tsx가 파일 시스템에서 /invoices/page.tsx와 /customers/page.tsx 보다 더 높은 레벨에 있기 때문에, 해당 페이지에도 적용된다.

Route Group을 사용해 이것을 바꿀 수 있다.

dashboard 폴더 안에 /(overview) 폴더를 생성한다. 그 다음, loading.tsx와 page.tsx 파일을 폴더 안으로 이동한다.

이렇게 하면 loading.tsx 파일은 대시보드 개요 페이지에만 적용된다.

 

경로 그룹을 사용하면 URL 경로 구조에 영향을 주지 않고 파일을 논리적 그룹으로 구성할 수 있다.

괄호를 사용하여 새 폴더를 생성하면 해당 이름이 URL 경로에 포함되지 않는다.

따라서 /dashboard/(overview)/page.tsx는 /dashboard가 된다.

여기서는 loading.tsx가 대시보드 개요 페이지에만 적용되도록 경로 그룹을 사용하고 있지만 경로 그룹을 사용하여 애플리케이션을 섹션(ex. (marketing) 경로와 (shop)경로)으로 분리하거나 대규모 애플리케이션의 경우 팀별로 분리할 수 있다.

 

 

💜 컴포넌트 스트리밍

지금은 전체 페이지를 스트리밍하지만 React Suspense를 사용하면 더욱 세분화되고 특정 컴포넌트를 스트리밍할 수 있다.

 

Suspense를 사용하면 일부 조건이 충족될 때까지(ex. 데이터 로드) 애플리케이션의 렌더링 부분을 연기할 수 있다.

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 latestInvoices = await fetchLatestInvoices();
  const {
    numberOfInvoices,
    numberOfCustomers,
    totalPaidInvoices,
    totalPendingInvoices,
  } = await fetchCardData();
 
  return (
    // ...
  );
}

 

React에서 <Suspense>를 가져와 <RevenueChart />를 감싼다.

그리고 <Suspense>에서 <RevenueChartSkeleton>라는 대체 컴포넌트를 전달한다.

import { Suspense } from 'react';
import { RevenueChartSkeleton } from '@/app/ui/skeletons';
 
export default async function Page() {
// ...
  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>
  );
}

 

마지막으로 <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';
 
// ...
 
export default async function RevenueChart() { // Make component async, remove the props
  const revenue = await fetchRevenue(); // Fetch data inside the component
 
  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> 스트리밍

fetchLatestInvoices( )를 Page에서 <LatestInvoices> 컴포넌트로 이동한다.

<LatestInvoicesSkeleton> 폴백을 사용해 <Suspense>컴포넌트로 감싸준다.

import { fetchCardData } from '@/app/lib/data';
import { Suspense } from 'react';
import {
  RevenueChartSkeleton,
  LatestInvoicesSkeleton,
} from '@/app/ui/skeletons';
 
export default async function Page() {
  const {
    numberOfInvoices,
    numberOfCustomers,
    totalPaidInvoices,
    totalPendingInvoices,
  } = await fetchCardData();
 
  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>
        <Suspense fallback={<LatestInvoicesSkeleton />}>
          <LatestInvoices />
        </Suspense>
      </div>
    </main>
  );
}

 

<LatestInvoices> 컴포넌트에서 prop을 제거하고 자체적으로 데이터를 받아온다.

import { fetchLatestInvoices } from '@/app/lib/data';
 
export default async function LatestInvoices() { // Remove props
  const latestInvoices = await fetchLatestInvoices();
 
  return (
    // ...
  );
}

 

 

 

 

9-3. 컴포넌트 그룹화

이제 Suspense에서 <Card> 컴포넌트를 랩핑해야 한다.

각 개별 카드에 대한 데이터를 가져올 수 있지만 이로 인해 카드가 로드될 때 팝업 효과가 발생할 수 있으며 이는 유저에게 시각적으로 불편할 수 있다.

이 시차 효과를 해결하기 위한 방법으로 랩퍼 컴포넌트를 사용해 카드를 그룹화할 수 있다.

즉, 정적인 <SideNav />가 먼저 표시되고 그 다음에 카드 등이 표시된다.

 

실행 방법은 page.tsx에서

  1. <Card> 컴포넌트를 삭제한다.
  2. fetchCardData( ) 함수를 삭제한다.
  3. <CardWrapper /> 컴포넌트를 가져온다.
  4. <CardsSkeleton> 컴포넌트를 가져온다.
  5. Suspense에서 <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"
      />
    </>
  );
}

 

페이지를 새로 고치면 모든 카드가 동시에 로드된다!

여러 컴포넌트를 동시에 로드하는 경우 이 패턴을 사용할 수 있다.

 

 

 

9-4. Suspense 바운더리를 배치할 위치 결정

Suspense 바운더리를 배치하는 위치는 몇 가지 사항에 따라 달라진다.

  1. 페이지가 스트리밍될 때 유저가 페이지를 경험하기를 원하는 방식
  2. 어떤 콘텐츠에 우선 순위를 두고 싶은지
  3. 컴포넌트가 데이터 가져오기에 의존하는 경우

 

Suspense 바운더리를 배치하는 위치는 애플리케이션에 따라 달라진다.

일반적으로 데이터 패칭을 필요한 컴포넌트로 이동한 다음 Suspense에서 해당 컴포넌트를 랩핑하는 것이 좋다.

그러나 애플리케이션에 필요한 경우 섹션이나 전체 페이지를 스트리밍하는 데 아무런 문제가 없다.

 

 

728x90