React Hook Form + Zod 통합
폼은 웹 앱에서 가장 다루기 까다로운 영역이다. 입력값 추적, 유효성 검증, 에러 메시지, 제출 처리까지 — 간단한 로그인 폼이라도 직접 구현하면 useState가 줄줄이 늘어나고, 필드가 10개만 넘어가면 코드가 걷잡을 수 없어진다.
React Hook Form(이하 RHF)은 이 문제를 비제어 컴포넌트(uncontrolled component) 기반으로 해결한다. ref를 통해 DOM에 직접 접근하기 때문에 입력할 때마다 리렌더링이 발생하지 않는다. 그런데 유효성 검증은? RHF 자체에도 register의 required, minLength 같은 검증 옵션이 있지만, 복잡한 조건(비밀번호 확인 일치, 조건부 필수 필드, 서버 DTO와 타입 공유 등)에는 한계가 있다.
여기서 Zod가 등장한다. Zod는 TypeScript-first 스키마 검증 라이브러리로, 스키마를 정의하면 런타임 검증과 타입 추론을 동시에 해결한다. RHF + Zod 조합은 "폼 상태 관리는 RHF가, 검증 로직은 Zod가" 라는 깔끔한 역할 분리를 만들어낸다.
왜 이 조합인가
기존 방식의 문제
순수 RHF만 쓰는 경우를 보자.
const { register, handleSubmit, formState: { errors } } = useForm();
<input
{...register("email", {
required: "이메일을 입력하세요",
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: "유효한 이메일을 입력하세요"
}
})}
/>
필드 하나에 검증 규칙이 이렇게 붙는다. 필드가 20개면? register 호출마다 검증 옵션이 덕지덕지 붙어서 JSX가 검증 로직으로 오염된다. 게다가 이 검증 규칙에서는 TypeScript 타입이 자동으로 추론되지 않는다. 폼 데이터의 타입을 별도로 정의해야 하고, 검증 규칙과 타입이 따로 놀 수 있다.
Zod를 붙이면
const schema = z.object({
email: z.string().min(1, "이메일을 입력하세요").email("유효한 이메일을 입력하세요"),
password: z.string().min(8, "8자 이상 입력하세요"),
});
type FormData = z.infer<typeof schema>;
스키마 하나에서 검증 규칙과 타입이 동시에 나온다. z.infer로 추론된 타입은 스키마와 항상 동기화되기 때문에 "타입은 맞는데 런타임에서 틀리는" 상황이 발생하지 않는다.
기본 연결: zodResolver
RHF와 Zod를 연결하는 브릿지가 @hookform/resolvers의 zodResolver다.
npm install react-hook-form zod @hookform/resolvers
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const signupSchema = z.object({
name: z.string().min(1, "이름을 입력하세요"),
email: z.string().email("유효한 이메일을 입력하세요"),
password: z.string().min(8, "8자 이상 입력하세요"),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "비밀번호가 일치하지 않습니다",
path: ["confirmPassword"],
});
type SignupForm = z.infer<typeof signupSchema>;
function SignupPage() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<SignupForm>({
resolver: zodResolver(signupSchema),
});
const onSubmit = (data: SignupForm) => {
// data는 검증 완료된 타입 안전한 객체
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name")} />
{errors.name && <span>{errors.name.message}</span>}
<input {...register("email")} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register("password")} />
{errors.password && <span>{errors.password.message}</span>}
<input type="password" {...register("confirmPassword")} />
{errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}
<button type="submit">가입</button>
</form>
);
}
resolver가 하는 일
zodResolver는 RHF의 resolver 옵션에 들어가서 폼 제출 시점에 전체 폼 데이터를 Zod 스키마로 검증한다. 검증이 통과하면 onSubmit이 호출되고, 실패하면 에러 객체가 RHF의 formState.errors에 자동으로 매핑된다. 개발자가 에러를 수동으로 세팅할 필요가 없다.
내부적으로는 이런 흐름이다:
- 사용자가 submit 버튼 클릭
- RHF가 현재 폼 데이터를 수집
resolver함수(= zodResolver)에 폼 데이터 전달- Zod가
safeParse실행 - 성공이면
{ values: parsedData, errors: {} }반환 - 실패면
{ values: {}, errors: zodErrorsToRHFErrors }반환 - RHF가 결과에 따라
onSubmit호출 또는formState.errors업데이트
mode 옵션과 검증 시점
기본적으로 RHF는 mode: "onSubmit"이라 submit 시에만 검증한다. 실시간 피드백을 원하면 mode를 바꿀 수 있다.
useForm<SignupForm>({
resolver: zodResolver(signupSchema),
mode: "onBlur", // 포커스를 벗어날 때 검증
// mode: "onChange", // 입력할 때마다 검증
// mode: "onTouched", // 첫 blur 이후부터 onChange로 검증
// mode: "all", // onBlur + onChange
});
onBlur가 가장 균형 잡힌 선택이다. onChange는 타이핑할 때마다 검증이 돌아서 UX가 공격적이고, onSubmit은 피드백이 너무 늦다. onTouched는 한 번 건드린 필드만 실시간 검증하는데, 대부분의 상용 폼에서 이 모드가 가장 자연스럽다.
Zod 스키마 테크닉
조건부 검증: refine과 superRefine
refine은 단일 커스텀 검증을 추가한다. 비밀번호 확인처럼 여러 필드를 비교하는 경우에 쓴다.
const schema = z.object({
type: z.enum(["personal", "business"]),
companyName: z.string().optional(),
}).refine(
(data) => data.type !== "business" || (data.companyName && data.companyName.length > 0),
{
message: "법인은 회사명이 필수입니다",
path: ["companyName"],
}
);
superRefine은 여러 에러를 한 번에 추가할 수 있다. 복잡한 교차 필드 검증에 유용하다.
const schema = z.object({
startDate: z.string(),
endDate: z.string(),
budget: z.number(),
}).superRefine((data, ctx) => {
if (new Date(data.endDate) <= new Date(data.startDate)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "종료일은 시작일 이후여야 합니다",
path: ["endDate"],
});
}
if (data.budget < 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "예산은 0 이상이어야 합니다",
path: ["budget"],
});
}
});
transform: 입력값 변환
Zod의 transform은 검증과 동시에 값을 변환한다. 폼에서 받은 문자열을 숫자로 바꾸거나, 공백을 제거하는 등의 처리가 가능하다.
const schema = z.object({
price: z.string()
.min(1, "가격을 입력하세요")
.transform((val) => Number(val))
.pipe(z.number().positive("양수만 가능합니다")),
email: z.string()
.transform((val) => val.trim().toLowerCase())
.pipe(z.string().email("유효한 이메일을 입력하세요")),
});
transform 후에 .pipe()로 변환된 값을 다시 검증할 수 있다. 이러면 z.infer<typeof schema>의 타입에서 price는 number가 된다. 폼 입력은 항상 문자열이지만, 검증 후 타입은 올바른 타입으로 추론된다.
coerce: 자동 타입 변환
transform + pipe 패턴이 번거로우면 z.coerce를 쓸 수 있다.
const schema = z.object({
age: z.coerce.number().min(1).max(150),
joinDate: z.coerce.date(),
isActive: z.coerce.boolean(),
});
z.coerce.number()는 내부적으로 Number(input)을 먼저 실행한 후 검증한다. HTML <input type="number">에서 받은 문자열을 자동으로 숫자로 변환해준다.
discriminatedUnion: 폼 타입에 따른 분기
회원가입에서 "개인/법인" 선택에 따라 다른 필드가 필요한 경우:
const schema = z.discriminatedUnion("accountType", [
z.object({
accountType: z.literal("personal"),
name: z.string().min(1),
ssn: z.string().length(13),
}),
z.object({
accountType: z.literal("business"),
companyName: z.string().min(1),
bizNumber: z.string().length(10),
}),
]);
refine으로 조건 분기하는 것보다 훨씬 명확하다. 타입 추론도 accountType에 따라 자동으로 좁혀진다.
Create/Edit 폼 재사용 패턴
실무에서 가장 많이 부딪히는 문제: 생성 폼과 수정 폼이 거의 같은데, 미묘하게 다르다. 수정 폼에는 초기값이 있고, 일부 필드는 읽기 전용이고, 검증 규칙도 살짝 다를 수 있다. 폼 컴포넌트를 두 개 만들면 중복이 심하고, 하나로 합치면 조건문 지옥이 된다.
방법 1: 스키마 합성
기본 스키마를 정의하고, Create/Edit에서 확장한다.
// 공통 필드
const baseProductSchema = z.object({
name: z.string().min(1, "상품명을 입력하세요"),
price: z.coerce.number().positive("가격은 양수여야 합니다"),
description: z.string().max(500).optional(),
category: z.enum(["electronics", "clothing", "food"]),
});
// 생성: 모든 필드 필수
const createProductSchema = baseProductSchema.extend({
sku: z.string().min(1, "SKU를 입력하세요"),
});
// 수정: 부분 업데이트 가능, id 필수
const updateProductSchema = baseProductSchema.partial().extend({
id: z.string().uuid(),
});
type CreateProduct = z.infer<typeof createProductSchema>;
type UpdateProduct = z.infer<typeof updateProductSchema>;
partial()은 모든 필드를 optional로 만든다. 수정 시에는 변경된 필드만 보내면 되기 때문이다. extend()로 추가 필드를 붙인다.
방법 2: 제네릭 폼 컴포넌트
interface ProductFormProps<T extends FieldValues> {
schema: z.ZodType<T>;
defaultValues?: DefaultValues<T>;
onSubmit: (data: T) => void;
mode: "create" | "edit";
}
function ProductForm<T extends FieldValues>({
schema,
defaultValues,
onSubmit,
mode,
}: ProductFormProps<T>) {
const {
register,
handleSubmit,
formState: { errors, isDirty },
} = useForm<T>({
resolver: zodResolver(schema),
defaultValues,
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name" as Path<T>)} />
<input {...register("price" as Path<T>)} type="number" />
{mode === "create" && (
<input {...register("sku" as Path<T>)} placeholder="SKU" />
)}
<button type="submit" disabled={mode === "edit" && !isDirty}>
{mode === "create" ? "등록" : "수정"}
</button>
</form>
);
}
방법 3: 커스텀 훅으로 추출 (권장)
폼 로직을 훅으로 분리하면 UI 컴포넌트는 순수하게 렌더링만 담당한다.
function useProductForm(mode: "create" | "edit", initialData?: Product) {
const schema = mode === "create" ? createProductSchema : updateProductSchema;
const defaultValues = mode === "edit" && initialData
? {
id: initialData.id,
name: initialData.name,
price: initialData.price,
description: initialData.description,
category: initialData.category,
}
: {
name: "",
price: 0,
description: "",
category: "electronics" as const,
sku: "",
};
const form = useForm({
resolver: zodResolver(schema),
defaultValues,
});
return form;
}
// 사용
function CreateProductPage() {
const form = useProductForm("create");
const createMutation = useCreateProduct();
return (
<ProductFormUI
form={form}
onSubmit={form.handleSubmit((data) => createMutation.mutate(data))}
/>
);
}
function EditProductPage({ product }: { product: Product }) {
const form = useProductForm("edit", product);
const updateMutation = useUpdateProduct();
return (
<ProductFormUI
form={form}
onSubmit={form.handleSubmit((data) => updateMutation.mutate(data))}
/>
);
}
이 패턴의 장점은 명확하다:
- 스키마: 검증 로직이 한 곳에 모여 있다
- 훅: 폼 상태 초기화와 모드별 분기를 담당한다
- UI 컴포넌트:
form객체를 받아서 렌더링만 한다
defaultValues와 reset
비동기 데이터로 초기화
수정 폼에서 서버 데이터를 기다려야 하는 경우:
function EditProductPage({ productId }: { productId: string }) {
const { data: product, isLoading } = useQuery({
queryKey: ["product", productId],
queryFn: () => fetchProduct(productId),
});
const form = useForm<UpdateProduct>({
resolver: zodResolver(updateProductSchema),
defaultValues: {
name: "",
price: 0,
},
});
// 서버 데이터가 도착하면 폼을 리셋
useEffect(() => {
if (product) {
form.reset({
id: product.id,
name: product.name,
price: product.price,
description: product.description,
category: product.category,
});
}
}, [product, form]);
if (isLoading) return <Skeleton />;
return <ProductFormUI form={form} />;
}
reset은 defaultValues를 새 값으로 교체하면서 폼의 dirty 상태도 초기화한다. setValue로 하나씩 세팅하는 것보다 reset이 낫다 — dirty/touched 상태가 깨끗해지기 때문이다.
RHF v7.43+에서는 useForm에 values 옵션을 직접 전달할 수도 있다:
const form = useForm<UpdateProduct>({
resolver: zodResolver(updateProductSchema),
values: product, // product가 바뀌면 자동으로 폼이 리셋됨
});
이러면 useEffect + reset 패턴이 필요 없다.
에러 처리 심화
서버 에러를 폼에 매핑
서버에서 돌아온 검증 에러(예: "이미 존재하는 이메일")를 특정 필드에 표시하려면 setError를 사용한다.
const onSubmit = async (data: SignupForm) => {
try {
await signup(data);
} catch (error) {
if (error.response?.status === 409) {
form.setError("email", {
type: "server",
message: "이미 사용 중인 이메일입니다",
});
} else {
form.setError("root", {
type: "server",
message: "서버 오류가 발생했습니다. 다시 시도해주세요.",
});
}
}
};
// root 에러 표시
{errors.root && <div className="error-banner">{errors.root.message}</div>}
root 에러는 특정 필드에 속하지 않는 전체 폼 에러다. 네트워크 실패나 권한 에러 같은 것을 표시할 때 쓴다.
필드 배열에서의 검증
동적으로 필드를 추가/삭제하는 경우에도 Zod가 자연스럽게 동작한다.
const orderSchema = z.object({
items: z.array(
z.object({
productId: z.string().min(1, "상품을 선택하세요"),
quantity: z.coerce.number().min(1, "1개 이상 입력하세요"),
})
).min(1, "최소 1개 항목이 필요합니다"),
});
function OrderForm() {
const { control, register, formState: { errors } } = useForm({
resolver: zodResolver(orderSchema),
defaultValues: {
items: [{ productId: "", quantity: 1 }],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: "items",
});
return (
<form>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.productId`)} />
{errors.items?.[index]?.productId && (
<span>{errors.items[index].productId.message}</span>
)}
<input {...register(`items.${index}.quantity`)} type="number" />
{errors.items?.[index]?.quantity && (
<span>{errors.items[index].quantity.message}</span>
)}
<button type="button" onClick={() => remove(index)}>삭제</button>
</div>
))}
{/* 배열 자체의 에러 (min 검증 실패 시) */}
{errors.items?.root && <span>{errors.items.root.message}</span>}
<button type="button" onClick={() => append({ productId: "", quantity: 1 })}>
항목 추가
</button>
</form>
);
}
배열 검증에서 주의할 점: 개별 항목의 에러는 errors.items?.[index]?.fieldName으로 접근하고, 배열 전체의 에러(min/max 검증)는 errors.items?.root로 접근한다.
FormProvider와 중첩 컴포넌트
폼이 커지면 컴포넌트를 분리하게 되는데, 이때 register나 errors를 일일이 props로 내려보내면 prop drilling이 발생한다. FormProvider가 이걸 해결한다.
import { FormProvider, useFormContext } from "react-hook-form";
function CheckoutForm() {
const form = useForm({
resolver: zodResolver(checkoutSchema),
});
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<ShippingSection />
<PaymentSection />
<button type="submit">결제</button>
</form>
</FormProvider>
);
}
function ShippingSection() {
const { register, formState: { errors } } = useFormContext();
return (
<fieldset>
<legend>배송 정보</legend>
<input {...register("shipping.address")} />
{errors.shipping?.address && (
<span>{errors.shipping.address.message}</span>
)}
<input {...register("shipping.phone")} />
</fieldset>
);
}
FormProvider는 React Context로 폼 인스턴스를 공유하고, 자식 컴포넌트에서 useFormContext()로 접근한다. 다만 남용하면 안 된다 — Context 값이 바뀔 때 모든 소비자가 리렌더링될 수 있기 때문이다. 폼 성능이 중요하면 Controller나 useWatch로 구독 범위를 좁히는 게 낫다.
Controller vs register
register는 네이티브 HTML 입력 요소(<input>, <select>, <textarea>)에 직접 연결할 때 쓴다. 하지만 커스텀 UI 컴포넌트(DatePicker, Select, Slider 등)는 ref를 직접 받지 않는 경우가 많다. 이때 Controller를 사용한다.
import { Controller } from "react-hook-form";
import { DatePicker } from "@/components/ui/date-picker";
<Controller
name="birthDate"
control={control}
render={({ field, fieldState }) => (
<div>
<DatePicker
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
/>
{fieldState.error && <span>{fieldState.error.message}</span>}
</div>
)}
/>
Controller는 내부적으로 useController 훅을 사용하며, field 객체에 value, onChange, onBlur, name, ref를 담아서 제공한다. 커스텀 컴포넌트의 인터페이스에 맞게 이 값들을 연결하면 된다.
성능 팁
watch 대신 useWatch
watch는 폼 전체를 구독해서 어떤 필드가 바뀌어도 리렌더링이 발생한다. useWatch는 특정 필드만 구독한다.
// ❌ 모든 필드 변경에 리렌더링
const allValues = watch();
// ✅ category 필드만 구독
const category = useWatch({ control, name: "category" });
shouldUnregister
동적 폼에서 조건부로 보이는 필드가 있으면, 숨겨진 필드의 값이 남아있을 수 있다. shouldUnregister: true로 설정하면 DOM에서 사라진 필드의 값이 자동으로 제거된다.
useForm({
resolver: zodResolver(schema),
shouldUnregister: true,
});
기본값은 false다. 탭이나 스텝 폼에서 이전 단계의 값을 유지해야 하면 false가 맞고, 조건부 필드의 값이 남으면 안 되는 경우에만 true로 쓴다.
실전 패턴: 멀티스텝 폼
const step1Schema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
const step2Schema = z.object({
address: z.string().min(1),
phone: z.string().min(10),
});
const fullSchema = step1Schema.merge(step2Schema);
type FullForm = z.infer<typeof fullSchema>;
function MultiStepForm() {
const [step, setStep] = useState(1);
const form = useForm<FullForm>({
resolver: zodResolver(fullSchema),
mode: "onTouched",
});
const validateStep = async () => {
const fieldsToValidate = step === 1
? ["name", "email"] as const
: ["address", "phone"] as const;
const isValid = await form.trigger(fieldsToValidate);
if (isValid) setStep((prev) => prev + 1);
};
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
{step === 1 && <Step1Fields form={form} />}
{step === 2 && <Step2Fields form={form} />}
{step < 2 && <button type="button" onClick={validateStep}>다음</button>}
{step === 2 && <button type="submit">완료</button>}
</form>
);
}
핵심은 trigger다. trigger는 특정 필드만 검증을 실행하고 결과를 반환한다. 전체 스키마는 최종 submit 시에 돌아가고, 각 단계에서는 해당 단계의 필드만 검증한다.
정리
| 개념 | 역할 |
|---|---|
zodResolver | Zod 스키마를 RHF resolver로 변환하는 브릿지 |
z.infer | 스키마에서 TypeScript 타입 자동 추론 |
refine / superRefine | 교차 필드 검증, 커스텀 검증 로직 |
transform / coerce | 입력값을 다른 타입으로 변환 |
partial() / extend() | 스키마 합성으로 Create/Edit 재사용 |
FormProvider | 폼 인스턴스를 Context로 공유 |
Controller | 커스텀 컴포넌트와 RHF 연결 |
trigger | 특정 필드만 검증 실행 |
RHF + Zod 조합의 본질은 관심사 분리다. RHF는 폼 상태(값, dirty, touched, 제출)를 관리하고, Zod는 "이 데이터가 올바른가?"를 판단한다. 이 둘을 zodResolver가 연결해서 타입 안전하면서도 유연한 폼 시스템을 만든다.