React Hook Form과 Zod를 사용하여 타입 안전하고 성능 좋은 폼을 구현하는 방법을 학습합니다.
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. 타입 안전성 없음
}기본 폼의 문제점
왜 이 조합인가?
React Hook Form은 성능을, Zod는 타입 안전성을 제공합니다. @hookform/resolvers로 두 라이브러리를 쉽게 연결할 수 있습니다.
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>;| 메서드 | 설명 | 예시 |
|---|---|---|
| .min() / .max() | 길이/범위 제한 | .min(2).max(50) |
| .email() | 이메일 형식 | .email() |
| .regex() | 정규식 패턴 | .regex(/^[0-9]+$/) |
| .refine() | 커스텀 검증 | .refine(fn) |
| .optional() | 선택적 필드 | .optional() |
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폼 초기화
성능 이점
shadcn/ui의 Form 컴포넌트로 일관된 스타일의 폼을 만듭니다. React Hook 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> | 에러 메시지 자동 표시 |
Next.js Server Actions와 React Hook Form을 함께 사용합니다.
// 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>
);
}이중 검증의 중요성
동적 필드, 조건부 검증 등 고급 폼 패턴을 알아봅니다.
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'],
});
}
});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>
);
}실제 이커머스 주문 폼을 구현합니다.
// 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>
);
}폼 처리 베스트 프랙티스