Skip to Content
PatternsForm Validation

Form Validation

폼 검증 및 에러 처리 구현 패턴입니다.

개요

Form Validation 패턴은 사용자 입력을 검증하고, 명확한 피드백을 제공하며, 접근 가능한 에러 메시지를 표시하는 검증된 구현 방법을 제공합니다. 실시간 검증, 서버 검증 통합, 다국어 에러 메시지, 복잡한 비즈니스 규칙 검증 등 프로덕션 환경에서 필요한 모든 폼 검증 시나리오를 다룹니다.

사용 사례:

  • 회원가입/로그인 폼
  • 주문/결제 폼
  • 프로필 업데이트 폼
  • 데이터 입력 폼
  • 복잡한 다단계 폼

사용하지 말아야 할 때:

  • 검색창 (실시간 제안만 필요)
  • 단순 필터 (즉시 적용)
  • 읽기 전용 폼

기본 패턴

1. 기본 폼 검증

React Hook Form을 사용한 기본 폼 검증 패턴입니다.

"use client"; import { useForm } from "react-hook-form"; import { Input, Button, Label, Alert } from "@vortex/ui-foundation"; interface FormData { email: string; password: string; confirmPassword: string; } export default function BasicValidation() { const { register, handleSubmit, formState: { errors, isSubmitting }, watch, } = useForm<FormData>(); const onSubmit = async (data: FormData) => { try { const response = await fetch("/api/signup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); if (!response.ok) throw new Error("회원가입 실패"); alert("회원가입 성공!"); } catch (error) { console.error(error); } }; return ( <form onSubmit={handleSubmit(onSubmit)} className="space-y-4 w-full max-w-md" > <div className="space-y-2"> <Label htmlFor="email">이메일</Label> <Input id="email" type="email" {...register("email", { required: "이메일을 입력해주세요", pattern: { value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, message: "올바른 이메일 형식이 아닙니다", }, })} aria-invalid={errors.email ? "true" : "false"} /> {errors.email && ( <p className="text-sm text-red-600" role="alert"> {errors.email.message} </p> )} </div> <div className="space-y-2"> <Label htmlFor="password">비밀번호</Label> <Input id="password" type="password" {...register("password", { required: "비밀번호를 입력해주세요", minLength: { value: 8, message: "비밀번호는 최소 8자 이상이어야 합니다", }, pattern: { value: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, message: "대소문자와 숫자를 포함해야 합니다", }, })} aria-invalid={errors.password ? "true" : "false"} /> {errors.password && ( <p className="text-sm text-red-600" role="alert"> {errors.password.message} </p> )} </div> <div className="space-y-2"> <Label htmlFor="confirmPassword">비밀번호 확인</Label> <Input id="confirmPassword" type="password" {...register("confirmPassword", { required: "비밀번호 확인을 입력해주세요", validate: (value) => value === watch("password") || "비밀번호가 일치하지 않습니다", })} aria-invalid={errors.confirmPassword ? "true" : "false"} /> {errors.confirmPassword && ( <p className="text-sm text-red-600" role="alert"> {errors.confirmPassword.message} </p> )} </div> <Button type="submit" className="w-full" disabled={isSubmitting}> {isSubmitting ? "처리 중..." : "회원가입"} </Button> </form> ); }

2. 실시간 검증

사용자가 입력하는 동안 즉시 피드백을 제공하는 패턴입니다.

"use client"; import { useState } from "react"; import { Input, Label } from "@vortex/ui-foundation"; export default function RealtimeValidation() { const [email, setEmail] = useState(""); const [emailError, setEmailError] = useState(""); const [isValidating, setIsValidating] = useState(false); const validateEmail = async (value: string) => { // 기본 형식 검증 const emailRegex = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i; if (!emailRegex.test(value)) { setEmailError("올바른 이메일 형식이 아닙니다"); return; } // 서버 검증 (이메일 중복 확인) setIsValidating(true); try { const response = await fetch(`/api/check-email?email=${value}`); const { available } = await response.json(); if (!available) { setEmailError("이미 사용 중인 이메일입니다"); } else { setEmailError(""); } } catch (error) { setEmailError("이메일 확인 중 오류가 발생했습니다"); } finally { setIsValidating(false); } }; const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => { const value = e.target.value; setEmail(value); // 디바운스 적용 (500ms 후 검증) const timeoutId = setTimeout(() => { if (value) validateEmail(value); }, 500); return () => clearTimeout(timeoutId); }; return ( <div className="space-y-2"> <Label htmlFor="email">이메일</Label> <div className="relative"> <Input id="email" type="email" value={email} onChange={handleEmailChange} className={emailError ? "border-red-500" : ""} aria-invalid={emailError ? "true" : "false"} aria-describedby={emailError ? "email-error" : undefined} /> {isValidating && ( <span className="absolute right-3 top-3"> <svg className="animate-spin h-5 w-5" viewBox="0 0 24 24"> {/* Loading spinner icon */} </svg> </span> )} </div> {emailError && ( <p id="email-error" className="text-sm text-red-600" role="alert"> {emailError} </p> )} {!emailError && email && !isValidating && ( <p className="text-sm text-green-600">사용 가능한 이메일입니다</p> )} </div> ); }

고급 패턴

3. Zod 스키마 검증

Zod를 사용한 타입 안전 스키마 기반 검증 패턴입니다.

"use client"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Input, Button, Label } from "@vortex/ui-foundation"; // Zod 스키마 정의 const signupSchema = z.object({ email: z .string() .min(1, "이메일을 입력해주세요") .email("올바른 이메일 형식이 아닙니다"), password: z .string() .min(8, "비밀번호는 최소 8자 이상이어야 합니다") .regex( /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, "대소문자와 숫자를 포함해야 합니다" ), age: z .number() .min(18, "18세 이상만 가입 가능합니다") .max(120, "올바른 나이를 입력해주세요"), terms: z.boolean().refine((val) => val === true, "이용약관에 동의해주세요"), }); type SignupFormData = z.infer<typeof signupSchema>; export default function ZodValidation() { const { register, handleSubmit, formState: { errors }, } = useForm<SignupFormData>({ resolver: zodResolver(signupSchema), }); const onSubmit = (data: SignupFormData) => { console.log("Valid data:", data); }; return ( <form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> <div className="space-y-2"> <Label htmlFor="email">이메일</Label> <Input id="email" {...register("email")} /> {errors.email && ( <p className="text-sm text-red-600">{errors.email.message}</p> )} </div> <div className="space-y-2"> <Label htmlFor="password">비밀번호</Label> <Input id="password" type="password" {...register("password")} /> {errors.password && ( <p className="text-sm text-red-600">{errors.password.message}</p> )} </div> <div className="space-y-2"> <Label htmlFor="age">나이</Label> <Input id="age" type="number" {...register("age", { valueAsNumber: true })} /> {errors.age && ( <p className="text-sm text-red-600">{errors.age.message}</p> )} </div> <div className="flex items-center space-x-2"> <input id="terms" type="checkbox" {...register("terms")} /> <Label htmlFor="terms">이용약관에 동의합니다</Label> </div> {errors.terms && ( <p className="text-sm text-red-600">{errors.terms.message}</p> )} <Button type="submit" className="w-full"> 회원가입 </Button> </form> ); }

4. 서버 검증 통합

클라이언트와 서버 검증을 통합한 패턴입니다.

"use client"; import { useState } from "react"; import { useForm } from "react-hook-form"; import { Input, Button, Label, Alert } from "@vortex/ui-foundation"; interface FormData { email: string; username: string; } interface ServerError { field: string; message: string; } export default function ServerValidation() { const { register, handleSubmit, setError, formState: { errors, isSubmitting }, } = useForm<FormData>(); const [serverError, setServerError] = useState(""); const onSubmit = async (data: FormData) => { setServerError(""); try { const response = await fetch("/api/signup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); if (!response.ok) { const errorData = await response.json(); // 필드별 에러 처리 if (errorData.errors) { errorData.errors.forEach((error: ServerError) => { setError(error.field as keyof FormData, { type: "server", message: error.message, }); }); } // 전역 에러 처리 if (errorData.message) { setServerError(errorData.message); } return; } alert("회원가입 성공!"); } catch (error) { setServerError("서버 오류가 발생했습니다. 다시 시도해주세요."); } }; return ( <form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> {serverError && ( <Alert variant="destructive" role="alert"> {serverError} </Alert> )} <div className="space-y-2"> <Label htmlFor="email">이메일</Label> <Input id="email" {...register("email", { required: "이메일을 입력해주세요", })} /> {errors.email && ( <p className="text-sm text-red-600">{errors.email.message}</p> )} </div> <div className="space-y-2"> <Label htmlFor="username">사용자명</Label> <Input id="username" {...register("username", { required: "사용자명을 입력해주세요", minLength: { value: 3, message: "사용자명은 최소 3자 이상이어야 합니다", }, })} /> {errors.username && ( <p className="text-sm text-red-600">{errors.username.message}</p> )} </div> <Button type="submit" disabled={isSubmitting} className="w-full"> {isSubmitting ? "처리 중..." : "회원가입"} </Button> </form> ); }

5. 다단계 폼 검증

여러 단계로 나누어진 폼의 검증 패턴입니다.

"use client"; import { useState } from "react"; import { useForm } from "react-hook-form"; import { Button } from "@vortex/ui-foundation"; interface Step1Data { email: string; password: string; } interface Step2Data { name: string; phone: string; } interface Step3Data { address: string; zipcode: string; } export default function MultiStepValidation() { const [step, setStep] = useState(1); const [formData, setFormData] = useState({}); const { register, handleSubmit, formState: { errors }, trigger, } = useForm(); const onSubmitStep1 = async (data: Step1Data) => { const isValid = await trigger(["email", "password"]); if (isValid) { setFormData({ ...formData, ...data }); setStep(2); } }; const onSubmitStep2 = async (data: Step2Data) => { const isValid = await trigger(["name", "phone"]); if (isValid) { setFormData({ ...formData, ...data }); setStep(3); } }; const onSubmitStep3 = async (data: Step3Data) => { const finalData = { ...formData, ...data }; console.log("Final submission:", finalData); // API 제출 }; return ( <div className="w-full max-w-md"> {/* Progress Bar */} <div className="flex gap-2 mb-6"> {[1, 2, 3].map((s) => ( <div key={s} className={`flex-1 h-2 rounded ${ s <= step ? "bg-blue-500" : "bg-gray-200" }`} /> ))} </div> {/* Step 1 */} {step === 1 && ( <form onSubmit={handleSubmit(onSubmitStep1)} className="space-y-4"> <h2 className="text-xl font-bold">Step 1: 계정 정보</h2> {/* Email, Password inputs */} <Button type="submit">다음</Button> </form> )} {/* Step 2 */} {step === 2 && ( <form onSubmit={handleSubmit(onSubmitStep2)} className="space-y-4"> <h2 className="text-xl font-bold">Step 2: 개인 정보</h2> {/* Name, Phone inputs */} <div className="flex gap-2"> <Button type="button" variant="outline" onClick={() => setStep(1)}> 이전 </Button> <Button type="submit">다음</Button> </div> </form> )} {/* Step 3 */} {step === 3 && ( <form onSubmit={handleSubmit(onSubmitStep3)} className="space-y-4"> <h2 className="text-xl font-bold">Step 3: 주소 정보</h2> {/* Address, Zipcode inputs */} <div className="flex gap-2"> <Button type="button" variant="outline" onClick={() => setStep(2)}> 이전 </Button> <Button type="submit">완료</Button> </div> </form> )} </div> ); }

Best Practices

✅ 권장 사항

  1. 접근성 우선

    • aria-invalid 속성으로 에러 상태 표시
    • role="alert"로 에러 메시지 즉시 알림
    • aria-describedby로 입력 필드와 에러 메시지 연결
  2. 명확한 에러 메시지

    • 구체적인 문제 설명 (“이메일을 입력해주세요” vs “필수 항목”)
    • 해결 방법 제시 (“8자 이상 입력” vs “비밀번호 오류”)
    • 긍정적인 표현 (“최소 8자 필요” vs “너무 짧음”)
  3. 실시간 피드백

    • 디바운스 적용 (500ms 권장)
    • 성공 상태도 표시 (녹색 체크 아이콘)
    • 로딩 상태 명시
  4. 클라이언트 + 서버 검증

    • 클라이언트: 사용자 경험 개선
    • 서버: 보안 및 데이터 무결성
    • 둘 다 필수!
  5. 타입 안전성

    • Zod/Yup 스키마로 타입 추론
    • TypeScript 타입 정의
    • 컴파일 타임 에러 방지

⚠️ 피해야 할 것

  1. 보안 취약점

    • 클라이언트 검증만 의존 (우회 가능)
    • 구체적인 오류 정보 노출 (“john@example.com은 이미 존재” → “이메일 중복”)
  2. UX 문제

    • submit 전까지 에러 숨김 (실시간 피드백 제공)
    • 모든 에러를 한 번에 표시 (첫 에러에 포커스)
    • 애매한 에러 메시지 (“유효하지 않음”)
  3. 성능 문제

    • 디바운스 없이 실시간 API 호출
    • 불필요한 재검증
    • 큰 폼에서 전체 재렌더링

보안 고려사항

Input Sanitization

// ✅ DOMPurify로 XSS 방어 import DOMPurify from "dompurify"; const sanitizeInput = (input: string) => { return DOMPurify.sanitize(input, { ALLOWED_TAGS: [] }); };

Rate Limiting

// ✅ 클라이언트 사이드 rate limiting let submitAttempts = 0; const MAX_ATTEMPTS = 5; const onSubmit = async (data) => { if (submitAttempts >= MAX_ATTEMPTS) { setError("너무 많은 시도가 있었습니다. 잠시 후 다시 시도해주세요."); return; } submitAttempts++; // ... submit logic };

Foundation 예제

범용 회원가입 폼

Foundation 컴포넌트로 구현한 중립적인 회원가입 폼입니다.

import { useForm } from "react-hook-form"; import { Input, Button, Label } from "@vortex/ui-foundation"; export default function FoundationSignup() { const { register, handleSubmit, formState: { errors }, } = useForm(); return ( <form onSubmit={handleSubmit((data) => console.log(data))} className="space-y-4" > <div> <Label htmlFor="email">이메일</Label> <Input id="email" {...register("email", { required: "이메일을 입력해주세요" })} /> {errors.email && ( <p className="text-red-600 text-sm">{errors.email.message}</p> )} </div> <Button type="submit">가입하기</Button> </form> ); }

전체 예제 보기 →


iCignal 예제

데이터 입력 폼

iCignal Blue 브랜드를 적용한 분석 데이터 입력 폼입니다.

import "@vortex/ui-icignal/theme"; import { useForm } from "react-hook-form"; import { Input, Button, Card } from "@vortex/ui-icignal"; export default function ISignalDataForm() { const { register, handleSubmit } = useForm(); return ( <Card className="p-6 border-blue-500"> <h3 className="text-lg font-bold text-blue-600 mb-4">데이터 입력</h3> <form className="space-y-4"> <Input {...register("metric", { required: true })} placeholder="지표명" className="border-blue-200" /> <Button variant="primary" className="bg-blue-500"> 데이터 제출 </Button> </form> </Card> ); }

전체 예제 보기 →


Cals 예제

예약 정보 입력 폼

Cals Pink 브랜드를 적용한 예약 정보 입력 폼입니다.

import "@vortex/ui-cals/theme"; import { useForm } from "react-hook-form"; import { Input, Button, Card } from "@vortex/ui-cals"; export default function CalsBookingForm() { const { register, handleSubmit } = useForm(); return ( <Card className="p-6 border-pink-500"> <h3 className="text-lg font-bold text-pink-600 mb-4">예약 정보</h3> <form className="space-y-4"> <Input {...register("customerName", { required: "이름을 입력해주세요" })} placeholder="고객명" className="border-pink-200 focus:border-pink-500" /> <Input {...register("phone", { required: "연락처를 입력해주세요" })} placeholder="연락처" className="border-pink-200 focus:border-pink-500" /> <Button variant="primary" className="bg-pink-500"> 예약 확인 </Button> </form> </Card> ); }

전체 예제 보기 →


CodeSandbox

CodeSandbox 예제는 곧 제공될 예정입니다.

로컬에서 실행하기

  1. 프로젝트 생성

    npx @vortex/cli init my-form-project --template vite-react cd my-form-project
  2. 의존성 추가

    pnpm add react-hook-form @hookform/resolvers zod
  3. 컴포넌트 추가

    # Foundation npx @vortex/cli add input button label alert --package foundation # iCignal npx @vortex/cli add input button card --package icignal # Cals npx @vortex/cli add input button card --package cals
  4. 코드 복사 및 실행

    pnpm dev

관련 패턴

Last updated on