코딩/React

NextJS MongoDB 5편 SSR, SSG, ISG 예제

드리프트 2021. 9. 15. 19:08
728x170

 

 

안녕하세요?

 

지난 시간에 이어 NextJS와 MongoDB를 이용한 유저 로그인 세션 구현하기 4편을 계속 이어 나가도록 하겠습니다.

 

1편 : https://cpro95.tistory.com/463

 

NextJS와 MongoDB로 유저 로그인 세션 구현하기 1편

안녕하세요? 오늘부터 새로운 NextJS 강좌를 시작해 볼까 합니다. 일반 ReactJS 앱이 아닌 최근 들어 ReactJS 프로트엔드에서 가장 각광받고 있는 NextJS를 이용할 예정인데요. NextJS는 Serverless 앱 구현에

cpro95.tistory.com

 

2편 : https://cpro95.tistory.com/465

 

NextJS와 MongoDB로 유저 로그인 세션 구현하기 2편

안녕하세요? 지난 시간에 이어 NextJS와 MongoDB를 이용한 유저 로그인 세션 구현하기 2편을 계속 이어 나가도록 하겠습니다. 1편: https://cpro95.tistory.com/463 NextJS와 MongoDB로 유저 로그인 세션 구현하기.

cpro95.tistory.com

 

3편 : https://cpro95.tistory.com/478

 

NextJS와 MongoDB로 유저 로그인 세션 구현하기 3편

안녕하세요? 지난 시간에 이어 NextJS와 MongoDB를 이용한 유저 로그인 세션 구현하기 3편을 계속 이어 나가도록 하겠습니다. 1편 : https://cpro95.tistory.com/463 NextJS와 MongoDB로 유저 로그인 세션 구현하..

cpro95.tistory.com

 

4편: https://cpro95.tistory.com/493

 

NextJS와 MongoDB 4편 Chakra-UI 적용하기

안녕하세요? 지난 시간에 이어 NextJS와 MongoDB를 이용한 유저 로그인 세션 구현하기 4편을 계속 이어 나가도록 하겠습니다. 1편 : https://cpro95.tistory.com/463 NextJS와 MongoDB로 유저 로그인 세션 구현하..

cpro95.tistory.com

1편에서는 기본 준비작업을 했었고,

 

2편에서는 로그인, 로그아웃

 

3편에서는 유저 정보 업데이트와 패스워드 변경까지 알아보았습니다.

 

4편에서는 UI 부분을 Chakra-UI로 적용했습니다.

 

5편에서는 NextJS의 핵심기능인 Serverless Function 인 SSR, SSG, ISR에 대해 실전 예제를 통해 알아 보겠습니다.

 

참고로 NextJS의 Data Fetching 방법에 대해 원론적인 공부를 하시고 싶으시면 아래 글을 참고 바랍니다.

 

https://cpro95.tistory.com/492

 

NextJS Data Fetching 방법 SSG, SSR, ISR

목차 1. Static Site Generation (SSG) 란? 2. Server Side Rendering (SSR) 란? 3. 스켈레톤 앱 만들기 4. Client Side Rendering (CSR) 코드   4-1. CSR의 특징 5. Server Side Rendering (SSR) 코드 5-1. SSR..

cpro95.tistory.com

 

 

Add Post 작성

 

일단 SSG(Server Side Rendering) 이나 SSG (Static Site Generation) 이나, ISR(Incremental Static Regeneration)을 할려면 그 대상이 있어야 겠죠.

 

먼저 Post를 서버에 업로드하는 컴포넌트를 만들어 보겠습니다.

 

 

/pages/index.js

 

import React from "react";
import Link from "next/link";
import { useCurrentUser } from "../hooks/index";

import { Flex, VStack, Heading, Text, Button } from "@chakra-ui/react";

const IndexPage = () => {
  const [user] = useCurrentUser();

  return (
    <>
      <Flex align="center" justify="center">
        <VStack>
          <Heading m={4}>Welcome!</Heading>
          {user ? (
            <Text fontSize="2xl">
              Hello, {user.name}
            </Text>
          ) : (
            <Text fontSize="2xl">
              Please, Log In
            </Text>
          )}
          <Link href="/addpost">
            <Button size="lg" colorScheme="telegram">Add Post</Button>
          </Link>
        </VStack>
      </Flex>
    </>
  );
};

export default IndexPage;

 

/pages/addpost.js

 

import React, { useState, useEffect } from "react";
import Head from "next/head";
import { useCurrentUser } from "@/hooks/index";

import { Flex, Heading, Input, Button, Text, VStack } from "@chakra-ui/react";

export default function AddPostPage() {
  const title = "Add Post";

  const [user] = useCurrentUser();
  const [msg, setMsg] = useState({ message: "", isError: false });

  async function hanldeSubmit(e) {
    e.preventDefault();
    const body = {
      content: e.currentTarget.content.value,
    };
    if (!e.currentTarget.content.value) return;
    e.currentTarget.content.value = "";
    const res = await fetch("/api/posts", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });
    if (res.ok) {
      setMsg("Posted!");
      setTimeout(() => setMsg(null), 5000);
    }
  }

  if (!user) {
    return (
      <>
        <Head>
          <title>{title}</title>
        </Head>
        <Text fontSize="2xl">Please, Log In</Text>
      </>
    );
  } else {
    return (
      <>
        <Head>
          <title>{title}</title>
        </Head>

        <Flex align="center" justify="center">
          <VStack>
            <Heading m={4}>Add Post</Heading>

            <VStack direction="column" spacing={8}>
              {msg.message ? (
                <Text align="center" colorSchme={msg.isError ? "red" : "blue"}>
                  {msg.message}
                </Text>
              ) : null}

              <form onSubmit={hanldeSubmit} autoComplete="off">
                <Input
                  size="lg"
                  name="content"
                  type="text"
                  placeholder="Add query"
                />
                <Button w="full" mt={4} size="lg" type="submit">
                  Post
                </Button>
              </form>
            </VStack>
          </VStack>
        </Flex>
      </>
    );
  }
}

 

 

/pages/index.js 파일에 Add Post 링크를 하나 걸었습니다.

 

그럼 본격적으로 /pages/addpost.js 파일을 분석해 볼까요?

 

Input 컴포넌트에서 간단한 글을 입력하면 그걸 이용해서 content 로 저장해서 form submit을 이용해 /api/posts 라우팅에 POST 메쏘드로 글을 올리는 로직입니다.

 

그럼 /api/posts 라우팅을 구현해야겠네요.

 

 

/pages/api/posts/index.js

 

import nc from 'next-connect';
import { all } from '@/middlewares/index';
import { insertPost } from '@/db/index';

const handler = nc();

handler.use(all);

handler.post(async (req, res) => {
  if (!req.user) {
    return res.status(401).send('unauthenticated');
  }

  if (!req.body.content) return res.status(400).send('You must write something');

  const post = await insertPost(req.db, {
    content: req.body.content,
    creatorId: req.user._id,
  });

  return res.json({ post });
});

export default handler;

API 라우팅은 handler.post를 이용했으며,

 

req.body부분에 content 부분이 우리가 입력했던 Post가 됩니다.

 

그래서 그걸 insertPost 함수를 이용해 mongoDB에 업로드하는 로직입니다.

 

그리고 업로드할 때 creatorId를 user._id로 지정해서 누가 업로드했는지 구분하고 있습니다.

 

그럼, insertPost 함수를 살펴볼까요?

 

 

/db/post.js

 

import { nanoid } from 'nanoid';

export async function insertPost(db, { content, creatorId }) {
  return db.collection('posts').insertOne({
    _id: nanoid(12),
    content,
    creatorId,
    createdAt: new Date(),
  }).then(({ ops }) => ops[0]);
}

insertPost 함수는 간단합니다.

 

MongoDB 를 불러오고 collection('posts')을 지정한 다음 insertOne mongodb 함수를 이용해 저장합니다.

 

insertOne 함수는 성공하면 배열(여기서는 ops)을 리턴하는데 그 첫번째 배열값을 다시 프로미스로 돌려주는 로직입니다.

 

이제 Post 도 서버에 업로드 했으니까 그 Post를 읽어 들이는 로직을 만들어 보겠습니다.

 

여기서 우리가 오늘 주제로 하는 SSR, SSG, ISR 에 대해 심도 있게 알아볼 시간이 됐습니다.

 

 

Static Site Generation

 

일단 /pages/index.js 에 list-ssg 링크 버튼을 추가합시다.

import React from "react";
import Link from "next/link";
import { useCurrentUser } from "../hooks/index";

import { Flex, VStack, Heading, Text, Button } from "@chakra-ui/react";

const IndexPage = () => {
  const [user] = useCurrentUser();

  return (
    <>
      <Flex align="center" justify="center">
        <VStack>
          <Heading m={4}>Welcome!</Heading>
          {user ? (
            <Text fontSize="2xl">Hello, {user.name}</Text>
          ) : (
            <Text fontSize="2xl">Please, Log In</Text>
          )}
          <Link href="/addpost">
            <Button size="lg" colorScheme="telegram">
              Add Post
            </Button>
          </Link>
          <Link href="/list-ssg">
            <Button size="lg" colorScheme="telegram">
              List Post - SSG
            </Button>
          </Link>
        </VStack>
      </Flex>
    </>
  );
};

export default IndexPage;

 

/pages/list-ssg.js 파일을 통해 SSG 방식으로 Post 리스트를 구현하도록 하겠습니다.

 

 

/pages/list-ssg.js

 

import React, { useState, useEffect } from "react";
import Head from "next/head";
import Error from "next/error";

import {
  Flex,
  VStack,
  Heading,
  Table,
  Thead,
  Tbody,
  Tr,
  Th,
  Td,
} from "@chakra-ui/react";

import { MongoClient } from "mongodb";

global.mongo = global.mongo || {};

export default function ListPostsPage({ posts }) {
  const title = "List with Static Site Generation";

  if (!posts) return <Error statusCode={404} />;

  return (
    <>
      <Head>
        <title>{title}</title>
      </Head>

      <Flex justify="center">
        <Flex w="full" p={[0, 4, 6]}>
          <VStack w="full" h="full" spacing={5}>
            <Heading mb={2}>Total Posts : {posts.length} </Heading>

            <Table size="sm" mb={12}>
              <Thead>
                <Tr>
                  <Th>Time</Th>
                  <Th>Contents</Th>
                </Tr>
              </Thead>
              <Tbody>
                {posts.map((post) => (
                  <Tr key={post.createdAt.toString()}>
                    <Td>{post.createdAt.toLocaleString()}</Td>
                    <Td>{post.content}</Td>
                  </Tr>
                ))}
              </Tbody>
            </Table>
          </VStack>
        </Flex>
      </Flex>
    </>
  );
}

export async function getStaticProps() {
  if (!global.mongo.client) {
    global.mongo.client = new MongoClient(process.env.MONGODB_URI, {
      useNewUrlParser: true,
      useUnifiedTopology: true,
    });
    await global.mongo.client.connect();
  }
  const db = await global.mongo.client.db(process.env.DB_NAME);
  const posts = await db.collection("posts").find({}).toArray();
  if (!posts) {
    return {
      notFound: true,
    };
  }
  return { props: { posts, user } };
}

 

 

일단 Post 2개가 있습니다.

 

그런데 이 Post는 누가 썼는지 알 수 없을까요?

 

여기서 문제가 생깁니다.

 

Static Site Generation 에서는 getStaticProps NextJS의 고유 함수를 이용해 Server 쪽 작업을 수행할 수 있는데,

 

위 코드에서 보면 db에 접속하여 일단 posts 만 가져오는 로직입니다.

 

posts 에 대한 각각의 User 정보를 가져올려면

 

posts를 가져오고 다시 각각의 posts에 해당하는 creatorId에 대한 user.name을 가져와야 되는데

 

그럴러면 다시 /api/users/[userId] 라우팅을 이용해야 되는데 이렇게는 작업이 불가능합니다.

 

그래서 Static Site Generation 에서는 한번의 서버 작업으로만 가져올 수 있는 데이터만 보내야 됩니다.

 

그래서 이럴때는 Post를 서버에 저장할때 creatorName 항목을 미리 저장하는 방법이 있습니다.

 

/pages/api/posts/index.js 파일을 다음과 같이 바꾸면 됩니다.

 

handler.post(async (req, res) => {
  if (!req.user) {
    return res.status(401).send("unauthenticated");
  }

  if (!req.body.content)
    return res.status(400).send("You must write something");

  const post = await insertPost(req.db, {
    content: req.body.content,
    creatorId: req.user._id,
    creatorName: req.user.name,   // 추가된 부분
  });

  return res.json({ post });
});

export default handler;

 

creatorName을 저장했습니다.

 

그리고 insertPost 함수에서 직접 creatorName부분도 추가 해야겠죠.

 

/db/post.js 부분에서 insertPost 함수만 다음과 같이 수정하시면 됩니다.

 

export async function insertPost(db, { content, creatorId, creatorName }) {
  return db.collection('posts').insertOne({
    _id: nanoid(12),
    content,
    creatorId,
    creatorName,  // 추가된 부분, 위에 함수 인자에도 추가됐습니다.
    createdAt: new Date(),
  }).then(({ ops }) => ops[0]);
}

 

그럼 처음부터 다시 해볼까요?

 

 

정상적으로 USER 부분에 작성자 이름이 나옵니다.

 

여기까지가 Static Site Generation 부분이었습니다.

 

SSG 방식은 NextJS가 처음 빌드 될 시 정적으로 페이지가 작성되기 때문에 웹에서 볼때 엄청 빠른 로딩효과를 누릴 수 있습니다.

 

위와 같은 DB 자료 수집 문제만 아니라면 간단한 DB 작업은 무조건 SSG 방식을 이용하는 게 전체적인 속도 측면에서 유리하니 참고 바랍니다.

 

 

 

Incremental Static Regeneration

 

ISR는 Static Site Generation 이 NextJS 앱이 처음 빌드 될때 한번 작성되는 단점을 보완한 방식인데요.

 

getStaticProps 함수에다가 return되는 props외에 revalidate 항목에 몇 초를 지정하면 그 몇 초마다 페이지가 생성되는 방식입니다.

 

그러면 ISR 방식의 페이지를 만들겠습니다.

 

/pages/list-isr.js

 

import React, { useState, useEffect } from "react";
import Head from "next/head";
import Error from "next/error";

import {
  Flex,
  VStack,
  Heading,
  Table,
  Thead,
  Tbody,
  Tr,
  Th,
  Td,
} from "@chakra-ui/react";

import { MongoClient } from "mongodb";

global.mongo = global.mongo || {};

export default function ListPostsPage({ posts }) {
  const title = "List with Static Site Generation";

  if (!posts) return <Error statusCode={404} />;

  return (
    <>
      <Head>
        <title>{title}</title>
      </Head>

      <Flex justify="center">
        <Flex w="full" p={[0, 4, 6]}>
          <VStack w="full" h="full" spacing={5}>
            <Heading mb={2}>Total Posts : {posts.length} </Heading>

            <Table size="sm" mb={12}>
              <Thead>
                <Tr>
                  <Th>Time</Th>
                  <Th>User</Th>
                  <Th>Contents</Th>
                </Tr>
              </Thead>
              <Tbody>
                {posts.map((post) => (
                  <Tr key={post.createdAt.toString()}>
                    <Td>{post.createdAt.toLocaleString()}</Td>
                    <Td>{post.creatorName}</Td>
                    <Td>{post.content}</Td>
                  </Tr>
                ))}
              </Tbody>
            </Table>
          </VStack>
        </Flex>
      </Flex>
    </>
  );
}

export async function getStaticProps() {
  if (!global.mongo.client) {
    global.mongo.client = new MongoClient(process.env.MONGODB_URI, {
      useNewUrlParser: true,
      useUnifiedTopology: true,
    });
    await global.mongo.client.connect();
  }
  const db = await global.mongo.client.db(process.env.DB_NAME);
  const posts = await db.collection("posts").find({}).toArray();
  if (!posts) {
    return {
      notFound: true,
    };
  }
  return { props: { posts }, revalidate: 60*60*24 };
}

SSG 파일과 같습니다.

 

다만 마지막에 getStaticProps 함수에서 return 할때 revalidate 부분만 60*60*24초로 지정했습니다.

 

즉, 하루마다 갱신하라는 얘기입니다.

 

 

Server Side Rendering

 

이제 NextJS 앱의 백미 서버 사이드 렌더링에 대해 알아 보겠습니다.

 

import React, { useState, useEffect } from "react";
import Head from "next/head";
import Error from "next/error";
import { all } from "@/middlewares/index";
import { useCurrentUser } from "@/hooks/index";
import { findPostsAll } from "@/db/index";

import {
  Flex,
  VStack,
  Heading,
  Table,
  Thead,
  Tbody,
  Tr,
  Th,
  Td,
} from "@chakra-ui/react";

export default function ListPostsPage({ posts }) {
  const title = "List with Server Side Rendering";

  const [user] = useCurrentUser();

  if (!posts) return <Error statusCode={404} />;

  return (
    <>
      <Head>
        <title>{title}</title>
      </Head>

      <Flex justify="center">
        <Flex w="full" p={[0, 4, 6]}>
          <VStack w="full" h="full" spacing={5}>
            <Heading mb={2}>Total Posts : {posts.length} </Heading>

            <Table size="sm" mb={12}>
              <Thead>
                <Tr>
                  <Th>Time</Th>
                  <Th>User</Th>
                  <Th>Contents</Th>
                </Tr>
              </Thead>
              <Tbody>
                {posts.map((post) => (
                  <Tr key={post.createdAt.toString()}>
                    <Td>{post.createdAt.toLocaleString()}</Td>
                    <Td>{post.creatorName}</Td>
                    <Td>{post.content}</Td>
                  </Tr>
                ))}
              </Tbody>
            </Table>
          </VStack>
        </Flex>
      </Flex>
    </>
  );
}

export async function getServerSideProps(context) {
  await all.run(context.req, context.res);
  const posts = await findPostsAll(context.req.db);
  if (!posts) context.res.statusCode = 404;
  return { props: { posts } };
}

 

서버사이드 렌더링은 getServerSideProps(context) 함수를 이용하는데요.

 

여기에 context 인자를 넘길 수 있습니다.

 

여기 context에는 request, response가 들어있습니다.

 

그래서 all.run(context.req, context.req) 를 실행하면 우리가 만든 DB 함수를 활용할 수 있게 됩니다.

 

getStaticProps에서는 context함수를 전달하지 못해 직접 MongoDB를 불러와서 작업한 거랑 천지 차지입니다.

 

 

getServerSideProps함수에서 우리는 findPostsAll이란 DB 함수를 이용해 모든 Post를 불러옵니다.

 

findPostsAll 함수를 볼까요?

 

/db/post.js

export async function findPostsAll(db) {
  return db.collection("posts").find({}).toArray();
}

간단합니다.

 

db.collection("posts")에서 find 명령어를 빈객체로 전달하고 toArray함수로 배열로 리턴받으면 끝입니다.

 

테스트한게 많이 보이네요.

 

그럼 여기서 한가지 더 나아갈게 있는데요.

 

바로 Post 삭제입니다.

 

Post 삭제 기능을 서버사이드 렌더링 페이지에 추가해 보겠습니다.

 

일단 UI 부분에 X 버튼을 추가해서 삭제 로직을 달아 보겠습니다.

 

import React, { useState } from "react";
import Head from "next/head";
import Router from "next/router";
import Error from "next/error";
import { all } from "@/middlewares/index";
import { findPostsAll } from "@/db/index";

import {
  Flex,
  VStack,
  Heading,
  Table,
  Thead,
  Tbody,
  Tr,
  Th,
  Td,
  useDisclosure,
  AlertDialog,
  AlertDialogBody,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogContent,
  AlertDialogOverlay,
  AlertDialogCloseButton,
  Button,
} from "@chakra-ui/react";

export default function ListPostsPage({ posts }) {
  const title = "List with Server Side Rendering";

  const [isUpdating, setIsUpdating] = useState(false);

  const { isOpen, onOpen, onClose } = useDisclosure();
  const cancelRef = React.useRef();

  if (!posts) return <Error statusCode={404} />;

  async function handleSubmit(_id) {
    if (isUpdating) return;
    setIsUpdating(true);
    const body = {
      _id: _id,
    };
    const res = await fetch("/api/posts/handle", {
      method: "PATCH",
      body: JSON.stringify(body),
    });
    setIsUpdating(false);
    Router.reload(window.location.pathname);
  }

  return (
    <>
      <Head>
        <title>{title}</title>
      </Head>

      <Flex justify="center">
        <Flex w="full" p={[0, 4, 6]}>
          <VStack w="full" h="full" spacing={5}>
            <Heading mb={2}>Total Posts : {posts.length} </Heading>

            <Table size="sm" mb={12}>
              <Thead>
                <Tr>
                  <Th>Del</Th>
                  <Th>Time</Th>
                  <Th>User</Th>
                  <Th>Contents</Th>
                </Tr>
              </Thead>
              <Tbody>
                {posts.map((post) => (
                  <Tr key={post.createdAt.toString()}>
                    <Td>
                      <Button size="sm" onClick={onOpen}>
                        X
                      </Button>

                      <AlertDialog
                        motionPreset="slideInBottom"
                        leastDestructiveRef={cancelRef}
                        onClose={onClose}
                        isOpen={isOpen}
                        isCentered
                      >
                        <AlertDialogOverlay />

                        <AlertDialogContent>
                          <AlertDialogHeader>Delete?</AlertDialogHeader>
                          <AlertDialogCloseButton />
                          <AlertDialogBody>
                            Are you sure for delete?
                          </AlertDialogBody>
                          <AlertDialogFooter>
                            <Button ref={cancelRef} onClick={onClose}>
                              No
                            </Button>
                            <Button
                              colorScheme="red"
                              ml={3}
                              onClick={(e) => {
                                e.preventDefault();
                                handleSubmit(post._id);
                              }}
                            >
                              Yes
                            </Button>
                          </AlertDialogFooter>
                        </AlertDialogContent>
                      </AlertDialog>
                    </Td>
                    <Td>{post.createdAt.toLocaleString()}</Td>
                    <Td>{post.creatorName}</Td>
                    <Td>{post.content}</Td>
                  </Tr>
                ))}
              </Tbody>
            </Table>
          </VStack>
        </Flex>
      </Flex>
    </>
  );
}

export async function getServerSideProps(context) {
  await all.run(context.req, context.res);
  const posts = await findPostsAll(context.req.db);
  if (!posts) context.res.statusCode = 404;
  return { props: { posts } };
}

 

Chakra-UI의 AlertDialog를 이용했으며,

 

X 버튼을 누르면 아래와 같이 Delete 화면이 보이게 됩니다.

 

그리고 Yes 버튼을 누르면 Post가 삭제되게 됩니다.

 

위 코드에서 보시면 Post 삭제는  /api/posts/handle 라우팅을 이용했습니다.

 

 

/api/posts/handle.js

 

import nc from "next-connect";
import { all } from "@/middlewares/index";
import { findOneAndDeleteById } from "@/db/index";

const handler = nc();

handler.use(all);

handler.patch(async (req, res) => {
  if (!req.user) {
    req.status(401).end();
    return;
  }
  const { _id } = JSON.parse(req.body);

  await findOneAndDeleteById(req.db, _id);

  res.end("ok");
});

export default handler;

 

이 라우팅은 findOneAndDeleteById 함수를 불러오는데요. 다음과 같습니다.

 

 

/db/post.js

 

export async function findOneAndDeleteById(db, postId) {
  return db
    .collection("posts")
    .findOneAndDelete({
      _id: postId,
    })
    .then((post) => post || null);
}

MongoDB의 findOneAndDelete 함수를 이용하는 간단한 로직입니다.

 

이렇게 해서 NextJS와 MongoDB를 이용한 서버사이드 렌더링, 정적 사이트 생성등에 대한 강좌를 마치도록 하겠습니다.

 

그리드형