Ch 04. 글 작성·수정·삭제 — Server Actions 활용
PART 05에서 Server Actions가 어떻게 동작하는지 배웠습니다. 이제 실전에서 사용합니다. 글 작성, 수정, 삭제는 데이터를 변경하는 작업이므로 Server Actions로 처리하는 것이 자연스럽습니다.
글 작성 Server Action
// 파일: lib/actions.ts (글 관련 액션 추가)"use server";import { prisma } from "@/lib/prisma";import { auth } from "@/auth";import { redirect } from "next/navigation";import { revalidateTag } from "next/cache";import { z } from "zod";const postSchema = z.object({ title: z.string().min(1, "제목을 입력해주세요").max(200, "제목이 너무 깁니다"), content: z.string().min(1, "내용을 입력해주세요"), published: z.boolean().default(false),});function slugify(text: string): string { return text .toLowerCase() .replace(/[^a-z0-9가-힣]/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, "");}export async function createPost(formData: FormData) { const session = await auth(); if (!session?.user?.id) { throw new Error("로그인이 필요합니다"); } const raw = { title: formData.get("title") as string, content: formData.get("content") as string, published: formData.get("published") === "on", }; const parsed = postSchema.safeParse(raw); if (!parsed.success) { return { error: parsed.error.errors[0].message }; } const slug = `${slugify(parsed.data.title)}-${Date.now()}`; const post = await prisma.post.create({ data: { title: parsed.data.title, content: parsed.data.content, published: parsed.data.published, slug, authorId: session.user.id, }, }); // posts 태그의 캐시를 무효화 revalidateTag("posts"); redirect(`/posts/${post.slug}`);}export async function updatePost(postId: string, formData: FormData) { const session = await auth(); if (!session?.user?.id) { throw new Error("로그인이 필요합니다"); } // 작성자 확인 const post = await prisma.post.findUnique({ where: { id: postId } }); if (!post || post.authorId !== session.user.id) { throw new Error("수정 권한이 없습니다"); } const raw = { title: formData.get("title") as string, content: formData.get("content") as string, published: formData.get("published") === "on", }; const parsed = postSchema.safeParse(raw); if (!parsed.success) { return { error: parsed.error.errors[0].message }; } await prisma.post.update({ where: { id: postId }, data: { title: parsed.data.title, content: parsed.data.content, published: parsed.data.published, }, }); revalidateTag("posts"); redirect(`/posts/${post.slug}`);}export async function deletePost(postId: string) { const session = await auth(); if (!session?.user?.id) { throw new Error("로그인이 필요합니다"); } const post = await prisma.post.findUnique({ where: { id: postId } }); if (!post || post.authorId !== session.user.id) { throw new Error("삭제 권한이 없습니다"); } await prisma.post.delete({ where: { id: postId } }); revalidateTag("posts"); redirect("/posts");}```text### 글 작성 페이지```typescript// 파일: app/posts/new/page.tsximport { auth } from "@/auth";import { redirect } from "next/navigation";import { createPost } from "@/lib/actions";export default async function NewPostPage() { const session = await auth(); if (!session) redirect("/login"); return ( <main className="container mx-auto px-4 py-8 max-w-3xl"> <h2 className="text-2xl font-bold mb-8">새 글 작성</h2> <form action={createPost} className="space-y-6"> <div> <label htmlFor="title" className="block text-sm font-medium mb-2"> 제목 </label> <input id="title" name="title" type="text" required className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="글 제목을 입력하세요" /> </div> <div> <label htmlFor="content" className="block text-sm font-medium mb-2"> 내용 (마크다운 지원) </label> <textarea id="content" name="content" required rows={20} className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm" placeholder="마크다운으로 내용을 입력하세요..." /> </div> <div className="flex items-center gap-2"> <input id="published" name="published" type="checkbox" className="rounded" /> <label htmlFor="published" className="text-sm"> 즉시 공개 </label> </div> <div className="flex gap-3"> <button type="submit" className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700" > 발행하기 </button> <a href="/posts" className="px-6 py-2 border rounded-lg hover:bg-gray-50" > 취소 </a> </div> </form> </main> );}```text### 글 수정 페이지기존 데이터를 불러와 폼에 채워야 합니다.```typescript// 파일: app/posts/[slug]/edit/page.tsximport { prisma } from "@/lib/prisma";import { auth } from "@/auth";import { redirect, notFound } from "next/navigation";import { updatePost } from "@/lib/actions";type Props = { params: Promise<{ slug: string }> };export default async function EditPostPage({ params }: Props) { const { slug } = await params; const session = await auth(); if (!session) redirect("/login"); const post = await prisma.post.findUnique({ where: { slug } }); if (!post) notFound(); // 작성자가 아니면 접근 불가 if (post.authorId !== session.user?.id) { redirect(`/posts/${slug}`); } // updatePost에 postId를 bind로 고정 const updatePostWithId = updatePost.bind(null, post.id); return ( <main className="container mx-auto px-4 py-8 max-w-3xl"> <h2 className="text-2xl font-bold mb-8">글 수정</h2> <form action={updatePostWithId} className="space-y-6"> <div> <label htmlFor="title" className="block text-sm font-medium mb-2"> 제목 </label> <input id="title" name="title" type="text" required defaultValue={post.title} className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" /> </div> <div> <label htmlFor="content" className="block text-sm font-medium mb-2"> 내용 </label> <textarea id="content" name="content" required rows={20} defaultValue={post.content} className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm" /> </div> <div className="flex items-center gap-2"> <input id="published" name="published" type="checkbox" defaultChecked={post.published} className="rounded" /> <label htmlFor="published" className="text-sm"> 공개 </label> </div> <div className="flex gap-3"> <button type="submit" className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700" > 저장하기 </button> <a href={`/posts/${slug}`} className="px-6 py-2 border rounded-lg hover:bg-gray-50" > 취소 </a> </div> </form> </main> );}```text`updatePost.bind(null, post.id)`는 Server Action에 인자를 미리 바인딩하는 방법입니다. `<form action>`에 직접 인자를 전달할 수 없으므로, `bind`로 첫 번째 인자를 고정합니다.### 삭제 버튼 (확인 다이얼로그 포함)```typescript// 파일: components/DeletePostButton.tsx"use client";import { deletePost } from "@/lib/actions";export function DeletePostButton({ postId }: { postId: string }) { const deleteWithId = deletePost.bind(null, postId); function handleClick(formData: FormData) { if (!confirm("정말 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.")) { return; } deleteWithId(formData); } return ( <form action={handleClick}> <button type="submit" className="px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200" > 삭제 </button> </form> );}
revalidateTag("posts")는 posts라는 태그가 붙은 캐시를 모두 무효화합니다. 글 목록 페이지에서 데이터를 가져올 때 이 태그를 달아두면, 글을 추가/수정/삭제했을 때 자동으로 최신 데이터가 표시됩니다.
다음 챕터에서는 댓글 기능을 추가합니다.