React Hook Form과 Zod: 보일러플레이트 코드나 숨겨진 함정 없이 프로덕션에서 1년
대규모 프로젝트의 프로덕션 환경에서 React Hook Form과 Zod 조합을 1년간 활발히 사용한 후, 팀에서 확인했습니다: 폼의 선언적 접근 방식은 코드 양을 30–40% 줄여주지만 구현 세부 사항에 세심한 주의가 필요합니다. 발견한 함정들과 이를 극복하는 방법을 공유합니다.
MobX에서 선언적 폼으로: 왜 접근 방식을 바꾸었을까?
이전 프로젝트 버전에서는 MobX를 활용해 MVC 스타일로 폼을 구축했습니다. 이 방식은 상태 제어를 가능하게 했지만 보일러플레이트 코드를 양산했습니다: 각 필드마다 컨트롤러에 별도의 메서드, 오류 배열, UI와의 수동 동기화가 필요했습니다. 예를 들어 새 필드를 추가하려면:
export class SomeController {
public setDuration = (v: number) => {
this.store.duration = v;
}
}
이런 반복적인 템플릿 코드는 비즈니스 로직과 무관했지만 컴포넌트 용량의 최대 60%를 차지했습니다. React Hook Form(RHF)과 Zod로 전환하니 문제가 깔끔히 해결되었습니다: 유효성 검사 스키마를 선언적으로 정의하고 라이브러리가 상태 동기화와 오류 처리를 자동화합니다.
기본 폼 예시:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const formSchema = z.object({
name: z.string().min(1, 'Enter name'),
email: z.string().email('Invalid email'),
age: z.coerce.number().min(0, 'Invalid vozrast').optional(),
});
type FormValues = z.infer<typeof formSchema>;
export function ExampleForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
email: '',
},
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<div>
<input {...register('name')} />
{errors.name && <span>{errors.name.message}</span>}
</div>
<div>
<input type="email" {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
</div>
<div>
<input type="number" {...register('age')} />
{errors.age && <span>{errors.age.message}</span>}
</div>
<button type="submit">Send</button>
</form>
);
}
결과적으로 코드 양이 줄고 유지보수가 쉬워지며 새 폼 개발 속도가 크게 향상되었습니다. 그러나 실제 사용 과정에서 문서에 다루지 않은 세부적인 문제들이 드러났습니다.
React Hook Form과 Zod 조합의 세 가지 함정
RHF와 Zod를 1년간 사용하며 특정 상황에서 폼 기능을 망가뜨릴 수 있는 세 가지 주요 문제를 마주쳤습니다.
1. undefined와 null 처리
RHF는 필드 값으로 undefined를 지원하지 않습니다. 다음처럼 설정하려 하면:
methods.setValue('age', undefined)
필드가 변경을 무시합니다. 문서에 언급되어 있지만 직관적이지 않습니다. 일반적인 해결책은 null을 사용하고 필드를 .nullable()로 선언하는 것입니다:
const schema = z.object({
age: z.number().nullable()
});
methods.setValue('age', null);
하지만 숫자를 기대하는 커스텀 컴포넌트와 충돌할 수 있습니다. 예를 들어 나이 입력 컴포넌트가 null에서 동작하지 않을 수 있습니다. 반대로 컴포넌트가 초기화될 때 undefined를 반환하면 RHF가 그 값을 처리하지 않습니다.
리셋 버튼은 추가 복잡성을 초래합니다. reset() 메서드는 defaultValues에서 값을 가져오지만 undefined로 되어 있으면 실패합니다. 해결책: 문자열 필드는 null 또는 빈 문자열을 사용하세요.
2. Zod의 예상치 못한 타입 오류
데이터 타입 불일치에서 문제가 발생합니다. 예를 들어 이런 스키마:
const schema = z.object({
field1: z.string().min(1, 'Fill field').optional()
});
컴포넌트가 null (undefined가 아님)을 반환하면 Zod가 invalid type (expected string received null) 오류를 발생시킵니다. 사용자는 커스텀 메시지 대신 시스템 메시지를 보게 됩니다.
해결책: 모든 유효성 검사 단계에 오류 메시지를 명시적으로 지정하세요:
const schema = z.object({
field1: z.string('Fill field').min(1, 'Fill field').optional()
});
이렇게 하면 어떤 오류든 사용자 정의 메시지가 표시됩니다.
3. isDirty 플래그의 불안정성
formState.isDirty 플래그는 폼에 변경 사항이 있는지 나타냅니다. 하지만 빈 defaultValues로 초기화하면:
const methods = useForm({
resolver: zodResolver(formSchema),
defaultValues: {},
});
렌더 직후 isDirty가 true로 바뀌고, 사용자가 아무것도 건드리지 않았어도 그렇습니다. 반면 dirtyFields는 빈 객체로 남습니다.
이는 defaultValues 구조와 스키마 간 불일치 때문입니다. 거짓 양성 방지를 위해 defaultValues에 스키마의 모든 필드를 포함하세요—even undefined나 null이라도.
흔한 실수를 피하는 방법?
경험에서 도출한 검증된 베스트 프랙티스입니다:
- 빈 값 처리:
- 문자열 필드: 기본값으로 빈 문자열 사용.
- 숫자/객체 필드: Zod에서 .nullable() 적용하고 값은 null.
- 선택한 타입을 커스텀 컴포넌트가 지원하는지 항상 확인.
- 안정적인 유효성 검사:
- 위 예처럼 모든 유효성 검사 단계에 일반 오류 메시지 지정.
- 컴포넌트 개발 시 null과 undefined 시나리오 테스트.
- 정확한 isDirty 동작:
- defaultValues를 스키마에 맞게 초기화하고 모든 필드 포함.
- 빈 객체 {} 피함—undefined나 null로 필드 명시적 나열.
프로젝트에 폼용 통합 헬퍼를 추가하는 것도 추천합니다. RHF 특성을 처리하는 리셋 함수처럼:
const resetForm = () => {
methods.reset({
name: null,
email: null,
age: null,
});
};
핵심 정리
- 코드 감소가 테스트 필요성을 없애지 않습니다: 선언적 접근은 시간을 절약하지만 철저한 엣지 케이스 검사가 필수입니다.
- 타입 안전이 최고의 친구: Zod와 TypeScript가 수많은 버그를 막아주지만 스키마를 올바르게 설정해야 합니다.
- 특이점을 문서화하세요: 일반적인 RHF와 Zod 문제에 대한 워크어라운드를 프로젝트 내부 문서에 기록하세요.
— Editorial Team
아직 댓글이 없습니다.