๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
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: "[email protected]",
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("[email protected]");  // โœ…

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("[email protected]"); // โœ…
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: "[email protected]" }); // โœ… ๋น ๋ฆ„

// profile๋งŒ ๊ฒ€์ฆ
const profileOnly = user.pick({ profile: true });

๋‹ค์Œ ๋‹จ๊ณ„