# [Next.js + TS 실전] 4. 폼 검증하기 (zod)

[Next.js + TS 실전] 4. 폼 검증하기 (zod)

지금까지 검증은 if (!title) + throw new Error 수준이었습니다.
이번 편은 필드별 메시지를 폼 아래에 보여 줍니다.
목표: zod 스키마 · Server Action 반환값 · useActionState.


이 편에서 할 일

  1. throw만으로는 UX가 약한지 이해하기
  2. zod로 메모 입력 규칙 정하기
  3. createMemo가 성공/실패 결과를 반환하게 바꾸기
  4. 클라이언트 폼에서 useActionState로 에러 표시하기
  5. 수정 폼에도 같은 패턴 적용하기

DB·배포는 건드리지 않습니다.
이미 있는 작성/수정 폼의 품질만 올립니다.


지금 방식의 한계

현재 Action 대략:

if (!title || !content) {
  throw new Error("제목과 내용을 모두 입력하세요");
}

문제:

  • 어떤 필드가 문제인지 모호하다
  • 화면에는 error.tsx로 크게 깨지거나, 메시지가 폼과 떨어져 보인다
  • HTML required만 믿으면 브라우저마다 다르고, 서버 검증과 중복된다

원하는 UX:

제목
[                    ]
→ 제목은 1자 이상 입력하세요

내용
[                    ]
→ 내용은 1자 이상 입력하세요

전체 흐름

폼 제출
  ↓
createMemo (Server Action)
  ↓
zod로 검증
  ├─ 실패 → { ok: false, errors, values } 반환 (페이지 유지)
  └─ 성공 → DB 저장 후 redirect
  ↓
useActionState가 state를 받아 에러 표시

핵심 변화:

  • 실패할 때 throw하지 않고 객체를 반환
  • 폼이 그 객체를 읽어 메시지를 그림

1) zod 설치

memo-app에서:

npm install zod

zod = TypeScript와 잘 맞는 스키마 검증 라이브러리입니다.
“이 값은 이런 모양이어야 한다”를 한곳에서 선언합니다.


2) 스키마 파일

경로: lib/memo-schema.ts

import { z } from "zod";

export const memoFormSchema = z.object({
  title: z
    .string()
    .trim()
    .min(1, "제목은 1자 이상 입력하세요")
    .max(100, "제목은 100자 이하여야 합니다"),
  content: z
    .string()
    .trim()
    .min(1, "내용은 1자 이상 입력하세요")
    .max(5000, "내용은 5000자 이하여야 합니다"),
});

export type MemoFormInput = z.infer<typeof memoFormSchema>;

export type MemoFormState = {
  ok: boolean;
  message?: string;
  errors?: {
    title?: string[];
    content?: string[];
  };
  values?: {
    title: string;
    content: string;
  };
};

포인트:

코드 의미
z.object 폼 필드 묶음
min / max 길이 규칙 + 한국어 메시지
z.infer 스키마에서 TS 타입 자동 추출
MemoFormState Action이 폼에 돌려줄 상태

errors.title을 배열로 둔 이유: zod가 필드당 메시지를 배열로 주기 때문입니다.


3) createMemo를 “결과 반환”형으로

app/memos/actions.ts의 생성 액션을 바꿉니다.

"use server";

import { z } from "zod";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { addMemo, deleteMemo, updateMemo } from "@/lib/memos";
import { memoFormSchema, type MemoFormState } from "@/lib/memo-schema";

export async function createMemo(
  _prevState: MemoFormState,
  formData: FormData
): Promise<MemoFormState> {
  const values = {
    title: String(formData.get("title") ?? ""),
    content: String(formData.get("content") ?? ""),
  };

  const parsed = memoFormSchema.safeParse(values);

  if (!parsed.success) {
    return {
      ok: false,
      // Zod 4: parsed.error.flatten() 대신 z.flattenError
      errors: z.flattenError(parsed.error).fieldErrors,
      values,
    };
  }

  await addMemo(parsed.data);

  revalidatePath("/memos");
  redirect("/memos");
}

바뀐 점:

  1. 인자가 (prevState, formData)useActionState 규약
  2. safeParse — 실패해도 throw하지 않음
  3. 실패 시 errors + 사용자가 친 values를 돌려줌 (입력 유지)
  4. 성공 시만 redirect

deleteMemoAction은 이번 편에서 그대로 둬도 됩니다.


4) 작성 폼을 Client로 (에러 표시)

useActionState는 훅이라 클라이언트 컴포넌트가 필요합니다.
페이지 전체를 클라이언트로 올리기보다, 폼만 분리합니다. (5편 패턴)

경로: components/MemoForm.tsx

"use client";

import { useActionState } from "react";
import type { MemoFormState } from "@/lib/memo-schema";

type MemoFormProps = {
  action: (
    prevState: MemoFormState,
    formData: FormData
  ) => Promise<MemoFormState>;
  initialTitle?: string;
  initialContent?: string;
  submitLabel?: string;
  hiddenFields?: Record<string, string>;
};

const initialState: MemoFormState = { ok: true };

export default function MemoForm({
  action,
  initialTitle = "",
  initialContent = "",
  submitLabel = "저장",
  hiddenFields,
}: MemoFormProps) {
  const [state, formAction, pending] = useActionState(action, initialState);

  const titleValue = state.values?.title ?? initialTitle;
  const contentValue = state.values?.content ?? initialContent;

  return (
    <form action={formAction}>
      {hiddenFields &&
        Object.entries(hiddenFields).map(([name, value]) => (
          <input key={name} type="hidden" name={name} value={value} />
        ))}

      <p>
        <label>
          제목
          <br />
          <input
            type="text"
            name="title"
            defaultValue={titleValue}
            key={`title-${titleValue}`}
          />
        </label>
        {state.errors?.title?.[0] && (
          <span style={{ color: "crimson" }}>{state.errors.title[0]}</span>
        )}
      </p>

      <p>
        <label>
          내용
          <br />
          <textarea
            name="content"
            rows={5}
            defaultValue={contentValue}
            key={`content-${contentValue}`}
          />
        </label>
        {state.errors?.content?.[0] && (
          <span style={{ color: "crimson" }}>{state.errors.content[0]}</span>
        )}
      </p>

      <button type="submit" disabled={pending}>
        {pending ? "저장 중..." : submitLabel}
      </button>
    </form>
  );
}

포인트:

  • useActionState(action, initialState)[state, formAction, pending]
  • defaultValue + key로 검증 실패 후 입력값 유지
  • pending으로 저장 중 버튼 비활성
  • HTML required는 빼도 됩니다 (서버+zod가 진실)

JSX를 <pre> 안에 넣을 때 &lt; &gt;로 이스케이프했습니다.
실제 파일에는 일반 <form>, <input>을 쓰세요.


5) 새 메모 페이지 연결

app/memos/new/page.tsx:

import MemoForm from "@/components/MemoForm";
import { createMemo } from "@/app/memos/actions";

export default function NewMemoPage() {
  return (
    <main>
      <h1>새 메모</h1>
      <MemoForm action={createMemo} />
    </main>
  );
}

확인:

  1. /memos/new
  2. 제목·내용 비우고 저장
  3. 필드 아래 빨간 메시지
  4. 올바르게 입력하면 /memos로 이동

6) 수정도 같은 패턴

updateMemoAction(prevState, formData)로 맞춥니다.

export async function updateMemoAction(
  _prevState: MemoFormState,
  formData: FormData
): Promise<MemoFormState> {
  const id = String(formData.get("id") ?? "").trim();
  const values = {
    title: String(formData.get("title") ?? ""),
    content: String(formData.get("content") ?? ""),
  };

  if (!id) {
    return { ok: false, message: "메모 id가 없습니다", values };
  }

  const parsed = memoFormSchema.safeParse(values);

  if (!parsed.success) {
    return {
      ok: false,
      errors: z.flattenError(parsed.error).fieldErrors,
      values,
    };
  }

  const updated = await updateMemo(id, parsed.data);

  if (!updated) {
    return { ok: false, message: "메모를 찾을 수 없습니다", values };
  }

  revalidatePath("/memos");
  revalidatePath(`/memos/${id}`);
  redirect(`/memos/${id}`);
}

app/memos/[id]/edit/page.tsx:

import { notFound } from "next/navigation";
import MemoForm from "@/components/MemoForm";
import { updateMemoAction } from "@/app/memos/actions";
import { getMemoById } from "@/lib/memos";

type EditMemoPageProps = {
  params: Promise<{ id: string }>;
};

export default async function EditMemoPage({ params }: EditMemoPageProps) {
  const { id } = await params;
  const memo = await getMemoById(id);

  if (!memo) {
    notFound();
  }

  return (
    <main>
      <h1>메모 수정</h1>
      <MemoForm
        action={updateMemoAction}
        initialTitle={memo.title}
        initialContent={memo.content}
        hiddenFields={{ id: memo.id }}
        submitLabel="수정 저장"
      />
    </main>
  );
}

작성·수정이 같은 MemoForm을 씁니다.


useActionState 짧게

const [state, formAction, pending] = useActionState(action, initialState);
역할
state 마지막 Action 반환값 (에러·values)
formAction <form action={...}>에 넘김
pending 제출 진행 중 여부

예전 useFormState와 같은 계열입니다.
React 19 / 최신 Next에서는 useActionState를 쓰면 됩니다.


왜 스키마를 Action에 두나

브라우저 required만으로도 빈 값은 막을 수 있습니다.
그래도 서버에서 다시 검증합니다.

  • DevTools로 required를 지우고 제출할 수 있음
  • API·Action은 폼 말고도 호출될 수 있음
  • 규칙(최대 글자 등)을 한 스키마로 공유 가능

클라이언트에서 zod를 한 번 더 돌리는 “즉시 검증”은 나중에 추가해도 됩니다.
입문 다음 단계에서는 서버 검증 + 메시지 표시만으로 충분합니다.


자주 하는 실수

1. Action 시그니처를 예전 (formData)만 유지
useActionState(prevState, formData) 필요

2. 실패 시 계속 throw
error.tsx로 튕김. 필드 에러는 return { errors }

3. 페이지 전체를 'use client'
→ 폼만 Client, 페이지는 Server 유지

4. safeParse 대신 parse
→ 실패 시 throw. 폼 UX에는 safeParse가 맞음

5. redirect를 try/catch로 감싸서 에러로 착각
→ Next의 redirect는 특수 throw를 씁니다. 성공 경로에서만 호출


이 편에서 가져갈 것

  • 검증 규칙은 zod 스키마로 한곳에 둔다
  • 실패는 throw보다 상태 객체 반환
  • useActionState로 에러를 필드 옆에 보여 준다
  • 폼만 Client Component로 분리한다
  • 작성·수정은 같은 폼 컴포넌트를 재사용한다

시리즈 목차 (실전)

  1. 메모 수정과 삭제
  2. Prisma + SQLite로 DB 붙이기
  3. Vercel에 배포하기
  4. 폼 검증하기 (zod) ← 지금