브라우저의 required 속성과 즉시 오류 표시는 사용자가 실수를 빨리 고치게 도와줘. 하지만 누구나 액션 엔드포인트에 직접 POST할 수 있으니 보안 판단에는 쓸 수 없어. 서버 액션은 들어오는 모든 필드를 다시 검증해야 해.
safeParse로 성공과 실패를 나눠
Zod schema에 폼 값을 넣고 safeParse를 호출해. 실패하면 필드별 오류를 useActionState의 다음 상태로 돌려주고, 성공했을 때만 DB 변경을 실행한 뒤 관련 경로나 태그를 재검증해.
폼 문자열을 도메인 값으로 바꿔
FormData의 숫자 입력도 기본적으로 문자열이야. schema에서 변환과 범위를 함께 정의하고, 빈 값과 누락된 값을 구분해. type assertion으로 “숫자일 것”이라 말하는 건 런타임 값을 바꾸지 않아.
상태 타입을 계약으로 써
액션이 돌려주는 State 타입에 성공 여부, 전체 메시지, 필드별 오류를 명시해. 프론트와 서버가 같은 모양을 공유하면 오류 키를 문자열로 흩뿌리는 일을 막을 수 있어.
Zod schema를 폼 전용 문자열 규칙과 도메인 규칙으로 나눠. 첫 단계는 빈 값·형식·숫자 변환을 처리하고, 두 번째는 재고·권한·중복처럼 DB 상태가 필요한 규칙을 검사해. 오류 키는 실제 input 이름과 맞추고 첫 오류 필드로 focus가 이동하는지 확인해.
schema가 통과했다고 변경이 안전한 건 아니야. 검증 뒤 쓰기 사이에 데이터가 바뀔 수 있으므로 고유 제약과 transaction이 최종 불변식을 지켜야 해. Zod는 요청 모양을 검증하고 DB는 동시성 속의 진실을 지킨다는 역할 차이를 기억해.
누락·잘못된 형식·너무 큰 값·중복 값을 직접 POST해 Zod와 DB 제약이 각각 어느 실패를 맡는지 확인해. 필드 오류가 올바른 label과 연결되고 첫 오류로 focus가 이동하는지, 실패한 요청이 어떤 DB 쓰기와 재검증도 만들지 않는지도 봐. 검증 실패 응답에는 사용자가 입력한 비밀을 되돌려 보내지 말고, 수정 가능한 필드와 이유만 남겨. 서버 로그도 원문 대신 schema 오류 코드와 요청 ID를 기록해야 디버깅과 개인정보 보호를 함께 지킬 수 있어.
동시 요청에서만 드러나는 중복과 소유권 경쟁은 schema만으로 막을 수 없어. 데이터베이스 제약과 transaction이 마지막 불변식을 지키는지도 함께 시험해.
Code
Zod로 검증하는 서버 액션·ts
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
const CreatePost = z.object({
title: z.string().min(1, 'Title is required').max(100),
content: z.string().min(10, 'Content must be at least 10 characters'),
email: z.string().email('Invalid email address'),
});
export type State = {
errors?: { title?: string[]; content?: string[]; email?: string[] };
message?: string;
};
export async function createPost(prev: State, formData: FormData): Promise<State> {
const parsed = CreatePost.safeParse({
title: formData.get('title'),
content: formData.get('content'),
email: formData.get('email'),
});
if (!parsed.success) {
return {
errors: parsed.error.flatten().fieldErrors,
message: 'Validation failed',
};
}
await db.post.create({ data: parsed.data });
revalidatePath('/posts');
return { message: 'Post created' };
}