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 스키마 불일치

🧩 함정 2: defaultValues 타입과 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개 이상인 폼에서는 onBluronTouched가 현실적인 선택이다.


🕳️ 함정 4: optional 필드의 빈 문자열 함정

🕳️ 함정 4: optional 필드의 빈 문자열 함정 디버깅에 지쳐가는 중
🕳️ 함정 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: 스키마 타입과 폼 타입의 이중 정의

🔄 함정 5: 스키마 타입과 폼 타입의 이중 정의 디버깅에 지쳐가는 중
🔄 함정 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.inputz.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>
  )}
/>

⚠️ 주의: Controllerrender prop에서 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 연동 체크리스트

다시는 🛡 예방 — react-hook-form + Zod 연동 체크리스트 실수를 반복하지 않겠다는 다짐
다시는 🛡 예방 — react-hook-form + Zod 연동 체크리스트 실수를 반복하지 않겠다는 다짐

프로젝트에 새 폼을 추가할 때마다 이 체크리스트를 돌려보면 삽질을 줄일 수 있다.

  1. @hookform/resolvers 설치했는가?
  2. useForm({ resolver: zodResolver(schema) }) 연결했는가?
  3. 숫자 필드에 z.coerce.number() 썼는가?
  4. mode를 UX 요구사항에 맞게 설정했는가?
  5. optional 필드의 빈 문자열 처리를 했는가?
  6. 타입을 z.infer<typeof schema>로 추론하고 있는가?
  7. 제어 컴포넌트는 Controller + fieldState.error 패턴을 쓰고 있는가?

💡 팁: 이 체크리스트를 프로젝트 루트의 docs/form-convention.md 같은 곳에 넣어두면, 팀원이 새 폼을 만들 때 같은 실수를 반복하지 않는다. 직접 도입한 뒤 3개월 동안 폼 관련 버그가 0건이었다.


📊 전체 흐름 — react-hook-form + Zod 유효성 검사 파이프라인

직접 정리한 react-hook-form + Zod 유효성 검사 흐름도
직접 정리한 react-hook-form + Zod 유효성 검사 흐름도

위 도식이 이 글에서 다룬 6가지 함정의 전체 맥락이다.

  1. 폼 제출(handleSubmit) → zodResolver가 Zod 스키마를 실행
  2. z.coerce타입 변환(문자열→숫자 등)을 처리
  3. 유효성 검사 분기: 성공하면 onSubmit 콜백 → API 호출, 실패하면 formState.errors → 에러 메시지 렌더링
  4. 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가지 패턴이다.