하위 태스크 1 ~ 3, 7, 9

Server Action 생성

“use server”로 서버 액션 파일 생성

FormData 처리

서버 액션에서 폼 데이터 추출

입력 검증

서버 사이드에서 데이터 검증

리뷰 생성 구현

createReviewAction 완성

캐시 무효화

revalidateTag로 데이터 갱신

app/create-review.action.ts:

"use server";
 
import { revalidateTag } from "next/cache";
 
interface FormState {
  successful: boolean;
  errors?: {
    title?: string;
    description?: string;
    score?: string;
  }
}
 
export async function createReviewAction(prevState: FormState, formData: FormData) {
  const title = String(formData.get("title")) ?? "";
  const description = String(formData.get("description"))?? "";
  const score = Number(formData.get("score")) ?? 0;
 
  await new Promise((resolve) => {
    setTimeout(() => {
      resolve(null);
    }, Math.random() * 3000);
  });
 
  if (title.length === 0) {
    return { successful: false, errors: { title: "리뷰의 제목은 1자 이상의 문자열입니다." } };
  }
 
  if (description.length === 0) {
    return { successful: false, errors: { description: "리뷰의 내용은 1자 이상의 문자열입니다."  } };
  }
 
  if (score < 0 || score > 5) {
    return { successful: false, errors: { score: "평점은 0 이상, 5 이하의 숫자입니다." } };
  }
 
  const review = { title, description, score };
  await fetch("http://localhost:3005/reviews", {
    headers: {
      "Content-Type": "application/json",
    },
    method: "POST",
    body: JSON.stringify(review)
  })
 
  revalidateTag("reviews", { expire: 0 });
 
  return { successful: true };
}

하위 태스크 4, 10 ~ 11

useActionState 사용

폼과 서버 액션 연결

폼 제출 처리

form action으로 서버 액션 호출

로딩 상태 관리

isPending으로 로딩 UI 표시

에러 처리

서버 액션 에러 처리 및 표시

사용자 피드백

성공/실패 메시지 제공

app/page.tsx:

import ReviewEditor from "./ReviewEditor";
import ReviewViewer from "./ReviewViewer";
 
export default function Home() {
  return (
    <main className="p-2">
      <h2 className="mb-4 text-lg font-bold">리뷰 보기</h2>
      <ReviewViewer />
      <h2 className="mb-4 text-lg font-bold">리뷰 작성</h2>
      <ReviewEditor />
    </main>
  );
}

app/ReviewViwer.tsx:

import { Review } from "@/types";
 
export default async function ReviewViewer() {
  const response = await fetch("http://localhost:3005/reviews", {
    next: { tags: ["reviews"] }
  });
  const reviews = await response.json() as Review[];
 
  return (
    <ul className="pl-4">
      {reviews.map((review) => (
        <li key={review.id}>
          <ul className="mb-2 list-disc">
            <div className="font-bold"> {review.title}</div>
            <li>내용: {review.description}</li>
            <li>평점: {review.score}</li>
          </ul>
        </li>
      ))}
    </ul>
  );
}

app/ReviewEditor.tsx:

"use client";
 
import { useActionState } from "react";
import { createReviewAction } from "./create-review.action";
 
export default function ReviewEditor() {
  const [state, formAction, pending] = useActionState(createReviewAction, { successful: false });
 
  return (
    <>
      <form action={formAction}>
        <div className="flex flex-col mb-4 w-60">
          <label id="title">제목</label>
          <input className="bg-slate-100" type="text" name="title" />
        </div>
        <div className="flex flex-col mb-4 w-60">
          <label id="description">내용</label>
          <textarea className="bg-slate-100" name="description" />
        </div>
        <div className="flex gap-x-2 mb-4">
          <div>
            <input type="radio" id="score-0" name="score" value="0"/>
            <label htmlFor="score-0">0점</label>
          </div>
          <div>
            <input type="radio" id="score-1" name="score" value="1"/>
            <label htmlFor="score-1">1점</label>
          </div>
          <div>
            <input type="radio" id="score-2" name="score" value="2"/>
            <label htmlFor="score-2">2점</label>
          </div>
          <div>
            <input type="radio" id="score-3" name="score" value="3"/>
            <label htmlFor="score-3">3점</label>
          </div>
          <div>
            <input type="radio" id="score-4" name="score" value="4"/>
            <label htmlFor="score-4">4점</label>
          </div>
          <div>
            <input type="radio" id="score-5" name="score" value="5"/>
            <label htmlFor="score-5">5점</label>
          </div>
        </div>
        <button className="px-2 border border-slate-300 bg-slate-100" type="submit">
          {pending ? "처리 중..." : "제출"}
        </button>
      </form>
      {state.successful && <p className="text-green-400">리뷰가 등록되었습니다.</p>}
      {state.errors && state.errors.title && <p className="text-red-400">{state.errors.title}</p>}
      {state.errors && state.errors.description && <p className="text-red-400">{state.errors.description}</p>}
      {state.errors && state.errors.score && <p className="text-red-400">{state.errors.score}</p>}
    </>
  );
}

폼 제출 처리 중:

오류 발생:

폼 제출 성공:

하위 태스크 8

리뷰 삭제 구현

deleteReviewAction 생성

app/delete-review.action.ts:

"use server";
 
import { revalidateTag } from "next/cache";
 
interface FormState {
  successful: boolean;
  errors?: {
    id?: string;
  }
}
 
export async function deleteReviewAction(prevState: FormState, formData: FormData) {
  const id = String(formData.get("id"));
 
  await new Promise((resolve) => {
    setTimeout(() => {
      resolve(null);
    }, Math.random() * 3000);
  });
 
  if (id === "") {
    return { successful: false, errors: { id: "ID가 빈 문자열입니다." } };
  }
 
  const response = await fetch("http://localhost:3005/reviews/" + id, {
    method: "DELETE",
  })
  const result = await response.json();
  console.log(result);
 
  revalidateTag("reviews", { expire: 0 });
 
  return { successful: true };
}

app/ReviewViewer.tsx:

import { Review } from "@/types";
import ReviewDeleteButton from "./ReviewDeleteButton";
 
export default async function ReviewViewer() {
  // ...
          <ul className="mb-2 list-disc">
            <div className="flex gap-x-1">
              <span className="font-bold">{review.title}</span>
              <ReviewDeleteButton id={review.id} />
            </div>
            <li>내용: {review.description}</li>
            <li>평점: {review.score}</li>
          </ul>
  // ...
}

app/ReviewDeleteButton.tsx:

"use client";
 
import { useActionState } from "react";
import { deleteReviewAction } from "./delete-review.action";
 
export default function ReviewDeleteButton({ id }: { id: string }) {
  const [state, formAction, pending] = useActionState(deleteReviewAction, { successful: false });
 
  return (
    <form action={formAction}>
      <input type="hidden" name="id" value={id} />
      <button className="px-2 border border-slate-300 bg-slate-100" type="submit">
        {pending ? "삭제 중..." : "삭제"}
      </button>
    </form>
  );
}

삭제 전:

삭제 중:

삭제 후: