Skip to Content
PatternsForm Validation

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.tsx

1. 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) }

검증 흐름:

  1. 요청 검증: LoginFormSchema.parse()로 전송 데이터 검증
  2. API 호출: 검증된 데이터로 서버 요청
  3. 응답 검증: LoginResponseSchema.parse()로 응답 데이터 검증
  4. 타입 안전: 검증 실패 시 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

  • rules prop으로 에러 메시지 전달
  • valuegetValues로 실시간 값 추적
  • 일관된 폼 필드 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> 등 네이티브 요소에 적합
  • 주의: 외부 value prop과 함께 사용하면 안 됨 (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> ) }

관련 패턴

Last updated on