코딩/React

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

드리프트 2021. 10. 8. 12:17
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

 

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

https://cpro95.tistory.com/528

 

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

안녕하세요? 지난시간에는 NextJS, NextAuth, Prisma 를 사용하여 카카오 로그인, 네이버 로그인, 구글 로그인에 대해 알아 보았는데요. 1편: 카카오 로그인 https://cpro95.tistory.com/516 카카오 로그인 구현 R

cpro95.tistory.com

 

 

 

오늘 시간에는 지난시간까지 만들었던 Post 작성 및 Drafts 보기까지에서 더 나아가 Drafts 를 Publish 즉, 누구나 볼 수 있게 발행하는 기능과 Post 삭제 및 편집 기능까지 추가해 보도록 하겠습니다.

 

Post 가 Draft 인지 아닌지는 아래 그림처럼 우리가 설계했던 Model Post의 published boolean 항목에 따라 갈립니다.

 

즉, false 이면 발행이 아직 안된 초안(draft)이라는 뜻이죠.

 

그럼, 발행하기 기능은 바로 Post DB 테이블에서 published 항목을 true로 바꾸는 기능입니다.

 

단순히 Drafts 항목이 보이는 화면에서 이걸 발행할지 말지 버튼만 추가하고 그 버튼을 누르면 서버 사이드에서 API 라우팅이 실행되게 하면 됩니다.

 

그럼, /pages/api/publish 라는 폴더를 만들고 그 폴더에 [id].ts 라는 다이내믹 라우팅이 필요한 ts파일을 만듭시다.

 

import prisma from '../../../lib/prisma';

// PUT /api/publish/:id
export default async function handle(req, res) {
    const postId = req.query.id;
    const post = await prisma.post.update({
        where: { id: Number(postId) },
        data: { published: true },
    });
    res.json(post);
}

 

코드는 간단합니다.

 

기본적인 함수를 export 하기만 하면 되는데요.

 

그 함수는 query.id 에서 Drafts id 를 받아오고 우리는 그 아이디를 postId 로 저장해서 prisma.post.update함수를 실행하기만 하면 됩니다.

 

여기서도 Prisma 의 쉬운 기능이 나오는데요.

 

update() 함수는 객체를 인자로 받고 그 객체안에 where 항목에서 id 를 지정합니다.

 

postId 는 query로 넘어오면서 string 이 되는데, 꼭 Number(postId) 형식으로 number type으로 캐스팅 해줘야 합니다.

 

그리고 update할 data 는 publish: true 입니다.

 

이제 post를 볼수 있는 화면을 만들어 보겠습니다.

 

 

/pages/p/[id].tsx

 

import React from "react";
import { GetServerSideProps } from "next";
import ReactMarkdown from "react-markdown";
import Router from "next/router";
import { PostProps } from "../../components/Post";
import { useSession } from "next-auth/client";
import prisma from "../../lib/prisma";

import {
  Flex,
  Box,
  HStack,
  Stack,
  Button,
  Heading,
  Text,
  useColorModeValue,
} from "@chakra-ui/react";

export const getServerSideProps: GetServerSideProps = async ({ params }) => {
  const post = await prisma.post.findUnique({
    where: {
      id: Number(params?.id) || -1,
    },
    include: {
      author: {
        select: { name: true, email: true },
      },
    },
  });
  return {
    props: post,
  };
};

async function publishPost(id: number): Promise<void> {
  await fetch(`http://localhost:3000/api/publish/${id}`, {
    method: "PUT",
  });
  await Router.push("/");
}

async function deletePost(id: number): Promise<void> {
  await fetch(`http://localhost:3000/api/post/${id}`, {
    method: "DELETE",
  });
  Router.push("/");
}

const Post: React.FC<PostProps> = (props) => {
  const [session, loading] = 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>
    );
  }

  const userHasValidSession = Boolean(session);
  const postBelongToUser = session?.user?.email === props.author?.email;
  let title = props.title;
  if (!props.published) {
    title = `${title} (초안)`;
  }

  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}>{title}</Heading>
          <Text mb={6}>{props?.author?.email || "Unknown author"}</Text>
          <Box marginTop={{ base: "1", sm: "5" }}>
            {console.log(props.content)}
            <ReactMarkdown>{props.content}</ReactMarkdown>
          </Box>
          <HStack>
            {!props.published && userHasValidSession && postBelongToUser && (
              <Button
                colorScheme="twitter"
                onClick={() => publishPost(props.id)}
              >
                발행
              </Button>
            )}
            {userHasValidSession && postBelongToUser && (
              <Button colorScheme="red" onClick={() => deletePost(props.id)}>
                삭제
              </Button>
            )}
          </HStack>
        </Stack>
      </Stack>
    </Flex>
  );
};

export default Post;

 

실행 화면을 볼까요?

 

 

밑에 발행, 삭제 버튼이 추가 되었습니다.

 

이제 발행버튼과 삭제 버튼에 대한 설명을 이어가보도록 하겠습니다.

 

async function publishPost(id: number): Promise<void> {
  await fetch(`http://localhost:3000/api/publish/${id}`, {
    method: "PUT",
  });
  await Router.push("/");
}

발행 버튼을 눌렀을때 실행되는 함수인데요.

 

아까 우리가 만들었던 API 라우팅인 /pages/api/publish/[id] 에 단순히 method PUT 을 보내 실행만 합니다.

 

그러면 아까 위에서 봤던 /pages/api/publish[id].ts 에 의해 Post 모델의 published 항목이 true가 되는거죠.

 

그리고 삭제 버튼을 볼까요?

 

async function deletePost(id: number): Promise<void> {
  await fetch(`http://localhost:3000/api/post/${id}`, {
    method: "DELETE",
  });
  Router.push("/");
}

API 라우팅이 하나 더 생겼는데요.

 

/pages/api/post/[id].ts 가 필요할거 같습니다.

 

method는 "DELETE" 를 보내고 삭제하라고 하는거 같네요.

 

 

/pages/api/post/[id].ts

import prisma from '../../../lib/prisma';

// DELETE /api/post/:id
export default async function handle(req, res) {
  const postId = req.query.id;
  if (req.method === 'DELETE') {
    const post = await prisma.post.delete({
      where: { id: Number(postId) },
    });
    res.json(post);
  } else {
    throw new Error(
      `The HTTP ${req.method} method is not supported at this route.`,
    );
  }
}

이번 라우팅 함수도 간단합니다.

 

prisma.post.delete 함수를 쓰고, where 항목에서 원하는 query.id 를 전달하기만 하면 Post가 지워집니다.

 

그럼 발행을 해볼까요?

 

publish가 true로 잘 변경되었네요.

 

이제 글 목록 보기가 있어야 되는데요.

 

index.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>
      {/* <ReactMarkdown>{post.content}</ReactMarkdown> */}
    </div>
  );
};

export default Post;

 

ReactMarkdown 부분을 주석처리했습니다.

 

그리고 최종적인 index.tsx 파일입니다.

 

 

/pages/index.tsx

import React from "react";
import type { NextPage, GetStaticProps } from "next";
import Head from "next/head";
import { signIn, signOut, useSession } from "next-auth/client";
import {
  Flex,
  Button,
  Heading,
  VStack,
  Image,
  Text,
  UnorderedList,
  ListItem,
  Divider,
} from "@chakra-ui/react";
import Router from "next/router";

import Post, { PostProps } from "../components/Post";
import prisma from "../lib/prisma";

export const getStaticProps: GetStaticProps = async () => {
  const feed = await prisma.post.findMany({
    where: { published: true },
    include: {
      author: {
        select: { email: true },
      },
    },
  });
  return { props: { feed } };
};

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

const Home: NextPage = (props) => {
  const [session, loading] = useSession();

  if (typeof window !== "undefined" && loading) return null;

  return (
    <div>
      <Head>
        <title>Kakao Naver Login NextAuthJS Prisma App</title>
        <meta name="description" content="Kakao Naver NextAuth Prisma App" />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <Flex py={12} align={"center"} justify={"center"}>
        <VStack mb={6}>
          <Heading mb={6}>카카오 네이버 구글 로그인 구현</Heading>

          <UnorderedList size="lg">
            {props.feed.map((post) => (
              <ListItem key={post.id}>
                <Post post={post} />
              </ListItem>
            ))}
          </UnorderedList>
          <br />
          <br />
          <Divider />
          <br />
          <br />

          {!session && (
            <Button colorScheme="facebook" onClick={() => signIn()}>
              로그인
            </Button>
          )}

          {session && (
            <VStack spacing={4}>
              <Text fontWeight="bold">{session.user.name}</Text>
              <Text fontWeight="bold">{session.user.email}</Text>
              <Image
                w={64}
                h={64}
                src={session.user.image}
                alt="프로필 이미지"
              />
              <Button
                colorScheme="twitter"
                onClick={() => Router.push("/create")}
              >
                글쓰기
              </Button>
              <Button
                colorScheme="twitter"
                onClick={() => Router.push("/drafts")}
              >
                초안 보기
              </Button>
              <Button colorScheme="twitter" onClick={() => signOut()}>
                로그아웃
              </Button>
            </VStack>
          )}
        </VStack>
      </Flex>
    </div>
  );
};

export default Home;

 

여기서 우리가 주목해야할 NextJS 기능은 바로 getStaticProps 함수입니다.

 

이 기능은 NextJS가 빌드될 시 한번만 실행되어 Static 파일을 만드는 기능입니다.

 

블로그 같은 시스템에서는 실시간으로 서버사이드렌더링이 필요하지 않아 이렇게 getStaticProps를 많이 이용합니다.

 

블로그 어플중에 유명한게 Gatsby.JS 인데요.

 

이 Gatsby.JS가 바로 NextJS 의 getStaticProps 함수를 이용한겁니다.

 

그래서 사이트가 빌드되면 그냥 단순히 html 파일이 되어 속도가 매우 빠르게 되는거죠.

 

export const getStaticProps: GetStaticProps = async () => {
  const feed = await prisma.post.findMany({
    where: { published: true },
    include: {
      author: {
        select: { email: true },
      },
    },
  });
  return { props: { feed } };
};

getStaticProps 함수도 간단합니다.

 

여기서는 그냥 단순히 data만 추출해서 React 컴포넌트에 보내기만 하는데요.

 

prisma.post.findMany() 함수를 썼습니다.

 

findMany() 함수는 이름 그대로 모든 항목을 불러오는데요.

 

where 항목에서 published: true 인것만 불러오고,

 

include 항목에서 author 부분의 email은 꼭 가져오도록 하고 있습니다.

 

getStaticProps 함수는 추출한 데이터를 리턴해야 하는데요.

 

return { props : {} } 형식을 씁니다.

 

우리는 props: { feed} 로 위에서 구한 feed 데이터를 넘겨주고 있습니다.

 

 

그러면 React 컴포넌트를 보시면

const Home: NextPage = (props) => {

함수 인자가 있네요. 바로 props 로 들어오게 되는겁니다.

 

그럼 props.feed 가 우리가 원하는 데이터가 됩니다.

 

그래서 Post 리스트를 쭉 화면에 뿌려 줄때 다음과 같은 함수를 사용하시면 됩니다.

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

 

실행화면을 볼까요?

 

 

어떤가요?

 

UI 가 정리가 안되었지만 기능적으로는 문제 없습니다.

 

이제, 발행된 글 목록을 클릭해 볼까요?

 

그럼 라우팅이 /pages/p/[id].tsx 로 이어집니다.

 

 

어떤가요? 발행 버튼이 없어졌죠?

 

삭제하기 버튼은 그대로 남아 있습니다.

 

지금까지 Prisma DB 를 이용한 NextJS 블로그 시스템 만들기에 대해 알아 보았는데요.

 

우리가 만든 기능 중에 여러분이 직접 만들어 보실게 있습니다.

 

바로 publish 를 다시 false 로 바꾸는 발행취소 버튼과

 

발행취소해서 초안(drafts) 가 된 상태에서 글 편집하는 Edit 기능을 한번 추가해 보시기 바랍니다.

 

그럼.

그리드형