코딩/React

NextJS Prisma DB로 블로그 시스템 만들기

드리프트 2021. 10. 6. 18:04
728x170

 

 

안녕하세요?

 

지난시간에는 NextJS, NextAuth, Prisma 를 사용하여 카카오 로그인, 네이버 로그인, 구글 로그인에 대해 알아 보았는데요.

 

1편: 카카오 로그인

https://cpro95.tistory.com/516

 

카카오 로그인 구현 React(리액트) Nextjs NextAuth kakao login

안녕하세요? 지난 시간에는 NextJS와 MongoDB로 유저 로그인 세션 구현하기에 도전해 봤는데요. 최근에는 직접 유저 가입과 그 정보를 DB에 저장하는 거는 굉장히 위험한 일입니다. 그래서 각 대표

cpro95.tistory.com

 

2편: 네이버 로그인

https://cpro95.tistory.com/517

 

네이버 로그인 구현 React(리액트) Nextjs NextAuth naver login

안녕하세요? 지난 시간에는 카카오 로그인에 대해 구현해 봤는데요. 1편. 카카오 로그인 https://cpro95.tistory.com/516 카카오 로그인 구현 React(리액트) Nextjs NextAuth kakao login 안녕하세요? 지난 시간에..

cpro95.tistory.com

 

3편: 구글 로그인

https://cpro95.tistory.com/518

 

구글 로그인 구현 React(리액트) Nextjs NextAuth google login

안녕하세요? 지난 시간에는 네이버 로그인, 카카오 로그인에 대해 구현해 봤는데요. 1편. 카카오 로그인 https://cpro95.tistory.com/516 카카오 로그인 구현 React(리액트) Nextjs NextAuth kakao login 안녕하세..

cpro95.tistory.com

 

4편: 커스텀 로그인 화면 만들기

https://cpro95.tistory.com/521

 

카카오 네이버 구글 커스텀 로그인 화면 만들기 NextAuth React Next

안녕하세요? 오늘은 지난 시간부터 알아본 NextAuth 강좌의 연장인데요. 우리는 지금까지 카카오 로그인, 네이버 로그인, 구글 로그인에 대해 알아보았습니다. 1편: 카카오 로그인 https://cpro95.tistory

cpro95.tistory.com

 

오늘 시간에는 지금까지 만들었던 로그인 세션 기반위에서 Prisma 라이브러리를 이용한 DB 작업에 초점을 맞춰 볼까합니다.

 

그래서 최종적으로는 Blog 시스템을 만들어 볼까합니다.

 

 

 

1. Post 모델 설정하기

 

Blog 시스템을 만든다고 하면 가장 먼저 생각나는게 바로 블로그 글을 담고 있는 DB table일건데요.

 

보통 Post라고 부릅니다.

 

그럼, 어떻게 Post 모델을 만들어야 할까요?

 

우리는 튜토리얼을 하고 있으니까, 최대한 간단하게 만들어 봅시다.

 

티스토리나 네이버 블로그 같은 경우에는 자체 글쓰는 에디터가 있고 사진이나 동영상도 첨가할 수 있는데 튜토리얼에서 여기까지 가는거는 좀 오버인거 같고 간단하게 제목(타이틀), 본문(컨텐트)만 구성하도록 하겠습니다.

 

Prisma Schema 파일에서 다음과 같이 Post 모델을 추가합시다.

 

/prisma/schema.prisma

model Post {
  id        Int     @default(autoincrement()) @id
  title     String
  content   String?
  published Boolean @default(false)
  author    User?   @relation(fields: [authorId], references: [id])
  authorId  Int?
}

Post 모델은 id, title, content, published, author, authorId 로 구성되어 있는데요.

 

id는 모든 모델의 기본입니다.

 

그리고 author 부분을 보면 그에 해당하는 타입이 User네요.

 

User는 지난 시간까지 우리가 카카오 로그인이나 네이버 로그인할 때 쓰던 그 User 모델입니다.

 

왜냐하면 로그인해야만 글을 쓸 수 있고 그 User 정보가 바로 author 가 되는거죠.

 

여기서 관계형 데이타베이스의 기능이 나오는데요.

 

바로 @relation항목입니다.

 

@relation 항목은 간단합니다.

 

괄호한에 author 가 User 의 어디랑 연결되는지 설정하는 건데요.

 

fields 항목에 authorId 라고 넣었습니다.

 

그리고 다음칸에는 references 항목에 id 라고 넣었는데요.

 

바로 references 항목이 User 모델에서 가져올 레퍼런스 이고 이 걸 바로 fields 에 연결시키는 겁니다.

 

그래서 User 모델의 id 가 Post 모델의 authorId에 연결되는 형식인 겁니다.

 

이 같은 관계형 설정이면 User 모델에는 해당 유저가 쓴 글 즉, Post가 무엇인지에 대해 설정해야 하는데요.

 

그래서 User 모델을 다음과 같이 변경토록 합시다.

 

model User {
  id            Int       @default(autoincrement()) @id
  name          String?
  email         String?   @unique
  createdAt     DateTime  @default(now()) @map(name: "created_at")
  updatedAt     DateTime  @updatedAt @map(name: "updated_at")
  posts         Post[]
  @@map(name: "users")
}

 

User 모델에 posts 항목이 있고 posts 항목은 바로 Post 모델의 배열입니다.

 

왜냐하면 Post를 여러개 가지고 있을 수 있기 때문입니다.

 

자 그러면, schema.prisma 파일을 설정했기 때문에 npx prisma db push 를 실행해서 db에 적용시켜 볼까요?

 

npx prisma db push

 

그리고 나서 npx prisma studio로 확인해 봅시다.

 

npx prisma studio

 

Post 모델이 잘 보이네요.

 

그리고 User 테이블에도 맨 끝에 posts 항목이 [] 이라고 잘 표시되어 있네요.

 

 

2. /pages/create.tsx

 

이제 Post 작성 컴포넌트를 만들어 볼까요?

 

import React, { useState } from "react";
import Router from "next/router";
import {
  Flex,
  Box,
  FormControl,
  FormLabel,
  Input,
  Textarea,
  Stack,
  InputGroup,
  InputLeftAddon,
  Button,
  Heading,
  Text,
  useColorModeValue,
} from "@chakra-ui/react";

const Draft: React.FC = () => {
  const [title, setTitle] = useState("");
  const [content, setContent] = useState("");

  const submitData = async (e: React.SyntheticEvent) => {
    e.preventDefault();
    try {
      const body = { title, content };
      await fetch("/api/post", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(body),
      });
      await Router.push("/drafts");
    } catch (error) {
      console.error(error);
    }
  };

  return (
    <Flex
      w={"full"}
      align={"center"}
      justify={"center"}
      bg={useColorModeValue("gray.50", "gray.800")}
    >
      <Stack w={"full"} spacing={8} mx={"auto"} py={12} px={6}>
        <Stack align={"center"}>
          <Heading fontSize={"4xl"}>글쓰기</Heading>
          <Text fontSize={"lg"} color={"gray.600"}>
            여러분의 글을 자유롭게 써주세요. ✌️
          </Text>
        </Stack>
        <Box
          rounded={"lg"}
          bg={useColorModeValue("white", "gray.700")}
          boxShadow={"lg"}
          p={8}
        >
          <Stack spacing={4}>
            <form onSubmit={submitData}>
              <InputGroup>
                <InputLeftAddon>제목</InputLeftAddon>
                <Input
                  id="title"
                  type="text"
                  onChange={(e) => setTitle(e.target.value)}
                  value={title}
                  mb={6}
                />
              </InputGroup>
              <FormControl id="content">
                <FormLabel>내용</FormLabel>
                <Textarea
                  rows={15}
                  onChange={(e) => setContent(e.target.value)}
                  type="text"
                  value={content}
                  mb={6}
                />
              </FormControl>
              <Button
                type="submit"
                disabled={!content || !title}
                bg={"blue.400"}
                color={"white"}
                _hover={{
                  bg: "blue.500",
                }}
              >
                저장하기
              </Button>
              <Button
                ml={6}
                colorScheme={"red"}
                onClick={() => Router.push("/")}
              >
                취소하기
              </Button>
            </form>
          </Stack>
        </Box>
      </Stack>
    </Flex>
  );
};

export default Draft;

UI는 마음에 안들어도 양해 바랍니다.

 

일단 가장 중요한 코드를 볼까요?

 

  const [title, setTitle] = useState("");
  const [content, setContent] = useState("");

  const submitData = async (e: React.SyntheticEvent) => {
    e.preventDefault();
    try {
      const body = { title, content };
      await fetch("/api/post", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(body),
      });
      await Router.push("/drafts");
    } catch (error) {
      console.error(error);
    }
  };

submitData 함수인데요.

 

title, content 라는 스테이트를 body에 저장하고나서 api 라우팅으로 "/api/post"로 POST 방식으로 보냅니다.

 

그 다음에는 Router.push 로 /drafts 경로로 보내는데 왜냐하면 Post를 저장하고 나면 Draft 라고 부르고 그걸 승인하면 최종적으로 Publish (발행) 되는 형식입니다.

 

모든 블로그가 다 이 방식을 쓰죠.

 

그럼 이제 뭘 만들어야 할까요?

 

바로 /api/post 에 해당되는 API 라우팅을 해결해 줘야 합니다.

 

 

 

3. /api/post 라우팅

 

바로 코드를 볼까요?

 

/pages/api/post/index.ts

 

import { getSession } from 'next-auth/client';
import prisma from '../../../lib/prisma';

export default async function handle(req, res) {
    const { title, content } = req.body;

    const session = await getSession({ req });
    const result = await prisma.post.create({
        data: {
            title: title,
            content: content,
            author: { connect: { email: session?.user?.email } },
        },
    });
    res.json(result);
}

 

async 함수인데요. 그냥 handle 이라고 이름 지으면 됩니다.

 

당연히 req, res 를 가지고 있고 req.body에서 /api/post 로 POST 방식으로 보낸 title과 content를 추출합니다.

 

그리고 session을 getSession({req}) 함수로 구합니다.

 

왜냐하면 session에 글 쓴 사람의 이메일이 있기 때문입니다.

 

그리고 DB에 저장해야 하는데요.

 

바로 prisma가 여기서 유용하게 쓰입니다.

 

prisma 로 글을 쓸려면 다음과 같은 형식으로 하면 됩니다.

 

prisma.테이블.create 함수인데요.

 

우리는 Post 모델 즉 Post 테이블에 글을 저장해야 하기 때문에 prisma.post 라고 하시면 됩니다.

 

모델명은 Post 라고 대문자로 시작하지만 코드에서는 prisma.post라고 소문자로 쓰셔야 합니다.

 

그리고 각 테이블은 create 함수가 있는데요.

 

create 함수는 객체를 인수로 받고 그 객체에 data 항목에 우리가 원하는 내용을 적으면 됩니다.

 

그리고 author 부분인데요.

 

author부분은 관계형 데이터베이스이기 때문에 다음과 같이 해야합니다.

 

author: { connect: { email: session?.user?.email } },

 

connect 항목을 적고 그리고 author 칸에 적을 내용을 객체로 전달하면 됩니다.

 

우리는 세션의 email 항목을 author라고 지정할 건데요. 왜냐하면 email은 중복될 수 가 없기 때문이죠.

 

이제 아까 Post 글 쓰기 화면에서 저장을 눌러볼까요?

 

당연히 로그인 하고 글을 써야 합니다.

 

Post 테이블에 저장이 잘 되었네요.

 

그리고 author부분은 관계형 데이터베이스로 User 테이블로 연결되고 그걸 누르면 이 글을 쓴 User 가 나옵니다.

 

당연히 User 테이블을 보시면 맨 끝에 posts가 1개의 Post가 있다고 나오고 있네요.

 

그리고 User 테이블로 가보면 맨 끝에 posts 항목을 눌러보면 당연히 관계형 데이터베이스에 의해 해당 되는 Post가 보입니다.

 

지금까지 잘 작동되고 있네요.

 

그럼, create 화면에서 글을 작성하고 Router.push 되는 /drafts 화면을 만들어 보겠습니다.

 

 

 

4. Post 보여주는 화면 만들기

 

Drafts 화면을 만들기 전에 Post가 어떻게 보여지는지가 필요한데요.

 

먼저, 우리의 Content가 Markdown을 지원토록 react-markdown 패키지를 설치합시다.

 

npm install react-markdown

 

그래서 Post.tsx 화면을 먼저 만들도록 하겠습니다.

 

/components/Post.tsx

 

import React from "react";
import Router from "next/router";
import ReactMarkdown from "react-markdown";

export type PostProps = {
  id: number;
  title: string;
  author: {
    name: string;
    email: string;
  } | null;
  content: string;
  published: boolean;
};

const Post: React.FC<{ post: PostProps }> = ({ post }) => {
  const authorEmail = post.author ? post.author.email : "Unknown author";
  return (
    <div onClick={() => Router.push("/p/[id]", `/p/${post.id}`)}>
      <h2>{post.title}</h2>
      <small>By {authorEmail}</small>
      <br />
      <ReactMarkdown>{post.content}</ReactMarkdown>
    </div>
  );
};

export default Post;

 

단순하게 post 리스트를 title과 author의 email로 리스트업해주는 컴포넌트입니다.

 

그리고 post 리스트를 누르면 바로 해당 post의 상세화면으로 넘어가는 링크를 추가했습니다.

 

/p/[id] 형식은 NextJS의 다이내믹 라우팅입니다. 나중에 이것도 만들어야 하죠.

 

그럼 본격적으로 drafts.tsx 화면을 볼까요?

 

 

/pages/drafts.tsx

 

import React from "react";
import { GetServerSideProps } from "next";
import Post, { PostProps } from "../components/Post";
import { useSession, getSession } from "next-auth/client";
import prisma from "../lib/prisma";

import {
  Heading,
  Flex,
  Stack,
  Text,
  Box,
  UnorderedList,
  ListItem,
} from "@chakra-ui/react";

export const getServerSideProps: GetServerSideProps = async ({ req, res }) => {
  const session = await getSession({ req });
  if (!session) {
    res.statusCode = 403;
    return { props: { drafts: [] } };
  }

  const drafts = await prisma.post.findMany({
    where: {
      author: { email: session.user.email },
      published: false,
    },
    include: {
      author: {
        select: { name: true, email: true },
      },
    },
  });
  return {
    props: { drafts },
  };
};

type Props = {
  drafts: PostProps[];
};

const Drafts: React.FC<Props> = (props) => {
  const [session] = useSession();

  if (!session) {
    return (
      <Flex w={"full"} align={"center"} justify={"center"}>
        <Stack w={"full"} spacing={8} mx={"auto"} py={12} px={6}>
          <Stack align={"center"}>
            <Heading>미발행 Post 모음</Heading>
            <Text>미발행 Post를 보시려면 로그인이 필요합니다.</Text>
          </Stack>
        </Stack>
      </Flex>
    );
  }

  return (
    <Flex w={"full"} align={"center"} justify={"center"}>
      <Stack w={"full"} spacing={8} mx={"auto"} py={12} px={6}>
        <Stack align={"center"}>
          <Heading mb={6}>미발행 Post 모음</Heading>
          <Box
            marginTop={{ base: "1", sm: "5" }}
            display="flex"
            flexDirection={{ base: "column", sm: "row" }}
            justifyContent="space-between"
          >
            <UnorderedList size="lg">
              {props.drafts.map((post) => (
                <ListItem key={post.id}>
                  <Post post={post} />
                </ListItem>
              ))}
            </UnorderedList>
          </Box>
        </Stack>
      </Stack>
    </Flex>
  );
};

export default Drafts;

 

실행화면을 볼까요?

 

 

아까 우리가 작성한 Post의 제목이 나왔습니다.

 

그리고 누구에 의해 작성됐는지 옆에 이메일도 나왔네요.

 

drafts 컴포넌트는 NextJS의 가장 강력한 기능인 Server Side Rendering 기능을 이용했습니다.

 

서버사이드 렌더링은 Front End 쪽에서 즉, 우리의 NextJS React 코드에서 서버사이드쪽 작업을 시킬 수 있다는 뜻입니다.

 

이게 굉장한 기술의 발전인데요.

 

예전에는 NodeJS를 이용해서 Express 서버를 구축해서 DB를 JSON 형식으로 프론트엔드쪽으로 넘겨줘야했었는데요.

 

그래서 MongoDB, ExpressJS, ReactJS, NodeJS 의 앞 글자를 따서 MERN Fullstack 이라고 했습니다.

 

백엔드 만들고 다시 프론트엔드 만들고 너무 어려웠는데요.

 

NextJS가 그 어려운 작업을 해낸거죠. 프론트 엔드쪽에서 간단한 백엔드 작업을 가능하게 한겁니다.

 

그럼 NextJS의 백엔드 작업은 어떻게 처리 될까요?

 

바로 getServerSideProps 함수를 통해 이루어집니다.

 

이 함수를 async로 실행하고 원하는 데이터를 리턴하면 React 컴포넌트가 그 데이터를 props 로 받고 그 props를 UI로 뿌려주는 형식입니다.

 

그럼 getServerSideProps 함수를 살펴보겠습니다.

 

export const getServerSideProps: GetServerSideProps = async ({ req, res }) => {
  const session = await getSession({ req });
  if (!session) {
    res.statusCode = 403;
    return { props: { drafts: [] } };
  }

  const drafts = await prisma.post.findMany({
    where: {
      author: { email: session.user.email },
      published: false,
    },
    include: {
      author: {
        select: { name: true, email: true },
      },
    },
  });
  return {
    props: { drafts },
  };
};

getServerSideProps 함수는 context 인자를 받는데요.

 

context 인자에 req, res가 들어 있습니다.

 

그래서 req를 이용해서 getSession() 함수를 통해 session 을 얻고, prisma를 통해 DB에서 데이터를 추출해 내고 있습니다.

 

prisma.post.findMany 함수가 조건에 해당되는 모든 데이터를 불러오는 함수입니다.

 

그 조건은 바로 where 항목에 객체로 지정됩니다.

 

우리는 author 의 email이 session.user.email 인 경우만 불러오는데요.

 

로그인한 유저의 글만 불러온다는 얘기입니다.

 

그리고 published 항목은 초기값이 false 이기 때문에 지금은 신경 안쓰셔도 되고요.

 

그리고 include 항목이 있는데요.

 

include 항목은 불러올때 꼭 include 즉, 포함해야할 항목을 지정하는 겁니다.

 

author 항목에서 name과 email이 꼭 필요하다고 select 항목에 객체로 지정하는 형태입니다.

 

한번 보시고 나중에 테스트해보시면 쉽게 이해 할 수 있을 겁니다.

 

그러면 prisma.post.findMany 함수가 리턴하는 값이 drafts 변수에 저장되는데요.

 

이 값을 리턴하면 되는 겁니다.

 

getServerSideProps 함수는 리턴하는 방식이 객체를 리턴해야 하는데요.

 

객체에 props 항목에다가 해당 객체를 넣어주면 됩니다.

 

그럼 getServerSideProps 가 언제 실행되냐면 해당 컴포넌트가 마운트 됐을 때 바로 실행됩니다.

 

그래서 getServerSideProps 함수가 있는 컴포넌트를 브라우저에서 볼때는 약간의 딜레이가 있습니다.

 

바로 DB에서 데이터를 추출하는 시간인거죠.

 

그래서 UI부분에서도 loading 인 경우도 신경써 줘야 합니다.

 

그래서 우리는 session 이 없을 경우 UI도 넣어줬습니다.

 

getServerSideProps로 받은 props 데이터는 다음과 같이 array의 map 함수로 돌리면 되는데요.

 

              {props.drafts.map((post) => (
                <ListItem key={post.id}>
                  <Post post={post} />
                </ListItem>
              ))}

 

뭔가 어려울 줄 알았던 서버 사이드 쪽 코드도 이렇게 쉽게 해결되었습니다.

 

다음 시간에는 Drafts 의 승인 및 삭제, Post 최종 화면에 대해 알아 보겠습니다.

그리드형