메인 콘텐츠로 건너뛰기
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 });

다음 단계