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
✅ 권장 사항
-
접근성 우선
aria-invalid속성으로 에러 상태 표시role="alert"로 에러 메시지 즉시 알림aria-describedby로 입력 필드와 에러 메시지 연결
-
명확한 에러 메시지
- 구체적인 문제 설명 (“이메일을 입력해주세요” vs “필수 항목”)
- 해결 방법 제시 (“8자 이상 입력” vs “비밀번호 오류”)
- 긍정적인 표현 (“최소 8자 필요” vs “너무 짧음”)
-
실시간 피드백
- 디바운스 적용 (500ms 권장)
- 성공 상태도 표시 (녹색 체크 아이콘)
- 로딩 상태 명시
-
클라이언트 + 서버 검증
- 클라이언트: 사용자 경험 개선
- 서버: 보안 및 데이터 무결성
- 둘 다 필수!
-
타입 안전성
- Zod/Yup 스키마로 타입 추론
- TypeScript 타입 정의
- 컴파일 타임 에러 방지
⚠️ 피해야 할 것
-
보안 취약점
- 클라이언트 검증만 의존 (우회 가능)
- 구체적인 오류 정보 노출 (“john@example.com은 이미 존재” → “이메일 중복”)
-
UX 문제
- submit 전까지 에러 숨김 (실시간 피드백 제공)
- 모든 에러를 한 번에 표시 (첫 에러에 포커스)
- 애매한 에러 메시지 (“유효하지 않음”)
-
성능 문제
- 디바운스 없이 실시간 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 예제는 곧 제공될 예정입니다.
로컬에서 실행하기
-
프로젝트 생성
npx @vortex/cli init my-form-project --template vite-react cd my-form-project -
의존성 추가
pnpm add react-hook-form @hookform/resolvers zod -
컴포넌트 추가
# 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 -
코드 복사 및 실행
pnpm dev
관련 패턴
- Authentication - 인증 폼 구현
- Error Handling - 폼 에러 처리
- Accessibility Patterns - 접근 가능한 폼
Last updated on