
React Hook Form + Zod - 08편. 타입 안전 폼 검증
React Hook Form과 Zod로 타입 안전한 폼 검증 시스템을 구축합니다.
이 글에서는 폼 상태 관리, 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 + 동일 스키마 검증 |
다음 편에서는 다국어 지원을 구현합니다.
💬 폼 관련 질문 있으면 댓글로 남겨주세요!
'바이브코딩' 카테고리의 다른 글
| 16. 제품 선택부터 이메일 발송까지 - React Hook Form 문의 폼 구현 (0) | 2026.01.17 |
|---|---|
| 09. 한국어/일본어 지원-next-intl 다국어 설정 (0) | 2026.01.16 |
| 07. 빠른 스타일링 설정 Tailwind CSS + shadcn/ui (0) | 2026.01.14 |
| 06. Supabase CLI 활용법 -TypeScript 타입 자동 생성 (0) | 2026.01.13 |
| 05. DB 설계부터 RLS까지-Supabase 연동 가이드 (0) | 2026.01.12 |