메인 μ½˜ν…μΈ λ‘œ κ±΄λ„ˆλ›°κΈ°
SonamuλŠ” Zodλ₯Ό μ‚¬μš©ν•˜μ—¬ TypeScript νƒ€μž…μ„ λŸ°νƒ€μž„μ—μ„œλ„ κ²€μ¦ν•©λ‹ˆλ‹€. 이 λ¬Έμ„œλŠ” Zod validation의 λͺ¨λ“  κΈ°λŠ₯κ³Ό νŒ¨ν„΄μ„ μ„€λͺ…ν•©λ‹ˆλ‹€.

Zodλž€?

νƒ€μž… μ•ˆμ „ μŠ€ν‚€λ§ˆ

TypeScript νƒ€μž… μžλ™ μΆ”λ‘  별도 νƒ€μž… μ •μ˜ λΆˆν•„μš”

λŸ°νƒ€μž„ 검증

μ‹€ν–‰ μ‹œ 데이터 검증 잘λͺ»λœ 데이터 차단

μƒμ„Έν•œ μ—λŸ¬

ν•„λ“œλ³„ μ—λŸ¬ λ©”μ‹œμ§€ 디버깅 용이

λ³€ν™˜ & κΈ°λ³Έκ°’

데이터 λ³€ν™˜ 및 κΈ°λ³Έκ°’ μœ μ—°ν•œ 처리

κΈ°λ³Έ μ‚¬μš©λ²•

Zod μŠ€ν‚€λ§ˆλŠ” Entityμ—μ„œ μžλ™ μƒμ„±λ˜μ§€λ§Œ, 직접 μ •μ˜ν•  μˆ˜λ„ μžˆμŠ΅λ‹ˆλ‹€.

μŠ€ν‚€λ§ˆ μ •μ˜

import { z } from "zod";

// Entityλ‘œλΆ€ν„° μžλ™ 생성
export const User = z.object({
id: z.number().int(),
email: z.string().max(100),
age: z.number().int(),
role: z.enum(["admin", "normal"]),
});

export type User = z.infer<typeof User>;

데이터 검증

import { User } from "./user.types";

try {
const user = User.parse({
id: 1,
email: "john@test.com",
age: 30,
role: "admin",
});
// βœ… 검증 성곡, userλŠ” User νƒ€μž…
console.log(user.email);
} catch (error) {
// ❌ 검증 μ‹€νŒ¨ μ‹œ ZodError
console.error(error);
}

parse vs safeParse:
  • parse(): μ—λŸ¬λ₯Ό λ˜μ§‘λ‹ˆλ‹€. try-catch둜 처리
  • safeParse(): μ—λŸ¬λ₯Ό λ°˜ν™˜ν•©λ‹ˆλ‹€. result.success둜 체크
API ν•Έλ“€λŸ¬μ—μ„œλŠ” parse()λ₯Ό μ‚¬μš©ν•˜μ—¬ μžλ™μœΌλ‘œ 400 μ—λŸ¬λ₯Ό λ°˜ν™˜ν•˜κ³ , UIμ—μ„œλŠ” safeParse()둜 μ•ˆμ „ν•˜κ²Œ μ²˜λ¦¬ν•©λ‹ˆλ‹€.

κΈ°λ³Έ νƒ€μž… 검증

Entity의 각 νƒ€μž…λ³„ Zod κ²€μ¦μž…λ‹ˆλ‹€.

λ¬Έμžμ—΄ 검증

const email = z.string();
email.parse("john@test.com");  // βœ…

const withLength = z.string().max(100);
withLength.parse("a".repeat(100)); // βœ…
withLength.parse("a".repeat(101)); // ❌

숫자 검증

const age = z.number().int();
age.parse(30);  // βœ…
age.parse(30.5);  // ❌ Must be integer

λ‚ μ§œ 검증

const birthDate = z.date();
birthDate.parse(new Date());  // βœ…
birthDate.parse("2024-01-01");  // ❌ Expected Date, received string

뢈린 검증

const isActive = z.boolean();
isActive.parse(true); // βœ…
isActive.parse(false); // βœ…
isActive.parse("true"); // ❌ Expected boolean, received string

// λ³€ν™˜: λ¬Έμžμ—΄ β†’ 뢈린
const coerced = z.coerce.boolean();
coerced.parse("true"); // βœ… true
coerced.parse("false"); // βœ… false
coerced.parse(1); // βœ… true
coerced.parse(0); // βœ… false

볡합 νƒ€μž… 검증

λ°°μ—΄

const tags = z.string().array();
tags.parse(["typescript", "nodejs"]);  // βœ…
tags.parse(["typescript", 123]);  // ❌

객체

const user = z.object({
  name: z.string(),
  profile: z.object({
    bio: z.string(),
    website: z.string().url().optional(),
  }),
});

user.parse({
name: "John",
profile: {
bio: "Hello",
website: "https://john.com",
},
}); // βœ…

Enum

const role = z.enum(["admin", "moderator", "normal"]);

role.parse("admin"); // βœ…
role.parse("guest"); // ❌ Invalid enum value

Union (μ—¬λŸ¬ νƒ€μž… 쀑 ν•˜λ‚˜)

const idOrEmail = z.union([z.number().int(), z.string().email()]);

idOrEmail.parse(123); // βœ…
idOrEmail.parse("john@test.com"); // βœ…
idOrEmail.parse("not-an-email"); // ❌

κ³ κΈ‰ 검증 νŒ¨ν„΄

쑰건뢀 검증

const password = z.string()
  .min(8)
  .refine(
    (val) => /[A-Z]/.test(val),
    { message: "λŒ€λ¬Έμžλ₯Ό 포함해야 ν•©λ‹ˆλ‹€" }
  )
  .refine(
    (val) => /[0-9]/.test(val),
    { message: "숫자λ₯Ό 포함해야 ν•©λ‹ˆλ‹€" }
  )
  .refine(
    (val) => /[!@#$%^&*]/.test(val),
    { message: "특수문자λ₯Ό 포함해야 ν•©λ‹ˆλ‹€" }
  );

password.parse("Abc12345!"); // βœ…
password.parse("abc12345"); // ❌ λŒ€λ¬Έμž μ—†μŒ

데이터 λ³€ν™˜

const age = z.string().transform(val => parseInt(val, 10));

age.parse("30"); // βœ… 30 (number)

κΈ°λ³Έκ°’ μ„€μ •

const settings = z.object({
  theme: z.enum(["light", "dark"]).default("light"),
  notifications: z.boolean().default(true),
});

settings.parse({}); // βœ… { theme: "light", notifications: true }
settings.parse({ theme: "dark" }); // βœ… { theme: "dark", notifications: true }

μ‹€μ „ 검증 νŒ¨ν„΄

API νŒŒλΌλ―Έν„° 검증

export const UserListParams = z.object({
  num: z.number().int().min(1).max(100).default(24),
  page: z.number().int().min(1).default(1),
  search: z.enum(["id", "email", "username"]).optional(),
  keyword: z.string().trim().optional(),
  orderBy: z.enum(["id-desc", "id-asc", "created_at-desc"]).optional(),
}).refine(
  (data) => {
    // keywordκ°€ 있으면 search도 ν•„μˆ˜
    if (data.keyword && !data.search) {
      return false;
    }
    return true;
  },
  {
    message: "keyword와 searchλŠ” ν•¨κ»˜ μ‚¬μš©ν•΄μ•Ό ν•©λ‹ˆλ‹€",
    path: ["search"],
  }
);

λΉ„μ¦ˆλ‹ˆμŠ€ 둜직 검증

const dateRange = z.object({
  startDate: z.date(),
  endDate: z.date(),
}).refine(
  (data) => data.endDate > data.startDate,
  {
    message: "μ’…λ£ŒμΌμ€ μ‹œμž‘μΌλ³΄λ‹€ 이후여야 ν•©λ‹ˆλ‹€",
    path: ["endDate"],
  }
).refine(
  (data) => {
    const diff = data.endDate.getTime() - data.startDate.getTime();
    const days = diff / (1000 * 60 * 60 * 24);
    return days <= 365;
  },
  {
    message: "기간은 μ΅œλŒ€ 1λ…„κΉŒμ§€λ§Œ κ°€λŠ₯ν•©λ‹ˆλ‹€",
    path: ["endDate"],
  }
);

Form 검증

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { UserSaveParams } from "@/services/user/user.types";

function UserForm() {
  const form = useForm({
    resolver: zodResolver(UserSaveParams),
    defaultValues: {
      email: "",
      username: "",
      role: "normal",
    },
  });

  const onSubmit = form.handleSubmit((data) => {
    // dataλŠ” μžλ™μœΌλ‘œ UserSaveParams νƒ€μž…
    console.log(data);
  });

  return (
    <form onSubmit={onSubmit}>
      <input {...form.register("email")} />
      {form.formState.errors.email && <span>{form.formState.errors.email.message}</span>}

      <input {...form.register("username")} />
      {form.formState.errors.username && <span>{form.formState.errors.username.message}</span>}

      <button type="submit">μ €μž₯</button>
    </form>
  );
}

μ—λŸ¬ 처리

ZodError ꡬ쑰

try {
  User.parse(invalidData);
} catch (error) {
  if (error instanceof z.ZodError) {
    console.log(error.issues);
    // [
    //   {
    //     code: "invalid_type",
    //     expected: "string",
    //     received: "number",
    //     path: ["email"],
    //     message: "Expected string, received number"
    //   },
    //   {
    //     code: "too_small",
    //     minimum: 8,
    //     type: "string",
    //     inclusive: true,
    //     path: ["password"],
    //     message: "String must contain at least 8 character(s)"
    //   }
    // ]
  }
}

μ—λŸ¬ λ©”μ‹œμ§€ μ»€μŠ€ν„°λ§ˆμ΄μ§•

const user = z.object({
  email: z.string().email("μœ νš¨ν•œ 이메일 μ£Όμ†Œλ₯Ό μž…λ ₯ν•˜μ„Έμš”"),
  age: z.number()
    .int("λ‚˜μ΄λŠ” μ •μˆ˜μ—¬μ•Ό ν•©λ‹ˆλ‹€")
    .min(0, "λ‚˜μ΄λŠ” 0 이상이어야 ν•©λ‹ˆλ‹€")
    .max(120, "λ‚˜μ΄λŠ” 120 μ΄ν•˜μ—¬μ•Ό ν•©λ‹ˆλ‹€"),
});

μ—λŸ¬ ν¬λ§·νŒ…

const result = User.safeParse(invalidData);

if (!result.success) {
  // Flat μ—λŸ¬ (ν•„λ“œλ³„)
  const flatErrors = result.error.flatten();
  console.log(flatErrors.fieldErrors);
  // {
  //   email: ["Invalid email"],
  //   age: ["Number must be greater than 0"]
  // }

  // Form μ—λŸ¬ (React Hook Form ν˜•μ‹)
  const formErrors = result.error.format();
  console.log(formErrors.email?._errors); // ["Invalid email"]
}

μ„±λŠ₯ μ΅œμ ν™”

μŠ€ν‚€λ§ˆ μž¬μ‚¬μš©

// ❌ 맀번 μƒˆλ‘œ 생성 (느림)
function validate(data: unknown) {
  const schema = z.object({
    name: z.string(),
    age: z.number(),
  });
  return schema.parse(data);
}

// βœ… μŠ€ν‚€λ§ˆ μž¬μ‚¬μš© (빠름)
const schema = z.object({
  name: z.string(),
  age: z.number(),
});

function validate(data: unknown) {
  return schema.parse(data);
}

λΆ€λΆ„ 검증

// 전체 검증 λΆˆν•„μš” μ‹œ λΆ€λΆ„ 검증
const user = z.object({
  id: z.number(),
  email: z.string().email(),
  profile: z.object({
    bio: z.string(),
    website: z.string().url(),
  }),
});

// μ΄λ©”μΌλ§Œ 검증
const emailOnly = user.pick({ email: true });
emailOnly.parse({ email: "john@test.com" }); // βœ… 빠름

// profile만 검증
const profileOnly = user.pick({ profile: true });

λ‹€μŒ 단계

E2E Type Safety

μ—”λ“œνˆ¬μ—”λ“œ νƒ€μž… μ•ˆμ „μ„±

Entity Types

Entity νƒ€μž… λ³€ν™˜

Generated Types

생성 νƒ€μž… ν™œμš©

Model Testing

Zod 검증 ν…ŒμŠ€νŠΈν•˜κΈ°