저번 글에 이어서 진행하겠습니다.

불러올 데이터를 넣어줬으니 이제 데이터 Fetching을 해보겠습니다. 보통 데이터를 Fetching하는 방법은 API Layer와 Database queries를 이용한 두 가지 방법이 있는데 이는 이 글을 작성한 이후에 설명하는 글을 작성하겠습니다.

계속해서 프로그램을 만들어 보겠습니다. 

 

    10. 데이터를 사용하기 위해 dashboard 페이지 수정

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

 

  • ReavenueChart 주석을 풀고 fetchRevenue에서 revenue를 가져와 선언한 후 ReavenueChart 컴포넌트 내부 주석 풀어주기
  • LatestInvoices 주석을 풀고 fetchLatestInvoices에서 latestInvoices를 가져와선언한 후 LatestInvoices 컴포넌트 내부 주석 풀어주기
  • Card 주석을 풀고 fetchCardData에서 totalPaidInvoices, totalPendingInvoices, numberOfInvoices, numberOfCustomers를 가져와 선언

Next.js fetch는 기본적으로 캐싱을 해주는데 캐싱하지 않고 최신데이터를 불러오고 싶다면

import { unstable_noStore as noStore } from 'next/cache'

 

위와 같이 import 해 준 후에 fetching 함수 내부의 최상단에 noStore()를 선언해 주면 됩니다.

만약에 API로 가져오는 거라면 fetch(url, {cache: 'no-store' })를 해주면 됩니다.

 

수정된 최종 dashboard/page.tsx 코드

// /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 { fetchCardData, fetchLatestInvoices, fetchRevenue } from '../lib/data';

export default async function Page() {
  const revenue = await fetchRevenue();
  const latestInvoices = await fetchLatestInvoices();
  const {
    totalPaidInvoices,
    totalPendingInvoices,
    numberOfInvoices,
    numberOfCustomers,
  } = 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>
  );
}

 

 

    11. dashboard 폴더에 loding.tsx 만들고 그룹화하기

(overview) : Route Groups

loding.tsx 파일을 특정 폴더에 만들 경우 하위의 모든 폴더(페이지)에 적용이 됩니다. 이를 (overview) 폴더를 만들고 해당 폴더에 넣어서 하위 폴더까지 적용하지 않고 loding.tsx 파일을 만든 폴더엔만 적용할 수 있습니다.

 

dashboard 폴더에 (overview) 폴더를 만들고 page.tsx와 loding.tsx 파일을 넣어줬습니다.

 

 

    12. Suspense 태그를 이용해 dashboard에서 데이터를 보여주는 컴포넌트들을 동적으로 로딩해주기

위의 작업을 하기 위해서 원래 dashborad(대시보드)의 page.tsx에서 데이터를 fetching하고 데이터를 가시화하는 컴포넌트에 넣어주던 방식을 각 데이터 가시화 컴포넌트 자체에서 데이터를 fetching 하는 방식으로 바꿔줍니다.

 

데이터를 가시화 하는 컴포넌트(RevenueChart, LatestInvoices, Card)가 props를 받는 구조를 없애고 각자의 내부에서 데이터를 fetching하는 방식으로 수정합니다.

여기서 Card 컴포넌트들은 card.tsx로 들어가면 CardWrapper이 만들어져 있습니다. 여기서 아래 주석들을 해제하고 필요한 데이터를 fetchCardData에서 가져와 선언하여 연결해주면 됩니다. 그리고 CardWrapper를 Suspense로 덮어줍니다.

fallback에 대한 컴포넌트들은 ui 폴더의 skeletons.tsx 파일에 만들어져 있습니다. 각 컴포넌트에 맞게 fallback에 넣어주면 됩니다.

 

완성된 대시보드의 페이지 컴포넌트 코드입니다.

// /app/dashboard/page.tsx

import CardWrapper, { 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 { Suspense } from 'react';
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>
      <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>
  );
}

 

    13. invoices 페이지 만들기 검색했을때 url의 쿼리스트링에 연결될 수 있도록 설정 

먼저 invoices 페이지를 컴포넌트 코드를 작성해 줍니다.

// /app/dashboard/invoices/page.tsx

import Pagination from '@/app/ui/invoices/pagination';
import Search from '@/app/ui/search';
import Table from '@/app/ui/invoices/table';
import { CreateInvoice } from '@/app/ui/invoices/buttons';
import { InvoicesTableSkeleton } from '@/app/ui/skeletons';
import { Suspense } from 'react';

export default function Invoices({
  searchParams,
}: {
  searchParams: {
    query?: string;
  };
}) {
  console.log(searchParams?.query);

  return (
    <div>
      <div className="w-full">
        <h1 className="flex w-full items-center justify-between">Invoices</h1>
      </div>
      <div className=" mt-4 flex items-center justify-between gap-2 md:mt-8">
        <Search placeholder="Search invoices..." />
        <CreateInvoice />
      </div>

      {/* <Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
        <Table query={query} currentPage={currentPage} />
      </Suspense> */}

      <div className=" mt-5 w-full justify-center">
        {/* <Pagination totalPages={totalPages}/> */}
      </div>
    </div>
  );
}

그리고 Search를 수정하여 input 창에 값을 입력했을 때 url에 쿼리스트링에 넣어주는 코드를 작성합니다.

 

<원래 코드 ▼>

// /app/ui/search.tsx

'use client';

import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { usePathname, useSearchParams, useRouter } from 'next/navigation';

export default function Search({ placeholder }: { placeholder: string }) {
  return (
    <div className="relative flex flex-1 flex-shrink-0">
      <label htmlFor="search" className="sr-only">
        Search
      </label>
      <input
        className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
        placeholder={placeholder}
      />
      <MagnifyingGlassIcon className="absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
    </div>
  );
}

 

< 수정한 코드 ▼ >

// /app/ui/search.tsx

'use client';

import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { usePathname, useSearchParams, useRouter } from 'next/navigation';

export default function Search({ placeholder }: { placeholder: string }) {
  const searchParams = useSearchParams();
  const pathname = usePathname():
  const {replace} = useRouter();
  
  const handleSearch = (value: string) => {
  	const params = new URLSearchParams(searchParams);
    if (value) {
    	params.set('query', value);
    }
    else {
    	params.delete('query');
    }
    replace(`${pathname}`?${params.toString()});
  }
  
  return (
    <div className="relative flex flex-1 flex-shrink-0">
      <label htmlFor="search" className="sr-only">
        Search
      </label>
      <input
        className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
        placeholder={placeholder}
        onChange={ (e) => {
        	handleSearch(e.target.value)
        }}
        defaultValue={searchParams.get('query')?.toString()}
      />
      <MagnifyingGlassIcon className="absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
    </div>
  );
}

수정한 코드를 살펴보면 onChange를 이용해 input 값을 가져오고 그 값을 params에 넣어서 useRouter replace를 이용해 url에 추가하는 방식을 사용하고 있습니다.

 

    14. Table과 Suspense 주석을 풀고 검색되는지 확인

// /app/dashboard/invoices/page.tsx

import Pagination from '@/app/ui/invoices/pagination';
import Search from '@/app/ui/search';
import Table from '@/app/ui/invoices/table';
import { CreateInvoice } from '@/app/ui/invoices/buttons';
import { InvoicesTableSkeleton } from '@/app/ui/skeletons';
import { Suspense } from 'react';

export default function Invoices({
  searchParams,
}: {
  searchParams: {
    query?: string;
    page?: string;
  };
}) {
  console.log(searchParams?.query);
  const query = searchParams?.query || '';
  const currentPage = Number(searchParams?.page) || 1;

  return (
    <div>
      <div className="w-full">
        <h1 className="flex w-full items-center justify-between">Invoices</h1>
      </div>
      <div className=" mt-4 flex items-center justify-between gap-2 md:mt-8">
        <Search placeholder="Search invoices..." />
        <CreateInvoice />
      </div>

      <Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
        <Table query={query} currentPage={currentPage} />
      </Suspense>

      <div className=" mt-5 w-full justify-center">
        {/* <Pagination totalPages={totalPages}/> */}
      </div>
    </div>
  );
}

Search 컴포넌트에서 useRouter를 이용해 searchParams를 보내줬기 때문에 invoices 페이지에서 props로 받아와 currentPage와 query를 정의하고 Suspense로 감싼 Table 컴포넌트의 주석을 풀어줍니다. 이렇게 하면 검색창에 입력된 값이 사용자 정보와 일치한다면 데이터를 가지고 옵니다. 

정보를 불러오는 함수를 보면 입력된 query를 사용자의 모든 정보와 비교하여 일치하는 데이터가 하나라도 있으면 불러오는 것을 알 수 있습니다.

 

검색 과정을 파악해 보자면, 검색창에 입력된 값을 쿼리스트링으로 보내고 각 컴포넌트에서 해당 쿼리스트링의 값을 받아와 일치하는 데이터를 보여주는 방식입니다. 강사님도 말씀해 주시긴 했는데 이런 방식보다 그냥 입력창에 입력된 값으로 데이터를 바로 찾아와서 화면에 보여주는게 더 낫지 않을까 싶네요. 하지만 useSearchParams와 usePathname을 이용해 자식 컴포넌트에서 부모 컴포넌트로 데이터를 보낼 수 있는 방법론적인 면에서는 알아두면 좋다고 하셨습니다.

 

    15. Invoice 데이터에 대한 생성, 수정, 삭제 기능 만들기

 

a. 데이터 생성

먼저 CreateInvoice 컴포넌트를 클릭했을 때 이동하는 /dashboard/invoices/create 페이지를 만들어 줍니다.

// /app/dashboard/create/page.tsx

import Form from '@/app/ui/invoices/create-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchCustomers } from '@/app/lib/data';

export default async function Page() {
  const customers = await fetchCustomers();

  return (
    <main>
      <Breadcrumbs
        breadcrumbs={[
          { label: 'invoices', href: 'dashboard/invoices' },
          {
            label: 'Create Invoice',
            href: 'dashboard/invoices/create',
            active: true,
          },
        ]}
      />
      <Form customers={customers} />
    </main>
  );
}

이렇게 완성해주고 Create invoice 버튼을 클릭하면 다음과 같은 화면이 표시됩니다.

그리고 Create Invoice 버튼을 클릭했을 때 데이터가 추가될 수 있도록 //app/lib 폴더에 actions.ts 파일을 만들어 데이터를 보내주는 함수를 만들어 줍니다. 

// /app/lib/actions.ts

'use server';

import { z } from 'zod';
import { sql } from '@vercel/postgres';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

const FormSchema = z.object({
  id: z.string(),
  customerId: z.string(),
  amount: z.coerce.number(),
  status: z.enum(['pending', 'paid']),
  date: z.string(),
});

const CreateInvoice = FormSchema.omit({ id: true, date: true });

export async function createInvoice(formData: FormData) {
  const { customerId, amount, status } = CreateInvoice.parse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
  const amountInCents = amount * 100;
  const date = new Date().toISOString().split('T')[0];

  await sql`
  INSERT INTO invoices (customer_id, amount, status, date)
  VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
  `;

  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

여기서 zod 라이브러리는 스키마 선언 및 데이터 검증을 위해 사용하는 라이브러리입니다. zod를 사용해 FormSchema를 선언하고 이를 통해 폼 데이터를 검증하기 위해 zod를 사용하고 있습니다.

z를 이용해 폼 데이터의 구조와 유형을 정의합니다.

 

그리고 CreateInvoice를 선언하며 인보이스 생성 시 필요한 필드만 포함한 스키마를 만들어줍니다.

 

이후 폼 데이터에서 필요한 필드를 검증하고 추출합니다. 

여기서 z.coerce.number()는 문자열을 숫자로 변환해 줍니다.

 

그리고 Form 컴포넌트에 action 속성이 지정되어 있지 않기 때문에 action 속성에 crateInvoice 함수를 연결해 주면 Create Invoice 버튼을 클릭했을 때, 입력한 데이터가 DB로 들어간 것을 확인할 수 있습니다.

 

 

b. 데이터 수정

// app/dashboard/invoices/page.tsx 의 Table 컴포넌트에서 UpdataInvoice 함수 찾아서 이동 주소 올바르게 바꾸기

 

edit 페이지 만들기

// /app/dashboard/invoices/[id]/edit/page.tsx

import Form from '@/app/ui/invoices/edit-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchCustomers, fetchInvoiceById } from '@/app/lib/data';

export default async function Page({ params }: { params: { id: string } }) {
  const id = params.id;
  const [invoice, customers] = await Promise.all([
    fetchInvoiceById(id),
    fetchCustomers(),
  ]);

  return (
    <main>
      <Breadcrumbs
        breadcrumbs={[
          { label: 'Invoices', href: '/dashboard/invoices' },
          {
            label: 'Edit Invoice',
            href: `/dashboard/invoices/${id}/edit`,
            active: true,
          },
        ]}
      />
      <Form invoice={invoice} customers={customers} />
    </main>
  );
}

 

데이터를 업데이트 해주는 함수 actions.ts 파일에 추가하기

// /app/lib/actions.ts

const UpdateInvoice = FormSchema.omit({ id: true, date: true });

export async function updateInvoice(id: string, formData: FormData) {
  const { customerId, amount, status } = UpdateInvoice.parse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
  const amountInCents = amount * 100;

  await sql`
  UPDATE invoices
  SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}
  WHERE id = ${id}
  `;

  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

 

위에서 create 페이지를 포면 각 페이지에 맞는 Form 컴포넌트가 준비되어 있다는 것을 알 수 있습니다. 그렇기에 edit 페이지에 대한 Form 컴포넌트가 updateInvoie 함수를 사용할 수 있도록 코드를 수정해 줘야 합니다. 그리고 updateInvoice 함수는 폼 데이터뿐만 아니라 id 값도 파라미터로 받고 있기 때문에 invoice.id를 바인드해준 후에 form 태그의 actiondp 넣어줍니다.

이렇게 하면 수정 기능도 완성입니다.

 

c. 데이터 삭제

삭제는 id를 가져와서 id에 일치하는 데이터를 삭제해 주면 되기 때문에 생성과 수정보다 간단합니다.

delete 버튼을 클릭했을 때는 따로 페이지를 이동하지 않기 때문에 

 여기서 바로 수정해 줍니다.

deleteInvoice 함수를 만들었다 가정하고 id를 함수에 반환해주고 button 태그를 form으로 감싼 후에 action에 바인드 처리한 함수를 넣어주면 됩니다.

 

그리고 actions.ts 파일에 deleteInvopice 함수를 만들어 주면 삭제 기능 완성입니다.

// /app/lib/actions.ts

export async function deleteInvoice(id: string) {
  await sql`
  DELETE FROM invoices
  WHERE id = ${id}
  `;

  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

 


여기까지 간단한 대시보드 프로그램을 함께 만들고 강의를 마무리 했습니다. 조금 아쉬웠던 점은 SPA 구현이 Suspense 사용해 Streaming 페이지를 구현하는 것을 얘기하는 것 같더라고요ㅠㅠ SPA 구현에 대해 자세히 배우지 못한 것 같아 아쉬움이 남았습니다. 그래서 Next.js 없이 React만 이용해 SPA를 구현하는 방법을 공부해서 구현한 후에 정리해서 올릴 예정입니다. 긴 글 읽어주셔서 감사합니다.

+ Recent posts