iBetter Books
수정

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라는 태그가 붙은 캐시를 모두 무효화합니다. 글 목록 페이지에서 데이터를 가져올 때 이 태그를 달아두면, 글을 추가/수정/삭제했을 때 자동으로 최신 데이터가 표시됩니다.

다음 챕터에서는 댓글 기능을 추가합니다.