React Hook Form: #2 유효성 검사 (Schema Validation)

ε( ε ˙³˙)з ○º·2025년 6월 29일
0
post-thumbnail

Intro

초기에는 필수 입력이나 간단한 길이 제한처럼 기본 validation만으로도 충분하다고 생각했지만, 폼이 복잡해질수록 yup, zod 같은 schema validation이 필요해지고, 비동기 서버 검증까지 고려해야 하는 상황이 찾아온다.

validation은 퍼포먼스와 UX에 직접적인 영향을 주기 때문에 어떤 방식으로 설계하느냐에 따라 폼의 반응 속도, 리렌더링, 그리고 최종 사용자 경험이 크게 달라질 수 있다.


React Hook Form validation의 기본 동작 원리

React Hook Form의 validation은 크게 두 가지 흐름으로 나눌 수 있다.

  • 1️⃣ HTML Constraint 기반 validation
  • 2️⃣ Schema 기반 validation (resolver 사용)

두 방식 모두 React Hook Form의 handleSubmit 과정에서 검증이 수행되지만 내부 동작 흐름은 다르다.

HTML Constraint 기반 validation (register 방식)

기본적으로 register를 사용하면 React Hook Form이 HTML input의 constraint 속성(required, minLength, maxLength 등)을 기반으로 유효성 검사를 수행한다.

<input
  {...register('username', {
    required: '필수 입력 항목입니다.',
    minLength: { value: 3, message: '3자 이상 입력해주세요.' },
  })}
/>
  • 이 방식은 퍼포먼스에 최적화되어있으며 React Hook Form이 input 요소를 직접 추적하고 validation 에러를 formState로 관리한다.
const { register, handleSubmit, formState: { errors } } = useForm();

<input {...register('email', { required: '이메일을 입력해주세요.' })} />
{errors.email && <span>{errors.email.message}</span>}
  • 서버 통신 없이 브라우저에서 바로 validation이 동작하고, 리렌더링도 최소화된다.

React Hook Form이 기본 제공하는 validation 옵션

  • required 필수 입력 여부
  • min 최소 값 제한 (숫자 input)
  • max 최대 값 제한 (숫자 input)
  • minLength 최소 글자 수 제한
  • maxLength 최대 글자 수 제한
  • pattern 정규식 패턴 검증
  • validate 커스텀 검증 함수
<input
  {...register('username', {
    required: '필수 입력 항목입니다.',
    minLength: { value: 3, message: '3자 이상 입력해주세요.' },
    maxLength: { value: 10, message: '10자 이하로 입력해주세요.' },
    pattern: { value: /^[A-Za-z0-9]+$/, message: '영문, 숫자만 입력 가능합니다.' },
  })}
/>

필수 입력, 최소, 최대 길이, 숫자 범위 등 간단한 검증은 React Hook Form의 기본 validation만으로 충분하다. yup, zod를 사용하는 schema validation에 비해 훨씬 가볍고 퍼포먼스도 빠르기 때문에 폼이 단순하고 필드가 적은 경우 기본 validation으로도 충분히 커버할 수 있다.


Schema 기반 validation (resolver 방식)

폼이 복잡해지고, 필드가 많아지면 HTML constraint 기반만으로 validation을 관리하기 어려워진다. 이때 사용하는 것이 resolver를 통한 yup, zod 등의 schema validation 연동이다.

const schema = yup.object({
  username: yup.string().required('필수 입력 항목입니다.'),
});

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

✔️ resolverReact Hook Form의 validation을 schema validator에게 위임하는 미들웨어 역할을 하고 validation 결과를 resolver가 직접 formState로 반환한다.

🔍 React Hook Form : SchemaValidation
We also support schema-based form validation with Yup, Zod , Superstruct & Joi, where you can pass your schema to useForm as an optional config. It will validate your input data against the schema and return with either errors or a valid result.

  • React Hook Form은 Yup, Zod, Superstruct, Joi와 같은 schema 기반 form validation을 지원한다. Schema는 useForm의 config로 전달할 수 있으며, 폼 데이터는 schema를 기준으로 검증되고, 검증 결과는 에러 또는 유효한 데이터로 반환된다.

Resolver로 schema validation 적용하기

register만으로 관리하면 input마다 개별 validation 규칙을 각각 작성해야 하지만 resolver를 사용하면 schema 단위로 검증 로직을 통합 관리할 수 있다.

Resolver 기본 사용법 (yup 기준)

import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';

const schema = yup.object({
  username: yup.string().required('이름을 입력해주세요.'),
  age: yup.number().min(10, '10세 이상 입력해주세요.'),
});

const { register, handleSubmit, formState: { errors } } = useForm({
  resolver: yupResolver(schema),
});
  • resolveryupResolver를 통해 schema를 전달받아, 폼 데이터가 변경될 때마다 schema 검증을 수행한다. 검증 결과는 React Hook Form의 formState.errors에 자동으로 반영된다.

🤔 errors는 어떻게 formState에 반영될까?
resolverhandleSubmit 호출 시 폼 전체의 값을 schema를 기준으로 검증한 결과를 반환하는 함수다. resolver가 리턴하는 결과 객체는 다음과 같은 구조로 되어 있다.

{
  values: { ... },  // 유효한 폼 데이터
  errors: { ... }   // 유효하지 않은 필드의 에러 정보
}
  • React Hook Form은 이 errors 객체를 내부에 formState.errors로 저장하고 컴포넌트가 formState.errors를 구독하고 있다면 자동으로 해당 에러를 리렌더링하도록 트리거한다.

resolver 동작 흐름

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

<input {...register('username')} />
{errors.username && <span>{errors.username.message}</span>}
  1. 사용자가 input에 값을 입력하고 React Hook Form이 handleSubmit 호출 시 input 데이터를 수집한다.
  2. resolver가 schema를 기준으로 데이터를 검증한다.
  3. resolver{ values, errors } 객체를 반환한다.
  4. 검증 결과가 유효하면 submit 함수 실행 유효하지 않으면 errors를 formState에 저장한다.
  5. 구독 중인 컴포넌트가 에러 메시지를 자동으로 UI 업데이트한다.

👀 resolver 사용 시 주의할 점

  • schema validator를 사용할 경우 입력값이 변경될 때마다 schema 전체를 검증하므로 매우 복잡한 폼에서는 퍼포먼스 병목이 발생할 수 있다.
  • validation mode를 onSubmit으로 설정하면 입력값마다 검증하지 않고 submit 시에만 검증할 수 있어 퍼포먼스 이슈를 줄일 수 있다.
const { register, handleSubmit, formState: { errors } } = useForm({
  resolver: yupResolver(schema),
  mode: 'onSubmit', // 퍼포먼스 최적화
});

🔍 mode 옵션이란?
mode는 React Hook Form이 validation을 언제 실행할지를 결정하는 옵션이다.

  • onChange 입력할 때마다 validation 실행 (기본값)
  • onBlur input이 포커스를 잃었을 때 validation 실행
  • onSubmit submit 시에만 validation 실행
  • onTouched input이 처음 터치된 이후 validation 실행
  • all onChange + onBlur + onSubmit 모두 실행

커스텀 validation 적용하기

React Hook Form은 schema validator와 별개로 각 input에 개별적으로 커스텀 validation 함수를 등록할 수 있는 기능도 지원한다.
이 방식은 특히 yup이나 zod를 도입하기 애매한 간단한 유효성 검증이나 input마다 서로 다른 복잡한 검증 로직이 필요한 경우에 유용하다.

커스텀 validation 기본 사용법

validate 속성을 사용하면 input 단위로 커스텀 검증 함수를 직접 작성할 수 있다.

<input
  type="checkbox"
  {...register('terms', {
    validate: (value) => value || '필수 약관에 동의해주세요.',
  })}
/>
  • validate는 함수 형태로 작성하며 검증이 성공하면 true 또는 undefined를 반환, 검증이 실패하면 string 타입의 에러 메시지를 반환해야 한다.
  • yup, zod를 사용하기엔 과한 간단한 체크박스 유효성 검증은 커스텀 validation이 훨씬 간결하고 자주 사용된다.

📝 예제: 비밀번호 확인 일치 검증

const { register, handleSubmit, watch, formState: { errors } } = useForm();

const password = watch('password', '');

<input
  {...register('confirmPassword', {
    validate: (value) => value === password || '비밀번호가 일치하지 않습니다.',
  })}
/>

{errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}
  • 다른 input의 값을 watch로 불러와서 실시간으로 비교 검증이 가능하다.

📝 예제: 시작일과 종료일 날짜 유효성 비교

const startDate = watch('startDate', '');
const endDate = watch('endDate', '');

<input
  type="date"
  {...register('endDate', {
    validate: (value) => new Date(value) >= new Date(startDate) || '종료일은 시작일보다 이후여야 합니다.',
  })}
/>
  • 날짜 input 두 개 비교 (예약, 이벤트 기간 등)도 가능하다.

📝 예제: 선택지 최소 개수 체크 (체크박스 그룹)

<input type="checkbox" value="A" {...register('options')} />
<input type="checkbox" value="B" {...register('options')} />
<input type="checkbox" value="C" {...register('options')} />

{...register('options', {
  validate: (value) => value.length >= 2 || '최소 2개 이상 선택해주세요.',
})}
  • 동의 항목, 필수 최소 선택 개수 검증 등 체크박스 배열 검증도 yup보다 커스텀 validate로 간단하게 처리 가능하다.

커스텀 validation의 장점
커스텀 validation의 경우 schema validator를 사용할 필요가 없고, 간단한 유효성 검증에 적합하다. 필드 간 비교, 커스텀 API 연동 등 복잡한 검증 로직 작성이 유연하다.

커스텀 validation의 단점
복잡한 폼에서는 validation 로직이 input마다 분산되어 관리가 어려울 수 있고 재사용성/유지보수성이 떨어질 수 있다. schema 기반 validation처럼 구조화된 관리가 어렵다.

실무에서는 register 기반 커스텀 validation과 resolver 기반 schema validation을 혼합해서 사용하는 경우가 많다.


비동기 validation 실무 적용

React Hook Form은 기본적으로 동기 검증에 최적화되어 있지만 비동기 validation도 커스텀 validate 함수 안에서 Promise를 반환하는 방식으로 지원한다.

실무에서는 이메일 중복 검사, 아이디 중복 검사, 인증번호 검증 등 서버와 통신해야 하는 validation이 필수다.

비동기 validation 기본 사용법

React Hook Form의 validate 함수 안에서 Promise를 반환하면 비동기 검증이 지원된다.

<input
  {...register('id', {
    validate: async (value) => {
      const isDuplicated = await checkIdDuplicate(value);
      return isDuplicated ? '이미 사용 중인 아이디입니다.' : true;
    },
  })}
/>
  • 검증 성공 시 true 반환하고 검증 실패 시 → 에러 메시지(string) 반환한다.

예제: 인증번호 유효성 검증

const verifyCode = async (code: string) => {
  const response = await fetch(`/api/verify-code?code=${code}`);
  const data = await response.json();
  return data.isValid;
};

<input
  {...register('verificationCode', {
    validate: async (value) => {
      const isValid = await verifyCode(value);
      return isValid ? true : '유효하지 않은 인증번호입니다.';
    },
  })}
/>
  • SMS 인증, 이메일 인증, 관리자 승인 등에 자주 사용되며 보통 인증 번호 유효 시간도 함께 표기된다.

🤔 비동기 validation UX 팁
1. 검증 중 로딩 표시하기
비동기 validation은 즉각적인 피드백이 어려우므로 로딩 상태를 추가해주면 UX가 좋아진다.

const [isChecking, setIsChecking] = useState(false);

<input
  {...register('email', {
    validate: async (value) => {
      setIsChecking(true);
      const isDuplicated = await checkEmailDuplicate(value);
      setIsChecking(false);
      return isDuplicated ? '이미 사용 중인 이메일입니다.' : true;
    },
  })}
/>

{isChecking && <span>이메일 확인 중...</span>}

2. 입력 debounce 처리하기
서버 과부하를 막기 위해 상황에 따라 validation 호출을 debounce 처리하면 좋다.

import debounce from 'lodash.debounce';

const validateEmail = debounce(async (value) => {
  const isDuplicated = await checkEmailDuplicate(value);
  return isDuplicated ? '이미 사용 중인 이메일입니다.' : true;
}, 300);
  • debounce를 적용하면 빠르게 입력하더라도 서버 요청이 과도하게 발생하는 것을 효과적으로 방지할 수 있다.

🔍 debounce 함수란?
debounce는 짧은 시간 동안 연속해서 발생하는 이벤트 호출을 하나로 묶어주는 함수다. 일정 시간 동안 이벤트가 계속 발생하면 실행을 미루다가 마지막 이벤트 이후 설정한 시간만큼 기다린 후 단 한 번만 함수를 실행한다.

import debounce from 'lodash.debounce';

const handleSearch = debounce((query) => {
  console.log('API 호출: ', query);
}, 300);

<input onChange={(e) => handleSearch(e.target.value)} />
  • 사용자가 빠르게 입력하면 이전 API 호출은 모두 취소되고 마지막 입력 후 300ms가 지나야 API가 실제 호출

💭 마무리하며

복잡한 폼을 다루다 보면 유효성 관리와 유지보수의 중요성이 점점 커진다. 이럴 때 적절한 Schema Validation을 적용하는 것만으로도 검증 로직을 명확하고 유지보수하기 좋은 구조로 만들 수 있다.
초기에 validation 구조를 잘 설계해두면 이후 폼 수정이나 확장 시 불필요한 리스크를 크게 줄일 수 있다. 💪🏻


📚 Reference


이 글은 공식 문서를 기반으로 내용을 정리한 포스팅입니다.
혹시 내용 중 틀린 부분이나 보완할 부분이 있다면 댓글로 남겨주시면 감사하겠습니다. 🙏🏻

profile
console.log(🐛🔨🧐🍀)

0개의 댓글