juni
타입 검증 (zod, ReactHookForm) 본문
Zod
- TypeScript 기반의 런타임 타입 검증 라이브러리
- 스키마를 정의하고 데이터의 유효성을 검증하는 도구
- TypeScript 타입과 런타임 검증을 동시에 제공
React Hook Form
- React 애플리케이션에서 폼 상태를 관리하는 라이브러리
- 성능 최적화와 사용자 경험 개선에 중점
- 폼 상태 관리와 유효성 검증을 효율적으로 처리
왜 함께 사용하나?
- Zod: 타입 안전성과 데이터 검증
- React Hook Form: 폼 상태 관리와 사용자 경험
- 두 라이브러리의 장점을 결합하여 더 강력한 폼 구현 가능
Zod 스키마 작성법
기본 스키마 정의
import * as z from "zod";
// 기본 객체 스키마
const userSchema = z.object({
name: z.string(),
age: z.number(),
email: z.string().email(),
});
// 중첩 객체 스키마
const addressSchema = z.object({
street: z.string(),
city: z.string(),
country: z.string(),
});
const userWithAddressSchema = z.object({
...userSchema.shape,
address: addressSchema,
});
다양한 검증 메서드
// 문자열 검증
const stringSchema = z.string()
.min(1, "최소 1자 이상")
.max(100, "최대 100자")
.trim()
.regex(/^[a-zA-Z]+$/, "영문자만 가능");
// 숫자 검증
const numberSchema = z.number()
.min(0, "0 이상")
.max(100, "100 이하")
.positive("양수만 가능");
// 배열 검증
const arraySchema = z.array(z.string())
.min(1, "최소 1개 이상")
.max(5, "최대 5개");
// 선택적 필드
const optionalSchema = z.object({
name: z.string(),
nickname: z.string().optional(),
});
커스텀 검증
// refine을 사용한 커스텀 검증
const passwordSchema = z.string()
.min(8, "8자 이상")
.refine(
(password) => /[A-Z]/.test(password),
"대문자를 포함해야 합니다"
)
.refine(
(password) => /[a-z]/.test(password),
"소문자를 포함해야 합니다"
);
// 여러 필드 간의 관계 검증
const signUpSchema = z.object({
password: z.string(),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{
message: "비밀번호가 일치하지 않습니다",
path: ["confirmPassword"],
}
);
주요 zod 메서드
// 문자열 검증
z.string()
.min(1) // 최소 길이
.max(100) // 최대 길이
.trim() // 공백 제거
.regex(/패턴/) // 정규식 검증
.email() // 이메일 형식
.url() // URL 형식
.uuid() // UUID 형식
// 숫자 검증
z.number()
.min(0) // 최소값
.max(100) // 최대값
.positive() // 양수
.negative() // 음수
.int() // 정수
// 배열 검증
z.array(z.string())
.min(1) // 최소 길이
.max(10) // 최대 길이
.nonempty() // 비어있지 않음
// 객체 검증
z.object({
name: z.string(),
age: z.number(), // 해당 필드 선택적
}).partial() // 모든 필드 선택적
// Nullable
const schema = z.object({
name: z.string().nullable(), // null 허용
age: z.number().nullable(),
address: z.string().nullable().optional(), // null과 undefined 모두 허용
});
// Union 타입
const statusSchema = z.union([
z.literal("active"),
z.literal("inactive"),
z.literal("pending")
]);
const userSchema = z.object({
name: z.string(),
status: statusSchema,
role: z.enum(["admin", "user", "guest"]), // enum도 유니온의 한 형태
});
// 배열
const schema = z.object({
tags: z.array(z.string())
.min(1, "최소 1개의 태그가 필요합니다")
.max(5, "최대 5개의 태그만 가능합니다"),
scores: z.array(z.number())
.nonempty("점수를 하나 이상 입력해주세요"),
images: z.array(z.instanceof(File))
.optional(),
});
// 객체, 중첩 객체
const addressSchema = z.object({
street: z.string(),
city: z.string(),
country: z.string(),
postalCode: z.string().optional(),
});
const userSchema = z.object({
name: z.string(),
age: z.number(),
address: addressSchema, // 중첩된 객체
contacts: z.array(z.object({ // 객체 배열
type: z.enum(["email", "phone"]),
value: z.string(),
})),
});
// 데이터 변환 (Transform)
const schema = z.object({
name: z.string()
.transform(val => val.trim()), // 공백 제거
age: z.string()
.transform(val => parseInt(val)), // 문자열을 숫자로 변환
email: z.string()
.transform(val => val.toLowerCase()), // 소문자로 변환
});
// 커스텀 검증 (Refine)
const passwordSchema = z.string()
.min(8)
.refine(
(password) => /[A-Z]/.test(password),
"대문자를 포함해야 합니다"
)
.refine(
(password) => /[a-z]/.test(password),
"소문자를 포함해야 합니다"
);
const dateSchema = z.object({
startDate: z.date(),
endDate: z.date(),
}).refine(
(data) => data.endDate > data.startDate,
{
message: "종료일은 시작일보다 이후여야 합니다",
path: ["endDate"],
}
);
// Discriminated Union (구분된 유니온)
const shapeSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("circle"),
radius: z.number(),
}),
z.object({
type: z.literal("square"),
side: z.number(),
}),
z.object({
type: z.literal("rectangle"),
width: z.number(),
height: z.number(),
}),
]);
ReactHookForm 사용법
기본 설정
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
// Zod 스키마로부터 타입 추론
type FormValues = z.infer<typeof userSchema>;
// 폼 초기화
const form = useForm<FormValues>({
resolver: zodResolver(userSchema),
defaultValues: {
name: "",
age: 0,
email: "",
},
mode: "onChange", // 실시간 검증
});
검증 모드 종류
const form = useForm({
// mode 옵션들
mode: "onChange" | "onBlur" | "onSubmit" | "onTouched" | "all",
});
onChange (기본값)
- 입력할 때마다 실시간 검증
- 즉각적인 피드백
- 사용자 경험이 좋음
- 성능에 영향을 줄 수 있음
onBlur
- 필드에서 포커스가 벗어날 때 검증
- 실시간 검증보다 성능이 좋음
- 사용자가 필드를 완성한 후 피드백
onSubmit
- 폼 제출 시에만 검증
- 가장 성능이 좋음
- 사용자 경험이 상대적으로 낮음
onTouched
- 필드가 한 번이라도 상호작용된 후부터 검증
- 초기 렌더링 시에는 검증하지 않음
- 사용자 경험과 성능의 균형
all
- 모든 이벤트에서 검증
- 가장 포괄적인 검증
- 성능에 가장 큰 영향
React Hook Form 주요 기능
폼 제출 처리
// 1. 기본적인 폼 제출
const onSubmit = async (data: FormValues) => {
try {
await api.submit(data);
// 성공 처리
} catch (error) {
// 에러 처리
}
};
// 2. 제출 상태 관리
const form = useForm<FormValues>();
const { isSubmitting } = form.formState;
// 3. 제출 중 에러 처리
const onSubmit = async (data: FormValues) => {
try {
await api.submit(data);
toast.success("제출 완료");
} catch (error) {
form.setError("root", {
message: "제출 중 오류가 발생했습니다",
});
}
};
필드 값 감시 (watch)
// 1. 단일 필드 감시
const name = form.watch("name");
// 2. 여러 필드 감시
const { name, email } = form.watch(["name", "email"]);
// 3. 조건부 렌더링을 위한 감시
const isPasswordVisible = form.watch("showPassword");
// 4. 실시간 유효성 검사
useEffect(() => {
const subscription = form.watch((value, { name, type }) => {
if (name === "password") {
// 비밀번호 변경 시 추가 검증
validatePasswordStrength(value.password);
}
});
return () => subscription.unsubscribe();
}, [form]);
필드 값 설정 (setValue)
// 1. 기본적인 값 설정
form.setValue("name", "새로운 값");
// 2. 옵션과 함께 값 설정, 각 옵션은 기본값 false 옵션 더 존재
form.setValue("email", "new@email.com", {
shouldValidate: true, // 값 설정 후 유효성 검사 실행
shouldDirty: true, // 폼 상태를 dirty로 표시
shouldTouch: true, // 필드가 상호작용됐다고 표시
});
// 3. 여러 필드 동시 설정
form.setValue("user", {
name: "홍길동",
email: "hong@example.com",
});
// 4. 조건부 값 설정
if (form.watch("type") === "company") {
form.setValue("companyName", "");
}
폼 초기화 (reset)
// 1. 기본 초기화
form.reset();
// 2. 특정 값으로 초기화
form.reset({
name: "기본값",
email: "default@email.com",
});
// 3. 옵션과 함께 초기화, 각 옵션은 기본값 false 옵션 더 존재
form.reset(defaultValues, {
keepDirty: true, // 변경된 필드 유지
keepErrors: true, // 에러 상태 유지
keepIsSubmitted: true, // 제출 상태 유지
});
에러 처리
// 1. 에러 상태 확인
const { errors } = form.formState;
// 2. 특정 필드의 에러 확인
const nameError = form.formState.errors.name;
// 3. 에러 메시지 표시
<FormMessage>
{form.formState.errors.email?.message}
</FormMessage>
// 4. 수동으로 에러 설정
form.setError("email", {
type: "manual",
message: "이미 사용 중인 이메일입니다",
});
폼 상태 관리
// 1. 폼 상태 확인
const {
isDirty, // 값이 변경되었는지
isTouched, // 필드가 상호작용되었는지
isValid, // 모든 필드가 유효한지
isSubmitting, // 제출 중인지
isSubmitted, // 제출되었는지
} = form.formState;
// 2. 상태에 따른 UI 처리
<Button
disabled={!isDirty || isSubmitting}
onClick={form.handleSubmit(onSubmit)}
>
{isSubmitting ? "제출 중..." : "제출"}
</Button>
필드 등록 (register)
// 1. 기본 등록
<input {...form.register("name")} />
// 2. 옵션과 함께 등록
<input
{...form.register("email", {
required: "이메일을 입력해주세요",
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: "올바른 이메일을 입력해주세요",
},
})}
/>
// 3. 조건부 등록
{showPassword && (
<input
{...form.register("password", {
required: "비밀번호를 입력해주세요",
minLength: {
value: 8,
message: "8자 이상 입력해주세요",
},
})}
/>
)}
폼 유효성 검사
// 1. 전체 폼 유효성 검사
const isValid = await form.trigger();
// 2. 특정 필드 유효성 검사
const isEmailValid = await form.trigger("email");
// 3. 여러 필드 유효성 검사
const isValid = await form.trigger(["email", "password"]);
// 4. 조건부 유효성 검사
if (form.watch("type") === "email") {
await form.trigger("email");
}
폼 필드 구현 예시
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>이름</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
'CS' 카테고리의 다른 글
React의 라이프사이클과 가상 DOM 관점에서 Hook과 메모이제이션 동작 (0) | 2025.03.11 |
---|---|
HTML 이스케이프 (1) | 2025.02.27 |
프론트엔드 ( CS 정리 ) (0) | 2025.01.23 |
프론트엔드 CS 정리 6~10 (0) | 2025.01.15 |
프론트엔드 CS 정리 1~5 (0) | 2025.01.14 |