초기에는 필수 입력이나 간단한 길이 제한처럼 기본 validation만으로도 충분하다고 생각했지만, 폼이 복잡해질수록 yup, zod 같은 schema validation이 필요해지고, 비동기 서버 검증까지 고려해야 하는 상황이 찾아온다.
validation은 퍼포먼스와 UX에 직접적인 영향을 주기 때문에 어떤 방식으로 설계하느냐에 따라 폼의 반응 속도, 리렌더링, 그리고 최종 사용자 경험이 크게 달라질 수 있다.
React Hook Form의 validation은 크게 두 가지 흐름으로 나눌 수 있다.
두 방식 모두 React Hook Form의 handleSubmit
과정에서 검증이 수행되지만 내부 동작 흐름은 다르다.
기본적으로 register
를 사용하면 React Hook Form이 HTML input의 constraint 속성(required
, minLength
, maxLength
등)을 기반으로 유효성 검사를 수행한다.
<input
{...register('username', {
required: '필수 입력 항목입니다.',
minLength: { value: 3, message: '3자 이상 입력해주세요.' },
})}
/>
input
요소를 직접 추적하고 validation
에러를 formState
로 관리한다.const { register, handleSubmit, formState: { errors } } = useForm();
<input {...register('email', { required: '이메일을 입력해주세요.' })} />
{errors.email && <span>{errors.email.message}</span>}
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으로도 충분히 커버할 수 있다.
폼이 복잡해지고, 필드가 많아지면 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),
});
✔️ resolver
는 React 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를 기준으로 검증되고, 검증 결과는 에러 또는 유효한 데이터로 반환된다.
register
만으로 관리하면 input마다 개별 validation 규칙을 각각 작성해야 하지만 resolver를 사용하면 schema 단위로 검증 로직을 통합 관리할 수 있다.
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),
});
resolver
는 yupResolver
를 통해 schema를 전달받아, 폼 데이터가 변경될 때마다 schema 검증을 수행한다. 검증 결과는 React Hook Form의 formState.errors
에 자동으로 반영된다.🤔 errors는 어떻게 formState에 반영될까?
resolver
는handleSubmit
호출 시 폼 전체의 값을 schema를 기준으로 검증한 결과를 반환하는 함수다.resolver
가 리턴하는 결과 객체는 다음과 같은 구조로 되어 있다.{ values: { ... }, // 유효한 폼 데이터 errors: { ... } // 유효하지 않은 필드의 에러 정보 }
- React Hook Form은 이 errors 객체를 내부에 formState.errors로 저장하고 컴포넌트가 formState.errors를 구독하고 있다면 자동으로 해당 에러를 리렌더링하도록 트리거한다.
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: yupResolver(schema),
});
<input {...register('username')} />
{errors.username && <span>{errors.username.message}</span>}
resolver
가 schema를 기준으로 데이터를 검증한다.resolver
가 { values, errors }
객체를 반환한다.👀 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마다 서로 다른 복잡한 검증 로직이 필요한 경우에 유용하다.
validate
속성을 사용하면 input 단위로 커스텀 검증 함수를 직접 작성할 수 있다.
<input
type="checkbox"
{...register('terms', {
validate: (value) => value || '필수 약관에 동의해주세요.',
})}
/>
validate
는 함수 형태로 작성하며 검증이 성공하면 true
또는 undefined
를 반환, 검증이 실패하면 string
타입의 에러 메시지를 반환해야 한다.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>}
const startDate = watch('startDate', '');
const endDate = watch('endDate', '');
<input
type="date"
{...register('endDate', {
validate: (value) => new Date(value) >= new Date(startDate) || '종료일은 시작일보다 이후여야 합니다.',
})}
/>
<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개 이상 선택해주세요.',
})}
커스텀 validation의 장점
커스텀 validation의 경우 schema validator를 사용할 필요가 없고, 간단한 유효성 검증에 적합하다. 필드 간 비교, 커스텀 API 연동 등 복잡한 검증 로직 작성이 유연하다.커스텀 validation의 단점
복잡한 폼에서는 validation 로직이 input마다 분산되어 관리가 어려울 수 있고 재사용성/유지보수성이 떨어질 수 있다. schema 기반 validation처럼 구조화된 관리가 어렵다.실무에서는 register 기반 커스텀 validation과 resolver 기반 schema validation을 혼합해서 사용하는 경우가 많다.
React Hook Form은 기본적으로 동기 검증에 최적화되어 있지만 비동기 validation도 커스텀 validate 함수 안에서 Promise를 반환하는 방식으로 지원한다.
실무에서는 이메일 중복 검사, 아이디 중복 검사, 인증번호 검증 등 서버와 통신해야 하는 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 : '유효하지 않은 인증번호입니다.';
},
})}
/>
🤔 비동기 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 구조를 잘 설계해두면 이후 폼 수정이나 확장 시 불필요한 리스크를 크게 줄일 수 있다. 💪🏻
이 글은 공식 문서를 기반으로 내용을 정리한 포스팅입니다.
혹시 내용 중 틀린 부분이나 보완할 부분이 있다면 댓글로 남겨주시면 감사하겠습니다. 🙏🏻