키모스토리

Next.js Server Actions: <form action>로 CRUD 만들기 (메모리 DB 예시) 본문

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>
  );
}
반응형