본문 바로가기

바이브코딩

08. 타입 안전 폼 검증 - React Hook Form + Zod

React Hook Form + Zod - 08편. 타입 안전 폼 검증

React Hook FormZod로 타입 안전한 폼 검증 시스템을 구축합니다.
이 글에서는 폼 상태 관리, Zod 스키마, 복잡한 검증, Server Action 연동을 다룹니다.


😤 폼 개발의 고통

문의하기 폼을 만들 차례였습니다.

간단하게 생각했습니다. 인풋 몇 개, 버튼 하나.

직접 짜보니 생각보다 코드가 길었습니다.

function LoginForm() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [emailError, setEmailError] = useState('')
  const [passwordError, setPasswordError] = useState('')
  const [isSubmitting, setIsSubmitting] = useState(false)

  const handleSubmit = async (e) => {
    e.preventDefault()

    if (!email) {
      setEmailError('이메일을 입력하세요')
      return
    }
    if (!email.includes('@')) {
      setEmailError('올바른 이메일 형식이 아닙니다')
      return
    }
    if (!password) {
      setPasswordError('비밀번호를 입력하세요')
      return
    }
    if (password.length < 8) {
      setPasswordError('비밀번호는 8자 이상이어야 합니다')
      return
    }

    // 제출 로직...
  }

  // ... 더 긴 JSX
}

이게 로그인 폼 하나입니다.

문의 폼? 견적서 폼? 회원가입 폼?

생각만 해도 지쳤습니다.


🎯 React Hook Form 발견

Claude에게 물었습니다.

폼 관리를 좀 더 깔끔하게 할 수 없을까?

React Hook Form을 추천받았습니다.

npm install react-hook-form

같은 로그인 폼을 다시 짰습니다.

import { useForm } from 'react-hook-form'

function LoginForm() {
  const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm()

  const onSubmit = async (data) => {
    console.log(data) // { email: '...', password: '...' }
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email', { required: '이메일을 입력하세요' })} />
      {errors.email && <span>{errors.email.message}</span>}

      <input
        type="password"
        {...register('password', {
          required: '비밀번호를 입력하세요',
          minLength: { value: 8, message: '8자 이상 입력하세요' }
        })}
      />
      {errors.password && <span>{errors.password.message}</span>}

      <button disabled={isSubmitting}>로그인</button>
    </form>
  )
}

코드가 절반으로 줄었습니다.

useState 5개가 사라졌습니다. 검증 로직도 register에 포함됩니다.

이게 맞는 방향이었습니다.


🛡️ 그런데 타입이 없다

React Hook Form만으로도 충분히 좋았습니다.

하지만 문제가 있었습니다.

const onSubmit = async (data) => {
  console.log(data.emial) // 오타인데 에러 안 남!
}

data의 타입이 any입니다. 오타를 쳐도 잡지 못합니다.

TypeScript를 쓰는 의미가 없어집니다.

Claude에게 물었습니다.

폼 데이터에도 타입을 적용할 수 있어?

Zod를 추천받았습니다.


📝 Zod로 스키마 정의

npm install zod @hookform/resolvers

Zod는 스키마를 정의하고 검증하는 라이브러리입니다.

import { z } from 'zod'

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

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

스키마를 정의하면 타입이 자동으로 나옵니다.

React Hook Form과 연결

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

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

  const onSubmit = async (data: LoginFormData) => {
    // data가 타입 안전!
    console.log(data.email) // ✅ 자동완성
    console.log(data.emial) // ❌ 빨간 줄!
  }

  // ...
}

이제 오타를 칠 수 없습니다.


📋 실제 문의 폼

프로젝트에서 사용한 문의 폼입니다.

스키마 정의

// lib/validations/inquiry.ts
import { z } from 'zod'

export const inquirySchema = z.object({
  name: z.string().min(1, '이름을 입력하세요'),
  email: z.string().email('올바른 이메일을 입력하세요'),
  phone: z
    .string()
    .regex(/^[0-9-]+$/, '올바른 전화번호를 입력하세요')
    .optional(),
  company: z.string().optional(),
  productId: z.string().min(1, '제품을 선택하세요'),
  content: z
    .string()
    .min(10, '최소 10자 이상 입력하세요')
    .max(1000, '최대 1000자까지 입력 가능합니다'),
})

export type InquiryFormData = z.infer<typeof inquirySchema>

폼 컴포넌트

'use client'

import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { inquirySchema, InquiryFormData } from '@/lib/validations/inquiry'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'

export function InquiryForm() {
  const form = useForm<InquiryFormData>({
    resolver: zodResolver(inquirySchema),
    defaultValues: {
      name: '',
      email: '',
      phone: '',
      company: '',
      productId: '',
      content: '',
    }
  })

  const onSubmit = async (data: InquiryFormData) => {
    try {
      const response = await fetch('/api/inquiries', {
        method: 'POST',
        body: JSON.stringify(data),
      })

      if (!response.ok) throw new Error('전송 실패')

      alert('문의가 접수되었습니다!')
      form.reset()
    } catch (error) {
      alert('오류가 발생했습니다.')
    }
  }

  return (
    <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
      <div>
        <Input placeholder="이름" {...form.register('name')} />
        {form.formState.errors.name && (
          <p className="text-red-500 text-sm mt-1">
            {form.formState.errors.name.message}
          </p>
        )}
      </div>

      <div>
        <Input placeholder="이메일" {...form.register('email')} />
        {form.formState.errors.email && (
          <p className="text-red-500 text-sm mt-1">
            {form.formState.errors.email.message}
          </p>
        )}
      </div>

      <div>
        <Textarea placeholder="문의 내용" {...form.register('content')} />
        {form.formState.errors.content && (
          <p className="text-red-500 text-sm mt-1">
            {form.formState.errors.content.message}
          </p>
        )}
      </div>

      <Button type="submit" disabled={form.formState.isSubmitting}>
        {form.formState.isSubmitting ? '전송 중...' : '문의하기'}
      </Button>
    </form>
  )
}

스키마 하나로 클라이언트 검증과 타입 안전성을 동시에 얻었습니다.


🔄 복잡한 검증

프로젝트를 진행하면서 복잡한 검증이 필요한 경우도 있었습니다.

비밀번호 확인

const signupSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
  passwordConfirm: z.string(),
}).refine((data) => data.password === data.passwordConfirm, {
  message: '비밀번호가 일치하지 않습니다',
  path: ['passwordConfirm'],
})

refine으로 여러 필드를 비교할 수 있습니다.

조건부 필수

const orderSchema = z.object({
  deliveryType: z.enum(['pickup', 'delivery']),
  address: z.string().optional(),
}).refine((data) => {
  if (data.deliveryType === 'delivery') {
    return data.address && data.address.length > 0
  }
  return true
}, {
  message: '배송 주소를 입력하세요',
  path: ['address'],
})

배송을 선택하면 주소가 필수가 됩니다.


⚠️ 삽질했던 것들

폼이 제출이 안 됨

검증이 실패하는데 에러 메시지가 안 보였습니다.

// 디버깅
console.log(form.formState.errors)

이걸로 어떤 필드에서 막혔는지 확인했습니다.

서버 에러 표시

API에서 "이미 사용 중인 이메일입니다" 같은 에러를 폼에 표시하고 싶었습니다.

const onSubmit = async (data) => {
  try {
    // ...
  } catch (error) {
    // setError로 특정 필드에 에러 설정
    form.setError('email', {
      message: '이미 사용 중인 이메일입니다'
    })
  }
}

setError로 해결했습니다.


💡 Server Action과 연동

Next.js Server Action과 연동하면 더 깔끔해집니다.

// actions/inquiry.ts
'use server'

import { inquirySchema } from '@/lib/validations/inquiry'
import { createClient } from '@/lib/supabase/server'

export async function createInquiry(formData: FormData) {
  const rawData = Object.fromEntries(formData)

  // 서버에서도 같은 스키마로 검증!
  const result = inquirySchema.safeParse(rawData)

  if (!result.success) {
    return { error: result.error.flatten().fieldErrors }
  }

  const supabase = await createClient()
  const { error } = await supabase
    .from('inquiries')
    .insert(result.data)

  if (error) return { error: '저장 실패' }
  return { success: true }
}

클라이언트와 서버에서 같은 Zod 스키마를 씁니다. 검증 로직이 일관됩니다.


🎯 Claude와 스키마 작성

스키마 작성도 Claude에게 맡겼습니다.

회원가입 폼용 Zod 스키마를 만들어줘.

필드:
- 이메일 (필수, 이메일 형식)
- 비밀번호 (필수, 8자 이상, 영문+숫자 조합)
- 비밀번호 확인 (비밀번호와 일치해야 함)
- 이름 (필수)
- 전화번호 (선택, 000-0000-0000 형식)

Claude가 바로 완성된 스키마를 만들어줬습니다.

폼 개발이 빨라졌습니다.


📋 이번 편 요약

항목 내용
폼 라이브러리 React Hook Form
검증 라이브러리 Zod + @hookform/resolvers
장점 코드 간소화, 타입 안전성, 자동완성
핵심 기능 register, handleSubmit, formState
서버 연동 Server Action + 동일 스키마 검증

다음 편에서는 다국어 지원을 구현합니다.

💬 폼 관련 질문 있으면 댓글로 남겨주세요!