Form Validation
폼 검증 및 에러 처리 구현 패턴입니다.
개요
Form Validation 패턴은 사용자 입력을 검증하고, 명확한 피드백을 제공하며, 접근 가능한 에러 메시지를 표시하는 검증된 구현 방법을 제공합니다. 실시간 검증, 서버 검증 통합, 다국어 에러 메시지, 복잡한 비즈니스 규칙 검증 등 프로덕션 환경에서 필요한 모든 폼 검증 시나리오를 다룹니다.
사용 사례:
- 회원가입/로그인 폼
- 주문/결제 폼
- 프로필 업데이트 폼
- 데이터 입력 폼
- 복잡한 다단계 폼
사용하지 말아야 할 때:
- 검색창 (실시간 제안만 필요)
- 단순 필터 (즉시 적용)
- 읽기 전용 폼
Best Practices
로그인 폼 (React Hook Form + Zod)
Feature 기반 구조
src/features/auth/
├── api/
│ └── login.ts # API 호출 함수
├── hooks/
│ └── mutations/
│ └── use-login.ts # React Query Mutation
├── schemas/
│ └── form.ts # 폼 검증 스키마
└── components/
└── login-form.tsx1. Zod 스키마 정의 (schemas/form.ts)
폼 검증 및 API 요청/응답 스키마를 정의합니다.
import { z } from "zod"
// 폼 검증 스키마
export const LoginFormSchema = z.object({
email: z
.string()
.min(1, "이메일을 입력해주세요")
.email("올바른 이메일 형식이 아닙니다")
.max(255, "이메일은 255자를 초과할 수 없습니다")
.toLowerCase()
.trim(),
password: z
.string()
.min(1, "비밀번호를 입력해주세요")
.min(6, "비밀번호는 최소 6자 이상이어야 합니다")
.max(100, "비밀번호는 100자를 초과할 수 없습니다")
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
"비밀번호는 대문자, 소문자, 숫자를 각각 최소 1개 이상 포함해야 합니다",
)
.regex(
/^[A-Za-z\d@$!%*?&#^()_+\-=\[\]{};':"\\|,.<>\/~`]+$/,
"비밀번호에 허용되지 않는 특수문자가 포함되어 있습니다",
),
})
export type LoginFormValues = z.infer<typeof LoginFormSchema>
// API 응답 스키마
export const LoginResponseSchema = z.object({
token: z.string(),
user: z.object({
id: z.number(),
email: z.string(),
name: z.string(),
}),
})
export type LoginResponse = z.infer<typeof LoginResponseSchema>2. API 함수 (api/login.ts)
요청/응답 데이터를 Zod로 검증하여 타입 안전성을 보장합니다.
import { api } from "@/lib/axios"
import {
LoginFormSchema,
LoginFormValues,
LoginResponseSchema,
LoginResponse,
} from "../schemas/form"
export async function login(data: LoginFormValues): Promise<LoginResponse> {
// 요청 데이터 검증
const validatedData = LoginFormSchema.parse(data)
// API 호출
const response = await api.$post<LoginResponse>("/auth/login", validatedData)
// 응답 데이터 검증
return LoginResponseSchema.parse(response)
}검증 흐름:
- 요청 검증:
LoginFormSchema.parse()로 전송 데이터 검증 - API 호출: 검증된 데이터로 서버 요청
- 응답 검증:
LoginResponseSchema.parse()로 응답 데이터 검증 - 타입 안전: 검증 실패 시
ZodError발생
3. React Query Mutation Hook (hooks/mutations/use-login.ts)
import { useMutation } from "@tanstack/react-query"
import { login } from "../../api/login"
import type { LoginFormValues } from "../../schemas/form"
export function useLogin() {
return useMutation({
mutationFn: login,
onError: (error) => {
console.error("로그인 실패:", error)
},
})
}4. 로그인 폼 컴포넌트 (components/login-form.tsx)
"use client"
import { useCallback } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { Input, FormItem } from "@vortex/ui-foundation"
import { LoginFormSchema, LoginFormValues } from "../schemas/form"
import { useLogin } from "../hooks/mutations/use-login"
import { useAuthStore } from "@/stores/auth"
export function UserLoginForm() {
const router = useRouter()
const { login: authLogin } = useAuthStore()
const { mutateAsync: login, isPending, error } = useLogin()
const {
register,
handleSubmit,
getValues,
formState: { errors },
} = useForm<LoginFormValues>({
resolver: zodResolver(LoginFormSchema),
defaultValues: {
email: "",
password: "",
},
})
const onSubmit = useCallback(
async (data: LoginFormValues) => {
try {
const response = await login(data)
authLogin(response.token, response.user)
router.push("/dashboard")
} catch (error) {
// 에러는 useLogin의 onError에서 처리됨
console.error("로그인 실패:", error)
}
},
[login, authLogin, router],
)
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4 max-w-md"
>
<div>
<FormItem
label="이메일"
value={getValues("email") || ""}
rules={[() => errors.email?.message || true]}
>
<Input
{...register("email")}
type="email"
placeholder="이메일을 입력하세요"
className="w-full"
aria-invalid={errors.email ? "true" : "false"}
/>
</FormItem>
</div>
<div>
<FormItem
label="비밀번호"
value={getValues("password") || ""}
rules={[() => errors.password?.message || true]}
>
<Input
{...register("password")}
type="password"
placeholder="비밀번호를 입력하세요"
className="w-full"
aria-invalid={errors.password ? "true" : "false"}
/>
</FormItem>
</div>
{error && (
<p className="text-red-500 text-sm">
{error.message || "로그인에 실패했습니다."}
</p>
)}
<button
type="submit"
disabled={isPending}
className="bg-blue-500 text-white p-3 rounded hover:bg-blue-600 disabled:opacity-50"
>
{isPending ? "로그인 중..." : "로그인"}
</button>
</form>
)
}주요 패턴 정리
1. 계층 분리
- Schemas: Zod 스키마로 검증 로직 정의
- API: HTTP 요청 로직
- Hooks: React Query Mutation으로 상태 관리
- Components: UI 렌더링 및 폼 제출
2. React Hook Form + Zod
zodResolver로 Zod 스키마 연결formState.errors로 클라이언트 검증 에러- 타입 안전한 폼 데이터 (
LoginFormValues)
3. React Query Mutation
mutate로 비동기 로그인 처리isPending으로 로딩 상태 관리onSuccess콜백으로 성공 후 처리 (인증 상태 저장, 리다이렉트)error로 서버 에러 표시
4. Vortex UI FormItem
rulesprop으로 에러 메시지 전달value와getValues로 실시간 값 추적- 일관된 폼 필드 UI
5. 접근성
aria-invalid로 에러 상태 표시disabled상태로 중복 제출 방지- 명확한 에러 메시지 제공
useForm 반환값 활용
useForm은 폼 상태를 제어하는 다양한 메서드를 반환합니다. 각 메서드의 역할과 실전 사용법을 정리합니다.
반환값 한눈에 보기
const {
register, // input을 ref 기반(uncontrolled)으로 등록
handleSubmit, // 폼 제출 시 검증 후 콜백 실행
control, // Controller 컴포넌트에 전달 (controlled 방식)
setValue, // 프로그래밍 방식으로 필드 값 설정
setError, // 수동으로 에러 설정 (서버 에러 등)
setFocus, // 특정 필드에 포커스 이동
reset, // 폼 전체를 초기값으로 리셋
getValues, // 현재 폼 값 조회
watch, // 필드 값 변경을 구독 (리렌더링 발생)
trigger, // 수동으로 검증 실행
clearErrors, // 에러 초기화
formState, // { errors, isDirty, isValid, isSubmitting, ... }
} = useForm<FormValues>({
resolver: zodResolver(FormSchema),
defaultValues: { ... },
})register vs Controller
useForm에서 input을 연결하는 두 가지 방식입니다. 상황에 따라 올바른 방식을 선택해야 합니다.
register — ref 기반 (uncontrolled)
// register는 { onChange, onBlur, ref, name }을 반환
<Input {...register("email")} placeholder="이메일" />- DOM ref를 통해 값을 읽으므로 리렌더링이 최소화됨
- 일반
<input>,<textarea>등 네이티브 요소에 적합 - 주의: 외부
valueprop과 함께 사용하면 안 됨 (uncontrolled ↔ controlled 충돌)
Controller — state 기반 (controlled)
<Controller
name="role"
control={control}
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange}
items={ROLE_OPTIONS}
/>
)}
/>- react-hook-form이 값을 state로 관리하고,
field.value/field.onChange로 동기화 - Select, Switch, CheckboxGroup 등 커스텀 UI 컴포넌트에 적합
- 외부에서 값을 주입하거나 읽어야 하는 경우에 사용
선택 기준:
| 상황 | 방식 |
|---|---|
일반 <input>, <textarea> | register |
| Select, Switch, DatePicker 등 커스텀 컴포넌트 | Controller |
| 외부 상태와 양방향 동기화 필요 | Controller |
setValue — 프로그래밍 방식으로 값 설정
외부 데이터를 폼에 주입할 때 사용합니다. register로 등록된 input에 직접 value prop을 전달하면 안 되고, 반드시 setValue를 사용해야 합니다.
const { setValue } = useForm<FormValues>({ ... })
// 서버에서 불러온 데이터를 폼에 채워넣기
useEffect(() => {
if (userData) {
setValue("firstName", userData.firstName)
setValue("email", userData.email)
}
}, [userData, setValue])
// 버튼 클릭으로 임시 데이터 주입
<button onClick={() => {
setValue("firstName", "홍길동")
setValue("email", "hong@example.com")
}}>
샘플 데이터 채우기
</button>옵션:
// 검증 실행 + dirty 상태 반영
setValue("email", "new@email.com", {
shouldValidate: true, // 값 설정 후 검증 실행
shouldDirty: true, // isDirty 상태 업데이트
shouldTouch: true, // isTouched 상태 업데이트
})setError — 수동 에러 설정
서버 응답 에러나 커스텀 비즈니스 로직 에러를 폼에 표시할 때 사용합니다.
const { setError, setFocus } = useForm<FormValues>({ ... })
const onSubmit = async (data: FormValues) => {
try {
await login(data)
} catch (error) {
if (error.code === "EMAIL_NOT_FOUND") {
// 특정 필드에 서버 에러 표시
setError("email", {
type: "server",
message: "등록되지 않은 이메일입니다",
})
setFocus("email") // 에러 필드로 포커스 이동
}
if (error.code === "INVALID_PASSWORD") {
setError("password", {
type: "server",
message: "비밀번호가 일치하지 않습니다",
})
setFocus("password")
}
// 특정 필드가 아닌 폼 전체 에러
setError("root", {
type: "server",
message: "서버에 문제가 발생했습니다. 잠시 후 다시 시도해주세요.",
})
}
}
// root 에러 표시
{errors.root && (
<p className="text-red-500 text-sm">{errors.root.message}</p>
)}setFocus — 포커스 이동
특정 필드로 포커스를 이동시킵니다. 에러 발생 시 해당 필드로 안내하거나, 폼 마운트 시 첫 번째 필드에 포커스를 줄 때 유용합니다.
const { setFocus } = useForm<FormValues>({ ... })
// 마운트 시 첫 번째 필드에 포커스
useEffect(() => {
setFocus("email")
}, [setFocus])
// 검증 실패 시 첫 번째 에러 필드로 이동
const onInvalid = (errors: FieldErrors<FormValues>) => {
const firstErrorField = Object.keys(errors)[0] as keyof FormValues
if (firstErrorField) {
setFocus(firstErrorField)
}
}
<form onSubmit={handleSubmit(onSubmit, onInvalid)}>reset — 폼 전체 리셋
폼의 모든 값, 에러, dirty 상태를 초기화합니다. 새로운 값으로 리셋할 수도 있습니다.
const { reset } = useForm<FormValues>({ ... })
// 기본값(defaultValues)으로 리셋
<button onClick={() => reset()}>초기화</button>
// 새로운 값으로 리셋 (서버 데이터 로드 후)
useEffect(() => {
if (serverData) {
reset({
firstName: serverData.firstName,
lastName: serverData.lastName,
email: serverData.email,
})
}
}, [serverData, reset])
// 특정 상태만 유지하면서 리셋
reset(undefined, {
keepErrors: true, // 에러 유지
keepDirty: true, // dirty 상태 유지
keepValues: true, // 값 유지, 상태만 리셋
keepDefaultValues: true, // defaultValues 유지
})setValue vs reset 선택 기준:
| 상황 | 방법 |
|---|---|
| 개별 필드 1~2개 업데이트 | setValue |
| 폼 전체를 새 데이터로 교체 | reset |
| 서버 데이터 초기 로드 | reset |
| 사용자 인터랙션으로 일부 필드 변경 | setValue |
실전 예시: 사용자 프로필 수정 폼
위 메서드들을 종합적으로 활용하는 예시입니다.
import { useEffect } from "react"
import { useForm, Controller } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { Input, Select, Button } from "@vortex/ui-icignal"
import { ProfileSchema, type ProfileValues } from "../schemas/profile"
import { useProfile, useUpdateProfile } from "../hooks"
export function ProfileForm() {
const { data: profile } = useProfile()
const { mutateAsync: updateProfile, isPending } = useUpdateProfile()
const {
register,
handleSubmit,
control,
reset,
setError,
setFocus,
formState: { errors, isDirty },
} = useForm<ProfileValues>({
resolver: zodResolver(ProfileSchema),
defaultValues: {
name: "",
email: "",
department: "",
},
})
// 서버 데이터 로드 시 폼 리셋
useEffect(() => {
if (profile) {
reset(profile)
}
}, [profile, reset])
// 마운트 시 첫 필드 포커스
useEffect(() => {
setFocus("name")
}, [setFocus])
const onSubmit = async (data: ProfileValues) => {
try {
await updateProfile(data)
reset(data) // 저장 성공 후 dirty 상태 초기화
} catch (error) {
if (error.code === "EMAIL_DUPLICATE") {
setError("email", {
type: "server",
message: "이미 사용 중인 이메일입니다",
})
setFocus("email")
}
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Input
{...register("name")}
label="이름"
error={errors.name?.message}
/>
<Input
{...register("email")}
label="이메일"
type="email"
error={errors.email?.message}
/>
<Controller
name="department"
control={control}
render={({ field }) => (
<Select
label="부서"
value={field.value}
onValueChange={field.onChange}
items={DEPARTMENT_OPTIONS}
error={errors.department?.message}
/>
)}
/>
{errors.root && (
<p className="text-destructive text-sm">{errors.root.message}</p>
)}
<div className="flex gap-2 justify-end">
<Button
type="button"
variant="outline"
onClick={() => reset()}
disabled={!isDirty}
>
취소
</Button>
<Button type="submit" disabled={isPending || !isDirty}>
{isPending ? "저장 중..." : "저장"}
</Button>
</div>
</form>
)
}관련 패턴
- Authentication - 인증 폼 구현
- Error Handling - 폼 에러 처리
- Accessibility Patterns - 접근 가능한 폼