# # Next.js의 폼을 계약으로: Zod, fieldErrors, 클라이언트와 서버의 통합 규칙
폼은 단순한 필드와 버튼의 집합이 아닙니다—UI, 유효성 검사, 데이터 소스 간의 계약입니다. 이 계약이 명확히 정의되지 않으면 혼란이 발생합니다: UI는 데이터를 유효하다고 판단하지만 서버는 오류를 던지고, 메시지가 무작위로 나타나며 폼 동작이 예측 불가능해집니다. 해결책? Zod, safeParse, flatten을 사용해 유효성 검사 스키마, 오류 형식, 상태를 통합하여 클라이언트와 서버에서 동일한 규칙을 보장하세요.
계약 없이 폼이 무너지는 이유
많은 프로젝트는 간단하게 시작합니다: 입력, 상태, 제출. 작은 폼에서는 잘 작동합니다. 하지만 코드베이스가 커지면서 세 가지 치명적인 분기점이 나타납니다:
- 불일치한 유효성 검사 규칙. 클라이언트는 문자열 길이를 확인하지만 서버는 금지된 단어나 공백도 표시합니다. 사용자는 녹색 체크 표시를 보지만 제출이 실패합니다.
- 맞지 않는 오류 형식. 한 번은 문자열, 다음은 객체, 그다음은 배열입니다. UI는 응답 구조를 추측해야 합니다.
- 정의되지 않은 상태. pending, disabled, success, formError—각 컴포넌트가 다르게 처리해 UX가 파편화됩니다.
단일 계약이 없으면 모든 폼이 각자의 로컬 규칙을 따릅니다. 확장되지 않습니다.
폼을 계약으로: 핵심 요소
폼 계약은 다음 질문을 명확히 답해야 합니다:
- 어떤 데이터가 유효한가?
- 어떤 오류는 특정 필드(fieldErrors)에 속하고, 어떤 것은 전체 작업(formError)에 속하는가?
- pending 상태는 어떻게 처리하나?
- 성공 시 무엇을 반환하나?
- 서버가 다른 형식의 응답을 반환할 수 있나?
- 클라이언트와 서버에서 동일한 규칙을 사용하나?
이 답변들은 UI 구현 전에 확정해야 합니다. Workbench에서는 이 접근으로 Zod 스키마와 일관된 응답 형태를 중심으로 폼을 표준화했습니다.
하나의 Zod 스키마, 두 가지 용도
클라이언트와 서버 간 유효성 검사 로직을 중복하지 말고, 모든 곳에서 단일 Zod 스키마를 사용하세요. 노트 예시 스키마:
import { z } from "zod";
export const noteSchema = z.object({
title: z
.string()
.trim()
.min(3, "Enter minimum 3 simvola")
.max(80, "Maximum 80 characters"),
description: z
.string()
.trim()
.max(500, "Maximum 500 characters")
.optional()
.or(z.literal("")),
}).refine(
data => data.title.toLowerCase() !== "test",
{
path: ["title"],
message: "Withlishkom tekhnicheskoe name for zametki",
}
);
export type NoteInput = z.infer<typeof noteSchema>;
이 스키마는 유효한 값, 변환(trim 등), 사용자 정의 규칙(refine)을 정의합니다. 유효성 검사의 단일 진실 원천이 됩니다.
safeParse로 안전한 유효성 검사
폼에서는 parse 대신 safeParse를 사용하는 것이 핵심입니다. parse는 오류 시 예외를 던집니다—잘못된 입력이 예상되는 UX에 나쁩니다. safeParse는 처리 가능한 결과를 반환합니다:
export function validateNote(input: unknown) {
return noteSchema.safeParse(input);
}
결과에는 success 플래그와 필요 시 error 객체가 포함됩니다. UI와 서버 로직이 try/catch 없이 동일한 응답 타입을 처리할 수 있습니다.
flatten으로 오류 통합
Zod는 복잡한 오류 객체를 반환합니다. 폼에는 두 수준이면 충분합니다: 필드 오류와 폼 전체 오류. flatten() 메서드가 완벽합니다:
export function formatZodError(error: import("zod").ZodError) {
const flat = error.flatten();
return {
fieldErrors: flat.fieldErrors,
formError: flat.formErrors[0] ?? null,
};
}
예시 차이:
- fieldErrors: "Title 필드가 비어 있을 수 없습니다", "Description이 500자를 초과합니다".
- formError: "이 슬러그의 노트가 이미 존재합니다", "서버가 일시적으로 사용할 수 없습니다".
이 분리는 UX를 예측 가능하게 합니다: 사용자는 입력을 어디서 고칠지, 언제 재시도하거나 기다릴지 정확히 알 수 있습니다.
클라이언트와 서버의 통합 계약
클라이언트에서 Zod 스키마는 조기 유효성 검사와 UI 상태 관리를 가능하게 합니다:
const clientResult = useMemo(() => {
return noteSchema.pick({ title: true }).safeParse({ title });
}, [title]);
const titleError = clientResult.success
? null
: clientResult.error.flatten().fieldErrors.title?.[0] ?? null;
const canSubmit = clientResult.success;
서버에서 동일한 스키마는 최종 방어선 역할을 합니다:
export async function createNoteAction(raw: unknown) {
const parsed = noteSchema.safeParse(raw);
if (!parsed.success) {
const flat = parsed.error.flatten();
return {
ok: false,
fieldErrors: flat.fieldErrors,
formError: flat.formErrors[0] ?? null,
};
}
try {
// save to database
return {
ok: true,
fieldErrors: {},
formError: null,
};
} catch {
return {
ok: false,
fieldErrors: {},
formError: "Not succeeded sokhranit zametku",
};
}
}
하나의 규칙, 다른 역할: 클라이언트에서 UX, 서버에서 보안.
표준 폼 상태 형태
파편화를 피하기 위해 모든 폼에 단일 타입을 정의하세요:
export type FormState = {
ok: boolean;
fieldErrors: Record<string, string[] | undefined>;
formError: string | null;
};
이 형태는 모든 상태를 커버합니다: 성공, 필드 오류, 일반 오류. 프로젝트의 모든 폼이 동일한 구조를 반환해 UI 처리를 단순화합니다.
React Hook Form을 언제 사용하나
React Hook Form (RHF)은 강력하지만 만능은 아닙니다. 다음 경우에 적합합니다:
- 많은 필드와 종속 유효성 검사
- 동적 리스트나 중첩 구조
- blur 유효성 검사, touched/dirty 상태
- 포커스 관리 커스텀 컨트롤
- 렌더 최적화 필요
1~2개 필드와 기본 유효성 검사의 간단한 폼에는 RHF가 불필요한 복잡성을 더합니다. useMemo + Zod 파생 상태로 해결된다면—간단하게 유지하세요.
사고 전환: UI에서 계약으로
핵심 변화? 폼을 UI 컴포넌트로 보지 마세요. 폼은 다음을 정의하는 계약입니다:
- 유효 데이터 스키마
- 오류 응답 형식
- 상태(pending, success, error)에 대한 동작
- 클라이언트-서버 책임 경계
계약을 앞세우면 UI 구현은 기계적 작업이 됩니다. 가장 중요한 점은 프로젝트의 모든 폼이 동일한 합의를 따르며 예측 가능하게 동작한다는 것입니다.
핵심 요약
- 클라이언트와 서버에 하나의 Zod 스키마 사용—규칙 분기 제거.
- 항상
safeParse사용—예외 대신 처리 가능한 결과 반환. fieldErrors와formError분리—UX 개선 및 표시 로직 단순화.- 표준
FormState형태 정의—모든 폼이 동일한 구조 반환. - React Hook Form은 진짜 필요할 때만 추가—트렌드가 아니라 복잡한 시나리오에서.
— Editorial Team
아직 댓글이 없습니다.