[Next.js + TS 실전] 4. 폼 검증하기 (zod)
지금까지 검증은
if (!title)+throw new Error수준이었습니다.
이번 편은 필드별 메시지를 폼 아래에 보여 줍니다.
목표: zod 스키마 · Server Action 반환값 ·useActionState.
이 편에서 할 일
- 왜
throw만으로는 UX가 약한지 이해하기 - zod로 메모 입력 규칙 정하기
createMemo가 성공/실패 결과를 반환하게 바꾸기- 클라이언트 폼에서
useActionState로 에러 표시하기 - 수정 폼에도 같은 패턴 적용하기
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");
}
바뀐 점:
- 인자가
(prevState, formData)—useActionState규약 safeParse— 실패해도 throw하지 않음- 실패 시
errors+ 사용자가 친values를 돌려줌 (입력 유지) - 성공 시만
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>안에 넣을 때<>로 이스케이프했습니다.
실제 파일에는 일반<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>
);
}
확인:
/memos/new- 제목·내용 비우고 저장
- 필드 아래 빨간 메시지
- 올바르게 입력하면
/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로 분리한다
- 작성·수정은 같은 폼 컴포넌트를 재사용한다
시리즈 목차 (실전)
- 메모 수정과 삭제
- Prisma + SQLite로 DB 붙이기
- Vercel에 배포하기
- 폼 검증하기 (zod) ← 지금
'frontend > NextJs' 카테고리의 다른 글
| # [Next.js + TS 실전] 6. Neon Postgres 연결하기 (1) | 2026.07.31 |
|---|---|
| # [Next.js + TS 실전] 5. 로그인 붙이기 (Clerk) (0) | 2026.07.30 |
| # [Next.js + TS 실전] 3. Vercel에 배포하기 (0) | 2026.07.30 |
| # [Next.js + TS 실전] 2. Prisma + SQLite로 DB 붙이기 (1) | 2026.07.30 |
| # [Next.js + TS 실전] 1. 메모 수정과 삭제 (0) | 2026.07.30 |
