[NextJS] Learn 7장 - 데이터 가져오기

알찬코·2024년 1월 19일

NextJS

목록 보기
8/20
post-thumbnail

이제 데이터베이스를 생성하고 시드했으므로 애플리케이션에 대한 데이터를 가져올 수 있는 다양한 방법에 대해 논의하고 대시보드 개요 페이지를 구축해 보겠습니다.

  • API, ORM, SQL 등 데이터를 가져오는 몇 가지 접근 방식에 대해 알아보세요.
  • 서버 구성 요소를 통해 백엔드 리소스에 보다 안전하게 액세스할 수 있는 방법.
  • 네트워크 폭포수란 무엇인가.
  • JavaScript 패턴을 사용하여 병렬 데이터를 가져와 구현하는 방법.

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

1-1. API 레이어

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

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

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

1-2. 데이터베이스 쿼리

풀스택 애플리케이션을 생성할 때 데이터베이스와 상호 작용하는 로직도 작성해야 합니다. Postgres 처럼 관계형 데이터베이스의 경우 SQL이나 Prisma 같은 ORM을 사용하여 이 작업을 수행할 수 있습니다.

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

  • API 엔드포인트를 생성할 때 데이터베이스와 상호 작용하는 논리를 작성해야 합니다.
  • React Server 구성 요소(서버에서 데이터 가져오기)를 사용하는 경우 API 계층을 건너뛰고 데이터베이스 비밀이 클라이언트에 노출될 위험 없이 데이터베이스를 직접 조회할 수 있습니다.

🔍 퀴즈

  • 다음 중 데이터베이스를 직접 쿼리하면 안 되는 시나리오는 무엇인가요?
    a. 클라이언트에서 데이터를 가져올 때
    b. 서버에서 데이터를 가져올 때
    c. 데이터베이스와 상호 작용하기 위해 자체 API 레이어를 생성하는 경우

React Server 구성 요소에 대해 자세히 알아 보겠습니다.

1-3. 서버 구성 요소를 사용하여 데이터 가져오기

기본적으로 Next.js 애플리케이션은 React Server Components를 사용합니다. 서버 구성 요소를 사용하여 데이터를 가져오는 것은 비교적 새로운 접근 방식이며 이를 사용하면 몇 가지 이점이 있습니다.

  • 서버 구성 요소는 Promise를 지원하여 데이터 가져오기와 같은 비동기 작업을 위한 더 간단한 솔루션을 제공합니다. useEffect, useState나 데이터를 가져오는 라이브러리에 접근하지 않고도 async/await 구문을 사용할 수 있습니다.
  • 서버 구성 요소는 서버에서 실행되므로 비용이 많이 드는 데이터 가져오기 및 로직을 서버에 보관하고 결과만 클라이언트로 보낼 수 있습니다.
  • 앞서 언급한 것처럼 Server Components는 서버에서 실행되기 때문에 별도의 API 계층 없이 데이터베이스에 직접 쿼리할 수 있습니다.

🔍 퀴즈

  • 데이터를 가져오기 위해 React Server 구성 요소를 사용하면 어떤 이점이 있나요?
    a. SQL 주입으로부터 자동으로 보호합니다.
    b. 추가 API 계층 없이 서버에서 직접 데이터베이스를 쿼리할 수 있습니다.
    c. API 계층을 사용하고 엔드포인트를 생성해야 합니다.

1-4. SQL 사용

대시보드 프로젝트의 경우 Vercel Postgres SDK와 SQL을 사용하여 데이터베이스 쿼리를 작성합니다. SQL을 사용하는 데에는 몇 가지 이유가 있습니다.

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

이전에 SQL을 사용해 본 적이 없더라도 걱정하지 마세요. 우리가 쿼리를 제공해 드리고 있습니다.

/app/lib/data.ts로 이동하면 우리가 @vercel/postgres에서 sql 함수를 가져오는 것을 볼 수 있습니다. 이 함수를 사용하면 데이터베이스를 쿼리할 수 있습니다.

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

sql을 모든 서버 구성 요소 내부에서 호출할 수 있습니다. 그러나 구성 요소를 더 쉽게 탐색할 수 있도록 모든 데이터 쿼리를 data.ts 파일에 보관했으며 이를 구성 요소로 가져올 수 있습니다.

🔍 퀴즈

  • 데이터 가져오기 측면에서 SQL을 사용하면 무엇을 할 수 있나요?
    a. 모든 데이터를 무차별적으로 가져옵니다.
    b. 특정 데이터를 가져오고 조작하기
    c. 더 나은 성능을 위해 데이터를 자동으로 캐시합니다.
    d. 데이터베이스 스키마 즉시 변경

참고: 6장에서 자체 데이터베이스 공급자를 사용한 경우 공급자와 함께 작동하려면 데이터베이스 쿼리를 업데이트해야 합니다. /app/lib/data.ts에서 쿼리를 찾을 수 있습니다.

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>. 현재는 애플리케이션 오류를 방지하기 위해 주석 처리되어 있습니다.

3. <RevenueChart/>의 데이터 가져오기

<RevenueChart/> 구성 요소에 대한 데이터를 가져오려면 data.ts에서 fetchRevenue 함수를 가져오고 구성 요소 내에서 호출하세요.

// /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';
import { fetchRevenue } from '@/app/lib/data'; // fetchRevenue 불러오기
 
export default async function Page() {
  const revenue = await fetchRevenue(); // 변수 선언
  // ...
}

그런 다음 <RevenueChart/> 구성 요소의 주석 처리를 제거하고 구성 요소 파일(/app/ui/dashboard/revenue-chart.tsx)로 이동하여 그 안에 있는 코드의 주석 처리를 제거합니다. 로컬 호스트를 확인하면 revenue 데이터를 사용하는 차트를 볼 수 있습니다.
recent-revenue

계속해서 더 많은 데이터 쿼리를 가져오겠습니다.

4. <LatestInvoices/>의 데이터 가져오기

<LatestInvoices /> 구성 요소의 경우 날짜별로 정렬된 최신 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`;

페이지에서 다음 fetchLatestInvoices 함수를 가져옵니다.

// /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';
import { fetchRevenue, fetchLatestInvoices } from '@/app/lib/data'; // fetchLatestInvoices 불러오기
 
export default async function Page() {
  const revenue = await fetchRevenue();
  const latestInvoices = await fetchLatestInvoices(); // 변수 선언
  // ...
}

그런 다음 <LatestInvoices /> 구성요소의 주석 처리를 제거하십시오. 또한 /app/ui/dashboard/latest-invoices에 있는 구성 요소 <LatestInvoices /> 자체에서 관련 코드의 주석 처리를 제거해야 합니다.

로컬 호스트를 방문하면 데이터베이스에서 마지막 5개만 반환되는 것을 볼 수 있습니다. 이제 데이터베이스를 직접 쿼리하는 것의 이점을 확인하기 시작하셨기를 바랍니다.
latest-invoices

✍ 연습문제: <Card> 구성요소 에 대한 데이터 가져오기

이제 <Card> 구성 요소에 대한 데이터를 가져올 차례입니다. 카드에는 다음 데이터가 표시됩니다.

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

다시 말하지만, 모든 송장과 고객을 가져오고 JavaScript를 사용하여 데이터를 조작하고 싶은 유혹을 받을 수도 있습니다. 예를 들어 Array.length를 사용하여 총 송장 수와 고객 수를 얻을 수 있습니다.

const totalInvoices = allInvoices.length;
const totalCustomers = allCustomers.length;

그러나 SQL을 사용하면 필요한 데이터만 가져올 수 있습니다. Array.length를 사용하는 것보다 약간 길지만 요청 중에 전송해야 하는 데이터가 적다는 것을 의미합니다. 이것은 SQL 대안입니다.

// /app/lib/data.ts

const invoiceCountPromise = sql`SELECT COUNT(*) FROM invoices`;
const customerCountPromise = sql`SELECT COUNT(*) FROM customers`;

가져와야 하는 함수를 fetchCardData이라고 합니다. 함수에서 반환된 값을 재구성해야 합니다.

힌트:

  • 카드 구성 요소를 확인하여 필요한 데이터가 무엇인지 확인하세요.
  • 함수가 반환하는 내용을 보려면 data.ts 파일을 확인하세요.

좋습니다. 이제 대시보드 개요 페이지에 대한 모든 데이터를 가져왔습니다. 귀하의 페이지는 다음과 같아야 합니다:
complete-dashboard

하지만... 알아두어야 할 두 가지 사항이 있습니다.

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

이번 장에서는 1번에 대해 논의하고, 다음 장에서는 2번에 대해 자세히 살펴보겠습니다.

5. 요청 폭포수란 무엇인가요?

"waterfall"는 이전 요청의 완료에 따라 달라지는 일련의 네트워크 요청을 나타냅니다. 데이터 가져오기의 경우 각 요청은 이전 요청이 데이터를 반환한 후에만 시작할 수 있습니다.
sequential-parallel-data-fetching

예를 들어, fetchLatestInvoices() 실행을 시작하려면 fetchRevenue()가 먼저 실행될 때까지 기다려야 합니다.

// /app/dashboard/page.tsx

const revenue = await fetchRevenue();
const latestInvoices = await fetchLatestInvoices(); // fetchRevenue()가 끝날 때까지 기다리기
const {
  numberOfInvoices,
  numberOfCustomers,
  totalPaidInvoices,
  totalPendingInvoices,
} = await fetchCardData(); // fetchLatestInvoices()가 끝날 때까지 기다리기

이 패턴이 반드시 나쁜 것은 아닙니다. 다음 요청을 하기 전에 조건이 충족되기를 원하기 때문에 폭포수를 원하는 경우가 있을 수 있습니다. 예를 들어 사용자 ID와 프로필 정보를 먼저 가져오고 싶을 수 있습니다. ID가 있으면 친구 목록을 가져올 수 있습니다. 이 경우 각 요청은 이전 요청에서 반환된 데이터에 따라 달라집니다.

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

🔍 퀴즈

  • 폭포 패턴을 사용하고 싶을 때는 언제여야 합니까?
    a. 다음 요청을 하기 전에 조건을 충족하려면
    b. 모든 요청을 동시에 수행하려면
    c. 한 번에 하나의 가져오기를 수행하여 서버 부하를 줄이려면

6. 병렬 데이터 가져오기

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

JavaScript에서는 Promise.all() 또는 Promise.allSettled()를 모든 promise를 동시에 시작하는 기능으로 사용할 수 있습니다. 예를 들어 data.ts에서, fetchCardData() 함수 안에 Promise.all() 함수를 사용하고 있습니다.

// /app/lib/data.js
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`;
 
    // Promise.all() 사용
    const data = await Promise.all([
      invoiceCountPromise,
      customerCountPromise,
      invoiceStatusPromise,
    ]);
    // ...
  }
}

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

  • 모든 데이터 가져오기 실행을 동시에 시작하면 성능이 향상될 수 있습니다.
  • 모든 라이브러리나 프레임워크에 적용할 수 있는 기본 JavaScript 패턴을 사용하세요.

그러나 이 JavaScript 패턴에만 의존하면 한 가지 단점이 있습니다. 하나의 데이터 요청이 다른 모든 데이터 요청보다 느리면 어떻게 될까요?

📖 참고자료

Chapter 7. Fetching Data

0개의 댓글