# 폼 및 유효성 검증 가이드 ## 📋 문서 개요 이 문서는 React Hook Form과 Zod를 사용하여 타입 안전하고 다국어를 지원하는 폼 컴포넌트를 구현하는 방법을 설명합니다. **작성일**: 2025-11-06 **프로젝트**: Multi-tenant ERP System **기술 스택**: - React Hook Form: 7.54.2 - Zod: 3.24.1 - @hookform/resolvers: 3.9.1 - next-intl: 4.4.0 --- ## 🎯 왜 React Hook Form + Zod인가? ### React Hook Form의 장점 - ✅ **성능 최적화**: 비제어 컴포넌트 기반으로 리렌더링 최소화 - ✅ **TypeScript 완벽 지원**: 타입 안전성 보장 - ✅ **작은 번들 크기**: ~8KB (gzipped) - ✅ **간단한 API**: 직관적이고 배우기 쉬움 - ✅ **유연한 검증**: 다양한 검증 라이브러리 지원 ### Zod의 장점 - ✅ **스키마 우선 검증**: 명확하고 재사용 가능한 검증 로직 - ✅ **TypeScript 타입 추론**: 스키마에서 자동으로 타입 생성 - ✅ **런타임 검증**: 컴파일 타임 + 런타임 안전성 - ✅ **체이닝 가능**: 읽기 쉽고 확장 가능한 검증 규칙 - ✅ **커스텀 에러 메시지**: 다국어 에러 메시지 완벽 지원 --- ## 📦 설치된 패키지 ```json { "dependencies": { "react-hook-form": "^7.54.2", "zod": "^3.24.1", "@hookform/resolvers": "^3.9.1" } } ``` **@hookform/resolvers**: React Hook Form과 Zod를 연결하는 어댑터 --- ## 🚀 기본 사용법 ### 1. Zod 스키마 정의 ```typescript // src/lib/validation/auth.schema.ts import { z } from 'zod'; export const loginSchema = z.object({ email: z .string() .min(1, 'validation.email.required') .email('validation.email.invalid'), password: z .string() .min(8, 'validation.password.min') .max(100, 'validation.password.max'), rememberMe: z.boolean().optional(), }); // TypeScript 타입 자동 추론 export type LoginFormData = z.infer; ``` ### 2. React Hook Form 통합 ```typescript // src/components/LoginForm.tsx 'use client'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { useTranslations } from 'next-intl'; import { loginSchema, type LoginFormData } from '@/lib/validation/auth.schema'; export default function LoginForm() { const t = useTranslations('auth'); const tValidation = useTranslations('validation'); const { register, handleSubmit, formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(loginSchema), defaultValues: { email: '', password: '', rememberMe: false, }, }); const onSubmit = async (data: LoginFormData) => { try { // Laravel API 호출 const response = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); if (!response.ok) throw new Error('Login failed'); const result = await response.json(); // 로그인 성공 처리 } catch (error) { console.error(error); } }; return (
{/* Email 입력 */}
{errors.email && (

{tValidation(errors.email.message as any)}

)}
{/* Password 입력 */}
{errors.password && (

{tValidation(errors.password.message as any)}

)}
{/* Remember Me */}
{/* Submit 버튼 */}
); } ``` --- ## 🌐 next-intl 통합 ### 1. 검증 메시지 번역 파일 추가 ```json // src/messages/ko.json { "validation": { "email": { "required": "이메일을 입력해주세요", "invalid": "유효한 이메일 주소를 입력해주세요" }, "password": { "required": "비밀번호를 입력해주세요", "min": "비밀번호는 최소 {min}자 이상이어야 합니다", "max": "비밀번호는 최대 {max}자 이하여야 합니다" }, "name": { "required": "이름을 입력해주세요", "min": "이름은 최소 {min}자 이상이어야 합니다" }, "phone": { "invalid": "유효한 전화번호를 입력해주세요" }, "required": "필수 입력 항목입니다" } } ``` ```json // src/messages/en.json { "validation": { "email": { "required": "Email is required", "invalid": "Please enter a valid email address" }, "password": { "required": "Password is required", "min": "Password must be at least {min} characters", "max": "Password must be at most {max} characters" }, "name": { "required": "Name is required", "min": "Name must be at least {min} characters" }, "phone": { "invalid": "Please enter a valid phone number" }, "required": "This field is required" } } ``` ```json // src/messages/ja.json { "validation": { "email": { "required": "メールアドレスを入力してください", "invalid": "有効なメールアドレスを入力してください" }, "password": { "required": "パスワードを入力してください", "min": "パスワードは{min}文字以上である必要があります", "max": "パスワードは{max}文字以下である必要があります" }, "name": { "required": "名前を入力してください", "min": "名前は{min}文字以上である必要があります" }, "phone": { "invalid": "有効な電話番号を入力してください" }, "required": "この項目は必須です" } } ``` ### 2. 다국어 에러 메시지 표시 유틸리티 ```typescript // src/lib/utils/form-error.ts import { FieldError } from 'react-hook-form'; export function getErrorMessage( error: FieldError | undefined, t: (key: string, values?: Record) => string ): string | undefined { if (!error) return undefined; // 에러 메시지가 번역 키인 경우 if (typeof error.message === 'string' && error.message.startsWith('validation.')) { return t(error.message); } // 직접 에러 메시지인 경우 return error.message; } ``` --- ## 💼 ERP 실전 예제 ### 1. 제품 등록 폼 ```typescript // src/lib/validation/product.schema.ts import { z } from 'zod'; export const productSchema = z.object({ sku: z .string() .min(1, 'validation.required') .regex(/^[A-Z0-9-]+$/, 'validation.sku.format'), name: z.object({ ko: z.string().min(1, 'validation.required'), en: z.string().min(1, 'validation.required'), ja: z.string().optional(), }), description: z.object({ ko: z.string().optional(), en: z.string().optional(), ja: z.string().optional(), }), price: z .number() .min(0, 'validation.price.min') .max(999999999, 'validation.price.max'), stock: z .number() .int('validation.stock.int') .min(0, 'validation.stock.min'), category: z.string().min(1, 'validation.required'), isActive: z.boolean().default(true), }); export type ProductFormData = z.infer; ``` ```typescript // src/components/ProductForm.tsx 'use client'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { useTranslations, useLocale } from 'next-intl'; import { productSchema, type ProductFormData } from '@/lib/validation/product.schema'; export default function ProductForm() { const t = useTranslations('product'); const tValidation = useTranslations('validation'); const locale = useLocale(); const { register, handleSubmit, formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(productSchema), }); const onSubmit = async (data: ProductFormData) => { try { const response = await fetch(`${process.env.NEXT_PUBLIC_LARAVEL_API_URL}/api/products`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${getAuthToken()}`, 'X-Locale': locale, }, body: JSON.stringify(data), }); if (!response.ok) throw new Error('Failed to create product'); const result = await response.json(); // 성공 처리 } catch (error) { console.error(error); } }; return (
{/* SKU */}
{errors.sku && (

{tValidation(errors.sku.message as any)}

)}
{/* 제품명 (다국어) */}
{errors.name?.ko && (

{tValidation(errors.name.ko.message as any)}

)}
{errors.name?.en && (

{tValidation(errors.name.en.message as any)}

)}
{/* 가격 */}
{errors.price && (

{tValidation(errors.price.message as any)}

)}
{/* 재고 */}
{errors.stock && (

{tValidation(errors.stock.message as any)}

)}
{/* 활성 상태 */}
{/* Submit 버튼 */}
); } ``` ### 2. 고급 검증: 조건부 필드 ```typescript // src/lib/validation/employee.schema.ts import { z } from 'zod'; export const employeeSchema = z .object({ name: z.string().min(1, 'validation.required'), email: z.string().email('validation.email.invalid'), department: z.string().min(1, 'validation.required'), position: z.string().min(1, 'validation.required'), employmentType: z.enum(['full-time', 'part-time', 'contract']), // 계약직인 경우 계약 종료일 필수 contractEndDate: z.string().optional(), // 관리자인 경우 승인 권한 레벨 필수 isManager: z.boolean().default(false), approvalLevel: z.number().min(1).max(5).optional(), }) .refine( (data) => { // 계약직인 경우 계약 종료일 필수 if (data.employmentType === 'contract') { return !!data.contractEndDate; } return true; }, { message: 'validation.contractEndDate.required', path: ['contractEndDate'], } ) .refine( (data) => { // 관리자인 경우 승인 권한 레벨 필수 if (data.isManager) { return data.approvalLevel !== undefined; } return true; }, { message: 'validation.approvalLevel.required', path: ['approvalLevel'], } ); export type EmployeeFormData = z.infer; ``` --- ## 🎨 재사용 가능한 폼 컴포넌트 ### 1. Input Field 컴포넌트 ```typescript // src/components/form/FormInput.tsx import { UseFormRegister, FieldError } from 'react-hook-form'; import { useTranslations } from 'next-intl'; interface FormInputProps { name: string; label: string; type?: 'text' | 'email' | 'password' | 'number' | 'tel'; placeholder?: string; required?: boolean; register: UseFormRegister; error?: FieldError; className?: string; } export default function FormInput({ name, label, type = 'text', placeholder, required = false, register, error, className = '', }: FormInputProps) { const tValidation = useTranslations('validation'); return (
{error && (

{tValidation(error.message as any)}

)}
); } ``` ### 2. Select Field 컴포넌트 ```typescript // src/components/form/FormSelect.tsx import { UseFormRegister, FieldError } from 'react-hook-form'; import { useTranslations } from 'next-intl'; interface FormSelectProps { name: string; label: string; options: { value: string; label: string }[]; required?: boolean; register: UseFormRegister; error?: FieldError; className?: string; } export default function FormSelect({ name, label, options, required = false, register, error, className = '', }: FormSelectProps) { const tValidation = useTranslations('validation'); return (
{error && (

{tValidation(error.message as any)}

)}
); } ``` ### 3. 간단한 폼 사용 예제 ```typescript // src/components/SimpleLoginForm.tsx 'use client'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { loginSchema, type LoginFormData } from '@/lib/validation/auth.schema'; import FormInput from '@/components/form/FormInput'; export default function SimpleLoginForm() { const { register, handleSubmit, formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(loginSchema), }); const onSubmit = async (data: LoginFormData) => { console.log(data); }; return (
); } ``` --- ## ✅ Best Practices ### 1. 스키마 구조화 ```typescript // src/lib/validation/schemas/ // ├── auth.schema.ts # 인증 관련 // ├── product.schema.ts # 제품 관련 // ├── employee.schema.ts # 직원 관련 // ├── order.schema.ts # 주문 관련 // └── common.schema.ts # 공통 스키마 // src/lib/validation/schemas/common.schema.ts import { z } from 'zod'; // 재사용 가능한 공통 스키마 export const emailSchema = z.string().email('validation.email.invalid'); export const phoneSchema = z .string() .regex(/^01[0-9]-[0-9]{4}-[0-9]{4}$/, 'validation.phone.invalid'); export const passwordSchema = z .string() .min(8, 'validation.password.min') .max(100, 'validation.password.max') .regex(/[a-z]/, 'validation.password.lowercase') .regex(/[A-Z]/, 'validation.password.uppercase') .regex(/[0-9]/, 'validation.password.number'); ``` ### 2. 타입 안전성 보장 ```typescript // 스키마에서 타입 추론 export type LoginFormData = z.infer; // API 응답 타입도 Zod로 정의 export const loginResponseSchema = z.object({ user: z.object({ id: z.string().uuid(), email: z.string().email(), name: z.string(), }), token: z.string(), }); export type LoginResponse = z.infer; ``` ### 3. 에러 처리 패턴 ```typescript // src/lib/utils/form-error-handler.ts import { ZodError } from 'zod'; import { FieldErrors, UseFormSetError } from 'react-hook-form'; export function handleZodError( error: ZodError, setError: UseFormSetError ) { error.errors.forEach((err) => { const path = err.path.join('.') as any; setError(path, { type: 'manual', message: err.message, }); }); } // API 에러를 폼 에러로 변환 export function handleApiError( apiError: any, setError: UseFormSetError ) { if (apiError.errors) { Object.entries(apiError.errors).forEach(([field, messages]) => { setError(field as any, { type: 'manual', message: (messages as string[])[0], }); }); } } ``` ### 4. 폼 상태 관리 ```typescript 'use client'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { useEffect } from 'react'; export default function EditProductForm({ productId }: { productId: string }) { const { register, handleSubmit, formState: { errors, isSubmitting, isDirty, isValid }, reset, watch, } = useForm({ resolver: zodResolver(productSchema), mode: 'onChange', // 실시간 검증 }); // 초기 데이터 로드 useEffect(() => { async function loadProduct() { const response = await fetch(`/api/products/${productId}`); const product = await response.json(); reset(product); // 폼 초기화 } loadProduct(); }, [productId, reset]); // 필드 값 감시 const price = watch('price'); const stock = watch('stock'); return (
{/* ... */} {/* 변경사항 경고 */} {isDirty && (

저장되지 않은 변경사항이 있습니다.

)} {/* 동적 계산 표시 */}
총 가치: {(price || 0) * (stock || 0)}원
); } ``` --- ## 🔒 보안 고려사항 ### 1. XSS 방지 ```typescript // Zod로 HTML 태그 제거 export const safeTextSchema = z .string() .transform((val) => val.replace(/<[^>]*>/g, '')); // 또는 명시적으로 검증 export const noHtmlSchema = z .string() .refine((val) => !/<[^>]*>/.test(val), { message: 'validation.noHtml', }); ``` ### 2. 파일 업로드 검증 ```typescript export const fileUploadSchema = z.object({ file: z .instanceof(File) .refine((file) => file.size <= 5 * 1024 * 1024, { message: 'validation.file.maxSize', // 5MB }) .refine( (file) => ['image/jpeg', 'image/png', 'image/webp'].includes(file.type), { message: 'validation.file.type', } ), }); ``` --- ## 📊 성능 최적화 ### 1. 검증 모드 선택 ```typescript useForm({ mode: 'onBlur', // 포커스를 잃을 때만 검증 (기본값) mode: 'onChange', // 입력할 때마다 검증 (실시간) mode: 'onSubmit', // 제출할 때만 검증 (가장 빠름) mode: 'onTouched', // 필드를 터치한 후 변경될 때마다 검증 }); ``` ### 2. 조건부 필드 렌더링 ```typescript const employmentType = watch('employmentType'); return (
{/* 계약직인 경우에만 계약 종료일 표시 */} {employmentType === 'contract' && ( )} ); ``` --- ## 🧪 테스트 ### 1. Zod 스키마 테스트 ```typescript // __tests__/validation/auth.schema.test.ts import { describe, it, expect } from '@jest/globals'; import { loginSchema } from '@/lib/validation/auth.schema'; describe('loginSchema', () => { it('should validate correct login data', () => { const validData = { email: 'user@example.com', password: 'SecurePass123', }; const result = loginSchema.safeParse(validData); expect(result.success).toBe(true); }); it('should reject invalid email', () => { const invalidData = { email: 'invalid-email', password: 'SecurePass123', }; const result = loginSchema.safeParse(invalidData); expect(result.success).toBe(false); }); it('should reject short password', () => { const invalidData = { email: 'user@example.com', password: 'short', }; const result = loginSchema.safeParse(invalidData); expect(result.success).toBe(false); }); }); ``` --- ## 📚 참고 자료 - [React Hook Form 공식 문서](https://react-hook-form.com/) - [Zod 공식 문서](https://zod.dev/) - [next-intl 공식 문서](https://next-intl-docs.vercel.app/) - [@hookform/resolvers](https://github.com/react-hook-form/resolvers) --- **문서 유효기간**: 2025-11-06 ~ **다음 업데이트**: 새로운 폼 패턴 추가 시 **작성자**: Claude Code