react-hook-form + Zod 연동에서 겪는 실전 함정 6가지 — 에러가 안 뜨는 이유부터 타입 불일치까지
react-hook-form과 Zod를 연동할 때 자주 발생하는 6가지 트러블슈팅 사례를 정리합니다. zodResolver 미연결, defaultValues 타입 불일치, mode 설정 누락 등 실전에서 놓치기 쉬운 함정과 해결법을 코드와 함께 설명합니다.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- zodResolver를 useForm에 연결하지 않으면 Zod 스키마가 아예 실행되지 않아 에러 메시지가 안 뜸
- defaultValues와 Zod 스키마의 타입이 불일치하면 submit 시 조용히 실패함
- mode: ‘onSubmit’(기본값) 상태에서는 submit 전까지 실시간 유효성 검사가 작동하지 않음
- z.coerce를 안 쓰면 input의 문자열 값이 number 스키마에서 항상 실패
- optional 필드의 빈 문자열은 Zod에서 undefined가 아니라
""로 들어감 — 별도 전처리 필수- 타입 추론은
z.infer<typeof schema>로 통일하면 스키마-폼-컴포넌트 간 불일치를 원천 차단
관리자 포털에 학원 등록 폼을 만들고 있었다. 학원 코드, 원장 이름, 이메일, 연락처 — 필드가 7개쯤 되는 폼이다. Zod 스키마를 정성스럽게 정의했다. 필수 필드, 최소 길이, 이메일 포맷, 연락처 정규화까지 전부 넣었다.
const academyFormSchema = z.object({
code: z.string().min(1, "학원 코드를 입력하세요")
.transform(v => v.toUpperCase())
.refine(v => /^[A-Z]{3}$/.test(v), "정확히 3자리 영문 대문자여야 합니다"),
name: z.string().min(1, "학원명을 입력하세요"),
ownerEmail: z.string().min(1, "이메일을 입력하세요").email("올바른 이메일 형식이 아닙니다"),
phone: phoneSchema,
email: z.string().email("올바른 이메일 형식이 아닙니다").optional().or(z.literal("")),
// ...
});
그런데 빈 폼을 제출해도 에러 메시지가 하나도 안 뜬다.
“Zod가 안 도는 건가?” 싶어서 스키마 단독으로 parse를 돌려봤다.
잘 터진다. 에러가 쏟아진다.
문제는 react-hook-form과 Zod 사이의 연결 고리였다. 이 삽질을 시작으로 폼 하나 만드는 데 6가지 함정을 밟았다.
🔍 증상: Zod 스키마는 멀쩡한데 폼 에러가 안 뜬다
가장 먼저 부딪힌 문제다.
폼을 submit해도 formState.errors가 빈 객체({})다.
// Zod 스키마 — 단독 테스트에서는 정상 작동
const userSchema = z.object({
name: z.string().min(2, '이름은 2자 이상이어야 합니다'),
email: z.string().email('올바른 이메일을 입력하세요'),
age: z.number().min(1, '나이를 입력하세요'),
});
// 폼 설정 — 여기가 문제
const { register, handleSubmit, formState: { errors } } = useForm({
defaultValues: { name: '', email: '', age: 0 },
});
빈 칸으로 submit을 눌러도 에러가 0개다. 콘솔에도 아무것도 안 찍힌다.
🔎 원인: zodResolver 연결 누락
react-hook-form은 기본적으로 HTML 표준 유효성 검사(required, minLength 등)만 사용한다.
Zod 스키마를 아무리 정의해도, resolver로 연결하지 않으면 폼은 그 스키마의 존재를 모른다.
react-hook-form의 공식 문서에서도 resolver는 “외부 유효성 검사 라이브러리를 통합하기 위한 옵션”으로 명시되어 있다.
기본값은 undefined다. 즉, 아무것도 안 넣으면 아무것도 안 돌아간다.
❌ Before — resolver 없이 Zod 스키마만 정의
// ❌ zodResolver를 연결하지 않았다
// → Zod 스키마가 아무리 정교해도 폼 유효성 검사에 반영되지 않음
const { register, handleSubmit, formState: { errors } } = useForm({
defaultValues: { name: '', email: '', age: 0 },
});
✅ After — zodResolver 연결
import { zodResolver } from '@hookform/resolvers/zod';
// ✅ resolver에 zodResolver를 넣어야 Zod가 폼 유효성 검사를 담당
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(userSchema),
defaultValues: { name: '', email: '', age: 0 },
});
📌 핵심:
@hookform/resolvers패키지를 설치하고,zodResolver(schema)를 useForm의 resolver 옵션에 넣어야 한다. 이 한 줄이 빠지면 Zod는 장식이다.
🧩 함정 2: defaultValues 타입과 Zod 스키마 불일치

resolver를 연결하고 나니 에러가 뜨기 시작했다.
그런데 이상한 에러가 나온다. age 필드에 0을 넣었는데 “Expected number, received string”이라고 한다.
age: Expected number, received string
🔎 원인: HTML input은 항상 string을 반환한다
HTML의 <input type="number">는 겉보기엔 숫자를 다루는 것 같지만, value 속성은 항상 문자열을 반환한다.
react-hook-form의 register도 이 값을 그대로 가져온다.
Zod 스키마에서 z.number()로 정의했으면, 문자열 "25"가 들어왔을 때 타입 불일치로 실패한다.
❌ Before — z.number()에 문자열이 들어옴
const schema = z.object({
// ❌ input에서 문자열 "25"가 들어오면 Zod가 타입 에러를 던짐
age: z.number().min(1, '나이를 입력하세요'),
});
✅ After — z.coerce.number()로 자동 변환
const schema = z.object({
// ✅ z.coerce가 문자열 "25"를 숫자 25로 자동 변환 후 유효성 검사
age: z.coerce.number().min(1, '나이를 입력하세요'),
});
⚠️ 주의:
z.coerce.number()는 빈 문자열""을0으로 변환한다. “비어있으면 에러”를 원하면.min(1)을 체이닝하거나,z.preprocess로 빈 문자열을undefined로 바꾸는 전처리가 필요하다.
react-hook-form의 register에도 valueAsNumber 옵션이 있지만, zodResolver를 쓸 때는 Zod 쪽에서 변환하는 게 일관성이 높다. register의 변환과 Zod의 변환이 동시에 돌면 예측하기 어려운 동작이 생길 수 있다.
⏱ 함정 3: mode 설정 누락 — submit 전까지 에러가 안 보인다
zodResolver를 연결하고, 타입도 맞췄다. 그런데 UX 테스트에서 “입력하면서 바로 에러가 보여야 하는 거 아니냐”는 피드백이 왔다.
react-hook-form의 기본 mode는 'onSubmit'이다.
submit 버튼을 누르기 전까지는 어떤 유효성 검사도 실행되지 않는다.
❌ Before — mode 기본값(onSubmit)
// ❌ mode를 지정하지 않으면 'onSubmit'이 기본값
// → submit 전까지 에러 메시지가 전혀 보이지 않음
const form = useForm({
resolver: zodResolver(schema),
defaultValues: { name: '', email: '' },
});
✅ After — mode: ‘onBlur’ 또는 ‘onChange’
// ✅ onBlur: 필드를 벗어날 때 유효성 검사 — 성능과 UX의 균형
const form = useForm({
resolver: zodResolver(schema),
defaultValues: { name: '', email: '' },
mode: 'onBlur',
});
💡 팁: mode별 특성 정리
onSubmit: 기본값. submit 시에만 검사. 가장 가볍지만 UX가 불친절onBlur: 필드를 벗어날 때 검사. 성능과 UX 균형이 가장 좋음onChange: 입력할 때마다 검사. 리렌더링이 많아 성능 주의onTouched: 첫 blur 이후부터 onChange처럼 동작. onBlur의 상위 호환all: blur + change 동시. 가장 공격적 — 복잡한 폼에서는 성능 저하 가능
react-hook-form 공식 문서에서도 onChange 모드는 “significant impact on performance”라고 경고하고 있다.
관리자 포털처럼 필드가 20개 이상인 폼에서는 onBlur나 onTouched가 현실적인 선택이다.
🕳️ 함정 4: optional 필드의 빈 문자열 함정

선택 입력 필드를 만들었다. “비고” 같은 필드다.
Zod에서 z.string().optional()로 정의했다.
사용자가 비고란을 비워두고 submit하면, 값이 undefined일 거라고 생각했다.
실제로는 **빈 문자열 ""**이 들어온다.
const schema = z.object({
name: z.string().min(2),
// ❌ optional()은 undefined만 허용 — 빈 문자열 ""은 string이므로 통과
// → 이후 DB에 빈 문자열이 저장됨
note: z.string().optional(),
});
HTML input의 빈 값은 undefined가 아니라 ""(빈 문자열)이다.
Zod의 .optional()은 undefined | T를 허용하는 거지, 빈 문자열을 undefined로 바꿔주지 않는다.
✅ After — 빈 문자열을 undefined로 전처리
const schema = z.object({
name: z.string().min(2),
// ✅ transform으로 빈 문자열을 undefined로 변환
note: z.string()
.transform(val => val === '' ? undefined : val)
.optional(),
});
또는 Zod v3.22+에서 지원하는 z.string().optional().or(z.literal(''))패턴을 쓸 수도 있다.
하지만 가장 깔끔한 방법은 preprocess로 폼 전체의 빈 문자열을 일괄 처리하는 유틸을 만드는 것이다.
// ✅ 재사용 가능한 빈 문자열 → undefined 변환 헬퍼
const emptyToUndefined = z.literal('').transform(() => undefined);
function optionalString() {
return z.union([
emptyToUndefined,
z.string().min(1),
]).optional();
}
// 사용
const schema = z.object({
name: z.string().min(2),
note: optionalString(), // 빈 문자열 → undefined, 1자 이상 → 통과
});
📌 핵심: HTML input에서 오는 빈 값은
undefined가 아니라""다.optional()만으로는 빈 칸 처리가 안 된다.
🔄 함정 5: 스키마 타입과 폼 타입의 이중 정의

프로젝트 초반에 이런 코드를 썼다.
// ❌ 타입을 두 번 정의 — 스키마 변경 시 동기화를 잊으면 버그
interface UserForm {
name: string;
email: string;
age: number;
}
const userSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
age: z.coerce.number().min(1),
});
const form = useForm<UserForm>({
resolver: zodResolver(userSchema),
});
스키마에 phone 필드를 추가했는데, UserForm 인터페이스에는 안 넣었다.
TypeScript는 에러를 안 던진다. register('phone')을 쓸 때서야 타입 에러가 터진다.
✅ After — z.infer로 단일 진실 공급원(SSOT)
const userSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
age: z.coerce.number().min(1),
});
// ✅ 스키마에서 타입을 추론 — 필드 추가/삭제가 자동 반영
type UserForm = z.infer<typeof userSchema>;
const form = useForm<UserForm>({
resolver: zodResolver(userSchema),
defaultValues: { name: '', email: '', age: 0 },
});
💡 팁:
z.infer<typeof schema>를 쓰면 스키마가 곧 타입이다. 인터페이스를 따로 만들 필요가 없다. 필드를 추가하면 타입도 자동으로 따라온다. 이게 Zod + react-hook-form 조합의 가장 큰 장점이다.
z.input과 z.output의 차이도 알아두면 좋다.
z.coerce.number()를 쓰면 input 타입은 string이고 output 타입은 number다.
폼 입력값의 타입이 필요하면 z.input, API에 보낼 변환된 값의 타입이 필요하면 z.output(= z.infer)을 쓴다.
🧱 함정 6: Controller + Zod에서 에러가 전파 안 되는 문제
shadcn/ui의 Select 컴포넌트처럼 register를 직접 쓸 수 없는 제어 컴포넌트(Controlled Component)가 있다.
이때 Controller를 사용하는데, 에러 메시지가 컴포넌트까지 전달이 안 되는 경우가 있다.
❌ Before — Controller에서 에러를 수동으로 꺼내는 실수
// ❌ errors.role을 직접 참조하면 Controller의 fieldState를 무시하게 됨
<Controller
name="role"
control={control}
render={({ field }) => (
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger>
<SelectValue placeholder="역할 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">관리자</SelectItem>
<SelectItem value="user">사용자</SelectItem>
</SelectContent>
</Select>
)}
/>
{/* errors.role이 undefined일 수 있다 — Controller의 에러 전파 타이밍 문제 */}
{errors.role && <p>{errors.role.message}</p>}
✅ After — fieldState.error 사용
// ✅ Controller의 render에서 fieldState.error를 직접 사용
// → 에러 전파 타이밍이 Controller와 동기화됨
<Controller
name="role"
control={control}
render={({ field, fieldState: { error } }) => (
<div>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className={error ? 'border-red-500' : ''}>
<SelectValue placeholder="역할 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">관리자</SelectItem>
<SelectItem value="user">사용자</SelectItem>
</SelectContent>
</Select>
{error && <p className="text-red-500 text-sm mt-1">{error.message}</p>}
</div>
)}
/>
⚠️ 주의:
Controller의renderprop에서fieldState.error를 쓰면 해당 필드의 에러 상태와 정확히 동기화된다. 상위의formState.errors를 직접 참조하면 타이밍 차이로 에러가 안 보이는 경우가 생긴다.
✅ 검증 — 전체 통합 예제
위 6가지 함정을 모두 반영한 최종 폼 코드다.
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// 빈 문자열 → undefined 헬퍼
const emptyToUndefined = z.literal('').transform(() => undefined);
function optionalString() {
return z.union([emptyToUndefined, z.string().min(1)]).optional();
}
// ✅ 스키마가 단일 진실 공급원(SSOT)
const userSchema = z.object({
name: z.string().min(2, '이름은 2자 이상'),
email: z.string().email('올바른 이메일 형식'),
age: z.coerce.number().min(1, '나이를 입력하세요'),
role: z.enum(['admin', 'user'], {
errorMap: () => ({ message: '역할을 선택하세요' }),
}),
note: optionalString(),
});
type UserForm = z.infer<typeof userSchema>;
export default function UserCreateForm() {
const {
register,
handleSubmit,
control,
formState: { errors, isSubmitting },
} = useForm<UserForm>({
resolver: zodResolver(userSchema),
defaultValues: { name: '', email: '', age: 0, role: undefined, note: '' },
mode: 'onBlur', // ✅ 필드 벗어날 때 검사
});
const onSubmit = async (data: UserForm) => {
// data는 Zod가 변환 완료한 타입-안전 객체
console.log(data);
// → { name: "홍길동", email: "[email protected]", age: 25, role: "admin", note: undefined }
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} placeholder="이름" />
{errors.name && <p>{errors.name.message}</p>}
<input {...register('email')} placeholder="이메일" />
{errors.email && <p>{errors.email.message}</p>}
<input {...register('age')} type="number" placeholder="나이" />
{errors.age && <p>{errors.age.message}</p>}
<Controller
name="role"
control={control}
render={({ field, fieldState: { error } }) => (
<div>
<select {...field}>
<option value="">역할 선택</option>
<option value="admin">관리자</option>
<option value="user">사용자</option>
</select>
{error && <p>{error.message}</p>}
</div>
)}
/>
<input {...register('note')} placeholder="비고 (선택)" />
<button type="submit" disabled={isSubmitting}>등록</button>
</form>
);
}
📌 핵심: 이 코드 하나에 zodResolver 연결, z.coerce, mode 설정, 빈 문자열 처리, z.infer 타입 추론, Controller fieldState 전부 녹아있다.
🛡 예방 — react-hook-form + Zod 연동 체크리스트

프로젝트에 새 폼을 추가할 때마다 이 체크리스트를 돌려보면 삽질을 줄일 수 있다.
@hookform/resolvers설치했는가?useForm({ resolver: zodResolver(schema) })연결했는가?- 숫자 필드에
z.coerce.number()썼는가? mode를 UX 요구사항에 맞게 설정했는가?- optional 필드의 빈 문자열 처리를 했는가?
- 타입을
z.infer<typeof schema>로 추론하고 있는가? - 제어 컴포넌트는
Controller+fieldState.error패턴을 쓰고 있는가?
💡 팁: 이 체크리스트를 프로젝트 루트의
docs/form-convention.md같은 곳에 넣어두면, 팀원이 새 폼을 만들 때 같은 실수를 반복하지 않는다. 직접 도입한 뒤 3개월 동안 폼 관련 버그가 0건이었다.
📊 전체 흐름 — react-hook-form + Zod 유효성 검사 파이프라인

위 도식이 이 글에서 다룬 6가지 함정의 전체 맥락이다.
- 폼 제출(handleSubmit) →
zodResolver가 Zod 스키마를 실행 z.coerce가 타입 변환(문자열→숫자 등)을 처리- 유효성 검사 분기: 성공하면
onSubmit콜백 → API 호출, 실패하면formState.errors→ 에러 메시지 렌더링 mode: 'onBlur'설정으로 검사 트리거 타이밍을 제어
이 파이프라인의 어느 지점에서 문제가 생기느냐에 따라, 위에서 다룬 6가지 함정 중 하나를 밟게 된다.
📋 정리 — 상황별 안티패턴 vs 권장 패턴
| 상황 | 안티패턴 ❌ | 권장 패턴 ✅ |
|---|---|---|
| Zod 연결 | 스키마만 정의하고 resolver 미연결 | zodResolver(schema) 필수 |
| 숫자 입력 | z.number() — input은 string을 반환 | z.coerce.number() |
| 실시간 검증 | mode 기본값(onSubmit) 방치 | mode: 'onBlur' 또는 'onTouched' |
| 선택 필드 | z.string().optional() — 빈 문자열 미처리 | transform으로 빈 문자열 → undefined |
| 타입 정의 | interface + schema 이중 정의 | z.infer<typeof schema> SSOT |
| 제어 컴포넌트 | formState.errors 직접 참조 | Controller render의 fieldState.error |
폼 유효성 검사는 “설정만 하면 끝”이 아니라 HTML의 동작 원리까지 이해해야 제대로 동작한다. Zod와 react-hook-form은 각각 훌륭한 라이브러리지만, 둘 사이의 간극에서 버그가 생긴다. 그 간극을 메우는 게 이 글에서 다룬 6가지 패턴이다.
📚 React 프론트엔드 삽질기 시리즈 (9편)
- 1. Vite 6.x 프록시에서 PATCH만 CORS 에러? 소문자 메서드 함정과 해결법
- 2. React Admin DataProvider 커스터마이징 삽질기
- 3. BE 응답 래퍼 언래핑 패턴 — API 200인데 왜 에러?
- 4. React useEffect 비동기 cleanup이 GPU를 죽이는 과정 — Pixi.js RenderTexture 실종 사건
- 5. shadcn init 실행했더니 프라이머리 컬러가 검정으로 — CSS 변수 덮어쓰기 트러블슈팅
- 6. Framer Motion whileInView 애니메이션이 스크린샷에서 사라지는 이유와 해결법
- 7. react-hook-form + Zod 연동에서 겪는 실전 함정 6가지 — 에러가 안 뜨는 이유부터 타입 불일치까지
- 8. Refine useCustom config.query가 정수를 보장하지 않는 함정 — 타입은 number인데 왜 400이야?
- 9. 패키지 설치 후 Invalid hook call? Vite 캐시 무효화가 답이다