frontend/react

[React] React.js 실무 강좌 34. React Hook Form과 zod로 폼 검증 완성하기

mirabo01 2025. 11. 11. 08:52

React Hook Form을 쓰다 보면 금세 이런 고민이 생깁니다.
"검증 로직이 자꾸 길어지네?", “백엔드에서 유효성 검증도 하는데, 클라이언트는 또 따로 써야 하나?”
저도 예전엔 minLength, pattern 같은 옵션으로만 검증을 처리했는데,
폼이 복잡해질수록 코드가 점점 흩어지고 관리가 어려워졌습니다.

그때 알게 된 게 zod였습니다.
React Hook Form과 찰떡궁합으로 쓰이는 스키마 검증 라이브러리죠.
처음엔 조금 생소했지만, 지금은 거의 모든 폼에 붙여서 씁니다.


예전엔 이런 식으로 썼다

처음에는 React Hook Form의 기본 옵션만으로 폼을 검증했습니다.

<input
  {...register('email', {
    required: '이메일을 입력해주세요.',
    pattern: { value: /^\S+@\S+$/, message: '이메일 형식이 올바르지 않습니다.' },
  })}
/>

이 방식이 나쁜 건 아닙니다.
단순한 폼에서는 딱 이 정도만으로 충분하죠.
하지만 회원가입, 결제, 설정 관리처럼 폼 필드가 열 개 이상 넘어가면
각 필드마다 register 옵션이 너무 길어지고,
정규식 검증이 중복되는 경우도 생깁니다.

특히 백엔드에서 유효성 검증을 따로 하다 보면
“서버는 생년월일을 YYYY-MM-DD로 요구하는데,
프론트는 그냥 문자열만 체크한다” 같은 어긋남도 자주 생깁니다.


zod를 도입하게 된 이유

zod는 말 그대로 스키마 기반 검증 라이브러리입니다.
폼 데이터의 모양과 검증 규칙을 동시에 정의할 수 있어서
React Hook Form과 함께 쓰면 딱 맞습니다.

설치는 간단합니다.

yarn add zod @hookform/resolvers

그리고 이렇게 쓸 수 있습니다.

import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';

const schema = z.object({
  email: z.string().email('이메일 형식이 올바르지 않습니다.'),
  password: z.string().min(6, '비밀번호는 6자 이상이어야 합니다.'),
});

function SignupForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm({
    resolver: zodResolver(schema),
  });

  const onSubmit = (data) => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} placeholder="이메일" />
      {errors.email && <p>{errors.email.message}</p>}

      <input {...register('password')} placeholder="비밀번호" type="password" />
      {errors.password && <p>{errors.password.message}</p>}

      <button type="submit">가입하기</button>
    </form>
  );
}

폼 구조를 딱 한 줄의 스키마로 정의하니까
검증 로직이 따로 흩어질 일이 없습니다.
그리고 zod의 진짜 장점은 타입 안정성입니다.
TypeScript와 함께 쓰면
폼의 데이터 타입이 자동으로 추론돼서 as 캐스팅할 일이 거의 없어집니다.


이걸 몰라서 삽질했던 부분

zod를 처음 쓸 때 실수했던 게,
“에러 메시지가 안 뜨네?”였습니다.
알고 보니 resolver를 등록하지 않은 상태로 쓰고 있었던 거죠.

React Hook Form은 기본적으로 내부 검증기를 가지고 있기 때문에
외부 스키마를 쓰려면 반드시 resolver를 설정해줘야 합니다.

useForm({ resolver: zodResolver(schema) });

이 한 줄이 없으면,
zod의 검증이 전혀 실행되지 않습니다.

또 하나 기억해야 할 건,
zod 스키마에서 refine을 쓰면 커스텀 검증도 쉽게 만들 수 있다는 점입니다.
예를 들어 비밀번호 확인 같은 경우 이렇게 처리합니다.

const schema = z
  .object({
    password: z.string().min(6),
    confirmPassword: z.string().min(6),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: '비밀번호가 일치하지 않습니다.',
    path: ['confirmPassword'],
  });

이렇게 하면 백엔드와 거의 같은 수준의 검증 로직을 프론트에서 구현할 수 있습니다.


실무에서 느낀 효율

실제 사용자 정보 수정 페이지를 만들 때,
폼 필드가 12개쯤 있었는데
기존에는 각 필드마다 register 옵션으로 검증을 따로 넣어뒀습니다.
한 달 뒤 수정 요청이 들어오니까
어디서 검증이 이루어지는지 찾는 데만 몇 시간을 썼어요.

그 뒤로는 zod를 기본으로 두고,
모든 검증 규칙을 한 파일에 모았습니다.

export const userSchema = z.object({
  name: z.string().min(2, '이름은 2자 이상 입력해주세요.'),
  email: z.string().email('유효한 이메일 주소를 입력해주세요.'),
  phone: z.string().regex(/^[0-9]{10,11}$/, '전화번호는 숫자만 입력해주세요.'),
});

이렇게 해두면 나중에 API 명세가 바뀌어도
zod 스키마만 고치면 끝입니다.


쓰면 쓸수록 느껴지는 안정감

React Hook Form만으로도 충분하지만,
zod를 함께 쓰면 확실히 유지보수가 쉬워집니다.

특히 큰 규모의 프로젝트에서는
“폼이 많을수록 스키마 관리가 편한 구조”가
코드의 수명에 직접적인 영향을 줍니다.

실제로 최근 참여한 프로젝트에서는
React Hook Form + zod 조합으로 30개 이상의 폼을 관리했는데,
검증 로직이 전부 한눈에 보이고
팀원 간 코드 충돌도 거의 없었습니다.

이 조합은 이제 선택이 아니라 사실상 표준처럼 느껴집니다.


다음 글에서는 React Hook Form과 React Query를 함께 써서
폼 제출 이후 서버 요청을 자연스럽게 연결하는 방법
을 다뤄보겠습니다.
API와 폼을 묶어 쓰는 구조를 만들면
에러 처리, 로딩 상태, UI 흐름까지 훨씬 깔끔해집니다.