Form Validation UX
3줄 요약
- Form은 input 값을 모으는 코드가 아니라 사용자 의도, 검증, 제출 상태, 서버 에러, 접근성을 연결하는 UX 흐름이다.
- 프론트 validation은 빠른 피드백이고, 백엔드 validation은 최종 권위다.
- number input, controlled/uncontrolled 경고, 중복 submit, field error 매핑은 개인 프로젝트에서 바로 만나는 실전 문제다.
핵심 정리
- React Hook Form은 form 상태와 성능을 효율적으로 다루는 도구다.
- Zod는 입력 값을 런타임에 검증하고 TypeScript 타입과 연결하는 데 유용하다.
- client validation은 required, format, length처럼 즉시 알려줄 수 있는 규칙에 적합하다.
- server validation은 중복, 권한, 재고, 결제, 최종 금액처럼 서버 권위가 필요한 규칙을 담당한다.
- form error는 field error, form-level error, toast error를 구분한다.
백엔드 개발자가 헷갈리는 지점
- 프론트 검증이 있으면 서버 검증이 필요 없다고 생각하기 쉽다.
- 판단 기준은 사용자가 프론트를 우회할 수 있다는 점이다.
- 서버 검증은 반드시 유지한다.
- number input 값을 number라고 믿기 쉽다.
- 판단 기준은 DOM input value가 string이라는 점이다.
- 제출 전 변환과 검증을 명시한다.
- submit 버튼 disable만으로 중복 요청을 완전히 막는다고 생각하기 쉽다.
- 판단 기준은 UX 방어와 백엔드 멱등성을 분리하는 것이다.
개인 프로젝트 기준
- React Hook Form과 Zod를 기본 조합으로 둔다.
- 서버 validation error는
{ fieldErrors: { email: ["이미 사용 중입니다."] } }처럼 매핑 가능한 형태로 받는다. - 제출 중에는 버튼을 disable하고, 성공/실패 후 focus와 메시지를 정리한다.
- 관련 함정은 number input 값이 실제로는 string인 문제를 어떻게 다룰까?와 uncontrolled input 경고는 왜 생길까?를 본다.
확인 질문
- client validation과 server validation의 역할 차이는 무엇인가?
- client validation은 빠른 피드백, server validation은 최종 데이터 무결성을 담당한다.
- 서버 field error를 프론트가 표시하려면 무엇이 필요한가?
- 프론트 field name과 매핑 가능한 일관된 error response schema가 필요하다.
- number input 값은 기본적으로 어떤 타입으로 들어오는가?
- string으로 들어온다.
01. Form Validation 에러 UX 상세
이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- Form 상태는 왜 단순
useState묶음보다 복잡해지는가?- React Hook Form과 Zod를 어떻게 개인 프로젝트 기준으로 사용할 수 있는가?
- 백엔드 validation error를 화면 field error로 어떻게 매핑하는가?
개요
Form은 사용자가 데이터를 입력하는 가장 직접적인 경계다. 여기서는 문자열과 숫자 변환, nullable, submit pending, 중복 요청, 서버 validation error, focus 이동, 접근성이 한 번에 만난다. 그래서 form은 단순히 input state를 모으는 코드가 아니라 UX와 API 계약을 이어주는 계층이다.
원리
프론트 validation은 사용자가 제출하기 전에 빠르게 피드백을 주기 위한 것이다. 백엔드 validation은 최종 데이터 무결성과 보안을 지키기 위한 것이다. 둘은 경쟁 관계가 아니라 같은 규칙을 서로 다른 시점에서 적용하는 관계다.
React Hook Form은 uncontrolled input을 활용해 불필요한 렌더링을 줄이고 form 상태를 관리한다. Zod는 schema를 기준으로 입력값을 런타임에 검증하고 TypeScript 타입을 추론한다. 다만 schema는 브라우저 입력 문자열을 도메인 값으로 바꾸는 경계이므로 빈 문자열, NaN, nullable을 의도적으로 처리해야 한다.
코드로 보는 핵심 예시
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
const signupSchema = z.object({
email: z.string().email("이메일 형식이 아닙니다."),
password: z.string().min(8, "비밀번호는 8자 이상이어야 합니다."),
age: z.preprocess(
(value) => (value === "" ? undefined : value),
z.coerce.number().int().min(14, "14세 이상만 가입할 수 있습니다."),
),
});
type SignupForm = z.infer<typeof signupSchema>;
const serverValidationErrorSchema = z.object({
code: z.literal("VALIDATION_ERROR"),
message: z.string(),
fieldErrors: z.record(z.string(), z.array(z.string())).optional(),
});
const signupFieldNames = {
email: true,
password: true,
age: true,
} satisfies Record<keyof SignupForm, true>;
function isSignupField(field: string): field is keyof SignupForm {
return field in signupFieldNames;
}
function SignupFormView() {
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<SignupForm>({
resolver: zodResolver(signupSchema),
defaultValues: {
email: "",
password: "",
age: 14,
},
});
const onSubmit = handleSubmit(async (values) => {
const response = await fetch("/api/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (response.status === 400) {
const error = serverValidationErrorSchema.parse(await response.json());
const fieldErrors = Object.entries(error.fieldErrors ?? {});
if (fieldErrors.length === 0) {
setError("root.server", {
type: "server",
message: error.message,
});
return;
}
fieldErrors.forEach(([field, messages]) => {
const message = messages[0] ?? "입력값을 확인해 주세요.";
if (!isSignupField(field)) {
setError("root.server", {
type: "server",
message,
});
return;
}
setError(field, {
type: "server",
message,
});
});
return;
}
if (!response.ok) {
throw new Error("가입에 실패했습니다.");
}
});
return (
<form onSubmit={onSubmit} noValidate>
<label>
이메일
<input {...register("email")} aria-invalid={!!errors.email} />
</label>
{errors.email && <p role="alert">{errors.email.message}</p>}
<label>
비밀번호
<input type="password" {...register("password")} aria-invalid={!!errors.password} />
</label>
{errors.password && <p role="alert">{errors.password.message}</p>}
<label>
나이
<input type="number" {...register("age")} aria-invalid={!!errors.age} />
</label>
{errors.age && <p role="alert">{errors.age.message}</p>}
{errors.root?.server && <p role="alert">{errors.root.server.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "가입 중" : "가입"}
</button>
</form>
);
}백엔드와 맞춰야 할 지점
- field name은 프론트 form field와 일치하거나 매핑 규칙이 있어야 한다.
- validation error는 field error와 form-level error를 구분하고, 알 수 없는 field는 무리하게 type assertion으로 밀어 넣지 않는다.
- 중복 submit이 치명적인 API는 idempotency key나 서버 중복 방어를 둔다.
- 날짜, 금액, enum은 validation 규칙과 표시 형식을 함께 문서화한다.
개인 프로젝트 기준
- 작은 form도 default value를 명시해서 controlled/uncontrolled 혼란을 줄인다.
- 숫자 입력은 빈 문자열과
NaN처리를 포함해 변환 지점을 명확히 한다. - submit 중에는 버튼을 disable하고, API 실패 후 사용자가 무엇을 고쳐야 하는지 field 단위로 보여준다.
실전 팁
noValidate를 붙이면 브라우저 기본 validation 대신 앱 validation UX를 일관되게 제어할 수 있다.- 서버 에러를 toast 하나로만 보여주면 사용자는 어떤 field를 고쳐야 하는지 모른다.
- 서버가 보낸 field name을
as keyof Form으로 바로 단언하면 백엔드 응답 변경을 놓치기 쉽다. field name type guard를 두고, 매핑할 수 없는 에러는 form-level error로 보여준다. - 관련 함정은 number input 값이 실제로는 string인 문제를 어떻게 다룰까?, uncontrolled input 경고는 왜 생길까?, falsy와 기본값 처리는 왜 위험할까?를 다시 본다.
위험 신호!
- 서버 field error를 표시하지 못해 모든 실패를 “요청 실패” toast로 처리한다.
- form 초기값이
undefined라 controlled/uncontrolled warning이 난다. - 금액, 수량, 나이 같은 값이 string 상태로 API에 전송된다.
- 중복 submit이 발생해 같은 데이터가 여러 번 생성된다.
확인 질문
확인 질문
- React Hook Form과 Zod를 함께 쓰는 이유는 무엇인가?
- form 상태 관리와 schema 기반 검증, 타입 추론을 연결하기 위해서다.
- 서버 validation error를 표시하려면 어떤 응답 구조가 좋은가?
- field name별 메시지를 담은
fieldErrors구조가 좋다.- submit 중 버튼 disable은 충분한 중복 방어인가?
- UX 방어로는 필요하지만, 중요한 생성 작업은 백엔드 멱등성도 필요하다.