1. API Layer

  • API는 애플리케이션 코드와 데이터베이스 사이의 중간 계층으로, API를 사용하는 경우는 아래와 같다.
    1. 3rd party 서비스를 사용하여 API를 제공하는 경우
    2. 클라이언트에서 데이터를 가져오는 경우, 데이터베이스의 secrets 데이터를 클라이언트에 노출시키지 않기 위해 서버에서 실행되는 API Layer가 필요하다.
  • Next.js에서는 Route Handlers를 사용하여 API 엔드포인트를 생성할 수 있다.
    • API 엔드포인트는 API 호출이 수행되는 부분이다.

 

2. Database Query

  • 만약, 풀스택 애플리케이션을 개발한다면 데이터베이스와 상호작용하는 로직을 작성하는 것은 필수라 할 수 있다,
    • Postgres와 같은 관계형 데이터베이스의 경우 SQL 또는 Prisma와 같은 ORM을 사용할 수 있다.
  • 데이터베이스 쿼리를 작성해야 하는 경우는 아래와 같다:
    1. API 엔드포인트를 생성할 때, 데이터베이스와 상호 작용하는 로직을 작성해야 한다.
    2. React Server Components(RSC)를 사용하는 경우(서버에서 데이터를 가져오는 경우), API Layer를 건너뛸 수 있으며, 데이터베이스의 secrets 데이터를 클라이언트에 노출시키지 않고도 데이터베이스에 직접 쿼리할 수 있다.

 

3. Using Server Components to fetch Data (서버컴포넌트에서 데이터 가져오기)

  • 기본적으로 Next.js 애플리케이션의 모든 컴포넌트는 React Server Component(RSC)로 구성된다.
  • Server Component를 사용하여, 데이터를 가져오는 것은 Next.js 업데이트에 따른 비교적 새로운 접근 방식임과 동시에, 다음과 같은 몇 가지 사용 이점이 존재한다.
    1. 서버 컴포넌트는 Promise를 지원하기 때문에, 데이터 Fetch와 같은 비동기 작업에 대한 간단한 솔루션을 제공한다. async/await 구문을 사용하면, useEffect, useState 또는 Fetch 용도의 각종 라이브러리에 의존하지 않고 데이터 Fetch를 진행할 수 있다.
    2. 서버 컴포넌트는 서버에서 실행되므로, 비용이 많이 드는 데이터 Fetch 및 로직을 서버에 유지하고 결과를 클라이언트에만 전송할 수 있다.
    3. (앞서 언급했듯) 서버 컴포넌트는 서버에서 실행되기 때문에, 추가적인 API Layer 없이 데이터베이스에 직접 쿼리할 수 있다.

 

4. Using SQL (SQL 사용하기)

  • 해당 과정에서는, 대시보드 프로젝트에 Vercel Postgres SDK 와 SQL을 사용하여, 데이터베이스 쿼리를 작성할 것이다.
  • SQL을 사용하는 이유에는 아래와 같은 몇 가지 이유가 있다.
    • SQL은 관계형 데이터베이스를 쿼리하는 산업 표준으로 사용된다. (예: ORM은 내부적으로 SQL을 생성한다)
    • SQL에 대한 기본적인 이해는 관계형 데이터베이스의 기본을 이해하는 데 도움이 되며, 이를 통해 다른 도구에도 지식을 적용할 수 있다.
    • SQL은 다양한 데이터를 가져오고 조작할 수 있는 유연성이 있다.
    • Vercel Postgres SDK는 SQL Injection(삽입 공격)에 대한 보호 기능을 제공한다.
  • /app/lib/data.ts 파일을 확인해보면, @vercel/postgres에서 SQL 함수를 가져오고 있음을 알 수 있다. 이 함수를 사용하여 데이터베이스를 쿼리할 수 있다.
import { sql } from "@vercel/postgres";
import {
  CustomerField,
  CustomersTable,
  InvoiceForm,
  InvoicesTable,
  LatestInvoiceRaw,
  User,
  Revenue,
} from "./definitions";
import { formatCurrency } from "./utils";

export async function fetchRevenue() {
  // Add noStore() here prevent the response from being cached.
  // This is equivalent to in fetch(..., {cache: 'no-store'}).

  try {
    // Artificially delay a reponse for demo purposes.
    // Don't do this in real life :)

    // console.log('Fetching revenue data...');
    // await new Promise((resolve) => setTimeout(resolve, 3000));

    const data = await sql<Revenue>`SELECT * FROM revenue`;

    // console.log('Data fetch complete after 3 seconds.');

    return data.rows;
  } catch (error) {
    console.error("Database Error:", error);
    throw new Error("Failed to fetch revenue data.");
  }
}

export async function fetchLatestInvoices() {
  try {
    const data = await sql<LatestInvoiceRaw>`
      SELECT invoices.amount, customers.name, customers.image_url, customers.email, invoices.id
      FROM invoices
      JOIN customers ON invoices.customer_id = customers.id
      ORDER BY invoices.date DESC
      LIMIT 5`;

    const latestInvoices = data.rows.map((invoice) => ({
      ...invoice,
      amount: formatCurrency(invoice.amount),
    }));
    return latestInvoices;
  } catch (error) {
    console.error("Database Error:", error);
    throw new Error("Failed to fetch the latest invoices.");
  }
}

export async function fetchCardData() {
  try {
    // You can probably combine these into a single SQL query
    // However, we are intentionally splitting them to demonstrate
    // how to initialize multiple queries in parallel with JS.
    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,
    ]);

    const numberOfInvoices = Number(data[0].rows[0].count ?? "0");
    const numberOfCustomers = Number(data[1].rows[0].count ?? "0");
    const totalPaidInvoices = formatCurrency(data[2].rows[0].paid ?? "0");
    const totalPendingInvoices = formatCurrency(data[2].rows[0].pending ?? "0");

    return {
      numberOfCustomers,
      numberOfInvoices,
      totalPaidInvoices,
      totalPendingInvoices,
    };
  } catch (error) {
    console.error("Database Error:", error);
    throw new Error("Failed to card data.");
  }
}

const ITEMS_PER_PAGE = 6;
export async function fetchFilteredInvoices(
  query: string,
  currentPage: number
) {
  const offset = (currentPage - 1) * ITEMS_PER_PAGE;

  try {
    const invoices = await sql<InvoicesTable>`
      SELECT
        invoices.id,
        invoices.amount,
        invoices.date,
        invoices.status,
        customers.name,
        customers.email,
        customers.image_url
      FROM invoices
      JOIN customers ON invoices.customer_id = customers.id
      WHERE
        customers.name ILIKE ${`%${query}%`} OR
        customers.email ILIKE ${`%${query}%`} OR
        invoices.amount::text ILIKE ${`%${query}%`} OR
        invoices.date::text ILIKE ${`%${query}%`} OR
        invoices.status ILIKE ${`%${query}%`}
      ORDER BY invoices.date DESC
      LIMIT ${ITEMS_PER_PAGE} OFFSET ${offset}
    `;

    return invoices.rows;
  } catch (error) {
    console.error("Database Error:", error);
    throw new Error("Failed to fetch invoices.");
  }
}

export async function fetchInvoicesPages(query: string) {
  try {
    const count = await sql`SELECT COUNT(*)
    FROM invoices
    JOIN customers ON invoices.customer_id = customers.id
    WHERE
      customers.name ILIKE ${`%${query}%`} OR
      customers.email ILIKE ${`%${query}%`} OR
      invoices.amount::text ILIKE ${`%${query}%`} OR
      invoices.date::text ILIKE ${`%${query}%`} OR
      invoices.status ILIKE ${`%${query}%`}
  `;

    const totalPages = Math.ceil(Number(count.rows[0].count) / ITEMS_PER_PAGE);
    return totalPages;
  } catch (error) {
    console.error("Database Error:", error);
    throw new Error("Failed to fetch total number of invoices.");
  }
}

export async function fetchInvoiceById(id: string) {
  try {
    const data = await sql<InvoiceForm>`
      SELECT
        invoices.id,
        invoices.customer_id,
        invoices.amount,
        invoices.status
      FROM invoices
      WHERE invoices.id = ${id};
    `;

    const invoice = data.rows.map((invoice) => ({
      ...invoice,
      // Convert amount from cents to dollars
      amount: invoice.amount / 100,
    }));

    return invoice[0];
  } catch (error) {
    console.error("Database Error:", error);
  }
}

export async function fetchCustomers() {
  try {
    const data = await sql<CustomerField>`
      SELECT
        id,
        name
      FROM customers
      ORDER BY name ASC
    `;

    const customers = data.rows;
    return customers;
  } catch (err) {
    console.error("Database Error:", err);
    throw new Error("Failed to fetch all customers.");
  }
}

export async function fetchFilteredCustomers(query: string) {
  try {
    const data = await sql<CustomersTable>`
		SELECT
		  customers.id,
		  customers.name,
		  customers.email,
		  customers.image_url,
		  COUNT(invoices.id) AS total_invoices,
		  SUM(CASE WHEN invoices.status = 'pending' THEN invoices.amount ELSE 0 END) AS total_pending,
		  SUM(CASE WHEN invoices.status = 'paid' THEN invoices.amount ELSE 0 END) AS total_paid
		FROM customers
		LEFT JOIN invoices ON customers.id = invoices.customer_id
		WHERE
		  customers.name ILIKE ${`%${query}%`} OR
        customers.email ILIKE ${`%${query}%`}
		GROUP BY customers.id, customers.name, customers.email, customers.image_url
		ORDER BY customers.name ASC
	  `;

    const customers = data.rows.map((customer) => ({
      ...customer,
      total_pending: formatCurrency(customer.total_pending),
      total_paid: formatCurrency(customer.total_paid),
    }));

    return customers;
  } catch (err) {
    console.error("Database Error:", err);
    throw new Error("Failed to fetch customer table.");
  }
}

export async function getUser(email: string) {
  try {
    const user = await sql`SELECT * from USERS where email=${email}`;
    return user.rows[0] as User;
  } catch (error) {
    console.error("Failed to fetch user:", error);
    throw new Error("Failed to fetch user.");
  }
}

 

5. Fetching data for the dashboard overview page

  • 대시보드 개요 페이지의 데이터를 가져오기 위해, Learning Course에서는 다음과 같은 코드를 제공하고 있다.
    • 이 코드는/app/dashboard/page.tsx경로의 파일에 붙여넣으면 된다.
    • 해당 컴포넌트는 비동기(async) 컴포넌트이며, await문을 사용하여 데이터를 가져올 수 있다.
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";

// 주석처리된 코드는 현재 시점에서, 오류발생을 방지하기 위함
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>
  );
}

 

6. Fetching data for <RevenueChart/>

  • 지금부터는 /app/dashboard/page.tsx 경로 상에 위치한 <RevenuChart/>의 주석을 해제하고, 필요한 데이터를 fetch할 것이다.
    • 먼저, /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/font";
import { fetchRevenue } from "../lib/data";

export default async function Page() {
  const revenue = await fetchRevenue();

  // 데이터 잘 나오는지 확인
  console.log("revenue: ", revenue);

  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>
  );
}
  • 참고로, fetchRevenue()는 아래 코드로 구성되어 있다.
export async function fetchRevenue() {
  // Add noStore() here prevent the response from being cached.
  // This is equivalent to in fetch(..., {cache: 'no-store'}).

  try {
    // Artificially delay a reponse for demo purposes.
    // Don't do this in real life :)

    // console.log('Fetching revenue data...');
    // await new Promise((resolve) => setTimeout(resolve, 3000));

    const data = await sql<Revenue>`SELECT * FROM revenue`;

    // console.log('Data fetch complete after 3 seconds.');

    return data.rows;
  } catch (error) {
    console.error("Database Error:", error);
    throw new Error("Failed to fetch revenue data.");
  }
}
  • 다음으로는, /app/ui/dashboard/revenue-chart.tsx 파일이 revenue 데이터를 정상적으로 전달받고 화면에 데이터를 출력할 수 있게끔 변경해주어야 한다.
import { generateYAxis } from "@/app/lib/utils";
import { CalendarIcon } from "@heroicons/react/24/outline";
import { lusitana } from "../font";
import { Revenue } from "@/app/lib/definitions";

// This component is representational only.
// For data visualization UI, check out:
// https://www.tremor.so/
// https://www.chartjs.org/
// https://airbnb.io/visx/

export default async function RevenueChart({
  revenue,
}: {
  revenue: Revenue[];
}) {
  const chartHeight = 350;
  // NOTE: comment in this code when you get to this point in the course

  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>
      {/* NOTE: comment in this code when you get to this point in the course */}

      <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>
  );
}
  • 그럼, 아래와 같은 화면을 확인할 수 있을 것이다.

 

7. Fetching data for <LatestInvoices/>

  • <LatestInvoices/> 컴포넌트는 날짜별로 정렬된 5개의 최신 송장 데이터를 가져와 출력하는 역할을 한다.
    • 데이터를 가져오는 부분은 /app/lib/data.tsfetchLatestInvoices()로 구현되어 있다.
export async function fetchLatestInvoices() {
  try {
    const data = await sql<LatestInvoiceRaw>`
      SELECT invoices.amount, customers.name, customers.image_url, customers.email, invoices.id
      FROM invoices
      JOIN customers ON invoices.customer_id = customers.id
      ORDER BY invoices.date DESC
      LIMIT 5`;

    const latestInvoices = data.rows.map((invoice) => ({
      ...invoice,
      amount: formatCurrency(invoice.amount),
    }));
    return latestInvoices;
  } catch (error) {
    console.error("Database Error:", error);
    throw new Error("Failed to fetch the latest invoices.");
  }
}
  • 화면에 데이터를 출력하기 위해, /app/ui/dashboard/latest-invoices.tsx파일의 주석도 아래와 같이 해제해야 한다.
    • 최신 버전의 Next.js(필자는 v14.0.0)에서는 <Image/> 컴포넌트의 alt 옵션이 필수이므로, 이에 유의하여야 한다.
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";

export default async function LatestInvoices({
  latestInvoices,
}: {
  latestInvoices: LatestInvoice[];
}) {
  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>
  );
}
  • 마지막으로, /app/dashboard/page.tsx파일에서, <LatestInvoices/> 컴포넌트를 호출하는 주석 부분을 해제하고, latestInvoices데이터를 가져오는 코드를 작성해야 한다.
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 { fetchLatestInvoices, fetchRevenue } from "../lib/data";

export default async function Page() {
  const revenue = await fetchRevenue();
  const latestInvoices = await fetchLatestInvoices();
  console.log("latestInvoices: ", latestInvoices);

  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>
  );
}
  • 출력되는 화면은 아래와 같다.

 

8. Fetch data for the <Card/> (Practice)

  • 해당 부분은, <Card/> 컴포넌트용 데이터들을 가져오는 것을 실습하는 단계이다.
    • <Card/>에는 수집된 송장의 총 금액, 보류 중인 송장의 총 금액, 총 송장 수, 총 고객 수의 데이터가 표시되어야 한다.
    • <Card/> 컴포넌트에서 필요한 데이터와 data.ts 파일에서의 함수가 반환하는 데이터를 종합적으로 확인해, 코드를 작성해보자.
  • fetch에 성공하면, 아래와 같은 화면을 확인할 수 있을 것이다.
  • 먼저, /app/dashboard/page.tsx에서, <Card/>컴포넌트를 호출하는 부분의 주석을 해제하여, 화면에 표시될 수 있도록 구성해야 한다.
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";

export default async function Page() {
  const revenue = await fetchRevenue();
  const latestInvoices = await fetchLatestInvoices();

  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>
  );
}
  • <Card/> 컴포넌트를 정의하는 부분(/app/ui/dashboard/cards.tsx)을 살펴보면, value props는 number 또는 string값을 전달받아, 값을 출력하고 있음을 확인할 수 있다.
export function Card({
  title,
  value,
  type,
}: {
  title: string;
  value: number | string;
  type: "invoices" | "customers" | "pending" | "collected";
}) {
  const Icon = iconMap[type];

  return (
    <div className="rounded-xl bg-gray-50 p-2 shadow-sm">
      <div className="flex p-4">
        {Icon ? <Icon className="h-5 w-5 text-gray-700" /> : null}
        <h3 className="ml-2 text-sm font-medium">{title}</h3>
      </div>
      <p
        className={`${lusitana.className}
          truncate rounded-xl bg-white px-4 py-8 text-center text-2xl`}
      >
        {value}
      </p>
    </div>
  );
}
  • /app/lib/data.tsfetchCardData()를 살펴보면, 현재 실습을 위한 <Card/>컴포넌트의 value props를 모두 return하고 있음을 확인할 수 있다.
    • invoiceCountPromise: invoices 테이블의 모든 레코드 수 반환
    • customerCountPromise: customers 테이블의 모든 레코드 수 반환
    • invoiceStatusPromise: invoices 테이블에서, status가 paid, pending 상태인 데이터들의 amount(금액)를 각각 합산하여 ,각각 반환
    • numberOfInvoices: invoices테이블로부터 추출한 총 송장 수
    • numberOfCustomers: customers테이블로부터 추출한 총 고객 수
    • totalPaidInvoices: invoices테이블로부터 추출한 paid 상태의 총 금액 수
    • totalPendingInvoices: invoices테이블로부터 추출한 pending 상태의 총 금액 수
export async function fetchCardData() {
  try {
    // You can probably combine these into a single SQL query
    // However, we are intentionally splitting them to demonstrate
    // how to initialize multiple queries in parallel with JS.
    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,
    ]);

    // console.log("data[0].rows[0] : ", data[0].rows[0]);
    // console.log("data[1].rows[0] : ", data[1].rows[0]);
    // console.log("data[2].rows[0] : ", data[2].rows[0]);

    const numberOfInvoices = Number(data[0].rows[0].count ?? "0");
    const numberOfCustomers = Number(data[1].rows[0].count ?? "0");
    const totalPaidInvoices = formatCurrency(data[2].rows[0].paid ?? "0");
    const totalPendingInvoices = formatCurrency(data[2].rows[0].pending ?? "0");

    return {
      numberOfCustomers,
      numberOfInvoices,
      totalPaidInvoices,
      totalPendingInvoices,
    };
  } catch (error) {
    console.error("Database Error:", error);
    throw new Error("Failed to card data.");
  }
}
  • 마지막으로, /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/font";
import { fetchCardData, fetchLatestInvoices, fetchRevenue } from "../lib/data";

export default async function Page() {
  const revenue = await fetchRevenue();
  const latestInvoices = await fetchLatestInvoices();

  // 추가
  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">
        <RevenueChart revenue={revenue} />
        <LatestInvoices latestInvoices={latestInvoices} />
      </div>
    </main>
  );
}
  • 여기까지의 상황에서, 알아두어야 할 두 가지 사항이 있다.
    1. 데이터 요청이 의도치 않게 서로를 차단하여 Request Waterfalls(요청 폭포)가 생성되고 있다.
    2. 기본적으로 Next.js는 성능 향상을 위해 경로를 미리 렌더링하는데, 이를 정적 렌더링 이라고 한다 . 따라서 데이터가 변경되면 대시보드에는 반영되지 않는다.

 

9. Request Waterfalls

  • waterfalls은, 이전 요청의 완료에 의존하는 일련의 네트워크 요청을 가리킨다.

    • Request Waterfalls는 데이터를 가져오는 상황에서, 각각의 요청은 이전 요청이 데이터를 반환한 후에만 시작될 수 있는 패턴을 의미한다.
    • 예를 들어, fetchRevenue()가 실행되고 데이터를 반환할 때까지 fetchLatestInvoices()가 실행되기를 기다려야 하며, fetchLatestInvoices()가 완료되어 데이터를 반환할 때까지 fetchCardData()를 실행할 수 없다.
    • 이러한 동작은 의도치 않게 성능에 영향을 미칠 수 있다.
  • 위에서는, Request Waterfalls 패턴을 좋지 않은 방향으로 소개하였는데, 다음 요청을 시작하기 전에 특정 조건을 충족시켜야만 하는 경우에는, 해당 패턴이 필요할 수 있다.

    • 예를 들어, 사용자의 ID와 프로필 정보를 먼저 가져와야 하는 경우가 있을 수 있다.
    • ID를 가져오면 그 다음에는 친구 목록을 가져오도록 진행할 수 있는데, 해당 상황은 각 요청이 이전 요청에서 반환된 데이터에 의존해야만 하는 특징을 가진다.

 

10. Parallel data fetching

  • waterfalls를 피하는 일반적인 방법 중 하나는 모든 데이터 요청을 병렬로 처리하는 것이다.
  • JavaScript에서는 Promise.all() 또는 Promise.allSettled()함수를 사용하여 모든 Promise 작업을 동시에 시작할 수 있다.
    • 예를 들어, /app/lib/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,
    ]);

    // ...
}
  • 이러한 패턴은 아래와 같은 장점이 있다.
    • 모든 fetch 작업을 동시에 시작하여 성능 향상을 이끌어낼 수 있다.
    • 다양한 라이브러리나 프레임워크에 적용 가능한 네이티브 JavaScript 패턴을 사용할 수 있다.
  • 그러나 이 JavaScript 패턴에 의존하는 경우, 두드러지는 한 가지 단점이 있는데, 바로 다른 모든 데이터 요청보다 하나의 데이터 요청이 느린 경우의 처리방식을 고민해야 한다는 것이다.
profile
프론트엔드 입문 개발자입니다.

0개의 댓글