공식 문서 보기를 돌같이 하는 버릇을 고치자!
Next.js
는 라우트를 이용해 API 엔드포인트를 제공하여 서드 파티 프로그램이나 서버 없이도 데이터를 패칭할 수 있다. 스킵했지만, 앞선 장에서 Vercel
이 제공하는 서비스를 이용해 postgres
DB를 생성했는데, prisma
같은 ORM
을 통해 관계형 DB를 호출할 수 있다.
ORM
이란?
Object Relational Mapping의 약자로, 구현한 객체와 관계형 DB의 불일치를 자동으로 매핑한 SQL문을 생성해 호환가능하게 해주는 기술이다.
데이터 패치를 하기에 앞서, Next.js
는 기본적으로 React Server Component
를 사용하는데, 몇 가지 이점을 알려준다.
useState
나 useEffect
, 데이터 패치 라이브러리, 추가 API 계층 없이 async/await
을 사용하여 데이터를 가져올 수 있다.제공해준 dashboard 페이지의 코드를 보자.
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>
);
}
페이지 컴포넌트는 async function
으로 되어 있다. 이는 await
을 곧장 사용할 수 있음을 의미한다. 주석 처리된 컴포넌트들은 모두 데이터를 받는다. 데이터를 패치해 보자.
// ...
import { fetchRevenue } from '@/app/lib/data';
export default async function Page() {
const revenue = await fetchRevenue();
// ...
}
컴포넌트에서 await
을 사용하는 게 매우 신기하다. ts 에러가 발생하지만 런타임에는 아무 지장없이 잘 실행된다.
여기서 두 가지 주의해야 할 사항이 있다고 한다.
Next.js
가 성능 개선을 위해 기본적으로 prerender하여 정적 렌더링을 하기 때문에 데이터 변화가 있어도 동적으로 반영되지 않는다는 점이다.이 장에서는 1번을 살피고, 다음 장에서 2번을 살핀다.
waterfall
은 이전 요청의 완료 여부에 따라 달라지는 일련의 네트워크 요청을 의미한다. 여기서는 앞선 데이터 패칭이 완료 되어야 다음 데이터 패칭이 이루어지는 것이다.
이전 패칭의 결과가 후행 패치에 영향을 미칠 경우에는 나쁘지 않은 패턴이다. 하지만 앞선 주의사항 대로 성능에 영향을 미칠 수 있다.
여러 데이터 요청이 동시에 발생하는 경우 JS
가 제공하는 Promise.all이나 Promise.allSettled를 사용하여 병행 처리할해 성능을 향상시킬 수 있다. 또한, 자바스크립트가 제공하는 함수를 사용하기 때문에 다른 프레임워크에서도 재사용 가능하다.
이전 챕터에서 문제삼은 주의사항 2번을 해결하는 챕터이다. 정적 렌더링은 빌드나 재검증(revalidate) 중 데이터를 가져오고 렌더링하는 과정이다. 이 결과물을 CDN에 배포해 캐싱한다.
이러한 방식은 다음과 같은 이점이 있다.
따라서 정적 렌더링은 데이터 변화가 없거나 적은 블로그나 제품 페이지 등에 적합하다. 그러나 dashboard
와 같이 데이터에 변화가 잦은 페이지에는 적합하지 않을 수 있다.
이와 반대되는 개념이 동적 렌더링(Dynamic Rendering)이다.
동적 렌더링은 사용자가 페이지에 방문했을 때 렌더링하여 콘텐츠를 생성한다. 이점은 다음과 같다.
데이터 패치 함수 초입에 unstable_noStore
를 불러와 적용한다.
// ...
import { unstable_noStore as noStore } from 'next/cache';
export async function fetchRevenue() {
noStore();
// ...fetch logic
}
export async function fetchLatestInvoices() {
noStore();
// ...fetch logic
}
export async function fetchCardData() {
noStore();
// ...fetch logic
}
export async function fetchFilteredInvoices(
query: string,
currentPage: number,
) {
noStore();
// ...fetch logic
}
export async function fetchInvoicesPages(query: string) {
noStore();
// ...fetch logic
}
export async function fetchFilteredCustomers(query: string) {
noStore();
// ...fetch logic
}
export async function fetchInvoiceById(query: string) {
noStore();
// ...fetch logic
}
unstable_noStore
는 실험적인 API이므로 추후 변경될 수도 있다고 한다. 안정적인 API는 Route Segment Config의 export const dynamic = "force-dynamic"
를 사용한다.
export const dynamic = 'force-dynamic';
export default function MyComponent() {}
동적 렌더링이 가져오는 문제는 느리게 도착하는 데이터에 의해 앱의 성능이 결정된다는 점이다. 이를 해결하는 과정을 다음 챕터에서 안내한다.
느린 데이터 가져오기 환경을 개선하는 방법을 알려주는 장이다.
스트리밍(streaming)은 데이터를 '작은 조각(chunk)'로 분할하여 서버에서 준비되는 대로 클라이언트 측에 보내는 전송 방식을 말한다. 느린 데이터 요청으로 인한 앱 전체가 차단되는 것을 방지하고, 전체 데이터 패칭이 완료되지 않아도 일부 페이지를 조작할 수 있도록 한다.
리액트 컴포넌트는 하나의 청크로 간주될 수 있기 때문에 스트리밍을 적용하기에 좋다. 페이지에서는 loading.tsx
을, 컴포넌트에서는 <Suspense>
를 사용하여 스트리밍을 적용할 수 있다.
페이지 전체 로딩을 적용하는 방법은 매우 간단하다. 라우트 경로에 loading.tsx
를 추가한다.
// app/dashboard/loading.tsx
export default function Loading() {
return <div>Loading...</div>;
}
의도적으로 데이터 패치 중 하나를 느리게 만들면 로딩 화면이 보인다.
제공한 스켈레톤으로 로딩을 교체했는데, 작은 버그가 하나 있다. dashboard
바로 아래에 loading
을 생성한 탓에 하위 라우트인 dashboard/invoices
와 dashboard/customers
에서도 로딩이 적용된다. dashboard
에만 적용하려면 하위에 (overview)
폴더를 추가하고, page.tsx
와 loading.tsx
를 옮긴다.
🗂app/
└─🗂dashboard/
├─layout.tsx
├─🗂(overview)
│ ├─loading.tsx
│ └─page.tsx
├─🗂invoices/
└─🗂customers/
이렇게 경로 나누는 방식이 Route Groups이며, 괄호로 작성한 폴더를 경로에 포함시키지 않으면서 나눌 수 있다. 예를 들어, 여기서 사용한 loading.tsx
는 (overview)
하위에 있는 page.tsx
에만 적용된다.
위의 방식이 전체 페이지 스트리밍에 해당한다면, <Suspense>
는 데이터가 필요한 특정 컴포넌트만 지연 로딩하는 방식이다. 지연 로딩할 부분을 <Suspense>
로 감싸고 지연되는 동안 보여줄 fallback
을 추가한다.
dashboard
에서 하나의 요청을 의도적으로 지연시켜 전체 페이지에 로딩이 발생했다. 해당 요청을 제거하고, 해당 컴포넌트를 <Suspense>
로 감싼다.
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
+ import { fetchLatestInvoices, fetchCardData } from '@/app/lib/data'; // remove fetchRevenue
import { Suspense } from 'react';
import { RevenueChartSkeleton } from '@/app/ui/skeletons';
export default async function Page() {
- const revenue = await fetchRevenue // delete this line
// ...
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">
{/* ...Cards */}
</div>
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
+ <Suspense fallback={<RevenueChartSkeleton />}>
+ <RevenueChart />
+ </Suspense>
{/* ...*/}
</div>
</main>
);
}
특정 컴포넌트의 요청이 끝날 때까지 전체 로딩하던 화면에서 특정 컴포넌트만 지연 로딩되는 화면으로 바뀌었다.
Suspense
의 경계는 원하는 사용자 경험, 콘텐츠 우선순위, 컴포넌트가 의존하는 데이터 패칭에 따라 달라진다. 정답은 없지만 일반적으로 데이터가 필요한 컴포넌트를 Suspense
로 감싸는 게 낫고, 필요한 경우 전체 페이지를 스트리밍한다.