React Hook Form + Zod: 타입 안전한 폼 검증 완전 가이드

프론트엔드

React Hook FormZod폼 검증TypeScriptReact

이 글은 누구를 위한 것인가

  • 복잡한 폼에서 타입 안전성과 성능을 동시에 원하는 팀
  • 서버 에러를 폼 필드에 연결하는 방법을 찾는 개발자
  • useFieldArray로 동적으로 추가/삭제되는 폼 필드를 구현하려는 팀

들어가며

React Hook Form은 비제어 컴포넌트 방식으로 리렌더링을 최소화하고, Zod는 TypeScript 타입과 런타임 검증을 통합한다. 둘의 결합은 "타입 추론 → 스키마 검증 → 에러 표시"를 완전 자동화한다.

이 글은 bluefoxdev.kr의 React 폼 관리 완전 가이드 를 참고하여 작성했습니다.


1. 기본 설정과 핵심 패턴

// 설치
// pnpm add react-hook-form zod @hookform/resolvers

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

// 1. Zod 스키마 정의 (진실의 근원)
const productSchema = z.object({
  name: z.string()
    .min(2, '상품명은 2자 이상 입력해주세요')
    .max(100, '상품명은 100자 이하로 입력해주세요'),
  
  price: z.coerce.number()  // 문자열 → 숫자 자동 변환
    .positive('가격은 0보다 커야 합니다')
    .multipleOf(0.01, '소수점 2자리까지 입력 가능합니다'),
  
  category: z.enum(['electronics', 'clothing', 'food'], {
    errorMap: () => ({ message: '카테고리를 선택해주세요' }),
  }),
  
  description: z.string().optional(),
  
  isPublished: z.boolean().default(false),
  
  tags: z.array(z.string().min(1)).max(10, '태그는 최대 10개까지'),
  
  // 조건부 검증
  discountRate: z.number().min(0).max(100).optional(),
}).refine(
  (data) => !data.discountRate || data.price > 0,
  { message: '할인율을 설정하려면 가격이 필요합니다', path: ['discountRate'] }
);

// 2. 타입 자동 추론 (별도 interface 불필요)
type ProductFormData = z.infer<typeof productSchema>;

// 3. 폼 훅 설정
function ProductForm({ onSubmit }: { onSubmit: (data: ProductFormData) => Promise<void> }) {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting, isDirty },
    setError,       // 서버 에러 설정
    reset,
    watch,          // 실시간 값 감시
    setValue,
  } = useForm<ProductFormData>({
    resolver: zodResolver(productSchema),
    defaultValues: {
      name: '',
      price: 0,
      category: 'electronics',
      isPublished: false,
      tags: [],
    },
  });
  
  const hasDiscountRate = watch('discountRate') !== undefined;
  
  async function handleFormSubmit(data: ProductFormData) {
    try {
      await onSubmit(data);
      reset();
    } catch (error) {
      if (error instanceof ApiError) {
        // 서버 에러를 특정 필드에 연결
        setError('name', {
          type: 'server',
          message: error.fieldErrors.name,
        });
        // 전체 폼 에러
        setError('root', {
          type: 'server',
          message: '서버 오류가 발생했습니다',
        });
      }
    }
  }
  
  return (
    <form onSubmit={handleSubmit(handleFormSubmit)} noValidate>
      <div>
        <label htmlFor="name">상품명 *</label>
        <input
          id="name"
          {...register('name')}
          aria-invalid={!!errors.name}
          aria-describedby={errors.name ? 'name-error' : undefined}
        />
        {errors.name && (
          <p id="name-error" role="alert">{errors.name.message}</p>
        )}
      </div>
      
      <div>
        <label htmlFor="price">가격 *</label>
        <input id="price" type="number" {...register('price')} />
        {errors.price && <p role="alert">{errors.price.message}</p>}
      </div>
      
      <div>
        <label htmlFor="category">카테고리 *</label>
        <select id="category" {...register('category')}>
          <option value="">선택해주세요</option>
          <option value="electronics">전자제품</option>
          <option value="clothing">의류</option>
          <option value="food">식품</option>
        </select>
        {errors.category && <p role="alert">{errors.category.message}</p>}
      </div>
      
      {errors.root && (
        <p role="alert" className="text-red-500">{errors.root.message}</p>
      )}
      
      <button type="submit" disabled={isSubmitting || !isDirty}>
        {isSubmitting ? '저장 중...' : '저장'}
      </button>
    </form>
  );
}
// useFieldArray - 동적 필드 (상품 옵션 추가/삭제)
import { useFieldArray, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const orderSchema = z.object({
  customerId: z.string().cuid(),
  items: z.array(z.object({
    productId: z.string().cuid(),
    quantity: z.coerce.number().int().positive(),
    note: z.string().optional(),
  })).min(1, '최소 1개 이상의 상품을 추가해주세요'),
});

type OrderFormData = z.infer<typeof orderSchema>;

function OrderForm() {
  const { register, handleSubmit, control, formState: { errors } } = useForm<OrderFormData>({
    resolver: zodResolver(orderSchema),
    defaultValues: {
      customerId: '',
      items: [{ productId: '', quantity: 1 }],
    },
  });
  
  const { fields, append, remove, move } = useFieldArray({
    control,
    name: 'items',
  });
  
  return (
    <form onSubmit={handleSubmit(console.log)}>
      <input {...register('customerId')} placeholder="고객 ID" />
      
      <h3>주문 상품</h3>
      {fields.map((field, index) => (
        <div key={field.id}>  {/* field.id가 안정적인 key */}
          <select {...register(`items.${index}.productId`)}>
            <option value="">상품 선택</option>
            {/* 상품 목록 */}
          </select>
          
          <input
            type="number"
            {...register(`items.${index}.quantity`)}
            min="1"
          />
          
          {errors.items?.[index]?.quantity && (
            <p>{errors.items[index].quantity?.message}</p>
          )}
          
          <button
            type="button"
            onClick={() => remove(index)}
            disabled={fields.length === 1}
          >
            삭제
          </button>
        </div>
      ))}
      
      {errors.items?.root && (
        <p role="alert">{errors.items.root.message}</p>
      )}
      
      <button
        type="button"
        onClick={() => append({ productId: '', quantity: 1 })}
      >
        상품 추가
      </button>
      
      <button type="submit">주문 생성</button>
    </form>
  );
}

class ApiError extends Error {
  fieldErrors: Record<string, string> = {};
}
// Controller로 커스텀 컴포넌트 통합
import { Controller } from 'react-hook-form';
import DatePicker from 'react-datepicker';

function EventForm() {
  const { control, handleSubmit } = useForm({
    resolver: zodResolver(z.object({
      title: z.string().min(1),
      startDate: z.date(),
      category: z.string(),
    })),
  });
  
  return (
    <form onSubmit={handleSubmit(console.log)}>
      {/* 커스텀 DatePicker - register 불가, Controller 사용 */}
      <Controller
        name="startDate"
        control={control}
        render={({ field, fieldState }) => (
          <div>
            <DatePicker
              selected={field.value}
              onChange={field.onChange}
              onBlur={field.onBlur}
              dateFormat="yyyy/MM/dd"
            />
            {fieldState.error && <p>{fieldState.error.message}</p>}
          </div>
        )}
      />
    </form>
  );
}

// 성능 최적화: watch는 리렌더링 유발
// 특정 필드만 구독
function PriceDisplay() {
  const price = useWatch({ name: 'price' });  // watch 대신 useWatch
  return <p>총액: ₩{price?.toLocaleString()}</p>;
}

마무리

React Hook Form + Zod의 핵심 가치는 z.infer<typeof schema>로 타입과 검증을 단일 소스로 유지하는 것이다. setError('root')로 서버 에러를 폼에 통합하고, useFieldArray로 동적 필드를 관리하면 대부분의 복잡한 폼 요구사항을 커버할 수 있다. 성능 최적화: watch() 대신 useWatch()를 사용하고, 검증이 필요 없는 필드는 mode: 'onBlur'로 설정하면 타이핑 중 불필요한 검증을 줄일 수 있다.