Web Devlopment/NextJs
Next.js Server Actions: <form action>로 CRUD 만들기 (메모리 DB 예시)
키모형
2026. 1. 17. 19:27
반응형
Next.js 16(App Router) 기준으로, Server Action이 객체를 return하고 그 결과를 useActionState로 UI에 바로 반영하는 “등록/삭제 + 목록 조회” 전체 예시입니다.
(메모리 DB 사용)
목표
- 메모리 DB: { id, title, comment, createdAt }
- 목록 조회: Server Component에서 렌더
- 등록/삭제: <form action={formAction}> + useActionState
- 액션 결과: { ok, message }를 return → 화면에 메시지 표시
- 액션 후 목록 갱신: revalidatePath("/memos")
1) 메모리 DB
실서비스에서는 DB(Postgres/MSSQL 등)로 바뀌지만, 개념 설명엔 메모리 DB가 가장 직관적입니다.
/lib/memo-db.ts
export type Memo = {
id: number;
title: string;
comment: string;
createdAt: string;
};
// 데모용 메모리 DB (서버 재시작 시 초기화됨)
let MEMOS: Memo[] = [
{
id: 1,
title: "첫 메모",
comment: "useActionState 예시",
createdAt: new Date().toISOString(),
},
{
id: 2,
title: "둘째 메모",
comment: "등록/삭제 후 메시지 표시",
createdAt: new Date().toISOString(),
},
];
let NEXT_ID = 3;
export function listMemos(): Memo[] {
return [...MEMOS].sort((a, b) => b.id - a.id);
}
export function addMemo(title: string, comment: string): Memo {
const memo: Memo = {
id: NEXT_ID++,
title,
comment,
createdAt: new Date().toISOString(),
};
MEMOS = [memo, ...MEMOS];
return memo;
}
export function removeMemo(id: number): boolean {
const before = MEMOS.length;
MEMOS = MEMOS.filter((m) => m.id !== id);
return MEMOS.length !== before;
}
2) Server Actions 작성
/app/memos/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { addMemo, removeMemo } from "@/lib/memo-db";
export type ActionState = {
ok: boolean;
message: string;
};
function toStr(v: FormDataEntryValue | null): string {
return typeof v === "string" ? v.trim() : "";
}
export async function createMemoAction(
_prev: ActionState,
formData: FormData
): Promise<ActionState> {
const title = toStr(formData.get("title"));
const comment = toStr(formData.get("comment"));
if (!title) return { ok: false, message: "제목을 입력해 주세요." };
if (title.length > 50)
return { ok: false, message: "제목은 50자 이내로 입력해 주세요." };
addMemo(title, comment);
revalidatePath("/memos");
return { ok: true, message: "등록되었습니다." };
}
export async function deleteMemoAction(
_prev: ActionState,
formData: FormData
): Promise<ActionState> {
const idRaw = toStr(formData.get("id"));
const id = Number(idRaw);
if (!Number.isFinite(id) || id <= 0) {
return { ok: false, message: "잘못된 요청입니다." };
}
const deleted = removeMemo(id);
revalidatePath("/memos");
return deleted
? { ok: true, message: "삭제되었습니다." }
: { ok: false, message: "이미 삭제되었거나 존재하지 않는 항목입니다." };
}
포인트
- useActionState와 연결되는 액션 시그니처는 반드시
(prevState, formData) => newState 형태입니다. - 따라서 첫 번째 파라미터 _prev가 필요합니다.
- 리턴 값은 직렬화 가능한 객체여야 합니다.
3) 공용 Submit 버튼 (pending 처리)
/app/memos/SubmitButton.client.tsx
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton({
children,
variant = "primary",
}: {
children: React.ReactNode;
variant?: "primary" | "danger";
}) {
const { pending } = useFormStatus();
const base =
`inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2
text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-60`;
const styles =
variant === "danger"
? "bg-red-600 text-white hover:bg-red-700"
: "bg-indigo-600 text-white hover:bg-indigo-700";
return (
<button type="submit" disabled={pending} className={`${base} ${styles}`}>
{pending && (
<span className={`inline-block h-4 w-4 animate-spin rounded-full border-2
border-white/60 border-t-white`} />
)}
{pending ? "처리중..." : children}
</button>
);
}
4) 등록 폼 (Tailwind 적용 + 성공 시 reset)
/app/memos/MemoCreateForm.client.tsx
"use client";
import { useActionState, useEffect, useRef } from "react";
import { createMemoAction, type ActionState } from "./actions";
import { SubmitButton } from "./SubmitButton.client";
const initialState: ActionState = { ok: false, message: "" };
export function MemoCreateForm() {
const [state, formAction] = useActionState(createMemoAction, initialState);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (state.ok) formRef.current?.reset();
}, [state.ok]);
return (
<section className="rounded-2xl border bg-white p-5 shadow-sm">
<div className="mb-4">
<h2 className="text-lg font-semibold text-gray-900">메모 등록</h2>
<p className="mt-1 text-sm text-gray-500">
제목과 내용을 입력 후 등록 버튼을 누르세요.
</p>
</div>
<form ref={formRef} action={formAction} className="grid gap-3">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
제목
</label>
<input
name="title"
placeholder="예) 회의 메모"
className="w-full rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 outline-none focus:border-indigo-500 focus:ring-4 focus:ring-indigo-100"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
내용
</label>
<textarea
name="comment"
placeholder="내용을 입력하세요..."
rows={4}
className="w-full resize-none rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 outline-none focus:border-indigo-500 focus:ring-4 focus:ring-indigo-100"
/>
</div>
<div className="mt-1 flex items-center justify-between gap-3">
<div className="min-h-5 text-sm">
{state.message ? (
<span
className={
state.ok
? "text-emerald-600"
: "text-red-600"
}
>
{state.ok ? "✅ " : "❌ "}
{state.message}
</span>
) : (
<span className="text-gray-400"> </span>
)}
</div>
<SubmitButton>등록</SubmitButton>
</div>
</form>
</section>
);
}
5) 삭제 버튼 (Tailwind 적용 + 메시지 표시)
/app/memos/MemoDeleteButton.client.tsx
"use client";
import { useActionState } from "react";
import { deleteMemoAction, type ActionState } from "./actions";
import { SubmitButton } from "./SubmitButton.client";
const initialState: ActionState = { ok: false, message: "" };
export function MemoDeleteButton({ id }: { id: number }) {
const [state, formAction] = useActionState(deleteMemoAction, initialState);
return (
<div className="mt-3 flex items-center justify-between gap-3">
<form action={formAction}>
<input type="hidden" name="id" value={id} />
<SubmitButton variant="danger">삭제</SubmitButton>
</form>
<div className="min-h-5 text-right text-xs">
{state.message ? (
<span className={state.ok ? "text-emerald-600" : "text-red-600"}>
{state.ok ? "✅ " : "❌ "}
{state.message}
</span>
) : (
<span className="text-gray-400"> </span>
)}
</div>
</div>
);
}
6) 페이지 (Server Component: 목록 조회 + Tailwind 레이아웃)
/app/memos/page.tsx
import { listMemos } from "@/lib/memo-db";
import { MemoCreateForm } from "./MemoCreateForm.client";
import { MemoDeleteButton } from "./MemoDeleteButton.client";
export default function MemosPage() {
const memos = listMemos();
return (
<main className="min-h-screen bg-gray-50">
<div className="mx-auto max-w-3xl px-4 py-10">
<header className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">
Memo CRUD (useActionState + Tailwind)
</h1>
<p className="mt-1 text-sm text-gray-500">
API Route 없이 form action만으로 등록/삭제를 처리하는 예시입니다.
</p>
</header>
<MemoCreateForm />
<section className="mt-6 rounded-2xl border bg-white p-5 shadow-sm">
<div className="mb-4 flex items-end justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-gray-900">메모 목록</h2>
<p className="mt-1 text-sm text-gray-500">
총 <span className="font-semibold text-gray-900">{memos.length}</span>건
</p>
</div>
</div>
{memos.length === 0 ? (
<div className="rounded-xl border border-dashed p-6 text-center text-sm text-gray-500">
메모가 없습니다.
</div>
) : (
<ul className="grid gap-4">
{memos.map((m) => (
<li key={m.id} className="rounded-2xl border p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm text-gray-500">#{m.id}</div>
<h3 className="mt-1 text-base font-semibold text-gray-900">
{m.title}
</h3>
</div>
<span className="rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-600">
in-memory
</span>
</div>
<p className="mt-3 whitespace-pre-wrap text-sm text-gray-700">
{m.comment}
</p>
<MemoDeleteButton id={m.id} />
</li>
))}
</ul>
)}
</section>
</div>
</main>
);
}반응형