React Hook Form에 Zod를 붙여서 폼 검증을 하다 보면, 기본 예제 수준은 금방 따라하는데 실제 서비스에서 자주 부딪히는 케이스가 따로 있더라구요. 비밀번호 확인처럼 두 필드를 같이 봐야 하거나, 라디오 선택에 따라 다른 입력이 활성화되거나, 이메일 중복 체크를 비동기로 돌려야 하는 상황 같은 거요.
이 글에서는 React Hook Form Zod 조합으로 그런 실전 폼 검증 패턴을 정리했어요.
기본 사용법은 공식 문서가 가장 정확하니까, 여기서는 좀 더 깊게 들어가는 케이스만 다룰게요.
왜 React Hook Form + Zod 조합인가
폼 라이브러리 선택지가 꽤 많은데, 이 조합이 2026년 기준 사실상 표준 자리를 잡았거든요. 이유가 몇 가지 있어요.
- React Hook Form은 비제어 컴포넌트(uncontrolled) 기반이라 입력할 때마다 리렌더링이 발생하지 않아요. 큰 폼에서 성능 차이가 확실히 나요.
- Zod는 스키마 하나로 런타임 검증과 TypeScript 타입을 동시에 만들 수 있어요.
z.infer<typeof schema>한 줄이면 폼 타입이 자동 추론되거든요. @hookform/resolvers/zod가 두 라이브러리 사이를 깔끔하게 이어줘요.

결론부터 말하면, 타입 안전성이랑 성능을 같이 잡으려면 이 조합이 제일 안정적이에요.
기본 셋업
설치는 세 개를 같이 깔아야 해요. 둘 중 하나만 깔면 resolver가 동작을 안 하거든요.
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 schema = z.object({
email: z.string().email('이메일 형식이 아니에요'),
password: z.string().min(8, '8자 이상 입력해주세요'),
})
type FormValues = z.infer<typeof schema>
export function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: zodResolver(schema),
mode: 'onBlur',
})
const onSubmit = async (data: FormValues) => {
await login(data)
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit" disabled={isSubmitting}>로그인</button>
</form>
)
}
여기서 챙겨야 할 포인트가 두 개 있어요.
mode: 'onBlur'는 검증 타이밍을 정하는 옵션이에요. 기본값은 onSubmit인데, 사용자가 입력을 끝내고 다른 필드로 넘어갈 때 바로 알려주는 게 UX가 더 좋아서 onBlur를 많이 써요. 한 번 에러가 뜨고 나면 그 다음부터는 reValidateMode(기본 onChange)에 따라 실시간으로 검증되거든요.
z.infer<typeof schema>로 타입을 뽑아 쓰면, 스키마만 수정해도 타입이 자동으로 따라와요. 폼 필드를 추가했는데 타입 추가를 깜빡하는 실수가 사라져요.
패턴 1: 비밀번호 확인 (Cross-field 검증)
가장 자주 나오는 패턴이에요. password랑 passwordConfirm 두 필드를 같이 봐야 하잖아요.
z.object 자체로는 한 필드만 보기 때문에, 두 필드를 같이 검증할 때는 .refine()을 써요.
const schema = z
.object({
password: z.string().min(8, '8자 이상'),
passwordConfirm: z.string(),
})
.refine((data) => data.password === data.passwordConfirm, {
message: '비밀번호가 일치하지 않아요',
path: ['passwordConfirm'],
})
path: ['passwordConfirm']가 핵심이에요. 이걸 안 적으면 에러가 root에 붙어서 어느 필드 옆에 메시지를 보여줄지 알 수 없거든요. 사용자한테는 비밀번호 확인 필드에 빨간 메시지가 떠야 자연스러우니까 path를 꼭 명시해줘요.
조건이 여러 개일 때는 .superRefine()을 쓰는 편이에요. addIssue를 여러 번 호출할 수 있어서 한 번에 여러 에러를 띄울 수 있거든요.
.superRefine((data, ctx) => {
if (data.password !== data.passwordConfirm) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '비밀번호가 일치하지 않아요',
path: ['passwordConfirm'],
})
}
if (data.password.includes(data.email.split('@')[0])) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '이메일과 비슷한 비밀번호는 쓸 수 없어요',
path: ['password'],
})
}
})
패턴 2: 조건부 필드 (Discriminated Union)
라디오나 셀렉트로 분기가 갈리는 폼이 있어요. 예를 들어 결제수단으로 카드를 고르면 카드번호를 받고, 계좌이체를 고르면 은행이랑 계좌번호를 받는 식이요.
z.discriminatedUnion을 쓰면 TypeScript도 분기 타입을 정확히 추론해줘요.
const schema = z.discriminatedUnion('paymentMethod', [
z.object({
paymentMethod: z.literal('card'),
cardNumber: z.string().regex(/^\d{16}$/, '카드번호 16자리'),
cvc: z.string().length(3),
}),
z.object({
paymentMethod: z.literal('bank'),
bank: z.string().min(1, '은행을 선택해주세요'),
accountNumber: z.string().min(10, '계좌번호를 확인해주세요'),
}),
])
폼 안에서는 watch로 현재 선택값을 보면서 조건부 렌더링을 해주면 돼요.
const paymentMethod = watch('paymentMethod')
return (
<form onSubmit={handleSubmit(onSubmit)}>
<select {...register('paymentMethod')}>
<option value="card">카드</option>
<option value="bank">계좌이체</option>
</select>
{paymentMethod === 'card' && (
<>
<input {...register('cardNumber')} />
<input {...register('cvc')} />
</>
)}
{paymentMethod === 'bank' && (
<>
<input {...register('bank')} />
<input {...register('accountNumber')} />
</>
)}
</form>
)
다만 분기를 바꿨을 때 이전 필드값이 폼 상태에 남아 있을 수 있어요. register 옵션에 shouldUnregister: true를 주거나, 분기 변경 시 resetField로 정리해주는 편이 안전해요.
패턴 3: 비동기 검증 (이메일 중복 체크)
회원가입에서 이메일 중복을 서버에 물어봐야 할 때가 있어요. Zod 스키마 자체에 .refine으로 비동기 함수를 넣을 수도 있는데, 매 입력마다 API를 때리면 부담이 커요.
실전에서는 두 단계로 나누는 편이에요. 스키마에서는 형식만 검증하고, 중복 체크는 별도로 처리하는 방식이요.
const schema = z.object({
email: z.string().email('이메일 형식이 아니에요'),
})
export function SignUpForm() {
const { register, handleSubmit, setError, formState } = useForm<FormValues>({
resolver: zodResolver(schema),
})
const onSubmit = async (data: FormValues) => {
const { available } = await checkEmail(data.email)
if (!available) {
setError('email', {
type: 'duplicate',
message: '이미 가입된 이메일이에요',
})
return
}
await signUp(data)
}
}
setError로 서버 응답을 폼 에러에 직접 꽂아주면, 기존 에러 표시 UI를 그대로 재활용할 수 있어요. 입력 중 실시간 체크가 필요하면 watch + debounce로 따로 빼는 게 깔끔해요.
패턴 4: 동적 필드 (useFieldArray)
배송지를 여러 개 추가하거나, 옵션을 N개 입력받는 폼이 있잖아요. 이때는 useFieldArray를 써요.
const schema = z.object({
items: z
.array(
z.object({
name: z.string().min(1, '이름을 입력해주세요'),
quantity: z.number().int().positive(),
})
)
.min(1, '최소 1개는 추가해주세요')
.max(5, '최대 5개까지만 추가할 수 있어요'),
})
export function ItemForm() {
const { control, register, handleSubmit } = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { items: [{ name: '', quantity: 1 }] },
})
const { fields, append, remove } = useFieldArray({
control,
name: 'items',
})
return (
<form>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.name`)} />
<input
type="number"
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
/>
<button type="button" onClick={() => remove(index)}>삭제</button>
</div>
))}
<button type="button" onClick={() => append({ name: '', quantity: 1 })}>
추가
</button>
</form>
)
}
여기서 valueAsNumber: true를 빼먹으면, input 값이 문자열로 들어와서 Zod의 z.number()에 막혀요. 숫자/날짜 필드는 valueAsNumber 또는 valueAsDate를 챙겨야 해요.
그리고 key={field.id}도 잊지 말아야 해요. index를 key로 쓰면 중간 항목을 삭제할 때 React가 잘못된 노드를 재사용하면서 입력값이 섞이는 버그가 생기거든요.
패턴 5: 한국어 에러 메시지 한 번에 처리
필드마다 message를 일일이 한국어로 적는 게 좀 번거롭잖아요. Zod 4 기준으로는 글로벌 에러 맵을 설정해서 기본 메시지를 한국어로 바꿀 수 있어요.
import { z } from 'zod'
z.setErrorMap((issue, ctx) => {
if (issue.code === z.ZodIssueCode.too_small) {
if (issue.type === 'string') return { message: `${issue.minimum}자 이상 입력해주세요` }
}
if (issue.code === z.ZodIssueCode.invalid_type) {
if (issue.expected === 'string') return { message: '값을 입력해주세요' }
}
return { message: ctx.defaultError }
})
이걸 앱 진입점에서 한 번만 설정해두면, 스키마에서 .min(8)처럼 짧게 써도 한국어 메시지가 자동으로 나와요. 필드별로 커스텀이 필요할 때만 개별 메시지를 덮어쓰면 되구요.
자주 놓치는 디테일
마지막으로 실무에서 자주 빠뜨리는 부분 몇 가지만 정리할게요.
defaultValues를 빼먹으면 첫 렌더링에서 input이 uncontrolled로 시작하다가 controlled로 바뀌면서 경고가 떠요. 빈 값이라도 명시적으로 넣어주는 편이 좋아요.- 서버에서 받은 초기값으로 폼을 채울 때는
reset(serverData)를 써요. defaultValues는 mount 시점에만 적용되거든요. formState를 구조 분해할 때는 필요한 값만 꺼내는 게 좋아요. React Hook Form은 Proxy로 추적하기 때문에, 안 꺼낸 값은 리렌더링 트리거에서 제외돼요.- 폼 제출 후 다시 비우려면
reset()을 호출해요. 그냥 두면 이전 값이 남아 있어요. - Zod 스키마는 컴포넌트 바깥에 두는 게 좋아요. 안에 두면 매 렌더링마다 새 스키마 객체가 생겨서 resolver가 매번 재계산돼요.
폼 검증 로직을 스키마 한 곳에 모아두면 유지보수가 훨씬 편해져요. 처음엔 좀 장황해 보여도, 필드가 늘어날수록 이 구조가 빛을 발하더라구요. 백엔드랑 같은 Zod 스키마를 공유하는 패턴까지 가면 클라이언트/서버 검증을 진짜 한 곳에서 관리할 수 있어요.
'개발 > Frontend' 카테고리의 다른 글
| shadcn/ui 커스텀 테마 만들기 CSS 변수 가이드 (0) | 2026.06.09 |
|---|---|
| useTransition vs 디바운스 차이와 실전 활용 (0) | 2026.06.07 |
| pnpm vs yarn 차이와 선택 기준 (0) | 2026.06.06 |
| Next.js 다크 모드 next-themes 적용법 (1) | 2026.06.04 |