하위 태스크 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>
);
}삭제 전:

삭제 중:

삭제 후:
