Next.js 14 + MongoDB로 동적 블로그 만들기

By Yoon
·
thumbnail
YoonLog를 개발하게 된 계기와 Next.js 14 + MongoDB로 동적 블로그를 개발한 내용입니다 - 블로그 개발 후기 (1)

글을 시작하며

YoonLog를 개발하게 된 계기

프런트엔드 개발자로 진로를 정하고, 2023년부터 벨로그에서 꾸준히 학습한 내용을 기록해 왔습니다.
이전 벨로그에서 작성한 글들은 어떤 기술에 대한 정보나 책의 내용을 단순히 나열한 거에 그치지 못했습니다.
온전히 제 것이 아닌 글들로 이루어져 있다고 생각했고, 온전히 제 것인 기술 블로그를 만들고 싶어 YoonLog를 개발하게 되었습니다.

YoonLog 만의 차별점

유명한 블로그 템플릿과 정적 블로그로 개발할 수도 있었지만, 기술 블로그 래퍼런스들을 참고하여 나만의 디자인을 설계하고, 풀 스택으로 배포한 사이트에서 관리자가 글을 직접 CRUD 할 수 있는 동적 블로그를 만들고 싶었습니다.
학습의 목적 또한 있었기 때문에 라이브러리의 사용은 최소화하고, 직접 구현해 보는 시간을 가졌습니다.

YoonLog 개발 이야기 시작

여기까지 YoonLog 개발했는지에 대해 이야기를 마칩니다.
이제 YoonLog어떻게 개발했는지에 대해 이야기해 보겠습니다.


동적 블로그 만들기

MongoDB와 Mongoose

DB에는 많은 종류가 있습니다.

그중에서 NoSQL은 읽기와 쓰기라는 기능에 충실하고, 유연하게 스키마를 정의할 수 있어 블로그 개발에 적합하다고 생각했습니다.
JavaScript로 개발하기 때문에 JSON 데이터 구조에 유리한 NoSQLDocument Database 종류인 MongoDB를 선택했습니다.
Node.js 환경에서 MongoDB와 상호작용하기 위한 도구로 Mongoose를 사용했습니다.

MongooseMongoDBODM(객체 데이터 모델링) 라이브러리입니다. MongoDB의 데이터를 JavaScript 객체로 바꾸어주고, 이를 통해 MongoDB의 컬렉션과 문서를 JavaScript 객체와 배열과 같은 자료구조로 다룰 수 있었습니다.


포스트 CRUD 구현하기

1. MongoDB에 연결하기

// app/_lib/utils/connect-db.ts
import mongoose from 'mongoose'

// MongoDB URI를 환경 변수에 저장합니다.
mongoose.connect("mongodb+srv://<username>:<password>@cluster0.eyhty.mongodb.net/myFirstDatabase?retryWrites=true&w=majority")

전체 코드


2. Schema와 Model 만들기

  • DB에 저장할 postSchema를 정의하고, Post 모델을 만듭니다.
// app/_lib/posts/model.ts
import { InferSchemaType, Schema, model, models } from "mongoose";

const postSchema = new Schema(
  {
    title: {
      type: String,
      required: true,
    },
    // 생략...
    slug: {
      type: String,
      required: true,
      unique: true, //unique를 부여해 중복 값을 저장하지 않도록 합니다.
    },
  },
  { timestamps: true },
);

type PostType = InferSchemaType<typeof postSchema>;

const Post = models.Post ? model<PostType>("Post") : model("Post", postSchema);

export default Post;

3. CRUD Service 클래스 정의

  • 서비스 코드를 클래스로 정의함으로써 모듈화하고, 재사용성을 높였습니다.
  • 또한, 각자의 역할(CRUD)을 가지게 하여 단일 책임 원칙을 준수했습니다.
  • 정적 메서드 사용으로 클래스 이름을 통해 호출되므로 직관적이고 일관성을 유지할 수 있도록 했습니다.
// app/_lib/posts/service.ts
import Post from "./model";
import connectDB from "../utils/connect-db";
import { PostType } from "./serviceType";

export class PostsService {
  static async createPost({
    title,
    // 생략..
    slug,
  }: PostType) {
    await connectDB();
    const post = await Post.create({
      title,
      // 생략..
      slug,
    });
    return post;
  }
  static async getPosts() {
    await connectDB();
    const posts = await Post.find().sort({ createdAt: -1 }).lean().exec();
    return posts;
  }
  // 생략 ..

전체 코드


4. CRUD 구현하기

Admin은 포스트를 Create, Read, Update, Delete를 할 수 있습니다.
User는 포스트를 Read 할 수 있습니다.
관리자 기능에 대한 글을 확인 할 수 있습니다.

1. Create

  • Server Action을 사용해 inputname 속성으로 값을 가져와 DB에 저장합니다.
  • revalidatePath로 새로운 포스트 생성 시 캐시 된 데이터를 제거합니다.
// app/_components/PostForm/TitleInput.tsx
export default function TitleInput({
  title,

}: {
  title: string;
  style: StyleXStyles;
}) {
  const { isImageUploading } = useContentContext();
  const { pending } = useFormStatus();

  return (
    <input
      name="title"
      placeholder="제목을 입력해주세요.."
      defaultValue={title}
      disabled={pending || isImageUploading}
      {...stylex.props(styles.title, style)}
    />
  );
}
// app/(root)/write/page.tsx
async function handlePostSubmit(
 // 생략..
  formData: FormData,
) {
  "use server";
  const title = formData.get("title")?.toString();
  // 생략..
  const slug = formData.get("slug")?.toString();
  // 생략..
  try {
    await PostsService.createPost({
      title,
      // 생략..
      slug: generateFilteredSlug(slug),
    });
    revalidatePath("/");
    // 생략..
  } catch (error) {
    // 생략..
  }
}

export default async function WritePage() {
  return (
    <ContentProvider content={""}>
      <PostForm
        handleSubmit={handlePostSubmit}
        submitBtnName={"글 작성"}
        title={""}
        subTitle={""}
        thumbnailUrl={""}
        slug={""}
      />
    </ContentProvider>
  );
}

2. Read

전체 포스트 불러오기

  • revalidate0이 아닌 값을 부여해 SSR에서 ISR로 작동하게 합니다. (revalidate = 0 : SSR)
  • ISR로 정적 생성된 페이지를 요청할 때마다 설정된 시간 간격으로 페이지를 다시 생성하여 최신 정보를 유지하면서도 빠른 페이지 로딩 속도를 유지했습니다.
// app/page.tsx
export const revalidate = 30;

export default async function HomePage() {
  const posts = await PostsService.getPosts();

  return (
   // 생략..
  );
}

특정 포스트 불러오기

  • /posts/[slug] 경로를 generateStaticParams함수를 사용해 bulid 시에 페이지를 정적으로 생성하는 SSG로 구현했습니다.
  • 페이지를 사전에 렌더링하여 정적 파일을 제공하여, 로딩 속도가 빠르고 SEO에 유리하게 했습니다.
// app/(root)/posts/[slug]/page.tsx
export default async function PostPage({
  params,
}: {
  params: { slug: string };
}) {
  const post = await PostsService.getPost(decodeURI(params.slug));

  return (
    // 생략..
  );
}

export async function generateStaticParams() {
  const posts = await PostsService.getPosts();
  return posts.map((post) => ({
    slug: post.slug,
  }));
}
  • build/posts/[slug] 경로들은 SSG로 동작하고 있음을 알 수 있습니다.

build-image


3. Update

  • Create와 유사합니다.
// app/(root)/posts/[slug]/edit/page.tsx
export default async function PostEditPage({
  params,
}: {
  params: { slug: string };
}) {
  async function handlePostUpdate(
    // 생략..
    formData: FormData,
  ) {
    "use server";
    const title = formData.get("title")?.toString();
    // 생략..
    const newPost = await PostsService.updatePost(params.slug, {
      title,
      // 생략..
    });
    revalidatePath("/");
    revalidatePath(`/posts/${params.slug}`);
    // 생략..
  }

  const post = await PostsService.getPost(params.slug);

  return (
    <>
      {post && (
        <ContentProvider content={post.content}>
          <PostForm
            handleSubmit={handlePostUpdate}
            submitBtnName={"수정 완료"}
            title={post.title}
            subTitle={post.subTitle}
            thumbnailUrl={post.thumbnailUrl}
            slug={post.slug}
          />
        </ContentProvider>
      )}
    </>
  );
}

4. Delete

  • 포스트 삭제 기능은 button 태그의 onClick 속성을 사용하므로 Client Component에서 작동해야 합니다.
  • 따라서, Route Handlers에서 delete API를 작성하고, fetch를 사용해 삭제 로직을 동작시킵니다.
  • 포스트 상태를 바로 반영시켜 주기 위한 revalidate API를 작성해 주었습니다.
// app/_components/AdminButton/index.tsx
"use client";

export default function AdminButton({ slug }: { slug: string }) {

  async function handleDeleteClick() {
    await fetch(`/api/admin/posts/${decodeURI(slug)}`, {
      method: "DELETE",
    });
    const [resHome, resPost] = await Promise.all([
      fetch("/api/admin/revalidate?path=/"),
      fetch(`/api/admin/revalidate?path=/posts/${decodeURI(slug)}`),
    ]);
    // 생략..
  }

  return (
    <ClientBoundary>
      {cookies.isLogin && (
        <div>
          <Link
            href={`/posts/${decodeURI(slug)}/edit`}>
            수정
          </Link>
          <button onClick={handleDeleteClick}>
            삭제
          </button>
        </div>
      )}
    </ClientBoundary>
  );
}
// app/(route-handlers)/api/admin/posts/[slug]/route.ts
import { PostsService } from "@/app/_lib/posts/service";
import { NextResponse } from "next/server";

export async function DELETE(
  req: Request,
  { params }: { params: { slug: string } },
) {
  try {
    await PostsService.deletePost(encodeURI(params.slug));
    return NextResponse.json({ message: "Post successfully deleted!" });
  } catch (error) {
    return NextResponse.json(
      { error: "Internal Server Error" },
      { status: 500 },
    );
  }
}
// app/(route-handlers)/api/admin/revalidate/route.ts
import { NextRequest } from "next/server";
import { revalidatePath } from "next/cache";

export async function GET(req: NextRequest) {
  const path = req.nextUrl.searchParams.get("path");

  if (!path) {
    return Response.json({
      revalidated: false,
      message: "Missing path to revalidate",
    });
  }

  revalidatePath(path);
  return Response.json({ revalidated: true });
}

결과

  • mongoDB에서 다음과 같이 확인 할 수 있습니다.

build-image

글을 마치며

배운점

  • Node.js 환경에서 MongoDB와 Mongoose 사용법
  • Next.js의 SSR, SSG, ISR
  • Next.js의 Route-handlers

마무리

지금까지 YoonLog를 개발하게 된 계기와 Next.js 14 + MongoDB로 동적 블로그를 개발한 내용입니다.

읽어주셔서 감사합니다.

YoonLog 깃허브 레포