Ch 05. 댓글 기능 추가하기
글을 읽고 나서 아무것도 남길 수 없다면 조용한 도서관 같습니다. 댓글이 있으면 독자들이 반응을 남기고, 글쓴이와 소통할 수 있습니다. 댓글은 로그인한 사용자만 작성할 수 있고, 작성자 본인만 삭제할 수 있습니다.
댓글 Server Actions
// 파일: lib/actions.ts (댓글 관련 액션 추가)"use server";import { prisma } from "@/lib/prisma";import { auth } from "@/auth";import { revalidatePath } from "next/cache";import { z } from "zod";const commentSchema = z.object({ content: z .string() .min(1, "댓글 내용을 입력해주세요") .max(1000, "댓글은 1000자 이하로 작성해주세요"),});export async function createComment(postId: string, formData: FormData) { const session = await auth(); if (!session?.user?.id) { throw new Error("로그인이 필요합니다"); } const raw = { content: formData.get("content") as string }; const parsed = commentSchema.safeParse(raw); if (!parsed.success) { return { error: parsed.error.errors[0].message }; } await prisma.comment.create({ data: { content: parsed.data.content, postId, authorId: session.user.id, }, }); // 현재 페이지의 캐시를 즉시 갱신 revalidatePath(`/posts/[slug]`, "page");}export async function deleteComment(commentId: string, postSlug: string) { const session = await auth(); if (!session?.user?.id) { throw new Error("로그인이 필요합니다"); } const comment = await prisma.comment.findUnique({ where: { id: commentId }, }); if (!comment) { throw new Error("댓글을 찾을 수 없습니다"); } // 작성자 또는 관리자만 삭제 가능 const isAdmin = (session.user as { role?: string }).role === "admin"; if (comment.authorId !== session.user.id && !isAdmin) { throw new Error("삭제 권한이 없습니다"); } await prisma.comment.delete({ where: { id: commentId } }); revalidatePath(`/posts/${postSlug}`);}```text`revalidatePath`는 특정 경로의 캐시를 즉시 무효화합니다. 댓글이 추가되거나 삭제된 후 페이지를 새로고침하면 바로 변경사항이 반영됩니다.### 댓글 목록 컴포넌트```typescript// 파일: components/CommentList.tsximport { prisma } from "@/lib/prisma";import { auth } from "@/auth";import { deleteComment } from "@/lib/actions";type CommentListProps = { postId: string; postSlug: string;};export async function CommentList({ postId, postSlug }: CommentListProps) { const [comments, session] = await Promise.all([ prisma.comment.findMany({ where: { postId }, include: { author: { select: { id: true, name: true } }, }, orderBy: { createdAt: "asc" }, }), auth(), ]); if (comments.length === 0) { return ( <p className="text-gray-500 text-sm py-4"> 아직 댓글이 없습니다. 첫 댓글을 남겨보세요. </p> ); } return ( <div className="space-y-4"> {comments.map((comment) => { const isOwner = session?.user?.id === comment.author.id; const isAdmin = (session?.user as { role?: string })?.role === "admin"; const canDelete = isOwner || isAdmin; const deleteWithArgs = deleteComment.bind( null, comment.id, postSlug ); return ( <div key={comment.id} className="flex gap-3 p-4 bg-gray-50 rounded-lg" > <div className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white text-sm font-medium flex-shrink-0"> {comment.author.name.charAt(0)} </div> <div className="flex-1"> <div className="flex items-center justify-between mb-1"> <span className="text-sm font-medium"> {comment.author.name} </span> <span className="text-xs text-gray-400"> {new Date(comment.createdAt).toLocaleDateString("ko-KR")} </span> </div> <p className="text-sm text-gray-700 whitespace-pre-wrap"> {comment.content} </p> {canDelete && ( <form action={deleteWithArgs} className="mt-2"> <button type="submit" className="text-xs text-red-500 hover:text-red-700" > 삭제 </button> </form> )} </div> </div> ); })} </div> );}```text### 댓글 작성 폼```typescript// 파일: components/CommentForm.tsximport { auth } from "@/auth";import { createComment } from "@/lib/actions";import Link from "next/link";type CommentFormProps = { postId: string;};export async function CommentForm({ postId }: CommentFormProps) { const session = await auth(); const createCommentWithPostId = createComment.bind(null, postId); if (!session) { return ( <div className="p-4 bg-gray-50 rounded-lg text-center"> <p className="text-sm text-gray-600"> 댓글을 작성하려면{" "} <Link href="/login" className="text-blue-600 hover:underline"> 로그인 </Link> 이 필요합니다. </p> </div> ); } return ( <form action={createCommentWithPostId} className="space-y-3"> <div className="flex gap-3"> <div className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white text-sm font-medium flex-shrink-0"> {session.user?.name?.charAt(0)} </div> <textarea name="content" required rows={3} className="flex-1 px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none" placeholder={`${session.user?.name}님, 댓글을 입력하세요...`} /> </div> <div className="flex justify-end"> <button type="submit" className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700" > 댓글 등록 </button> </div> </form> );}```text### 글 상세 페이지에 댓글 섹션 추가```typescript// 파일: app/posts/[slug]/page.tsx (댓글 섹션 추가)import { prisma } from "@/lib/prisma";import { auth } from "@/auth";import { notFound } from "next/navigation";import { CommentList } from "@/components/CommentList";import { CommentForm } from "@/components/CommentForm";import { DeletePostButton } from "@/components/DeletePostButton";import Link from "next/link";import { Suspense } from "react";type Props = { params: Promise<{ slug: string }> };export default async function PostPage({ params }: Props) { const { slug } = await params; const post = await prisma.post.findUnique({ where: { slug }, include: { author: { select: { id: true, name: true } } }, }); if (!post) notFound(); const session = await auth(); const isAuthor = session?.user?.id === post.author.id; return ( <article className="container mx-auto px-4 py-12 max-w-3xl"> <header className="mb-8"> <div className="flex items-start justify-between"> <h2 className="text-3xl font-bold">{post.title}</h2> {isAuthor && ( <div className="flex gap-2 ml-4 flex-shrink-0"> <Link href={`/posts/${slug}/edit`} className="px-3 py-1 text-sm bg-gray-100 rounded hover:bg-gray-200" > 수정 </Link> <DeletePostButton postId={post.id} /> </div> )} </div> <p className="text-gray-500 mt-2"> {post.author.name} ·{" "} {new Date(post.createdAt).toLocaleDateString("ko-KR")} </p> </header> <div className="prose prose-lg max-w-none mb-12"> {post.content} </div> <section className="border-t pt-8"> <h3 className="text-xl font-semibold mb-6">댓글</h3> <div className="mb-6"> <CommentForm postId={post.id} /> </div> <Suspense fallback={ <div className="text-sm text-gray-500">댓글 불러오는 중...</div> } > <CommentList postId={post.id} postSlug={slug} /> </Suspense> </section> </article> );}
Suspense로 댓글 목록을 감싸면 댓글이 로드되는 동안 페이지의 나머지 부분이 먼저 표시됩니다. 글 내용을 빨리 볼 수 있고, 댓글은 이후에 순차적으로 나타납니다.
다음 챕터에서는 next/image로 이미지를 최적화합니다.