[Next.js V13] 정리 노트 - 3

서버 컴포넌트에 데이터 Fetching 하기 With PostgreSQL and Prisma

Intro


이 시간에


이전까지 과정을 보면 컴포넌트내 들어가는 음식 이름, 소개 등 모두 텍스트만 입력해 하드코딩되어 있었습니다. 이제 실제 데이터베이스에서 데이터를 가져와 서버 컴포넌트에 붙여보도록 합니다. 데이터베이스는 Postgres를 사용할 것이며 이 데이터 베이스에 대해 정리해보도록 하겠습니다.

PostgreSQL


postgreSQL은 관계형 데이터베이스입니다. 기존 관계형 데이터베이스와 다르게 특출난 점은 데이터 행마다 git과 같이 버전관리가 진행되는것입니다. 이와 관련된 내용은 추후 알아보도록 하겠습니다.

Superbase를 이용해 데이터베이스 구성


백엔드 구현에는 꽤 시간이 걸리므로 파이어베이스와 같은 클라우드 환경의 백엔드를 구축해봅시다. 그중 PostgreSQL을 지원하는 Superbase를 이용하겠습니다. 또한 데이터베이스 매핑을 위한 도구로 Prisma를 이용하겠습니다.

bash
npm i prisma@4.8.1

설치 완료 후 이 프로젝트에서prisma사용을 위해 초기화합니다.

bash
npx prisma init

초기화 이후 프로젝트 폴더내 prisma폴더, .evn파일이 생성되며 데이터 베이스 연결 및 model생성이 가능합니다.

text
내 프로젝트 폴더 이름
├── pages
├── public
│   └── config.json
├── prisma
│   └── schema.prisma
├── .env
...

env파일에는 데이터베이스에 연결할 URL을 입력해줍니다. 현재 단계에서는 Superbase에서 생성한 데이터베이스의 커넥션 URL을 입력해주겠습니다.

json
# Environment variables declared in this file are automatically made available to Prisma.
# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema

# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings

DATABASE_URL="커넥션 주소"

테이블 구성하기


다음과 같은 구조로 테이블을 구성해보겠습니다.

1. Restaurant

스크린샷 2023-03-08 오후 2 17 57

2.Items

스크린샷 2023-03-08 오후 2 18 07

3.Location

스크린샷 2023-03-08 오후 2 18 19

4.Cuisine

스크린샷 2023-03-08 오후 2 18 29

위에 정의한 테이블을 prisma를 이용해 스키마를 정의하면 다음과 같은 코드로 만들 수 있습니다.

json
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

// @id -> 기본키로 지정
// @updatedAt -> 업데이트 때마다 변경할 수 있도록 제약
// @default -> 기본값 지정
// @unique -> 유니크값 지정
// Item[] 여러 Item을 가짐.
model Restaurant {
  id            Int     @id     @default(autoincrement())
  name          String
  main_image    String
  images        String[]
  description   String
  open_time     String
  close_time    String
  slug          String    @unique
  price         PRICE
  items         Item[]
  location_id   Int
  location      Location    @relation(fields: [location_id], references: [id])
  cuisine_id    Int
  cuisine       Cuisine     @relation(fields: [cuisine_id], references: [id])
  createdAt     DateTime    @default(now())
  updatedAt     DateTime    @updatedAt
}

// Restaurant (1) : Item (N)
// restaurant    Restaurant  @relation(fields: [restaurant_id], references: [[id]])
// Restaurant 테이블의 id값을 Item 필드의 restaurant_id가 참조
model Item {
  id            Int       @id     @default(autoincrement())
  name          String
  price         String
  description   String
  restaurant_id Int
  restaurant    Restaurant  @relation(fields: [restaurant_id], references: [id])
  createdAt     DateTime    @default(now())
  updatedAt     DateTime    @updatedAt
}

// Restaurant (N) : Location (1)
model Location {
  id            Int       @id     @default(autoincrement())
  name          String
  restaurants   Restaurant[]
  createdAt     DateTime    @default(now())
  updatedAt     DateTime    @updatedAt
}

// Restaurant (N) : Cuisine (1)
model Cuisine {
  id            Int       @id     @default(autoincrement())
  name          String
  restaurants   Restaurant[]
  createdAt     DateTime    @default(now())
  updatedAt     DateTime    @updatedAt
}

// enum 을 추가해 특정 문자열만 입력되는 타입으로 사용할 수 있다.
enum PRICE {
  CHEAP
  REGULAR
  EXPENSIVE
}

이후 마이그레이션을 위해 다음과 같이 명령을 실행합니다.

bash
npx prisma db push

이후 실제 데이터베이스로 이동하면 테이블이 생성되어있을겁니다.

더미 데이터 생성하기


테스트를 위해 생성된 테이블에 더미 데이터를 생성할 수 있습니다. pages 폴더내 seed 파일을 만들고 /api/seed를 요청하면 더미 데이터를 생성할 수 있습니다. 더미 데이터 생성하기 Prisma 참고

서버 컴포넌트에 데이터 패칭


메인페이지에서 데이터가 정상적으로 조회되는지 확인해봅시다.

우선 restaurant에서 조회할 값은 id, name, main_image, cuisine, location, price입니다. 먼저 restaurant정보는 하위 RestaurantCard컴포넌트에 값을 전달해야하므로 인터페이스를 설정한 후 해당 인터페이스의 배열을 반환타입으로 설정하여 prisma를 이용해 데이터를 불러옵니다.

tsx
// /app/page.tsx
import Header from './components/Header'
import RestaurantCard from './components/RestaurantCard'
import { PrismaClient, Cuisine, Location, PRICE } from '@prisma/client'

const prisma = new PrismaClient();

export interface RestaurantCardType {
  id: number;
  name: string;
  main_image: string;
  slug: string;
  cuisine: Cuisine;
  location: Location;
  price : PRICE;
}

const fetchRestaurants = async () : Promise<RestaurantCardType[]> => {
  const restaurants = await prisma.restaurant.findMany({
    select: {
      id: true,
      name: true,
      main_image: true,
      cuisine: true,
      location: true,
      price: true,
      slug: true
    }
  });

  return restaurants;
}

export default async function Home() {

  // restaurant 리스트 조회
  const restaurants = await fetchRestaurants();

  // 서버 컴포넌트이므로 브라우저에서 콘솔창을 열어도 보이지 읺음. 서버측에서만 보임.
  console.log({restaurants});
  return (
    <main>
      <Header/>
      <div className="py-3 px-36 mt-10 flex flex-wrap justify-center">
        {restaurants.map(restaurant => (
          // restaurants를 순회하여 하위 컴포넌트 RestaurantCard 애 props로 restaurant 할당
          <RestaurantCard restaurant={restaurant}/>
        ))}
        {/* <RestaurantCard/> */}
      </div>
    </main>
  )
}

또한 하위 컴포넌트 RestaurantCard에서 RestaurantCardType타입을 전달받을 속성을 인터페이스로 정의한 후 해당 인터페이스를 타입으로 가질 객체를 받는다고 타입을 정의합니다.
이후 넘겨받은 restaurant속성으로 레스토랑 이름, 음식 이름, 주소, 이미지등을 카드마다 정의할 수 있습니다.

tsx
// /app/components/RestaurantCard.tsx
import Link from "next/link";
import { RestaurantCardType } from "../page";
import Price from "./Price";

interface Props {
  restaurant: RestaurantCardType;
}

export default function RestaurantCard({restaurant} : Props){
  return (
    <div className="w-64 h-72 m-3 rounded overflow-hidden border cursor-pointer">
      <Link href={`/restaurant/${restaurant.slug}`}>
        <img
          src={restaurant.main_image}
          alt=""
          className="w-full h-36"
        />
        <div className="p-1">
          <h3 className="font-bold text-2xl mb-2">{restaurant.name}</h3>
          <div className="flex items-start">
            <div className="flex mb-2">*****</div>
            <p className="ml-2">77 reviews</p>
          </div>
          <div className="flex text-reg font-light capitalize">
            <p className=" mr-3">{restaurant.cuisine.name}</p>
            <Price price={restaurant.price} />
            <p>{restaurant.location.name}</p>
          </div>
          <p className="text-sm mt-1 font-bold">Booked 3 times today</p>
        </div>
      </Link>
    </div>
  )
}

조건부 렌더링이 필요한 Price서버 컴포넌트 또한 생성합니다.

tsx
// /app/components/Price.tsx
import { PRICE } from '@prisma/client' // 열거형 PRICE를 가져온다.
import React from 'react'

export default function Price({price}: {price: PRICE}) {

  const renderPrice = () => {
    if(price === PRICE.CHEAP){
      return <>
        <span>$$</span><span className="text-gray-400">$$</span>
      </>
    }
    else if(price === PRICE.REGULAR){
      return <>
        <span>$$$</span><span className="text-gray-400">$</span>
      </>
    }
    else{
      return <>
        <span>$$$$</span>
      </>
    }
  }

  return <p className="flex mr-3">{renderPrice()}</p>
}

이제 특정 레스토랑의 slug를 이용해서 상세 정보 데이터를 가져와봅시다. 위치는 app/restaurant/[slig]/page.tsx를 바라보게 됩니다.
slug는 현재 @unique가 적용되어 있습니다. 따라서 가져오는 경우에도 findUnique를 사용합니다. 우선 이를 적용하기 위해서 slug값을 어떻게 추출할지 생각해봐야합니다.
현재 만일 특정 레스토랑으로 이동했다면 url주소는 다음과 같은 형식일 겁니다. http://localhost:3000/restaurant/coconut-lagoon-ottawa여기서 coconut-lagoon-ottawaslug입니다. 아래와 같이 이 컴포넌트에 주어지는 속성 props를 일단 any타입으로 설정해 어떠한 결과들이 보이는지 확인합시다.

tsx
// /app/restaurant/[slug]/page.tsx
export default async function RestaurantDetails(props: any){
  console.log((props));

이후 콘솔 로그를 본다면 다음과 같이 slug가 기본적으로 전해질 겁니다. 이 속성은 [slug]값 즉 현재 url에서 파라미터에 해당됩니다. params 값으로 전달됩니다.

log
{ params: { slug: 'coconut-lagoon-ottawa' }, searchParams: {} }

다음과 같이 변경한다면 해당 slug의 고유 정보 id, name, images, description, slug를 조회할 수 있습니다.

tsx
// /app/restaurant/[slug]/page.tsx
const fetchRestaurantBySlug = async (slug: string) => {
  const restaurant = await prisma.restaurant.findUnique({
    where: {
      slug
    },
    select: {
      id: true,
      name: true,
      images: true,
      description: true,
      slug: true
    }
  })

  return restaurant;
}

export default async function RestaurantDetails({params}: {params: {slug: string}}){
  // console.log((params));
  const restaurant = await fetchRestaurantBySlug(params.slug);

  console.log({restaurant})

여기서 prisma를 이용해 데이터를 요청한다음 상세정보 를 저장하게될 restaurant변수의 jsdoc을 보면 다음과 같습니다.

js
const restaurant: {
    id: number;
    name: string;
    images: string[];
    description: string;
    slug: string;
} | null

해당 slug의 정보가 존재하지 않으면 null을 가지게 되겠죠. 이는 원치 않은 값입니다. Promise를 이용해 반드시 상세정보의 타입만을 반환한다고 정리하고 만일 에러가 발생한다면 에러페이지를 호출하는게 좋죠. 따라서 null이 나와선 안됩니다. 다음과 같은 수정이 필요합니다.

tsx
const fetchRestaurantBySlug = async (slug: string): Promise<Restaurant> => {
  const restaurant = await prisma.restaurant.findUnique({
    where: {
      slug
    },
    select: {
      id: true,
      name: true,
      images: true,
      description: true,
      slug: true
    }
  })

  return restaurant;
}

하지만 위 코드를 봤을때 15번 라인에서 에러가 발생하게 됩니다. restaurant는 null도 가질 수 있기 때문이죠 그래서 반환 이전 에러를 throw하는 분기가 존재해야합니다.
일단 최종 코드는 다음과 같이 될겁니다.

tsx
// /app/restaurant/[slug]/page.tsx
 import { PrismaClient } from "@prisma/client";
import Description from "./components/Description";
import Header from "./components/Header";
import Images from "./components/Images"
import Rating from "./components/Rating";
import ReservationCard from "./components/ReservationCard";
import RestaurantNavBar from "./components/RestaurantNavBar";
import Reviews from "./components/Reviews";
import Title from "./components/Title";

const prisma = new PrismaClient();

interface Restaurant {
  id: number;
  name: string;
  images: string[];
  description: string;
  slug: string;
}

const fetchRestaurantBySlug = async (slug: string): Promise<Restaurant> => {
  const restaurant = await prisma.restaurant.findUnique({
    where: {
      slug
    },
    select: {
      id: true,
      name: true,
      images: true,
      description: true,
      slug: true
    }
  })
  // null 에러 처리
  if(!restaurant){
    throw new Error();
  }

  return restaurant;
}

export default async function RestaurantDetails({params}: {params: {slug: string}}){
  // console.log((params));
  const restaurant = await fetchRestaurantBySlug(params.slug);

  return (
    <>
      <div className="bg-white w-[70%] rounded p-3 shadow">
        <RestaurantNavBar slug={restaurant.slug}/>
        <Title name={restaurant.name}/>
        <Rating/>
        <Description description={restaurant.description}/>
        <Images images={restaurant.images}/>
        <Reviews/>
      </div>
      <div className="w-[27%] relative text-reg">
        <ReservationCard/>
      </div>
    </>
  )
}

이제 차례대로 데이터를 패칭시켜봅시다 하나만 예시로 본다면 위와 같이 속성을 하위 컴포넌트에 보내는 경우 하위컴포넌트는 다음과 같은 코드일 겁니다.

tsx
// /app/restaurant/[slug]/components/Images.tsx
export default function Images({images} : {images:string[]}) {
  return (
    <div>
      <h1 className="font-bold text-3xl mt-10 mb-7 border-b pb-5">
        {images.length} photo{images.length === 1 ? '' : 's'}
      </h1>
      <div className="flex flex-wrap">
        {images.map(image =>(
          <img
          className="w-56 h-44 mr-1 mb-1"
          src={image}
          alt=""
        />
        ))}
      </div>
    </div>
  )
}

layout.tsx에서도 변경이 필요합니다. 일단 [slug]값이 레스토랑 이름을 가리키니 이 값을 이용해봅시다.

tsx
// /app/restaurant/[slug]/layout.tsx
import Header from "./components/Header";

export default function RestaurantLayout({
  children,
  params
  // children은 타입이 React.ReactNode
}: {
  children: React.ReactNode;
  params: {slug: string};
}) {
  return (
    <main>
      <Header name={params.slug}/>
      <div className="flex m-auto w-2/3 justify-between items-start 0 -mt-11">
        {children}
      </div>
    </main>
  )
}

다음으로 Header컴포넌트의 내용을 변경합니다.

tsx
// /app/restaurant/[slug]/components/Header.tsx
export default function Header({name}: { name: string }) {

  const renderTitle = () => {
    const nameArray = name.split("-");

    // 마지막 문자
    nameArray[nameArray.length -1] = `(${nameArray[nameArray.length -1]})`

    return nameArray.join(" ")
  }

  return (
    <div className="h-96 overflow-hidden">
      <div className="bg-center bg-gradient-to-r from-[#0f1f47] to-[#5f6984] h-full flex justify-center items-center">
        <h1 className="text-7xl text-white capitalize text-shadow text-center">
          {renderTitle()}
        </h1>
      </div>
    </div>
  )
}

Link를 통해 검색기능 구현

NextLink테그에는 href속성으로 pathname, query를 가집니다. pathname이란 루트로부터의 경로이며, query는 파라미터 입니다. 이를 이용해 검색 기능도 구현할 수 있습니다. slug와 같이 query에 명시한 키-값도 파라미터이므로 해당 pathname으로 이동된 경우 파라미터로 전달되기 때문입니다.

tsx
// /app/search/page.tsx
import { PRICE, PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

interface SearchParams{
  city?: string; // city 라는 변수가 넘어오면 있고, 없다면 없다.
  cuisine?: string;
  price?: PRICE;
}

const fetchRestaurantsByCity = (searchParams: SearchParams) => {

  const where: any = {

  }

  if(searchParams.city){
    const location = {
      name: {
        equals: searchParams.city.toLowerCase()
      }
    }
    where.location = location
  }

  if(searchParams.cuisine){
    const cuisine = {
      name: {
        equals: searchParams.cuisine.toLowerCase()
      }
    }
    where.cuisine = cuisine
  }
  if(searchParams.price){
    const price = {
        equals: searchParams.price
    }
    where.price = price
  }

  const select = {
    id: true,
    name: true,
    main_image: true,
    price: true,
    cuisine: true,
    location: true,
    slug:true,
  }

  // 검색할 city 문자열이 없다면 전체 조회

  return prisma.restaurant.findMany({
    where,
    select,
  })
}

아래와 같이 Link태그를 이용해 조건에 따른 데이터를 가지고 올 수 있죠.

tsx
// /app/search/page.tsx
import { Cuisine, Location, PRICE } from "@prisma/client"
import Link from "next/link"



export default function SearchSideBar({
  locations, 
  cuisines, 
  searchParams
}: {
  locations: Location[], 
  cuisines: Cuisine[], 
  searchParams: {
  city?: string, cuisine?:string, price?: PRICE
}}) {

  const prices = [{
    price: PRICE.CHEAP,
    label: "$",
    className: "border w-full text-reg text-center font-light rounded-l p-2"
  },{
    price: PRICE.REGULAR,
    label: "$$",
    className: "border w-full text-reg text-center font-light p-2"
  },{
    price: PRICE.EXPENSIVE,
    label: "$$$",
    className: "border w-full text-reg text-center font-light rounded-r p-2"
  }]

  return (
    <div className="w-1/5">
    <div className="border-b pb-4 flex flex-col">
      <h1 className="mb-2">Region</h1>
      {locations.map(location => (
        <>
          <Link href={{
            pathname: "/search",
            query: {
              ...searchParams,
              city: location.name,
            }
          }} className="font-light text-reg capitalize" key={location.id}>{location.name}</Link>
        </>
      ))}
    </div>
    <div className="border-b pb-4 mt-3 flex flex-col">
      <h1 className="mb-2">Cuisine</h1>
      {cuisines.map(cuisine => (
        <>
          <Link href={{
            pathname: "/search",
            query: {
              ...searchParams,
              cuisine: cuisine.name
            }
          }} className="font-light text-reg capitalize" key={cuisine.id}>{cuisine.name}</Link>
        </>
      ))}
    </div>
    <div className="mt-3 pb-4">
      <h1 className="mb-2">Price</h1>
      <div className="flex">
        {prices.map(({price, label, className}) => (
          <>
            <Link href={{
              pathname: "/search",
              query: {
                ...searchParams,
                price
              }
            }}
            className={className}>
              {label}
            </Link>
          </>
        ))}
      </div>
    </div>
  </div>
  )
}

테이블 추가하기

이제 리뷰와 회원 테이블을 추가해봅시다.

json
// prisma/schema.prisma
model User {
  id            Int       @id     @default(autoincrement())
  first_name    String
  last_name     String
  city          String
  password      String
  email         String
  phone         String
  reviews       Review[]
  createdAt     DateTime    @default(now())
  updatedAt     DateTime    @updatedAt
}

// Review (N) : Restaurant (1)
model Review {
  id            Int       @id     @default(autoincrement())
  first_name    String
  last_name     String
  text          String
  rating        Float
  restaurant_id Int
  restaurant    Restaurant  @relation(fields: [restaurant_id], references: [id])
  user_id       Int
  user          User        @relation(fields: [user_id], references: [id])
  createdAt     DateTime    @default(now())
  updatedAt     DateTime    @updatedAt
}

이제 반영해봅시다.

bash
npx prisma db push