들어가며
폼에서 특정 조건에 따라 필드를 보여주거나 숨기는 건 아주 흔한 요구사항입니다.
예를 들어, 드롭다운에서 "기타"를 선택했을 때만 추가 입력란이 나타나는 경우죠. 하지만 React Hook Form과 Zod를 함께 사용할 때, unmount된 필드가 여전히 검증 에러를 발생시키는 문제를 겪으신 적 있나요? 😇
이 글에서는 이 문제의 원인과 어떤 식으로 해결하였는지 작성해 보려고 합니다.
shouldUnregister는 무엇인가?
우선, 예제를 보기 전에 shouldUnregister가 무슨 속성인지 알아야 합니다.
shouldUnregister는 React Hook Form의 옵션으로, 컴포넌트가 unmount될 때 해당 필드를 폼 상태에서 제거할지 결정합니다.
기본값은 false인데, true로 설정하면 언마운트된 필드는 폼 데이터에서 완전히 사라지고 검증 대상에서도 제외됩니다.
쉽게 말해, 화면에서 사라진 필드를 "없는 것"으로 취급할지 말지를 정하는 옵션입니다.
문제 상황: 언마운트된 필드의 검증 에러
사용자가 선택한 타입에 따라 추가 입력 필드가 나타나는 폼을 만들어봅시다.
✅ 정상 케이스
zod 없이 register나 Controller의 rules 옵션만 사용하면 정상 동작합니다.
const form = useForm({
shouldUnregister: true, // 언마운트 시 필드 제거
});
const watchType = form.watch('type');
const handleSubmit = (data: any) => {
toast.success('제출되었습니다.', {
description: JSON.stringify(data),
});
};
return (
<form onSubmit={form.handleSubmit(handleSubmit)}>
<input
{...form.register('type', {
required: {
message: '필수 입력입니다.',
value: true,
},
})}
/>
{/* type에 'extra'을 입력했을 때만 표시 */}
{watchType === 'extra' && (
<input
{...form.register('extra', {
required: {
message: '필수 입력입니다.',
value: true,
},
})}
/>
)}
<button type="submit">제출</button>
</form>
)
영상을 보시면 언마운트되었을 때, extra의 검증 규칙이 적용되지 않고 제출이 잘 되는 것을 볼 수 있습니다.
🚨 문제 케이스: zod resolver 사용
그런데 zodResolver를 사용하면 문제가 발생합니다.
예상 액션: shouldUnregister가 true이기 때문에 input에 값을 입력했다면 제출되어야 합니다.
const schema = z.object({
type: z.string(),
extra: z.string().min(1, '필수 입력입니다'),
});
const form = useForm({
resolver: zodResolver(schema),
shouldUnregister: true, // 언마운트 시 필드 제거
});
const watchType = form.watch('type');
const handleSubmit = (data) => {
console.log(data);
};
return (
<form onSubmit={form.handleSubmit(handleSubmit)}>
<input {...form.register('type')} />
{/* type에 'extra'을 입력했을 때만 표시 */}
{watchType === 'extra' && <input {...form.register('extra')} />}
<button type="submit">제출</button>
</form>
);
결과
watchType이 'extra'가 아닐 때 extra 필드는 화면에서 사라지지만, 폼을 제출하려고 하면 "필수 입력입니다" 에러가 여전히 발생합니다. shouldUnregister를 true로 설정했는데도 말이죠!
- 화면에 없는 필드의 검증 에러가 발생
- 폼 제출이 막힘
또한, 보통 검증 오류가 발생하면, 해당 input 아래에 에러 메시지를 표시하는 것이 일반적입니다.

하지만 에러가 나는 필드는 언마운트됐기 때문에 사용자는 보이지도 않는 필드 때문에 다음 단계로 넘어가지 못하는 이슈가 발생합니다.
원인 분석: Zod와 React Hook Form의 불일치
이 문제의 핵심은 React Hook Form과 Zod가 서로 다른 기준으로 동작한다는 점입니다.
React Hook Form의 동작
shouldUnregister: true로 설정하면, 언마운트된 필드를 실제 폼 상태(state)에서 제거합니다. 즉, 해당 필드는 더 이상 폼 데이터에 존재하지 않습니다.
Zod의 동작
검증할 때 정의된 스키마 전체를 기준으로 동작합니다. 스키마에 extra 필드가 정의되어 있으면, 실제 폼 상태에 그 필드가 없더라도 검증을 시도합니다.
정리하면, React Hook Form은 "이 필드는 unmount되어서 없어졌어"라고 하는데,
Zod는 "아니야, 스키마에 정의되어 있으니까 검증해야 해"라고 하는 상황입니다.
그렇다면, 왜 shouldUnregister만으로는 안 될까?
shouldUnregister는 React Hook Form의 내부 상태 관리 옵션일 뿐, Zod 스키마를 자동으로 변경하지 않습니다. 따라서 조건부 필드를 다룰 때는 스키마 레벨에서도 조건을 반영해야 합니다.
해결 방법 1: discriminatedUnion 활용
이 문제를 해결하는 방법은 크게 2가지입니다. 저는 첫 번째 방법인 Zod의 discriminatedUnion을 선택했는데요, 이는 식별자 필드(discriminator)의 값에 따라 완전히 다른 스키마를 적용하는 방식입니다.
TypeScript의 discriminated union type과 비슷한 개념이라고 생각하면 쉽습니다.
const schema = z.discriminatedUnion('type', [
// type이 'basic'일 때
z.object({
type: z.literal('basic'),
// extra 필드 자체가 스키마에 없음
}),
// type이 'extra'일 때
z.object({
type: z.literal('extra'),
extra: z.string().min(1, '필수 입력입니다'),
}),
]);
const form = useForm({
resolver: zodResolver(schema),
shouldUnregister: true,
defaultValues: {
type: 'basic',
extra: '', // 기본값은 넣어도 됨
}
});
✅ 장점
type이 'basic'일 때는 extra 필드가 아예 스키마에 존재하지 않습니다. 따라서 Zod가 extra를 검증하려고 시도 하지 않습니다.
복잡한 폼의 경우
실무에서는 조건부 필드 외에도 공통 필드들이 많습니다. 예를 들어, 회원 가입 폼에서 회원 유형에 따라 추가 정보를 받아야 하는 경우를 봅시다.
// 공통 필드 정의
const commonFields = {
name: z.string().min(1, '이름은 필수입니다'),
email: z.string().email('올바른 이메일을 입력하세요'),
password: z.string().min(8, '비밀번호는 8자 이상'),
};
// 회원 유형에 따른 조건부 스키마
const schema = z.discriminatedUnion('memberType', [
// 개인 회원
z.object({
...commonFields,
memberType: z.literal('individual'),
birthDate: z.string().min(1, '생년월일은 필수입니다'),
phone: z.string().optional(),
}),
// 사업자 회원
z.object({
...commonFields,
memberType: z.literal('business'),
businessNumber: z.string().min(1, '사업자등록번호는 필수입니다'),
companyName: z.string().min(1, '회사명은 필수입니다'),
representativeName: z.string().min(1, '대표자명은 필수입니다'),
}),
]);
이렇게 하면:
- 개인 회원 선택 시:
birthDate만 필수,businessNumber등은 검증하지 않음 - 사업자 회원 선택 시:
businessNumber,companyName,representativeName이 필수,birthDate는 검증하지 않음
discriminatedUnion 핵심 포인트
- 식별자 필드는 반드시 literal 타입으로 정의 (
z.literal) - 각 유니온 케이스는 독립적인 완전한 스키마여야 함
- 공통 필드는 spread 연산자(
...)로 재사용 가능 - TypeScript 타입 추론이 정확하게 작동함
⚠️ 단점
예를 들어, 서버 API 스펙이 다음과 같다고 가정해봅시다:
// 서버 API 스펙
interface UserRequest {
name: string;
email: string;
password: string;
birthDate?: string; // 개인 회원일 때만 존재
businessNumber?: string; // 사업자 회원일 때만 존재
companyName?: string; // 사업자 회원일 때만 존재
representativeName?: string; // 사업자 회원일 때만 존재
}
서버 스펙에는 memberType 필드가 없습니다. 서버는 businessNumber가 있으면 사업자, 없으면 개인으로 판단합니다.
이런 경우 프론트에서 추가 작업이 필요합니다:
// 1. 수정 모드: 서버 데이터를 폼에 넣을 때
const serverData = {
name: '홍길동',
email: 'hong@example.com',
password: '********',
businessNumber: '123-45-67890',
companyName: '(주)테스트',
representativeName: '홍길동',
};
// memberType 필드를 추가해서 defaultValues 생성
const formData = {
...serverData,
memberType: serverData.businessNumber ? 'business' : 'individual', // 프론트 전용 필드 추가
};
form.reset(formData);
// 2. 제출 시: memberType 제거하고 서버 형태로 변환
const handleSubmit = (data) => {
const { memberType, ...submitData } = data; // memberType 제거
// API 호출
api.post('/users', submitData);
};
즉, 프론트에서 추가적인 데이터 변환 로직이 필요합니다.
하지만 이 정도 변환 로직은 복잡하지 않고, 명확한 타입 안전성과 유지보수성을 얻을 수 있어 충분히 감수할 만한 trade-off라고 생각합니다.
해결 방법 2: refine/superRefine으로 조건부 검증
refine 사용 예제
const schema = z.object({
type: z.string(),
extra: z.string().optional(), // 일단 optional로
}).refine(
(data) => {
// type이 'extra'일 때만 extra 필드 검증
if (data.type === 'extra') {
return data.extra && data.extra.length > 0;
}
return true; // 다른 경우는 통과
},
{
message: '필수 입력입니다',
path: ['extra'], // 에러 표시 위치
}
);
✅ 장점
- 여러 필드 간 의존성 처리가 쉽습니다
- 복잡한 조건부 로직을 유연하게 표현할 수 있습니다
- discriminatedUnion으로 표현하기 어려운 다중 조건 처리가 가능합니다
⚠️ 단점
스키마는 보통 서버 API 스펙과 동일하게 정의하는 게 좋습니다. 예를 들어, 서버에서 필수인 필드는 스키마에서도 필수로 정의하는 식이죠.
하지만 refine을 사용하면:
const schema = z.object({
extra: z.string().optional(), // 서버에서는 필수인데 스키마에서는 optional
}).refine(...);
이렇게 스키마와 실제 서버 스펙이 불일치하게 됩니다.
문제점:
1. 타입 안전성 약화: TypeScript로 추론되는 타입이 실제와 다름
// refine 사용 시
type FormData = {
extra?: string; // optional로 추론됨
}
// 실제 서버 스펙
type ServerData = {
extra: string; // 필수 값
}
2. 혼란 유발: 나중에 코드를 보는 사람이 "이 필드가 필수인가 선택인가?"를 스키마만 보고 판단하기 어려움
3. 디버깅 어려움: 에러가 발생했을 때 스키마 정의 부분이 아니라 refine 로직을 찾아가야 함
// ❌ 혼란스러운 코드
const schema = z.object({
type: z.string(),
extra: z.string().optional(), // optional인데
detail: z.string().optional(), // optional인데
note: z.string().optional(), // 이것도 optional?
}).refine(...) // 실제로는 조건부로 필수
.refine(...) // 이것도?
.refine(...); // 어디까지가 필수인지 알 수 없음
이런 이유로 discriminatedUnion이 더 명확하고 유지보수하기 좋은 선택이라고 생각합니다.
어떤 방법을 선택해야 할까?
저는 discriminatedUnion를 사용하는 방식을 더 추천합니다.
discriminatedUnion
- 타입 안전성이 확실히 보장됨
- 스키마만 봐도 어떤 조건에서 어떤 필드가 필요한지 명확함
- 서버 스펙과 일치하여 디버깅이 쉬움
- 프론트에서만 추가로 관리하는 식별자 필드(예:
memberType)를 만드는 것은 그리 어렵지 않음
refine/superRefine
- 정말 복잡한 다중 필드 의존성이 있는 경우
- 식별자 필드를 만들기 어려운 특수한 상황
주의사항 및 팁
1. Controller 사용 시 반드시 control 연결하기
shouldUnregister: true나 discriminatedUnion을 사용할 때, 조건부 필드는 반드시 register 또는 Controller로 등록해야 합니다.
그렇지 않으면 값이 폼 상태에 반영되지 않습니다.
// ❌ 잘못된 예: control 없이 사용
const watchType = form.watch('type');
<input value={watchType} onChange={() => form.setValue(...)} />
// ✅ 올바른 예: Controller로 제대로 연결
<Controller
control={form.control}
name="type"
render={({ field }) => (
<input {...field} />
)}
/>
// ✅ 올바른 예: setValue를 사용할 때도 반드시 register 필요
<input
{...form.register('type')}
onChange={(e) => {
form.setValue('type', e.target.value, {
shouldValidate: true, // 값 변경 시 검증 실행
shouldDirty: true, // dirty 상태 업데이트
});
}}
/>
2. 기본값(defaultValues)은 자유롭게 설정 가능
discriminatedUnion을 사용할 때, defaultValues에 모든 필드의 초기값을 넣어도 괜찮습니다. handleSubmit 시점에 실제 스키마에 맞는 필드만 전달됩니다.
const form = useForm({
resolver: zodResolver(schema),
shouldUnregister: true,
defaultValues: {
type: 'basic',
extra: '', // type이 'basic'이어도 기본값 설정 가능
detail: '', // 나중에 필요할 수 있는 필드들
}
});
// handleSubmit 시 type이 'basic'이면
// 실제 제출 데이터: { type: 'basic' }
// extra, detail은 자동으로 제외됨
마무리
React Hook Form의 shouldUnregister와 Zod를 함께 사용할 때는 스키마 레벨에서도 조건을 반영해야 한다는 점을 기억하세요!
함께 읽으면 좋은 글:
React Hook Form의 useWatch와 watch 함수, 제대로 알고 쓰자!
살펴보기React Hook Form은 React에서 폼을 쉽게 관리할 수 있도록 도와주는 라이브러리입니다. 이 라이브러리에는 폼의 상태를 추적하는 데 사용되는 두 가지 함수가 있습니다. 바로 useWatch와 watch 함
toby2009.tistory.com
'Frontend > React Hook Form' 카테고리의 다른 글
| React Hook Form과 Yup을 활용한 폼 검증 방법 (2) | 2024.10.06 |
|---|---|
| React Hook Form의 Controller와 Register 함수: 활용 방법 및 비교 (0) | 2023.12.24 |
| React Hook Form의 useWatch와 watch 함수, 제대로 알고 쓰자! (0) | 2023.09.24 |
