Next.js 이론 10강

폼 처리 - React Hook Form + Zod

React Hook Form과 Zod를 사용하여 타입 안전하고 성능 좋은 폼을 구현하는 방법을 학습합니다.

1. 폼 처리의 어려움

React에서 폼을 다루는 것은 생각보다 복잡합니다. 상태 관리, 유효성 검사, 에러 처리 등 고려할 것이 많습니다. 이커머스에서는 회원가입, 주문, 결제 등 폼이 핵심입니다.

기본 React 폼의 문제점

// 기본 React 폼 - 문제점이 많음
function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [errors, setErrors] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  const validate = () => {
    const newErrors = {};
    if (!email) newErrors.email = '이메일을 입력하세요';
    if (!password) newErrors.password = '비밀번호를 입력하세요';
    return newErrors;
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    const validationErrors = validate();
    if (Object.keys(validationErrors).length > 0) {
      setErrors(validationErrors);
      return;
    }
    setIsSubmitting(true);
    // ... 제출 로직
  };

  // 문제점:
  // 1. 필드마다 useState 필요
  // 2. 입력할 때마다 전체 리렌더링
  // 3. 유효성 검사 로직 직접 작성
  // 4. 타입 안전성 없음
}

기본 폼의 문제점

  • • 필드마다 useState 필요 → 코드 중복
  • • 입력할 때마다 전체 리렌더링 → 성능 저하
  • • 유효성 검사 로직 직접 작성 → 버그 발생
  • • 타입 안전성 없음 → 런타임 에러

React Hook Form + Zod 조합

React Hook Form
  • • 비제어 컴포넌트 기반
  • • 최소한의 리렌더링
  • • 간단한 API
  • • 뛰어난 성능
Zod
  • • 스키마 기반 유효성 검사
  • • TypeScript 타입 자동 추론
  • • 선언적 검증 규칙
  • • 커스텀 에러 메시지

왜 이 조합인가?

React Hook Form은 성능을, Zod는 타입 안전성을 제공합니다. @hookform/resolvers로 두 라이브러리를 쉽게 연결할 수 있습니다.

2. Zod 스키마 정의

Zod로 폼 데이터의 구조와 유효성 검사 규칙을 정의합니다. 스키마를 정의하면 TypeScript 타입이 자동으로 추론됩니다.

설치

npm install zod react-hook-form @hookform/resolvers

기본 스키마

import { z } from 'zod';

// 로그인 스키마
export const loginSchema = z.object({
  email: z
    .string()
    .min(1, '이메일을 입력하세요')
    .email('올바른 이메일 형식이 아닙니다'),
  password: z
    .string()
    .min(1, '비밀번호를 입력하세요')
    .min(8, '비밀번호는 8자 이상이어야 합니다'),
});

// 타입 자동 추론
export type LoginFormData = z.infer<typeof loginSchema>;
// { email: string; password: string; }

회원가입 스키마

export const signupSchema = z.object({
  email: z.string().email('올바른 이메일을 입력하세요'),
  password: z
    .string()
    .min(8, '8자 이상')
    .regex(/[A-Z]/, '대문자 포함')
    .regex(/[0-9]/, '숫자 포함'),
  confirmPassword: z.string(),
  name: z.string().min(2, '이름은 2자 이상'),
  phone: z.string().regex(/^010-\d{4}-\d{4}$/, '010-0000-0000 형식'),
  agreeTerms: z.literal(true, {
    errorMap: () => ({ message: '약관에 동의해주세요' }),
  }),
}).refine((data) => data.password === data.confirmPassword, {
  message: '비밀번호가 일치하지 않습니다',
  path: ['confirmPassword'],
});

export type SignupFormData = z.infer<typeof signupSchema>;

주요 Zod 메서드

주요 검증 메서드

메서드설명예시
.min() / .max()길이/범위 제한.min(2).max(50)
.email()이메일 형식.email()
.regex()정규식 패턴.regex(/^[0-9]+$/)
.refine()커스텀 검증.refine(fn)
.optional()선택적 필드.optional()

3. React Hook Form 기초

React Hook Form과 Zod를 연결하여 타입 안전한 폼을 만듭니다. zodResolver를 사용하면 Zod 스키마로 자동 유효성 검사가 됩니다.

기본 사용법

'use client';

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { loginSchema, type LoginFormData } from '@/schemas/auth';

export function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<LoginFormData>({
    resolver: zodResolver(loginSchema),
  });

  const onSubmit = async (data: LoginFormData) => {
    // data는 타입 안전함
    console.log(data.email, data.password);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <input {...register('email')} placeholder="이메일" />
        {errors.email && <p>{errors.email.message}</p>}
      </div>
      
      <div>
        <input {...register('password')} type="password" />
        {errors.password && <p>{errors.password.message}</p>}
      </div>
      
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? '로그인 중...' : '로그인'}
      </button>
    </form>
  );
}

주요 반환값

register

입력 필드를 폼에 등록

handleSubmit

폼 제출 핸들러 래퍼

formState.errors

필드별 에러 메시지

formState.isSubmitting

제출 중 상태

watch

필드 값 실시간 감시

reset

폼 초기화

성능 이점

  • • 비제어 컴포넌트로 리렌더링 최소화
  • • 대규모 폼에서 큰 성능 차이
  • • 이커머스 주문 폼에 적합

4. shadcn/ui Form 컴포넌트

shadcn/ui의 Form 컴포넌트로 일관된 스타일의 폼을 만듭니다. React Hook Form과 완벽하게 통합되어 있어 사용이 편리합니다.

Form 컴포넌트 구조

'use client';

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';

export function LoginForm() {
  const form = useForm<LoginFormData>({
    resolver: zodResolver(loginSchema),
    defaultValues: {
      email: '',
      password: '',
    },
  });

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>이메일</FormLabel>
              <FormControl>
                <Input placeholder="email@example.com" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        
        <FormField
          control={form.control}
          name="password"
          render={({ field }) => (
            <FormItem>
              <FormLabel>비밀번호</FormLabel>
              <FormControl>
                <Input type="password" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        
        <Button type="submit" disabled={form.formState.isSubmitting}>
          로그인
        </Button>
      </form>
    </Form>
  );
}

컴포넌트 역할

컴포넌트역할
<Form>FormProvider 래퍼
<FormField>개별 필드 래퍼
<FormItem>레이블, 입력, 에러 그룹화
<FormLabel>접근성 있는 레이블
<FormControl>입력 컴포넌트 래퍼
<FormMessage>에러 메시지 자동 표시

5. Server Actions와 폼

Next.js Server Actions와 React Hook Form을 함께 사용합니다.

Server Action 정의

// actions/auth.ts
'use server';

import { loginSchema } from '@/schemas/auth';

export async function loginAction(formData: FormData) {
  const rawData = {
    email: formData.get('email'),
    password: formData.get('password'),
  };

  // 서버에서도 Zod로 검증
  const result = loginSchema.safeParse(rawData);
  
  if (!result.success) {
    return { error: result.error.flatten().fieldErrors };
  }

  // 로그인 로직
  const { email, password } = result.data;
  // ...

  return { success: true };
}

클라이언트에서 호출

'use client';

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

export function LoginForm() {
  const form = useForm<LoginFormData>({
    resolver: zodResolver(loginSchema),
  });

  const onSubmit = async (data: LoginFormData) => {
    const formData = new FormData();
    formData.append('email', data.email);
    formData.append('password', data.password);

    const result = await loginAction(formData);
    
    if (result.error) {
      // 서버 에러를 폼에 설정
      Object.entries(result.error).forEach(([key, messages]) => {
        form.setError(key as keyof LoginFormData, {
          message: messages?.[0],
        });
      });
      return;
    }

    // 성공 처리
  };

  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      {/* ... */}
    </form>
  );
}

이중 검증의 중요성

  • • 클라이언트: 빠른 피드백, UX 향상
  • • 서버: 보안, 데이터 무결성
  • • 같은 Zod 스키마를 공유하여 일관성 유지

6. 고급 패턴

동적 필드, 조건부 검증 등 고급 폼 패턴을 알아봅니다.

동적 필드 배열

import { useFieldArray } from 'react-hook-form';

// 스키마
const orderSchema = z.object({
  items: z.array(z.object({
    productId: z.string(),
    quantity: z.number().min(1),
  })).min(1, '최소 1개 상품 필요'),
});

// 컴포넌트
function OrderForm() {
  const form = useForm({ resolver: zodResolver(orderSchema) });
  
  const { fields, append, remove } = useFieldArray({
    control: form.control,
    name: 'items',
  });

  return (
    <form>
      {fields.map((field, index) => (
        <div key={field.id}>
          <input {...form.register(`items.${index}.productId`)} />
          <input 
            type="number" 
            {...form.register(`items.${index}.quantity`, { valueAsNumber: true })} 
          />
          <button type="button" onClick={() => remove(index)}>삭제</button>
        </div>
      ))}
      <button type="button" onClick={() => append({ productId: '', quantity: 1 })}>
        상품 추가
      </button>
    </form>
  );
}

조건부 검증

const checkoutSchema = z.object({
  deliveryType: z.enum(['delivery', 'pickup']),
  address: z.string().optional(),
  pickupStore: z.string().optional(),
}).refine((data) => {
  if (data.deliveryType === 'delivery') {
    return !!data.address;
  }
  return !!data.pickupStore;
}, {
  message: '배송지 또는 픽업 매장을 선택하세요',
  path: ['address'],
});

// 또는 superRefine으로 더 세밀한 제어
.superRefine((data, ctx) => {
  if (data.deliveryType === 'delivery' && !data.address) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: '배송지를 입력하세요',
      path: ['address'],
    });
  }
});

watch로 조건부 렌더링

function CheckoutForm() {
  const form = useForm();
  const deliveryType = form.watch('deliveryType');

  return (
    <form>
      <select {...form.register('deliveryType')}>
        <option value="delivery">배송</option>
        <option value="pickup">매장 픽업</option>
      </select>

      {deliveryType === 'delivery' && (
        <input {...form.register('address')} placeholder="배송지" />
      )}

      {deliveryType === 'pickup' && (
        <select {...form.register('pickupStore')}>
          <option value="store1">강남점</option>
          <option value="store2">홍대점</option>
        </select>
      )}
    </form>
  );
}

7. 이커머스 예제: 주문 폼

실제 이커머스 주문 폼을 구현합니다.

주문 스키마

// schemas/order.ts
import { z } from 'zod';

export const orderSchema = z.object({
  // 배송 정보
  recipientName: z.string().min(2, '수령인 이름을 입력하세요'),
  phone: z.string().regex(/^010-\d{4}-\d{4}$/, '올바른 전화번호 형식'),
  zipCode: z.string().length(5, '우편번호 5자리'),
  address: z.string().min(5, '주소를 입력하세요'),
  addressDetail: z.string().optional(),
  
  // 결제 정보
  paymentMethod: z.enum(['card', 'bank', 'kakao']),
  
  // 요청사항
  deliveryRequest: z.string().max(100).optional(),
  
  // 약관 동의
  agreeTerms: z.literal(true, {
    errorMap: () => ({ message: '필수 약관에 동의해주세요' }),
  }),
});

export type OrderFormData = z.infer<typeof orderSchema>;

주문 폼 컴포넌트

'use client';

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { orderSchema, type OrderFormData } from '@/schemas/order';
import { createOrder } from '@/actions/order';

export function OrderForm() {
  const form = useForm<OrderFormData>({
    resolver: zodResolver(orderSchema),
    defaultValues: {
      paymentMethod: 'card',
    },
  });

  const onSubmit = async (data: OrderFormData) => {
    const result = await createOrder(data);
    if (result.success) {
      // 결제 페이지로 이동
      window.location.href = result.paymentUrl;
    }
  };

  return (
    <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
      {/* 배송 정보 */}
      <section>
        <h3>배송 정보</h3>
        <input {...form.register('recipientName')} placeholder="수령인" />
        <input {...form.register('phone')} placeholder="010-0000-0000" />
        <input {...form.register('address')} placeholder="주소" />
      </section>

      {/* 결제 수단 */}
      <section>
        <h3>결제 수단</h3>
        <select {...form.register('paymentMethod')}>
          <option value="card">신용카드</option>
          <option value="bank">계좌이체</option>
          <option value="kakao">카카오페이</option>
        </select>
      </section>

      {/* 약관 동의 */}
      <label>
        <input type="checkbox" {...form.register('agreeTerms')} />
        필수 약관에 동의합니다
      </label>

      <button type="submit" disabled={form.formState.isSubmitting}>
        {form.formState.isSubmitting ? '처리 중...' : '결제하기'}
      </button>
    </form>
  );
}

핵심 정리

Zod로 스키마 정의 + 타입 추론
React Hook Form으로 성능 최적화
클라이언트 + 서버 이중 검증
shadcn/ui Form으로 일관된 UI

폼 처리 베스트 프랙티스

  • • 스키마를 별도 파일로 분리하여 재사용
  • • defaultValues로 초기값 명시
  • • isSubmitting으로 중복 제출 방지
  • • 서버 에러를 setError로 폼에 반영