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

글을 시작하며
YoonLog를 개발하게 된 계기
프런트엔드 개발자로 진로를 정하고, 2023년부터 벨로그에서 꾸준히 학습한 내용을 기록해 왔습니다.
이전 벨로그에서 작성한 글들은 어떤 기술에 대한 정보나 책의 내용을 단순히 나열한 거에 그치지 못했습니다.
온전히 제 것이 아닌 글들로 이루어져 있다고 생각했고, 온전히 제 것인 기술 블로그
를 만들고 싶어 YoonLog
를 개발하게 되었습니다.
YoonLog 만의 차별점
유명한 블로그 템플릿과 정적 블로그로 개발할 수도 있었지만, 기술 블로그 래퍼런스들을 참고하여 나만의 디자인을 설계하고, 풀 스택으로 배포한 사이트에서 관리자가 글을 직접 CRUD 할 수 있는 동적 블로그
를 만들고 싶었습니다.
학습의 목적 또한 있었기 때문에 라이브러리의 사용은 최소화하고, 직접 구현해 보는 시간을 가졌습니다.
YoonLog 개발 이야기 시작
여기까지 YoonLog
를 왜 개발했는지에 대해 이야기를 마칩니다.
이제 YoonLog
를 어떻게 개발했는지에 대해 이야기해 보겠습니다.
동적 블로그 만들기
MongoDB와 Mongoose
DB에는 많은 종류가 있습니다.
그중에서 NoSQL
은 읽기와 쓰기라는 기능에 충실하고, 유연하게 스키마를 정의할 수 있어 블로그 개발에 적합하다고 생각했습니다.
JavaScript
로 개발하기 때문에 JSON 데이터 구조에 유리한 NoSQL
의 Document Database
종류인 MongoDB
를 선택했습니다.
Node.js
환경에서 MongoDB
와 상호작용하기 위한 도구로 Mongoose
를 사용했습니다.
Mongoose
는 MongoDB
용 ODM(객체 데이터 모델링)
라이브러리입니다.
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을 사용해
input
값name
속성으로 값을 가져와 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
전체 포스트 불러오기
revalidate
에 0이 아닌 값을 부여해 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
로 동작하고 있음을 알 수 있습니다.
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
에서 다음과 같이 확인 할 수 있습니다.
글을 마치며
배운점
- Node.js 환경에서 MongoDB와 Mongoose 사용법
- Next.js의 SSR, SSG, ISR
- Next.js의 Route-handlers
마무리
지금까지 YoonLog를 개발하게 된 계기와 Next.js 14 + MongoDB로 동적 블로그를 개발한 내용입니다.
읽어주셔서 감사합니다.