[๐Ÿค–] Next.js Server Actions ์‹ค์ „: ์—๋Ÿฌ ์ฒ˜๋ฆฌ, ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ, ๋‚™๊ด€์  UI ์—…๋ฐ์ดํŠธ

Next.js Server Actions๋ฅผ ์‹ค๋ฌด์— ์ ์šฉํ•  ๋•Œ ๋งˆ์ฃผํ•˜๋Š” ์—๋Ÿฌ ์ฒ˜๋ฆฌ, ๋ฐ์ดํ„ฐ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ, ๊ทธ๋ฆฌ๊ณ  ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์„ ํ–ฅ์ƒ์‹œํ‚ค๋Š” ๋‚™๊ด€์  UI ์—…๋ฐ์ดํŠธ ๊ธฐ๋ฒ•์„ ์ƒ์„ธํ•œ ์ฝ”๋“œ ์˜ˆ์‹œ์™€ ํ•จ๊ป˜ ์•Œ์•„๋ณด์„ธ์š”.

31๋ถ„
๋‹จ์–ด: 3,270๊ฐœ
๊ฒŒ์‹œ๊ธ€ ์ธ๋„ค์ผ
์ •๋ณด

๐Ÿค– ์ด ํฌ์ŠคํŒ…์€ Gemini 2.5 Flash AI๊ฐ€ ์ž‘์„ฑํ–ˆ์–ด์š”.
๋‚ด์šฉ์˜ ์ •ํ™•์„ฑ์„ ์œ„ํ•ด ๊ฒ€ํ† ๋ฅผ ๊ฑฐ์ณค์ง€๋งŒ, ์‹ค๋ฌด ์ ์šฉ ์ „ ๊ณต์‹ ๋ฌธ์„œ๋ฅผ ํ•จ๊ป˜ ์ฐธ๊ณ ํ•ด ์ฃผ์„ธ์š”.

์œ ์šฉํ•œ ํŒ

Next.js Server Actions๋ฅผ ํ™œ์šฉํ•ด ์•ˆ์ „ํ•˜๊ณ  ์‚ฌ์šฉ์ž ์นœํ™”์ ์ธ ์›น ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ๋งŒ๋“œ๋Š” ๋ฐฉ๋ฒ•์„ ์—๋Ÿฌ ์ฒ˜๋ฆฌ, ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ, ๋‚™๊ด€์  UI ์—…๋ฐ์ดํŠธ ๊ธฐ๋ฒ•์„ ํ†ตํ•ด ์ƒ์„ธํžˆ ์•Œ๋ ค๋“œ๋ ค์š”.

์•ˆ๋…•ํ•˜์„ธ์š”, ๋ธ”๋ฃจ์˜ˆ์š”! ๐Ÿš€ 10๋…„ ๋„˜๊ฒŒ ํ’€์Šคํƒ ๊ฐœ๋ฐœ์ž๋กœ ์ผํ•˜๋ฉด์„œ ์ˆ˜๋งŽ์€ ๊ธฐ์ˆ  ๋ณ€ํ™”๋ฅผ ๊ฒฝํ—˜ํ•ด ์™”๋Š”๋ฐ์š”, ์ตœ๊ทผ ํ”„๋ŸฐํŠธ์—”๋“œ์™€ ๋ฐฑ์—”๋“œ์˜ ๊ฒฝ๊ณ„๋ฅผ ํ—ˆ๋ฌด๋Š” Next.js Server Actions์˜ ๋“ฑ์žฅ์€ ์ •๋ง ํฅ๋ฏธ๋กœ์šด ๋ณ€ํ™”๋ผ๊ณ  ์ƒ๊ฐํ•ด์š”.

Server Actions๋Š” ๋‹จ์ˆœํžˆ ์„œ๋ฒ„ ์ฝ”๋“œ๋ฅผ ํด๋ผ์ด์–ธํŠธ ์ปดํฌ๋„ŒํŠธ์—์„œ ํ˜ธ์ถœํ•˜๋Š” ๊ฒƒ์„ ๋„˜์–ด, ์‚ฌ์šฉ์ž ๊ฒฝํ—˜(UX)๊ณผ ๊ฐœ๋ฐœ์ž ๊ฒฝํ—˜(DX)์„ ํ˜์‹ ํ•  ์ž ์žฌ๋ ฅ์„ ๊ฐ€์ง€๊ณ  ์žˆ์–ด์š”.
ํ•˜์ง€๋งŒ ์‹ค๋ฌด์— ์ ์šฉํ•˜๋ ค๋ฉด ๋‹จ์ˆœํžˆ ํ˜ธ์ถœํ•˜๋Š” ๊ฒƒ์„ ๋„˜์–ด, ๋ฐ์ดํ„ฐ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ, ์—๋Ÿฌ ์ฒ˜๋ฆฌ, ๊ทธ๋ฆฌ๊ณ  ์‚ฌ์šฉ์ž์—๊ฒŒ ์ฆ‰๊ฐ์ ์ธ ํ”ผ๋“œ๋ฐฑ์„ ์ œ๊ณตํ•˜๋Š” ๋‚™๊ด€์  UI ์—…๋ฐ์ดํŠธ์™€ ๊ฐ™์€ ๊ณ ๊ธ‰ ๊ธฐ๋ฒ•๋“ค์„ ์ž˜ ์ดํ•ดํ•˜๊ณ  ์ ์šฉํ•ด์•ผ ํ•ด์š”.

์ด ๊ธ€์—์„œ๋Š” Server Actions๋ฅผ ๋”์šฑ ๊ฒฌ๊ณ ํ•˜๊ณ  ์‚ฌ์šฉ์ž ์นœํ™”์ ์œผ๋กœ ๋งŒ๋“œ๋Š” ์‹ค์ „ ๊ธฐ๋ฒ•๋“ค์„ ์ดˆ์ค‘๊ธ‰ ๊ฐœ๋ฐœ์ž๋ถ„๋“ค์˜ ๋ˆˆ๋†’์ด์— ๋งž์ถฐ ์ƒ์„ธํžˆ ์•Œ๋ ค๋“œ๋ฆด๊ฒŒ์š”.

๐Ÿค” Server Actions, ์™œ ์ค‘์š”ํ• ๊นŒ์š”?

0๏ธโƒฃ ํด๋ผ์ด์–ธํŠธ-์„œ๋ฒ„ ๊ฒฝ๊ณ„๋ฅผ ํ—ˆ๋ฌด๋Š” ์ƒˆ๋กœ์šด ํŒจ๋Ÿฌ๋‹ค์ž„

๊ธฐ์กด ์›น ๊ฐœ๋ฐœ์—์„œ๋Š” ํด๋ผ์ด์–ธํŠธ(๋ธŒ๋ผ์šฐ์ €)์—์„œ ์„œ๋ฒ„์˜ ๋ฐ์ดํ„ฐ๋ฅผ ์กฐ์ž‘ํ•˜๊ฑฐ๋‚˜ ํŠน์ • ๋กœ์ง์„ ์‹คํ–‰ํ•˜๋ ค๋ฉด REST API๋‚˜ GraphQL ๊ฐ™์€ ๋ณ„๋„์˜ API ์—”๋“œํฌ์ธํŠธ๋ฅผ ๋งŒ๋“ค๊ณ , ํด๋ผ์ด์–ธํŠธ์—์„œ fetch ๋“ฑ์˜ ๋น„๋™๊ธฐ ์š”์ฒญ์„ ๋ณด๋‚ด์•ผ ํ–ˆ์–ด์š”.
์ด ๊ณผ์ •์—์„œ API ๋ผ์šฐํŠธ ์ •์˜, ๋ฐ์ดํ„ฐ ์ง๋ ฌํ™”/์—ญ์ง๋ ฌํ™”, ์ƒํƒœ ๊ด€๋ฆฌ ๋“ฑ ์—ฌ๋Ÿฌ ๋ณต์žกํ•œ ๋‹จ๊ณ„๋ฅผ ๊ฑฐ์ณ์•ผ ํ–ˆ์ฃ .

ํ•˜์ง€๋งŒ Next.js Server Actions๋Š” ์ด๋Ÿฌํ•œ ๊ฒฝ๊ณ„๋ฅผ ๋ชจํ˜ธํ•˜๊ฒŒ ๋งŒ๋“ค์–ด์š”. ํด๋ผ์ด์–ธํŠธ ์ปดํฌ๋„ŒํŠธ๋‚˜ ์„œ๋ฒ„ ์ปดํฌ๋„ŒํŠธ์—์„œ ์ง์ ‘ ์„œ๋ฒ„ ์ฝ”๋“œ๋ฅผ ํ˜ธ์ถœํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋˜์–ด, ๋งˆ์น˜ ๋กœ์ปฌ ํ•จ์ˆ˜๋ฅผ ํ˜ธ์ถœํ•˜๋“ฏ์ด ์„œ๋ฒ„ ๋กœ์ง์„ ์‹คํ–‰ํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋˜์—ˆ์–ด์š”.
์ด๋Š” ๊ฐœ๋ฐœ ๋ณต์žก์„ฑ์„ ํฌ๊ฒŒ ์ค„์ด๊ณ , ๋” ๋น ๋ฅด๊ณ  ํšจ์œจ์ ์ธ ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ ํ๋ฆ„์„ ๊ฐ€๋Šฅํ•˜๊ฒŒ ํ•ด์š”.

1๏ธโƒฃ ๊ธฐ์กด API ๋ผ์šฐํŠธ ๋ฐฉ์‹๊ณผ์˜ ์ฐจ์ด์ 

Server Actions๋Š” Next.js App Router์—์„œ ์ œ๊ณตํ•˜๋Š” API ๋ผ์šฐํŠธ(Route Handlers)์™€ ์œ ์‚ฌํ•œ ๊ธฐ๋Šฅ์„ ์ˆ˜ํ–‰ํ•˜์ง€๋งŒ, ๋ช‡ ๊ฐ€์ง€ ์ค‘์š”ํ•œ ์ฐจ์ด์ ์ด ์žˆ์–ด์š”.

์ •๋ณด

API ๋ผ์šฐํŠธ (Route Handlers): ๋ณ„๋„์˜ HTTP ์—”๋“œํฌ์ธํŠธ(์˜ˆ: /api/users)๋ฅผ ์ •์˜ํ•˜๊ณ , ํด๋ผ์ด์–ธํŠธ์—์„œ fetch ์š”์ฒญ์„ ๋ณด๋‚ด๋Š” ๋ฐฉ์‹์ด์—์š”.
์ฃผ๋กœ ์™ธ๋ถ€ ์„œ๋น„์Šค์™€์˜ ์—ฐ๋™์ด๋‚˜ ๋ณต์žกํ•œ ๋ฐ์ดํ„ฐ ์กฐํšŒ ๋กœ์ง์— ์ ํ•ฉํ•ด์š”.

์œ ์šฉํ•œ ํŒ

Server Actions: ํด๋ผ์ด์–ธํŠธ ์ปดํฌ๋„ŒํŠธ์—์„œ ์ง์ ‘ ํ˜ธ์ถœ ๊ฐ€๋Šฅํ•œ ์„œ๋ฒ„ ํ•จ์ˆ˜์˜ˆ์š”.
์ฃผ๋กœ ํผ ์ œ์ถœ, ๋ฐ์ดํ„ฐ ๋ณ€๊ฒฝ, ์ธ์ฆ๊ณผ ๊ฐ™์€ ํŠน์ • ์‚ฌ์šฉ์ž ์ธํ„ฐ๋ž™์…˜์— ๋Œ€ํ•œ ์„œ๋ฒ„ ๋กœ์ง์„ ์ฒ˜๋ฆฌํ•˜๋Š” ๋ฐ ์ตœ์ ํ™”๋˜์–ด ์žˆ์–ด์š”.
๋„คํŠธ์›Œํฌ ์š”์ฒญ์„ ๋ช…์‹œ์ ์œผ๋กœ ์ž‘์„ฑํ•  ํ•„์š” ์—†์ด, ํ•จ์ˆ˜ ํ˜ธ์ถœ๋งŒ์œผ๋กœ ์„œ๋ฒ„ ์ฝ”๋“œ๋ฅผ ์‹คํ–‰ํ•  ์ˆ˜ ์žˆ๋‹ค๋Š” ๊ฒƒ์ด ๊ฐ€์žฅ ํฐ ์žฅ์ ์ด์—์š”.

Server Actions๋Š” POST ์š”์ฒญ์— ์ตœ์ ํ™”๋˜์–ด ์žˆ์œผ๋ฉฐ, FormData ๊ฐ์ฒด๋ฅผ ์ž๋™์œผ๋กœ ์ฒ˜๋ฆฌํ•ด์ฃผ๊ธฐ ๋•Œ๋ฌธ์— ํผ ์ œ์ถœ ์‹œ ๋งค์šฐ ํŽธ๋ฆฌํ•ด์š”. ๋˜ํ•œ, revalidatePath๋‚˜ revalidateTag์™€ ๊ฐ™์€ Next.js์˜ ์บ์‹ฑ ์žฌ๊ฒ€์ฆ ๊ธฐ๋Šฅ๊ณผ ๊ธด๋ฐ€ํ•˜๊ฒŒ ํ†ตํ•ฉ๋˜์–ด ์žˆ์–ด ๋ฐ์ดํ„ฐ ์ผ๊ด€์„ฑ์„ ์œ ์ง€ํ•˜๊ธฐ ์‰ฝ๋‹ค๋Š” ์žฅ์ ๋„ ์žˆ์–ด์š”.

โš™๏ธ Server Actions ๊ธฐ๋ณธ ์‚ฌ์šฉ๋ฒ•

๋ณธ๊ฒฉ์ ์œผ๋กœ Server Actions๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ์•Œ์•„๋ณผ๊ฒŒ์š”.

0๏ธโƒฃ ๊ฐ„๋‹จํ•œ ํผ ์ œ์ถœ ์˜ˆ์‹œ

๊ฐ€์žฅ ๊ธฐ๋ณธ์ ์ธ Server Actions๋Š” "use server" ์ง€์‹œ์–ด๋ฅผ ํŒŒ์ผ ๋งจ ์œ„์— ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜, ํ•จ์ˆ˜ ์•ž์— ๋ถ™์—ฌ ์„œ๋ฒ„์—์„œ ์‹คํ–‰๋  ํ•จ์ˆ˜์ž„์„ ๋ช…์‹œํ•˜๋Š” ๊ฒƒ์œผ๋กœ ์‹œ์ž‘ํ•ด์š”.
์ฃผ๋กœ <form> ํƒœ๊ทธ์˜ action ์†์„ฑ์— ์ง์ ‘ ์—ฐ๊ฒฐํ•˜์—ฌ ์‚ฌ์šฉํ•ด์š”.

app/actions.ts (์„œ๋ฒ„ ์•ก์…˜ ์ •์˜):

"use server"; // ์ด ํŒŒ์ผ์˜ ๋ชจ๋“  ํ•จ์ˆ˜๋Š” ์„œ๋ฒ„์—์„œ ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค. import { revalidatePath } from "next/cache"; interface FormData { title: string; content: string; } export async function createPost(formData: FormData) { // ์‹ค์ œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ €์žฅ ๋กœ์ง์„ ์—ฌ๊ธฐ์— ๊ตฌํ˜„ํ•ด์š”. // ์˜ˆ์‹œ๋ฅผ ์œ„ํ•ด ๊ฐ„๋‹จํžˆ ์ฝ˜์†”์— ์ถœ๋ ฅํ•˜๊ณ  ์ง€์—ฐ ์‹œ๊ฐ„์„ ์ค˜๋ดค์–ด์š”. console.log("๊ฒŒ์‹œ๊ธ€ ์ƒ์„ฑ ์š”์ฒญ:", formData); await new Promise((resolve) => setTimeout(resolve, 1000)); // ๋„คํŠธ์›Œํฌ ์ง€์—ฐ ํ‰๋‚ด // ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์ €์žฅํ–ˆ๋‹ค๊ณ  ๊ฐ€์ •ํ•˜๊ณ , ์„ฑ๊ณต ๋ฉ”์‹œ์ง€๋ฅผ ๋ฐ˜ํ™˜ํ•ด์š”. const newPost = { id: Date.now().toString(), ...formData }; console.log("์ƒˆ๋กœ์šด ๊ฒŒ์‹œ๊ธ€ ์ƒ์„ฑ ์™„๋ฃŒ:", newPost); // ์บ์‹œ๋œ ๋ฐ์ดํ„ฐ๋ฅผ ์žฌ๊ฒ€์ฆํ•˜์—ฌ UI๋ฅผ ์—…๋ฐ์ดํŠธํ•ด์š”. revalidatePath("/posts"); // /posts ๊ฒฝ๋กœ์˜ ์บ์‹œ๋ฅผ ๋ฌดํšจํ™”ํ•˜์—ฌ ์ตœ์‹  ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์˜ค๊ฒŒ ํ•ด์š”. return { success: true, message: "๊ฒŒ์‹œ๊ธ€์ด ์„ฑ๊ณต์ ์œผ๋กœ ์ƒ์„ฑ๋˜์—ˆ์–ด์š”.", post: newPost }; }

app/posts/create/page.tsx (ํด๋ผ์ด์–ธํŠธ ์ปดํฌ๋„ŒํŠธ์—์„œ ํผ ์‚ฌ์šฉ):

"use client"; import { createPost } from "@/app/actions"; // Server Action ์ž„ํฌํŠธ import { useState } from "react"; export default function CreatePostPage() { const [message, setMessage] = useState(""); // Server Action์„ ์ง์ ‘ ํ˜ธ์ถœํ•˜๋Š” ํ•จ์ˆ˜๋ฅผ ์ •์˜ํ•ด์š”. const handleSubmit = async (formData: FormData) => { // FormData ๊ฐ์ฒด๋Š” input์˜ name ์†์„ฑ์„ ๊ธฐ๋ฐ˜์œผ๋กœ ์ž๋™์œผ๋กœ ์ƒ์„ฑ๋ผ์š”. const title = formData.get("title") as string; const content = formData.get("content") as string; // Server Action์„ ํ˜ธ์ถœํ•ด์š”. const result = await createPost({ title, content }); setMessage(result.message); }; return ( <div className="max-w-md mx-auto p-4 bg-white shadow-md rounded-lg"> <h1 className="text-2xl font-bold mb-4">์ƒˆ ๊ฒŒ์‹œ๊ธ€ ์ž‘์„ฑ</h1> {/* <form action={createPost}> ์™€ ๊ฐ™์ด ์ง์ ‘ Server Action์„ ์—ฐ๊ฒฐํ•  ์ˆ˜๋„ ์žˆ์–ด์š”. */} {/* ์—ฌ๊ธฐ์„œ๋Š” ์ถ”๊ฐ€ ๋กœ์ง ์ฒ˜๋ฆฌ๋ฅผ ์œ„ํ•ด handleSubmit์„ ์‚ฌ์šฉํ–ˆ์–ด์š”. */} <form action={handleSubmit}> <div className="mb-4"> <label htmlFor="title" className="block text-gray-700 text-sm font-bold mb-2"> ์ œ๋ชฉ </label> <input type="text" id="title" name="title" // name ์†์„ฑ์ด ์ค‘์š”ํ•ด์š”! className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" required /> </div> <div className="mb-6"> <label htmlFor="content" className="block text-gray-700 text-sm font-bold mb-2"> ๋‚ด์šฉ </label> <textarea id="content" name="content" // name ์†์„ฑ์ด ์ค‘์š”ํ•ด์š”! rows={5} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline" required ></textarea> </div> <button type="submit" className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline" > ๊ฒŒ์‹œ๊ธ€ ์ƒ์„ฑ </button> </form> {message && <p className="mt-4 text-green-600">{message}</p>} </div> ); }

์œ„ ์˜ˆ์‹œ์—์„œ๋Š” createPost Server Action์„ ์ง์ ‘ handleSubmit ํ•จ์ˆ˜ ๋‚ด์—์„œ ํ˜ธ์ถœํ–ˆ์–ด์š”. ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ํผ ์ œ์ถœ ์ „ํ›„์— ์ถ”๊ฐ€์ ์ธ ํด๋ผ์ด์–ธํŠธ ์‚ฌ์ด๋“œ ๋กœ์ง์„ ์‹คํ–‰ํ•  ์ˆ˜ ์žˆ์–ด์š”.
๋˜๋Š” <form action={createPost}>์™€ ๊ฐ™์ด action ์†์„ฑ์— Server Action์„ ์ง์ ‘ ๋„˜๊ฒจ์ค„ ์ˆ˜๋„ ์žˆ๋‹ต๋‹ˆ๋‹ค.

1๏ธโƒฃ useFormStatus์™€ useFormState ํ›… ํ™œ์šฉ

Next.js๋Š” Server Actions์™€ ํ•จ๊ป˜ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ๋‘ ๊ฐ€์ง€ ์œ ์šฉํ•œ React ํ›…์„ ์ œ๊ณตํ•ด์š”. ๋ฐ”๋กœ useFormStatus์™€ useFormState์˜ˆ์š”.

useFormStatus๋กœ ๋กœ๋”ฉ ์ƒํƒœ ๊ด€๋ฆฌํ•˜๊ธฐ

useFormStatus ํ›…์€ ํผ์˜ ์ œ์ถœ ์ƒํƒœ(pending ์—ฌ๋ถ€)๋ฅผ ์•Œ๋ ค์ค˜์„œ, ํผ์ด ์ œ์ถœ๋˜๋Š” ๋™์•ˆ ๋ฒ„ํŠผ์„ ๋น„ํ™œ์„ฑํ™”ํ•˜๊ฑฐ๋‚˜ ๋กœ๋”ฉ ์Šคํ”ผ๋„ˆ๋ฅผ ๋ณด์—ฌ์ฃผ๋Š” ๋“ฑ์˜ ์ž‘์—…์„ ํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์š”.
์ด ํ›…์€ ๋ฐ˜๋“œ์‹œ <form> ์ปดํฌ๋„ŒํŠธ์˜ ์ž์‹ ์ปดํฌ๋„ŒํŠธ์—์„œ ํ˜ธ์ถœ๋˜์–ด์•ผ ํ•ด์š”.

app/posts/create/page.tsx (์ผ๋ถ€ ์ˆ˜์ •):

"use client"; import { createPost } from "@/app/actions"; import { useState } from "react"; +import { useFormStatus } from "react-dom"; // useFormStatus ์ž„ํฌํŠธ +// ์ œ์ถœ ๋ฒ„ํŠผ ์ปดํฌ๋„ŒํŠธ +function SubmitButton() { + const { pending } = useFormStatus(); // ํผ์˜ ์ œ์ถœ ์ƒํƒœ๋ฅผ ๊ฐ€์ ธ์™€์š”. + + return ( + <button + type="submit" + className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline disabled:opacity-50 disabled:cursor-not-allowed" + disabled={pending} // pending ์ƒํƒœ์ผ ๋•Œ ๋ฒ„ํŠผ ๋น„ํ™œ์„ฑํ™” + > + {pending ? "์ƒ์„ฑ ์ค‘..." : "๊ฒŒ์‹œ๊ธ€ ์ƒ์„ฑ"} + </button> + ); +} export default function CreatePostPage() { const [message, setMessage] = useState(""); const handleSubmit = async (formData: FormData) => { const title = formData.get("title") as string; const content = formData.get("content") as string; const result = await createPost({ title, content }); setMessage(result.message); }; return ( <div className="max-w-md mx-auto p-4 bg-white shadow-md rounded-lg"> <h1 className="text-2xl font-bold mb-4">์ƒˆ ๊ฒŒ์‹œ๊ธ€ ์ž‘์„ฑ</h1> <form action={handleSubmit}> <div className="mb-4"> <label htmlFor="title" className="block text-gray-700 text-sm font-bold mb-2"> ์ œ๋ชฉ </label> <input type="text" id="title" name="title" className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" required /> </div> <div className="mb-6"> <label htmlFor="content" className="block text-gray-700 text-sm font-bold mb-2"> ๋‚ด์šฉ </label> <textarea id="content" name="content" rows={5} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline" required ></textarea> </div> - <button - type="submit" - className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline" - > - ๊ฒŒ์‹œ๊ธ€ ์ƒ์„ฑ - </button> + <SubmitButton /> </form> {message && <p className="mt-4 text-green-600">{message}</p>} </div> ); }

useFormState๋กœ ์„œ๋ฒ„ ์‘๋‹ต ์ƒํƒœ ๊ด€๋ฆฌํ•˜๊ธฐ

useFormState ํ›…์€ Server Action์˜ ์ด์ „ ๊ฒฐ๊ณผ(state)์™€ ํ˜„์žฌ ์ œ์ถœ๋œ ํผ ๋ฐ์ดํ„ฐ๋ฅผ ์ธ์ž๋กœ ๋ฐ›์•„ ์ƒˆ๋กœ์šด ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋Š” ํ›…์ด์—์š”.
์ด๋ฅผ ํ†ตํ•ด ์„œ๋ฒ„ ์•ก์…˜์˜ ์„ฑ๊ณต/์‹คํŒจ ๋ฉ”์‹œ์ง€, ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ์—๋Ÿฌ ๋“ฑ์„ ํด๋ผ์ด์–ธํŠธ์—์„œ ํŽธ๋ฆฌํ•˜๊ฒŒ ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ์–ด์š”.

useFormState๋Š” useReducer์™€ ๋น„์Šทํ•œ ๋ฐฉ์‹์œผ๋กœ ๋™์ž‘ํ•˜๋ฉฐ, ์ดˆ๊ธฐ ์ƒํƒœ์™€ Server Action ํ•จ์ˆ˜๋ฅผ ์ธ์ž๋กœ ๋ฐ›์•„์š”.

app/posts/create/page.tsx (useFormState ์ ์šฉ):

"use client"; import { createPost } from "@/app/actions"; import { useState } from "react"; +import { useFormStatus, useFormState } from "react-dom"; // useFormState ์ž„ํฌํŠธ // ... SubmitButton ์ปดํฌ๋„ŒํŠธ๋Š” ๋™์ผํ•ด์š” ... export default function CreatePostPage() { - const [message, setMessage] = useState(""); - const handleSubmit = async (formData: FormData) => { - const title = formData.get("title") as string; - const content = formData.get("content") as string; - - const result = await createPost({ title, content }); - setMessage(result.message); - }; + // useFormState ํ›…์„ ์‚ฌ์šฉํ•ด์š”. ์ฒซ ๋ฒˆ์งธ ์ธ์ž๋Š” Server Action, ๋‘ ๋ฒˆ์งธ ์ธ์ž๋Š” ์ดˆ๊ธฐ ์ƒํƒœ์˜ˆ์š”. + // state๋Š” Server Action์˜ ๋ฐ˜ํ™˜๊ฐ’์ด์—์š”. + const [state, formAction] = useFormState(createPost, { + success: false, + message: "", + errors: { title: "", content: "" }, + }); + return ( <div className="max-w-md mx-auto p-4 bg-white shadow-md rounded-lg"> <h1 className="text-2xl font-bold mb-4">์ƒˆ ๊ฒŒ์‹œ๊ธ€ ์ž‘์„ฑ</h1> - <form action={handleSubmit}> + <form action={formAction}> {/* useFormState๊ฐ€ ๋ฐ˜ํ™˜ํ•œ formAction์„ ์‚ฌ์šฉํ•ด์š”. */} <div className="mb-4"> <label htmlFor="title" className="block text-gray-700 text-sm font-bold mb-2"> ์ œ๋ชฉ </label> <input type="text" id="title" name="title" className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" required /> </div> <div className="mb-6"> <label htmlFor="content" className="block text-gray-700 text-sm font-bold mb-2"> ๋‚ด์šฉ </label> <textarea id="content" name="content" rows={5} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline" required ></textarea> </div> <SubmitButton /> </form> - {message && <p className="mt-4 text-green-600">{message}</p>} + {state.message && ( + <p className={`mt-4 ${state.success ? "text-green-600" : "text-red-600"}`}> + {state.message} + </p> + )} </div> ); }

์ด์ œ state.message๋ฅผ ํ†ตํ•ด Server Action์˜ ๊ฒฐ๊ณผ๋ฅผ ํ‘œ์‹œํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋˜์—ˆ์–ด์š”. useFormState๋Š” Server Action์˜ ๋ฐ˜ํ™˜๊ฐ’์„ ํด๋ผ์ด์–ธํŠธ ์ปดํฌ๋„ŒํŠธ์—์„œ ์‰ฝ๊ฒŒ ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ๋„๋ก ํ•ด์ฃผ๋Š” ๊ฐ•๋ ฅํ•œ ๋„๊ตฌ์˜ˆ์š”.

๐Ÿšจ ์•ˆ์ „ํ•œ Server Actions ๊ตฌํ˜„ํ•˜๊ธฐ

์‚ฌ์šฉ์ž๋กœ๋ถ€ํ„ฐ ์ž…๋ ฅ๋ฐ›์€ ๋ฐ์ดํ„ฐ๋ฅผ ์„œ๋ฒ„์—์„œ ์ฒ˜๋ฆฌํ•  ๋•Œ๋Š” ํ•ญ์ƒ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ์™€ ์—๋Ÿฌ ์ฒ˜๋ฆฌ๋ฅผ ์ฒ ์ €ํžˆ ํ•ด์•ผ ํ•ด์š”. ๊ทธ๋ ‡์ง€ ์•Š์œผ๋ฉด ๋ณด์•ˆ ์ทจ์•ฝ์ ์ด๋‚˜ ์˜ˆ๊ธฐ์น˜ ์•Š์€ ๋ฒ„๊ทธ๋กœ ์ด์–ด์งˆ ์ˆ˜ ์žˆ๋‹ต๋‹ˆ๋‹ค.

0๏ธโƒฃ ๋ฐ์ดํ„ฐ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ (Zod ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ํ™œ์šฉ)

Server Actions๋Š” ์„œ๋ฒ„์—์„œ ์‹คํ–‰๋˜๊ธฐ ๋•Œ๋ฌธ์—, ํด๋ผ์ด์–ธํŠธ์—์„œ ํผ์„ ์ œ์ถœํ•˜๊ธฐ ์ „์— ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ๋ฅผ ํ•˜๋”๋ผ๋„ ์„œ๋ฒ„์—์„œ ํ•œ ๋ฒˆ ๋” ๊ฒ€์ฆํ•˜๋Š” ๊ฒƒ์ด ํ•„์ˆ˜์ ์ด์—์š”.
์—ฌ๊ธฐ์„œ๋Š” ๊ฐ•๋ ฅํ•˜๊ณ  ํƒ€์ž… ์•ˆ์ „ํ•œ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์ธ Zod๋ฅผ ์‚ฌ์šฉํ•ด๋ณผ๊ฒŒ์š”.

๋จผ์ € Zod๋ฅผ ์„ค์น˜ํ•ด์ฃผ์„ธ์š”:

npm install zod # ๋˜๋Š” yarn add zod # ๋˜๋Š” pnpm add zod

app/actions.ts (Zod๋ฅผ ์ด์šฉํ•œ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ์ถ”๊ฐ€):

"use server"; import { revalidatePath } from "next/cache"; +import { z } from "zod"; // Zod ์ž„ํฌํŠธ +// Zod ์Šคํ‚ค๋งˆ ์ •์˜: ๊ฒŒ์‹œ๊ธ€ ๋ฐ์ดํ„ฐ์˜ ์œ ํšจ์„ฑ ๊ทœ์น™์„ ์ •์˜ํ•ด์š”. +const postSchema = z.object({ + title: z.string().min(5, "์ œ๋ชฉ์€ 5์ž ์ด์ƒ์ด์–ด์•ผ ํ•ด์š”.").max(100, "์ œ๋ชฉ์€ 100์ž ์ดํ•˜์—ฌ์•ผ ํ•ด์š”."), + content: z.string().min(10, "๋‚ด์šฉ์€ 10์ž ์ด์ƒ์ด์–ด์•ผ ํ•ด์š”."), +}); -interface FormData { - title: string; - content: string; -} - -export async function createPost(formData: FormData) { +export async function createPost(prevState: any, formData: FormData) { + // FormData๋ฅผ Zod ์Šคํ‚ค๋งˆ์— ๋งž๊ฒŒ ํŒŒ์‹ฑํ•ด์š”. + const parsed = postSchema.safeParse({ + title: formData.get("title"), + content: formData.get("content"), + }); + + // ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ์‹คํŒจ ์‹œ ์—๋Ÿฌ ๋ฐ˜ํ™˜ + if (!parsed.success) { + const fieldErrors = parsed.error.flatten().fieldErrors; + return { + success: false, + message: "์ž…๋ ฅ๊ฐ’์ด ์œ ํšจํ•˜์ง€ ์•Š์•„์š”.", + errors: { + title: fieldErrors.title?.[0] || "", + content: fieldErrors.content?.[0] || "", + }, + }; + } + + const { title, content } = parsed.data; // ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ํ†ต๊ณผ๋œ ๋ฐ์ดํ„ฐ + console.log("๊ฒŒ์‹œ๊ธ€ ์ƒ์„ฑ ์š”์ฒญ:", formData); await new Promise((resolve) => setTimeout(resolve, 1000)); const newPost = { id: Date.now().toString(), title, content }; // parsed.data ์‚ฌ์šฉ console.log("์ƒˆ๋กœ์šด ๊ฒŒ์‹œ๊ธ€ ์ƒ์„ฑ ์™„๋ฃŒ:", newPost); revalidatePath("/posts"); - return { success: true, message: "๊ฒŒ์‹œ๊ธ€์ด ์„ฑ๊ณต์ ์œผ๋กœ ์ƒ์„ฑ๋˜์—ˆ์–ด์š”.", post: newPost }; + return { success: true, message: "๊ฒŒ์‹œ๊ธ€์ด ์„ฑ๊ณต์ ์œผ๋กœ ์ƒ์„ฑ๋˜์—ˆ์–ด์š”.", errors: {}, post: newPost }; }

createPost Server Action์ด ์ด์ œ prevState๋ฅผ ์ฒซ ๋ฒˆ์งธ ์ธ์ž๋กœ ๋ฐ›๋„๋ก ์ˆ˜์ •๋˜์—ˆ๊ณ , FormData๋Š” ๋‘ ๋ฒˆ์งธ ์ธ์ž๋กœ ๋ฐ›์•„์š”. ์ด๋Š” useFormState ํ›…์˜ ์‹œ๊ทธ๋‹ˆ์ฒ˜์— ๋งž์ถฐ์ง„ ๊ฒƒ์ด์—์š”.
Zod๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ formData์˜ ์œ ํšจ์„ฑ์„ ๊ฒ€์‚ฌํ•˜๊ณ , ์œ ํšจํ•˜์ง€ ์•Š์€ ๊ฒฝ์šฐ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€์™€ ํ•จ๊ป˜ ์‹คํŒจ ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋„๋ก ํ–ˆ์–ด์š”.

app/posts/create/page.tsx (์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ํ‘œ์‹œ):

"use client"; import { createPost } from "@/app/actions"; import { useState } from "react"; import { useFormStatus, useFormState } from "react-dom"; interface FormState { success: boolean; message: string; errors: { title: string; content: string }; } // ... SubmitButton ์ปดํฌ๋„ŒํŠธ๋Š” ๋™์ผํ•ด์š” ... export default function CreatePostPage() { const [state, formAction] = useFormState<FormState, FormData>(createPost, { success: false, message: "", errors: { title: "", content: "" }, }); return ( <div className="max-w-md mx-auto p-4 bg-white shadow-md rounded-lg"> <h1 className="text-2xl font-bold mb-4">์ƒˆ ๊ฒŒ์‹œ๊ธ€ ์ž‘์„ฑ</h1> <form action={formAction}> <div className="mb-4"> <label htmlFor="title" className="block text-gray-700 text-sm font-bold mb-2"> ์ œ๋ชฉ </label> <input type="text" id="title" name="title" className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" required /> + {state.errors.title && <p className="text-red-500 text-xs mt-1">{state.errors.title}</p>} </div> <div className="mb-6"> <label htmlFor="content" className="block text-gray-700 text-sm font-bold mb-2"> ๋‚ด์šฉ </label> <textarea id="content" name="content" rows={5} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline" required ></textarea> + {state.errors.content && <p className="text-red-500 text-xs mt-1">{state.errors.content}</p>} </div> <SubmitButton /> </form> {state.message && ( <p className={`mt-4 ${state.success ? "text-green-600" : "text-red-600"}`}> {state.message} </p> )} </div> ); }

์ด์ œ ํผ ํ•„๋“œ ์•„๋ž˜์— ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€๊ฐ€ ํ‘œ์‹œ๋˜๋Š” ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์–ด์š”.

1๏ธโƒฃ ์—๋Ÿฌ ์ฒ˜๋ฆฌ ์ „๋žต (try-catch, useFormState ์—๋Ÿฌ ์ƒํƒœ)

Server Actions ๋‚ด์—์„œ ์˜ˆ์ƒ์น˜ ๋ชปํ•œ ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ–ˆ์„ ๋•Œ๋„ ์‚ฌ์šฉ์ž์—๊ฒŒ ์ ์ ˆํ•œ ํ”ผ๋“œ๋ฐฑ์„ ์ œ๊ณตํ•˜๊ณ , ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์ด ๋ฉˆ์ถ”์ง€ ์•Š๋„๋ก ํ•ด์•ผ ํ•ด์š”.
Server Actions๋Š” ์ผ๋ฐ˜์ ์ธ ๋น„๋™๊ธฐ ํ•จ์ˆ˜์ฒ˜๋Ÿผ try-catch ๋ธ”๋ก์„ ์‚ฌ์šฉํ•˜์—ฌ ์—๋Ÿฌ๋ฅผ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ์–ด์š”.

app/actions.ts (์—๋Ÿฌ ์ฒ˜๋ฆฌ ์ถ”๊ฐ€):

"use server"; import { revalidatePath } from "next/cache"; import { z } from "zod"; const postSchema = z.object({ title: z.string().min(5, "์ œ๋ชฉ์€ 5์ž ์ด์ƒ์ด์–ด์•ผ ํ•ด์š”.").max(100, "์ œ๋ชฉ์€ 100์ž ์ดํ•˜์—ฌ์•ผ ํ•ด์š”."), content: z.string().min(10, "๋‚ด์šฉ์€ 10์ž ์ด์ƒ์ด์–ด์•ผ ํ•ด์š”."), }); export async function createPost(prevState: any, formData: FormData) { const parsed = postSchema.safeParse({ title: formData.get("title"), content: formData.get("content"), }); if (!parsed.success) { const fieldErrors = parsed.error.flatten().fieldErrors; return { success: false, message: "์ž…๋ ฅ๊ฐ’์ด ์œ ํšจํ•˜์ง€ ์•Š์•„์š”.", errors: { title: fieldErrors.title?.[0] || "", content: fieldErrors.content?.[0] || "", }, }; } const { title, content } = parsed.data; + try { console.log("๊ฒŒ์‹œ๊ธ€ ์ƒ์„ฑ ์š”์ฒญ:", formData); await new Promise((resolve) => setTimeout(resolve, 1000)); // ์˜๋„์ ์œผ๋กœ ์—๋Ÿฌ๋ฅผ ๋ฐœ์ƒ์‹œ์ผœ ํ…Œ์ŠคํŠธํ•ด๋ณผ ์ˆ˜ ์žˆ์–ด์š”. // if (title === "์—๋Ÿฌ") throw new Error("์˜๋„๋œ ์„œ๋ฒ„ ์—๋Ÿฌ ๋ฐœ์ƒ!"); const newPost = { id: Date.now().toString(), title, content }; console.log("์ƒˆ๋กœ์šด ๊ฒŒ์‹œ๊ธ€ ์ƒ์„ฑ ์™„๋ฃŒ:", newPost); revalidatePath("/posts"); return { success: true, message: "๊ฒŒ์‹œ๊ธ€์ด ์„ฑ๊ณต์ ์œผ๋กœ ์ƒ์„ฑ๋˜์—ˆ์–ด์š”.", errors: {}, post: newPost }; + } catch (error: any) { + console.error("๊ฒŒ์‹œ๊ธ€ ์ƒ์„ฑ ์ค‘ ์„œ๋ฒ„ ์—๋Ÿฌ ๋ฐœ์ƒ:", error); + return { + success: false, + message: `์„œ๋ฒ„ ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ–ˆ์–ด์š”: ${error.message || "์•Œ ์ˆ˜ ์—†๋Š” ์—๋Ÿฌ"}`, + errors: { title: "", content: "" }, + }; + } }

์ด์ œ Server Action ๋‚ด์—์„œ ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด catch ๋ธ”๋ก์—์„œ ์ด๋ฅผ ์žก์•„์„œ useFormState๋กœ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€๋ฅผ ํด๋ผ์ด์–ธํŠธ์— ์ „๋‹ฌํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋˜์—ˆ์–ด์š”.

๐Ÿš€ ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์„ ์œ„ํ•œ ๋‚™๊ด€์  UI ์—…๋ฐ์ดํŠธ

Server Actions๋Š” ๋น„๋™๊ธฐ์ ์œผ๋กœ ๋™์ž‘ํ•˜๊ธฐ ๋•Œ๋ฌธ์—, ์„œ๋ฒ„์—์„œ ์ž‘์—…์ด ์™„๋ฃŒ๋  ๋•Œ๊นŒ์ง€ ์•ฝ๊ฐ„์˜ ์ง€์—ฐ์ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์–ด์š”. ์ด ์‹œ๊ฐ„ ๋™์•ˆ ์‚ฌ์šฉ์ž์—๊ฒŒ ์•„๋ฌด๋Ÿฐ ํ”ผ๋“œ๋ฐฑ์ด ์—†๋‹ค๋ฉด ๋‹ต๋‹ตํ•จ์„ ๋А๋‚„ ์ˆ˜ ์žˆ์ฃ .
์ด๋•Œ ๋‚™๊ด€์  UI ์—…๋ฐ์ดํŠธ(Optimistic UI Update) ๊ธฐ๋ฒ•์„ ์‚ฌ์šฉํ•˜๋ฉด ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์„ ํฌ๊ฒŒ ํ–ฅ์ƒ์‹œํ‚ฌ ์ˆ˜ ์žˆ์–ด์š”.

0๏ธโƒฃ ๋‚™๊ด€์  ์—…๋ฐ์ดํŠธ๋ž€?

๋‚™๊ด€์  ์—…๋ฐ์ดํŠธ๋Š” ์„œ๋ฒ„ ์š”์ฒญ์ด ์„ฑ๊ณตํ•  ๊ฒƒ์ด๋ผ๊ณ  '๋‚™๊ด€์ ์œผ๋กœ' ๊ฐ€์ •ํ•˜๊ณ , ์„œ๋ฒ„ ์‘๋‹ต์„ ๊ธฐ๋‹ค๋ฆฌ์ง€ ์•Š๊ณ  ์ฆ‰์‹œ UI๋ฅผ ์—…๋ฐ์ดํŠธํ•˜๋Š” ๊ธฐ๋ฒ•์ด์—์š”.
๋งŒ์•ฝ ์„œ๋ฒ„ ์š”์ฒญ์ด ์‹คํŒจํ•˜๋ฉด, ์—…๋ฐ์ดํŠธํ–ˆ๋˜ UI๋ฅผ ์ด์ „ ์ƒํƒœ๋กœ ๋˜๋Œ๋ ค(๋กค๋ฐฑ) ์ฃผ๋ฉด ๋ผ์š”.

์ด๋ฅผ ํ†ตํ•ด ์‚ฌ์šฉ์ž๋Š” ๋งˆ์น˜ ์ฆ‰์‹œ ์ž‘์—…์ด ์™„๋ฃŒ๋œ ๊ฒƒ์ฒ˜๋Ÿผ ๋А๋ผ๊ฒŒ ๋˜์–ด, ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์ด ๋” ๋น ๋ฅด๊ณ  ๋ฐ˜์‘์„ฑ์ด ์ข‹๋‹ค๊ณ  ์ธ์‹ํ•˜๊ฒŒ ๋ผ์š”.

1๏ธโƒฃ useOptimistic ํ›… ํ™œ์šฉ ์˜ˆ์‹œ

React 18๋ถ€ํ„ฐ ์ œ๊ณต๋˜๋Š” useOptimistic ํ›…์€ ์ด๋Ÿฌํ•œ ๋‚™๊ด€์  UI ์—…๋ฐ์ดํŠธ๋ฅผ ์‰ฝ๊ฒŒ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ๋„๋ก ๋„์™€์ค˜์š”.
์ด ํ›…์€ ํ˜„์žฌ ์ƒํƒœ(state)์™€ ๋‚™๊ด€์  ์ƒํƒœ(optimisticState) ๋‘ ๊ฐ€์ง€๋ฅผ ๊ด€๋ฆฌํ•ด์š”. ์„œ๋ฒ„ ์ž‘์—…์ด ์ง„ํ–‰๋˜๋Š” ๋™์•ˆ ๋‚™๊ด€์  ์ƒํƒœ๋ฅผ ๋ณด์—ฌ์ฃผ๋‹ค๊ฐ€, ์„œ๋ฒ„ ์‘๋‹ต์ด ์˜ค๋ฉด ์‹ค์ œ ์ƒํƒœ๋กœ ์ „ํ™˜ํ•˜๋Š” ๋ฐฉ์‹์ด์ฃ .

๊ฐ„๋‹จํ•œ ๋Œ“๊ธ€ ์ถ”๊ฐ€ ๊ธฐ๋Šฅ์„ ์˜ˆ์‹œ๋กœ useOptimistic์„ ์‚ฌ์šฉํ•ด๋ณผ๊ฒŒ์š”.

app/comments/actions.ts (๋Œ“๊ธ€ ์ถ”๊ฐ€ Server Action):

"use server"; import { revalidatePath } from "next/cache"; import { z } from "zod"; const commentSchema = z.object({ postId: z.string(), content: z.string().min(1, "๋Œ“๊ธ€ ๋‚ด์šฉ์€ ๋น„์›Œ๋‘˜ ์ˆ˜ ์—†์–ด์š”.").max(200, "๋Œ“๊ธ€์€ 200์ž ์ดํ•˜์—ฌ์•ผ ํ•ด์š”."), }); interface Comment { id: string; postId: string; content: string; isOptimistic?: boolean; // ๋‚™๊ด€์  ์—…๋ฐ์ดํŠธ ์—ฌ๋ถ€๋ฅผ ํ‘œ์‹œ } // ์ž„์‹œ ๋Œ“๊ธ€ ์ €์žฅ์†Œ (์‹ค์ œ๋กœ๋Š” DB๋ฅผ ์‚ฌ์šฉํ•ด์š”) const comments: Comment[] = []; export async function addComment(prevState: any, formData: FormData) { const parsed = commentSchema.safeParse({ postId: formData.get("postId"), content: formData.get("content"), }); if (!parsed.success) { return { success: false, message: parsed.error.flatten().fieldErrors.content?.[0] || "", comment: null }; } const { postId, content } = parsed.data; try { console.log(`[Server Action] ๋Œ“๊ธ€ ์ถ”๊ฐ€ ์š”์ฒญ: postId=${postId}, content=${content}`); await new Promise((resolve) => setTimeout(resolve, 1500)); // ์˜๋„์ ์ธ ์ง€์—ฐ const newComment: Comment = { id: Date.now().toString(), postId, content }; comments.push(newComment); // ๋Œ“๊ธ€ ์ €์žฅ console.log("[Server Action] ๋Œ“๊ธ€ ์ถ”๊ฐ€ ์™„๋ฃŒ:", newComment); revalidatePath(`/posts/${postId}`); // ํ•ด๋‹น ๊ฒŒ์‹œ๊ธ€์˜ ์บ์‹œ ์žฌ๊ฒ€์ฆ return { success: true, message: "๋Œ“๊ธ€์ด ์„ฑ๊ณต์ ์œผ๋กœ ์ถ”๊ฐ€๋˜์—ˆ์–ด์š”.", comment: newComment }; } catch (error: any) { console.error("[Server Action] ๋Œ“๊ธ€ ์ถ”๊ฐ€ ์ค‘ ์—๋Ÿฌ ๋ฐœ์ƒ:", error); return { success: false, message: `๋Œ“๊ธ€ ์ถ”๊ฐ€ ์‹คํŒจ: ${error.message || "์•Œ ์ˆ˜ ์—†๋Š” ์—๋Ÿฌ"}`, comment: null }; } }

app/posts/[id]/page.tsx (๋Œ“๊ธ€ ๋ชฉ๋ก ๋ฐ ์ถ”๊ฐ€ ํผ - ํด๋ผ์ด์–ธํŠธ ์ปดํฌ๋„ŒํŠธ):

"use client"; import { addComment } from "@/app/comments/actions"; import { useState, useRef, useOptimistic } from "react"; import { useFormStatus, useFormState } from "react-dom"; interface Comment { id: string; postId: string; content: string; isOptimistic?: boolean; } interface FormState { success: boolean; message: string; comment: Comment | null; } // ์ž„์‹œ ๋ฐ์ดํ„ฐ (์‹ค์ œ๋กœ๋Š” DB์—์„œ ๊ฐ€์ ธ์™€์•ผ ํ•ด์š”) const initialComments: Comment[] = [ { id: "1", postId: "post-1", content: "์ฒซ ๋ฒˆ์งธ ๋Œ“๊ธ€์ด์—์š”!" }, { id: "2", postId: "post-1", content: "์ •๋ง ์œ ์ตํ•œ ๊ธ€์ด๋„ค์š”." }, ]; // ๋Œ“๊ธ€ ์ œ์ถœ ๋ฒ„ํŠผ ์ปดํฌ๋„ŒํŠธ function SubmitButton() { const { pending } = useFormStatus(); return ( <button type="submit" className="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline disabled:opacity-50 disabled:cursor-not-allowed" disabled={pending} > {pending ? "์ถ”๊ฐ€ ์ค‘..." : "๋Œ“๊ธ€ ์ถ”๊ฐ€"} </button> ); } export default function PostDetailPage({ params }: { params: { id: string } }) { const postId = params.id; const formRef = useRef<HTMLFormElement>(null); // useOptimistic ํ›…์„ ์‚ฌ์šฉํ•ด์š”. ์ฒซ ๋ฒˆ์งธ ์ธ์ž๋Š” ์‹ค์ œ ์ƒํƒœ, ๋‘ ๋ฒˆ์งธ ์ธ์ž๋Š” ๋‚™๊ด€์  ์—…๋ฐ์ดํŠธ ํ•จ์ˆ˜์˜ˆ์š”. const [optimisticComments, addOptimisticComment] = useOptimistic( initialComments, // ์ดˆ๊ธฐ ๋Œ“๊ธ€ ๋ชฉ๋ก (currentComments: Comment[], newCommentContent: string) => [ ...currentComments, { id: "temp-id-" + Date.now(), postId, content: newCommentContent, isOptimistic: true }, // ๋‚™๊ด€์  ๋Œ“๊ธ€ ์ถ”๊ฐ€ ] ); const [state, formAction] = useFormState<FormState, FormData>(addComment, { success: false, message: "", comment: null, }); const handleFormAction = async (formData: FormData) => { const commentContent = formData.get("content") as string; // ๋‚™๊ด€์  ์—…๋ฐ์ดํŠธ๋ฅผ ๋จผ์ € ์ ์šฉํ•ด์š”. addOptimisticComment(commentContent); // ์‹ค์ œ Server Action์„ ํ˜ธ์ถœํ•ด์š”. const result = await formAction(formData); // Server Action์ด ์‹คํŒจํ•˜๋ฉด (์˜ˆ: ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ์‹คํŒจ) UI๋ฅผ ๋กค๋ฐฑํ•˜๊ฑฐ๋‚˜ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€๋ฅผ ํ‘œ์‹œํ•ด์š”. if (!result.success) { console.error("๋Œ“๊ธ€ ์ถ”๊ฐ€ ์‹คํŒจ:", result.message); // ์—ฌ๊ธฐ์„œ๋Š” ๋กค๋ฐฑ ๋กœ์ง์„ ์ง์ ‘ ๊ตฌํ˜„ํ•˜์ง€ ์•Š๊ณ , ๋ฉ”์‹œ์ง€๋กœ๋งŒ ํ”ผ๋“œ๋ฐฑ์„ ์ค˜์š”. // ์‹ค์ œ ์•ฑ์—์„œ๋Š” `optimisticComments`์—์„œ ํ•ด๋‹น ์ž„์‹œ ํ•ญ๋ชฉ์„ ์ œ๊ฑฐํ•˜๋Š” ๋กœ์ง์ด ํ•„์š”ํ•  ์ˆ˜ ์žˆ์–ด์š”. } else { // ์„ฑ๊ณตํ•˜๋ฉด ํผ ์ดˆ๊ธฐํ™” formRef.current?.reset(); } }; return ( <div className="max-w-xl mx-auto p-6 bg-white shadow-md rounded-lg mt-8"> <h1 className="text-3xl font-bold mb-6">๊ฒŒ์‹œ๊ธ€ ์ƒ์„ธ ํŽ˜์ด์ง€ ({postId})</h1> <div className="mb-8"> <h2 className="text-2xl font-semibold mb-4">๋Œ“๊ธ€ ๋ชฉ๋ก</h2> { optimisticComments.length === 0 ? ( <p className="text-gray-500">์•„์ง ๋Œ“๊ธ€์ด ์—†์–ด์š”.</p> ) : ( <ul> {optimisticComments.map((comment) => ( <li key={comment.id} className={`border-b border-gray-200 py-2 ${comment.isOptimistic ? "opacity-60 text-gray-500 italic" : ""}`} > {comment.content} {comment.isOptimistic && <span className="ml-2 text-sm"> (์ „์†ก ์ค‘...)</span>} </li> ))} </ul> ) } </div> <div className="mb-4"> <h2 className="text-2xl font-semibold mb-4">๋Œ“๊ธ€ ์ž‘์„ฑ</h2> <form ref={formRef} action={handleFormAction} className="flex flex-col gap-4"> <input type="hidden" name="postId" value={postId} /> <textarea name="content" rows={3} placeholder="๋Œ“๊ธ€์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”." className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" required ></textarea> {state.message && <p className="text-red-500 text-sm">{state.message}</p>} <SubmitButton /> </form> </div> </div> ); }

์œ„ ์˜ˆ์‹œ์—์„œ addOptimisticComment ํ•จ์ˆ˜๋ฅผ ํ†ตํ•ด optimisticComments ์ƒํƒœ๋ฅผ ์ฆ‰์‹œ ์—…๋ฐ์ดํŠธํ•˜์—ฌ ์ƒˆ๋กœ์šด ๋Œ“๊ธ€์ด UI์— ๋ฐ”๋กœ ๋ณด์ด๋„๋ก ํ–ˆ์–ด์š”.
์‹ค์ œ ์„œ๋ฒ„ ์‘๋‹ต์ด ์˜ค๊ธฐ ์ „๊นŒ์ง€๋Š” isOptimistic ํ”Œ๋ž˜๊ทธ๋ฅผ ์ด์šฉํ•ด ์•ฝ๊ฐ„ ํ๋ฆฌ๊ฒŒ ํ‘œ์‹œํ•˜์—ฌ '์ „์†ก ์ค‘'์ž„์„ ์‚ฌ์šฉ์ž์—๊ฒŒ ์•Œ๋ฆด ์ˆ˜ ์žˆ์–ด์š”.
๋งŒ์•ฝ Server Action์ด ์‹คํŒจํ•˜๋ฉด state.message๋ฅผ ํ†ตํ•ด ์—๋Ÿฌ๋ฅผ ํ‘œ์‹œํ•˜๊ณ , ํ•„์š”ํ•˜๋‹ค๋ฉด ๋‚™๊ด€์ ์œผ๋กœ ์ถ”๊ฐ€ํ–ˆ๋˜ ํ•ญ๋ชฉ์„ ์ œ๊ฑฐํ•˜๋Š” ๋กค๋ฐฑ ๋กœ์ง์„ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ์–ด์š”.

๐Ÿ“ ์ •๋ฆฌํ•˜๋ฉฐ

์ง€๊ธˆ๊นŒ์ง€ Next.js Server Actions๋ฅผ ์‹ค๋ฌด์— ์ ์šฉํ•  ๋•Œ ํ•„์š”ํ•œ ํ•ต์‹ฌ ๊ธฐ๋ฒ•๋“ค์„ ํ•จ๊ป˜ ์‚ดํŽด๋ณด์•˜์–ด์š”. Server Actions๋Š” ์„œ๋ฒ„์™€ ํด๋ผ์ด์–ธํŠธ ๊ฐ„์˜ ๋ฐ์ดํ„ฐ ํ๋ฆ„์„ ๋‹จ์ˆœํ™”ํ•˜๊ณ  ๊ฐœ๋ฐœ ์ƒ์‚ฐ์„ฑ์„ ๋†’์ด๋Š” ๊ฐ•๋ ฅํ•œ ๋„๊ตฌ์ด์ง€๋งŒ, ๊ทธ๋งŒํผ ์ฃผ์˜ ๊นŠ์€ ์„ค๊ณ„์™€ ๊ตฌํ˜„์ด ํ•„์š”ํ•ด์š”.

0๏ธโƒฃ ํ•ต์‹ฌ ์š”์•ฝ

  • Server Actions: ํด๋ผ์ด์–ธํŠธ ์ปดํฌ๋„ŒํŠธ์—์„œ ์ง์ ‘ ์„œ๋ฒ„ ์ฝ”๋“œ๋ฅผ ํ˜ธ์ถœํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•˜์—ฌ ๊ฐœ๋ฐœ ๋ณต์žก์„ฑ์„ ์ค„์ด๊ณ  ํšจ์œจ์ ์ธ ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ๋ฅผ ๊ฐ€๋Šฅํ•˜๊ฒŒ ํ•ด์š”.
  • useFormStatus: ํผ ์ œ์ถœ ์‹œ ๋กœ๋”ฉ ์ƒํƒœ๋ฅผ ๊ด€๋ฆฌํ•˜์—ฌ ์‚ฌ์šฉ์ž์—๊ฒŒ ์ฆ‰๊ฐ์ ์ธ ํ”ผ๋“œ๋ฐฑ์„ ์ œ๊ณตํ•ด์š”.
  • useFormState: Server Action์˜ ๋ฐ˜ํ™˜๊ฐ’์„ ํด๋ผ์ด์–ธํŠธ ์ปดํฌ๋„ŒํŠธ์—์„œ ์‰ฝ๊ฒŒ ์ ‘๊ทผํ•˜๊ณ , ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ์—๋Ÿฌ๋‚˜ ์„œ๋ฒ„ ์‘๋‹ต ๋ฉ”์‹œ์ง€๋ฅผ ํ‘œ์‹œํ•˜๋Š” ๋ฐ ์œ ์šฉํ•ด์š”.
  • ๋ฐ์ดํ„ฐ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ: Zod์™€ ๊ฐ™์€ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์„œ๋ฒ„์—์„œ ์ž…๋ ฅ๊ฐ’์˜ ์œ ํšจ์„ฑ์„ ์ฒ ์ €ํžˆ ๊ฒ€์ฆํ•˜๋Š” ๊ฒƒ์ด ํ•„์ˆ˜์ ์ด์—์š”.
  • ์—๋Ÿฌ ์ฒ˜๋ฆฌ: try-catch ๋ธ”๋ก์„ ํ†ตํ•ด Server Actions ๋‚ด๋ถ€์˜ ์˜ˆ์™ธ๋ฅผ ์ฒ˜๋ฆฌํ•˜๊ณ , useFormState๋ฅผ ํ†ตํ•ด ์‚ฌ์šฉ์ž์—๊ฒŒ ์˜๋ฏธ ์žˆ๋Š” ์—๋Ÿฌ ๋ฉ”์‹œ์ง€๋ฅผ ์ „๋‹ฌํ•ด์•ผ ํ•ด์š”.
  • ๋‚™๊ด€์  UI ์—…๋ฐ์ดํŠธ: useOptimistic ํ›…์„ ํ™œ์šฉํ•˜์—ฌ ์„œ๋ฒ„ ์‘๋‹ต์„ ๊ธฐ๋‹ค๋ฆฌ์ง€ ์•Š๊ณ  UI๋ฅผ ์ฆ‰์‹œ ์—…๋ฐ์ดํŠธํ•จ์œผ๋กœ์จ ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์„ ํฌ๊ฒŒ ํ–ฅ์ƒ์‹œํ‚ฌ ์ˆ˜ ์žˆ์–ด์š”.

1๏ธโƒฃ ๋‹ค์Œ ์•ก์…˜

Server Actions๋Š” Next.js ๊ฐœ๋ฐœ์˜ ๋ฏธ๋ž˜๋ฅผ ์ด๋Œ ์ค‘์š”ํ•œ ๊ธฐ์ˆ  ์ค‘ ํ•˜๋‚˜์˜ˆ์š”. ์ด ๊ธ€์—์„œ ๋‹ค๋ฃฌ ๋‚ด์šฉ๋“ค์„ ๋ฐ”ํƒ•์œผ๋กœ ์‹ค์ œ ํ”„๋กœ์ ํŠธ์— ์ ์šฉํ•ด๋ณด์‹œ๋ฉด์„œ, Server Actions์˜ ๊ฐ•๋ ฅํ•จ์„ ์ง์ ‘ ๊ฒฝํ—˜ํ•ด๋ณด์‹œ๊ธธ ์ถ”์ฒœํ•ด์š”.
ํŠนํžˆ, ๋ฐ์ดํ„ฐ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ์™€ ์—๋Ÿฌ ์ฒ˜๋ฆฌ๋Š” ์‚ฌ์šฉ์ž์—๊ฒŒ ์‹ ๋ขฐ์„ฑ ์žˆ๋Š” ์„œ๋น„์Šค๋ฅผ ์ œ๊ณตํ•˜๊ธฐ ์œ„ํ•œ ํ•„์ˆ˜ ์š”์†Œ์ด๋‹ˆ, ๋ฐ˜๋“œ์‹œ ์Šต๊ด€ํ™”ํ•ด์ฃผ์‹œ๊ธธ ๋ฐ”๋ผ์š”.

๊ถ๊ธˆํ•œ ์ ์ด๋‚˜ ๋” ๊นŠ์ด ๋…ผ์˜ํ•˜๊ณ  ์‹ถ์€ ๋ถ€๋ถ„์ด ์žˆ๋‹ค๋ฉด ์–ธ์ œ๋“ ์ง€ ๋Œ“๊ธ€๋กœ ๋‚จ๊ฒจ์ฃผ์„ธ์š”! ๋‹ค์Œ์—๋Š” ๋” ์œ ์ตํ•œ ์ฃผ์ œ๋กœ ์ฐพ์•„์˜ฌ๊ฒŒ์š”.

๐Ÿ“ฎ ์ฐธ๊ณ 

์—ฐ๊ด€๋œ ํฌ์ŠคํŠธ