홈으로 돌아가기

React Hook Form과 Zod: 운영 경험 1년

프로덕션에서 React Hook Form과 Zod 사용 1년 경험. 보일러플레이트 코드 감소 장점 분석 및 undefined 처리, 타입 오류, isDirty 플래그 등의 숨겨진 문제 식별. 일반 오류 피하기 추천 사항.

React Hook Form과 Zod: 프로덕션 실제 사례
Advertisement 728x90

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로 전환하니 문제가 깔끔히 해결되었습니다: 유효성 검사 스키마를 선언적으로 정의하고 라이브러리가 상태 동기화와 오류 처리를 자동화합니다.

Google AdInline article slot

기본 폼 예시:

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년간 사용하며 특정 상황에서 폼 기능을 망가뜨릴 수 있는 세 가지 주요 문제를 마주쳤습니다.

Google AdInline article slot

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가 그 값을 처리하지 않습니다.

Google AdInline article slot

리셋 버튼은 추가 복잡성을 초래합니다. 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: {},
});

렌더 직후 isDirtytrue로 바뀌고, 사용자가 아무것도 건드리지 않았어도 그렇습니다. 반면 dirtyFields는 빈 객체로 남습니다.

이는 defaultValues 구조와 스키마 간 불일치 때문입니다. 거짓 양성 방지를 위해 defaultValues에 스키마의 모든 필드를 포함하세요—even undefinednull이라도.

흔한 실수를 피하는 방법?

경험에서 도출한 검증된 베스트 프랙티스입니다:

  • 빈 값 처리:

- 문자열 필드: 기본값으로 빈 문자열 사용.

- 숫자/객체 필드: Zod에서 .nullable() 적용하고 값은 null.

- 선택한 타입을 커스텀 컴포넌트가 지원하는지 항상 확인.

  • 안정적인 유효성 검사:

- 위 예처럼 모든 유효성 검사 단계에 일반 오류 메시지 지정.

- 컴포넌트 개발 시 nullundefined 시나리오 테스트.

  • 정확한 isDirty 동작:

- defaultValues를 스키마에 맞게 초기화하고 모든 필드 포함.

- 빈 객체 {} 피함—undefinednull로 필드 명시적 나열.

프로젝트에 폼용 통합 헬퍼를 추가하는 것도 추천합니다. RHF 특성을 처리하는 리셋 함수처럼:

const resetForm = () => {
  methods.reset({
    name: null,
    email: null,
    age: null,
  });
};

핵심 정리

  • 코드 감소가 테스트 필요성을 없애지 않습니다: 선언적 접근은 시간을 절약하지만 철저한 엣지 케이스 검사가 필수입니다.
  • 타입 안전이 최고의 친구: Zod와 TypeScript가 수많은 버그를 막아주지만 스키마를 올바르게 설정해야 합니다.
  • 특이점을 문서화하세요: 일반적인 RHF와 Zod 문제에 대한 워크어라운드를 프로젝트 내부 문서에 기록하세요.

— Editorial Team

Advertisement 728x90

다음 읽기