Notice
Recent Posts
Recent Comments
Link
«   2025/04   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30
Tags
more
Archives
Today
Total
관리 메뉴

juni

타입 검증 (zod, ReactHookForm) 본문

CS

타입 검증 (zod, ReactHookForm)

juni_shin 2025. 3. 31. 16:39

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>