iBetter Books
수정

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로 이미지를 최적화합니다.