메인 μ½˜ν…μΈ λ‘œ κ±΄λ„ˆλ›°κΈ°
SonamuλŠ” Zodλ₯Ό μ‚¬μš©ν•˜μ—¬ API νŒŒλΌλ―Έν„°λ₯Ό μžλ™μœΌλ‘œ 검증할 수 μžˆμŠ΅λ‹ˆλ‹€.

Zod 검증 κ°œμš”

νƒ€μž… μ•ˆμ „μ„±

컴파일 νƒ€μž„ κ²€μ¦λŸ°νƒ€μž„ 검증 μžλ™ν™”

μžλ™ 검증

μŠ€ν‚€λ§ˆ 기반 κ²€μ¦μ—λŸ¬ λ©”μ‹œμ§€ μžλ™ 생성

νƒ€μž… μΆ”λ‘ 

TypeScript νƒ€μž… μƒμ„±μžλ™μ™„μ„± 지원

ν™•μž₯ κ°€λŠ₯

μ»€μŠ€ν…€ 검증 κ·œμΉ™λ³€ν™˜(Transform) 지원

Zod μ„€μΉ˜

npm install zod
# or
pnpm add zod
SonamuλŠ” Zod v4λ₯Ό μ§€μ›ν•©λ‹ˆλ‹€. μ΅œμ‹  버전을 μ„€μΉ˜ν•˜μ„Έμš”.

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

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

import { z } from "zod";
import { BaseModelClass, api } from "sonamu";

// Zod μŠ€ν‚€λ§ˆ μ •μ˜
const CreateUserSchema = z.object({
  email: z.string().email("Invalid email format"),
  username: z.string().min(3, "Username must be at least 3 characters").max(20),
  password: z.string().min(8, "Password must be at least 8 characters"),
  age: z.number().int().min(18, "Must be at least 18 years old").optional(),
  role: z.enum(["admin", "manager", "normal"]),
});

// TypeScript νƒ€μž… μΆ”λ‘ 
type CreateUserParams = z.infer<typeof CreateUserSchema>;

class UserModelClass extends BaseModelClass<
  UserSubsetKey,
  UserSubsetMapping,
  typeof userSubsetQueries,
  typeof userLoaderQueries
> {
  constructor() {
    super("User", userSubsetQueries, userLoaderQueries);
  }
  
  @api({ httpMethod: "POST" })
  async create(params: CreateUserParams): Promise<{ userId: number }> {
    // μŠ€ν‚€λ§ˆλ‘œ 검증
    const validated = CreateUserSchema.parse(params);
    
    const wdb = this.getPuri("w");
    const [user] = await wdb
      .table("users")
      .insert(validated)
      .returning({ id: "id" });
    
    return { userId: user.id };
  }
}

검증 μ—λŸ¬ 처리

class UserModelClass extends BaseModelClass<
  UserSubsetKey,
  UserSubsetMapping,
  typeof userSubsetQueries,
  typeof userLoaderQueries
> {
  constructor() {
    super("User", userSubsetQueries, userLoaderQueries);
  }

  @api({ httpMethod: "POST" })
  async create(params: unknown): Promise<{ userId: number }> {
    try {
      // 검증
      const validated = CreateUserSchema.parse(params);
      
      const wdb = this.getPuri("w");
      const [user] = await wdb
        .table("users")
        .insert(validated)
        .returning({ id: "id" });
      
      return { userId: user.id };
    } catch (error) {
      if (error instanceof z.ZodError) {
        // Zod 검증 μ—λŸ¬
        const messages = error.errors.map((e) => e.message).join(", ");
        throw new Error(`Validation failed: ${messages}`);
      }
      throw error;
    }
  }
}

Zod μŠ€ν‚€λ§ˆ νƒ€μž…

μ›μ‹œ νƒ€μž…

import { z } from "zod";

// λ¬Έμžμ—΄
const StringSchema = z.string();
const EmailSchema = z.string().email();
const UrlSchema = z.string().url();
const UuidSchema = z.string().uuid();

// 숫자
const NumberSchema = z.number();
const IntSchema = z.number().int();
const PositiveSchema = z.number().positive();
const MinMaxSchema = z.number().min(0).max(100);

// λΆˆλ¦¬μ–Έ
const BooleanSchema = z.boolean();

// λ‚ μ§œ
const DateSchema = z.date();
const DateStringSchema = z.string().datetime(); // ISO 8601

객체

const UserSchema = z.object({
  email: z.string().email(),
  username: z.string().min(3).max(20),
  age: z.number().int().min(18).optional(),
  profile: z.object({
    bio: z.string().max(500).optional(),
    avatarUrl: z.string().url().optional(),
    socialLinks: z.object({
      twitter: z.string().url().optional(),
      github: z.string().url().optional(),
    }).optional(),
  }).optional(),
});

type User = z.infer<typeof UserSchema>;
// {
//   email: string;
//   username: string;
//   age?: number;
//   profile?: {
//     bio?: string;
//     avatarUrl?: string;
//     socialLinks?: {
//       twitter?: string;
//       github?: string;
//     };
//   };
// }

λ°°μ—΄

// λ¬Έμžμ—΄ λ°°μ—΄
const StringArraySchema = z.array(z.string());

// 객체 λ°°μ—΄
const UserArraySchema = z.array(
  z.object({
    id: z.number(),
    name: z.string(),
  })
);

// μ΅œμ†Œ/μ΅œλŒ€ 길이
const ArraySchema = z.array(z.string()).min(1).max(10);

// λΉ„μ–΄μžˆμ§€ μ•Šμ€ λ°°μ—΄
const NonEmptyArraySchema = z.array(z.string()).nonempty();

Enumκ³Ό Literal

// Enum
const RoleSchema = z.enum(["admin", "manager", "normal"]);
type Role = z.infer<typeof RoleSchema>; // "admin" | "manager" | "normal"

// Literal (Zod v4μ—μ„œ μ—¬λŸ¬ κ°’ 지원)
const StatusSchema = z.literal(["active", "inactive", "pending"]);

// Union
const RoleOrStatusSchema = z.union([
  z.literal("admin"),
  z.literal("user"),
  z.literal("guest"),
]);

μ˜΅μ…”λ„κ³Ό Nullable

// μ˜΅μ…”λ„ (undefined ν—ˆμš©)
const OptionalSchema = z.string().optional();
// string | undefined

// Nullable (null ν—ˆμš©)
const NullableSchema = z.string().nullable();
// string | null

// λ‘˜ λ‹€ ν—ˆμš©
const NullishSchema = z.string().nullish();
// string | null | undefined

// κΈ°λ³Έκ°’
const DefaultSchema = z.string().default("default value");

μ‹€μ „ 예제

μ‚¬μš©μž 등둝

const RegisterSchema = z.object({
  email: z.string().email("μœ νš¨ν•œ 이메일을 μž…λ ₯ν•˜μ„Έμš”"),
  username: z
    .string()
    .min(3, "μ‚¬μš©μžλͺ…은 μ΅œμ†Œ 3자 이상이어야 ν•©λ‹ˆλ‹€")
    .max(20, "μ‚¬μš©μžλͺ…은 μ΅œλŒ€ 20μžκΉŒμ§€ κ°€λŠ₯ν•©λ‹ˆλ‹€")
    .regex(/^[a-zA-Z0-9_]+$/, "μ‚¬μš©μžλͺ…은 영문, 숫자, μ–Έλ”μŠ€μ½”μ–΄λ§Œ μ‚¬μš© κ°€λŠ₯ν•©λ‹ˆλ‹€"),
  password: z
    .string()
    .min(8, "λΉ„λ°€λ²ˆν˜ΈλŠ” μ΅œμ†Œ 8자 이상이어야 ν•©λ‹ˆλ‹€")
    .regex(/[A-Z]/, "λΉ„λ°€λ²ˆν˜Έμ— λŒ€λ¬Έμžκ°€ μ΅œμ†Œ 1개 ν¬ν•¨λ˜μ–΄μ•Ό ν•©λ‹ˆλ‹€")
    .regex(/[a-z]/, "λΉ„λ°€λ²ˆν˜Έμ— μ†Œλ¬Έμžκ°€ μ΅œμ†Œ 1개 ν¬ν•¨λ˜μ–΄μ•Ό ν•©λ‹ˆλ‹€")
    .regex(/[0-9]/, "λΉ„λ°€λ²ˆν˜Έμ— μˆ«μžκ°€ μ΅œμ†Œ 1개 ν¬ν•¨λ˜μ–΄μ•Ό ν•©λ‹ˆλ‹€"),
  confirmPassword: z.string(),
  agreeToTerms: z.boolean().refine((val) => val === true, {
    message: "μ΄μš©μ•½κ΄€μ— λ™μ˜ν•΄μ•Ό ν•©λ‹ˆλ‹€",
  }),
}).refine((data) => data.password === data.confirmPassword, {
  message: "λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€",
  path: ["confirmPassword"],
});

type RegisterParams = z.infer<typeof RegisterSchema>;

class AuthModelClass extends BaseModelClass<
  AuthSubsetKey,
  AuthSubsetMapping,
  typeof authSubsetQueries,
  typeof authLoaderQueries
> {
  constructor() {
    super("Auth", authSubsetQueries, authLoaderQueries);
  }

  @api({ httpMethod: "POST" })
  async register(params: RegisterParams): Promise<{ userId: number }> {
    const validated = RegisterSchema.parse(params);
    
    const wdb = this.getPuri("w");
    
    // confirmPassword μ œμ™Έν•˜κ³  μ €μž₯
    const { confirmPassword, agreeToTerms, ...userData } = validated;
    
    const [user] = await wdb
      .table("users")
      .insert({
        ...userData,
        role: "normal",
      })
      .returning({ id: "id" });
    
    return { userId: user.id };
  }
}

νŽ˜μ΄μ§€λ„€μ΄μ…˜ νŒŒλΌλ―Έν„°

const PaginationSchema = z.object({
  page: z.number().int().positive().default(1),
  pageSize: z.number().int().min(1).max(100).default(20),
  sortBy: z.enum(["created_at", "updated_at", "name"]).optional(),
  sortOrder: z.enum(["asc", "desc"]).default("desc"),
});

const UserListSchema = PaginationSchema.extend({
  search: z.string().optional(),
  role: z.enum(["admin", "manager", "normal"]).optional(),
  isActive: z.boolean().optional(),
});

type UserListParams = z.infer<typeof UserListSchema>;

class UserModelClass extends BaseModelClass<
  UserSubsetKey,
  UserSubsetMapping,
  typeof userSubsetQueries,
  typeof userLoaderQueries
> {
  constructor() {
    super("User", userSubsetQueries, userLoaderQueries);
  }

  @api({ httpMethod: "GET" })
  async list(params: UserListParams): Promise<{
    users: User[];
    total: number;
    page: number;
    pageSize: number;
  }> {
    const validated = UserListSchema.parse(params);
    
    const rdb = this.getPuri("r");
    
    let query = rdb.table("users");
    
    if (validated.search) {
      query = query.where("username", "like", `%${validated.search}%`);
    }
    
    if (validated.role) {
      query = query.where("role", validated.role);
    }
    
    if (validated.isActive !== undefined) {
      query = query.where("is_active", validated.isActive);
    }
    
    const users = await query
      .orderBy(validated.sortBy || "created_at", validated.sortOrder)
      .limit(validated.pageSize)
      .offset((validated.page - 1) * validated.pageSize)
      .select("*");
    
    const [{ count }] = await rdb.table("users").count({ count: "*" });
    
    return {
      users,
      total: count,
      page: validated.page,
      pageSize: validated.pageSize,
    };
  }
}

λ‚ μ§œ λ²”μœ„ 검증

const DateRangeSchema = z.object({
  startDate: z.string().datetime(),
  endDate: z.string().datetime(),
}).refine(
  (data) => new Date(data.startDate) < new Date(data.endDate),
  {
    message: "μ‹œμž‘μΌμ€ μ’…λ£ŒμΌλ³΄λ‹€ 이전이어야 ν•©λ‹ˆλ‹€",
    path: ["endDate"],
  }
);

const ReportSchema = z.object({
  dateRange: DateRangeSchema,
  type: z.enum(["sales", "traffic", "conversion"]),
  format: z.enum(["pdf", "excel", "csv"]).default("pdf"),
});

type ReportParams = z.infer<typeof ReportSchema>;

class ReportModelClass extends BaseModelClass<
  ReportSubsetKey,
  ReportSubsetMapping,
  typeof reportSubsetQueries,
  typeof reportLoaderQueries
> {
  constructor() {
    super("Report", reportSubsetQueries, reportLoaderQueries);
  }

  @api({ httpMethod: "POST" })
  async generate(params: ReportParams): Promise<{ reportId: number }> {
    const validated = ReportSchema.parse(params);
    
    // 리포트 생성 둜직
    // ...
  }
}

Transform (데이터 λ³€ν™˜)

λ¬Έμžμ—΄ λ³€ν™˜

const TrimmedStringSchema = z.string().transform((val) => val.trim());

const LowercaseEmailSchema = z
  .string()
  .email()
  .transform((val) => val.toLowerCase());

const UserSchema = z.object({
  email: z.string().email().transform((val) => val.toLowerCase()),
  username: z.string().transform((val) => val.trim()),
});

숫자 λ³€ν™˜

// λ¬Έμžμ—΄μ„ 숫자둜 λ³€ν™˜
const StringToNumberSchema = z.string().transform((val) => parseInt(val, 10));

// Coerce (κ°•μ œ λ³€ν™˜)
const CoerceNumberSchema = z.coerce.number();
// "123" β†’ 123
// true β†’ 1
// false β†’ 0

λ‚ μ§œ λ³€ν™˜

// ISO λ¬Έμžμ—΄μ„ Date 객체둜
const DateFromStringSchema = z
  .string()
  .datetime()
  .transform((val) => new Date(val));

// Coerce
const CoerceDateSchema = z.coerce.date();
// "2025-01-07" β†’ Date object
// "2025-01-07T12:00:00Z" β†’ Date object

λ³΅μž‘ν•œ λ³€ν™˜

const ProductSchema = z.object({
  name: z.string(),
  price: z.string().transform((val) => parseFloat(val)),
  tags: z.string().transform((val) => val.split(",").map((t) => t.trim())),
  metadata: z.string().transform((val) => {
    try {
      return JSON.parse(val);
    } catch {
      return {};
    }
  }),
});

// μž…λ ₯:
// {
//   name: "Product",
//   price: "29.99",
//   tags: "electronics, gadget, new",
//   metadata: '{"color": "blue", "size": "M"}'
// }

// 좜λ ₯:
// {
//   name: "Product",
//   price: 29.99,
//   tags: ["electronics", "gadget", "new"],
//   metadata: { color: "blue", size: "M" }
// }

μ»€μŠ€ν…€ 검증

refine() μ‚¬μš©

const PasswordSchema = z
  .string()
  .min(8)
  .refine((val) => /[A-Z]/.test(val), {
    message: "λŒ€λ¬Έμž ν•„μš”",
  })
  .refine((val) => /[a-z]/.test(val), {
    message: "μ†Œλ¬Έμž ν•„μš”",
  })
  .refine((val) => /[0-9]/.test(val), {
    message: "숫자 ν•„μš”",
  });

// 객체 전체 검증
const UserSchema = z
  .object({
    password: z.string(),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: "λΉ„λ°€λ²ˆν˜Έ 뢈일치",
    path: ["confirmPassword"],
  });

superRefine() μ‚¬μš© (λ³΅μž‘ν•œ 검증)

const ComplexSchema = z
  .object({
    startDate: z.date(),
    endDate: z.date(),
    participants: z.array(z.string()),
  })
  .superRefine((data, ctx) => {
    // λ‚ μ§œ 검증
    if (data.startDate >= data.endDate) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "μ‹œμž‘μΌμ€ μ’…λ£ŒμΌλ³΄λ‹€ 이전이어야 ν•©λ‹ˆλ‹€",
        path: ["endDate"],
      });
    }
    
    // μ°Έκ°€μž 수 검증
    if (data.participants.length < 2) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "μ΅œμ†Œ 2λͺ… μ΄μƒμ˜ μ°Έκ°€μžκ°€ ν•„μš”ν•©λ‹ˆλ‹€",
        path: ["participants"],
      });
    }
    
    // 쀑볡 검증
    const uniqueParticipants = new Set(data.participants);
    if (uniqueParticipants.size !== data.participants.length) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "μ€‘λ³΅λœ μ°Έκ°€μžκ°€ μžˆμŠ΅λ‹ˆλ‹€",
        path: ["participants"],
      });
    }
  });

safeParse (μ•ˆμ „ν•œ 검증)

class UserModelClass extends BaseModelClass<
  UserSubsetKey,
  UserSubsetMapping,
  typeof userSubsetQueries,
  typeof userLoaderQueries
> {
  constructor() {
    super("User", userSubsetQueries, userLoaderQueries);
  }

  @api({ httpMethod: "POST" })
  async create(params: unknown): Promise<{ userId: number }> {
    // safeParseλŠ” μ˜ˆμ™Έλ₯Ό λ˜μ§€μ§€ μ•ŠμŒ
    const result = CreateUserSchema.safeParse(params);
    
    if (!result.success) {
      // 검증 μ‹€νŒ¨
      const context = Sonamu.getContext();
      context.reply.status(400);
      
      const errors = result.error.errors.map((e) => ({
        field: e.path.join("."),
        message: e.message,
      }));
      
      throw new Error(JSON.stringify({ errors }));
    }
    
    // 검증 성곡
    const validated = result.data;
    
    const wdb = this.getPuri("w");
    const [user] = await wdb
      .table("users")
      .insert(validated)
      .returning({ id: "id" });
    
    return { userId: user.id };
  }
}

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

μ „μ—­ μ—λŸ¬ λ§΅

import { z } from "zod";

// μ—λŸ¬ λ©”μ‹œμ§€ μ»€μŠ€ν„°λ§ˆμ΄μ§•
z.setErrorMap((issue, ctx) => {
  if (issue.code === z.ZodIssueCode.invalid_type) {
    if (issue.expected === "string") {
      return { message: "λ¬Έμžμ—΄μ΄μ–΄μ•Ό ν•©λ‹ˆλ‹€" };
    }
    if (issue.expected === "number") {
      return { message: "μˆ«μžμ—¬μ•Ό ν•©λ‹ˆλ‹€" };
    }
  }
  
  if (issue.code === z.ZodIssueCode.too_small) {
    if (issue.type === "string") {
      return { message: `μ΅œμ†Œ ${issue.minimum}자 이상이어야 ν•©λ‹ˆλ‹€` };
    }
  }
  
  return { message: ctx.defaultError };
});

κ°œλ³„ μ—λŸ¬ λ©”μ‹œμ§€

const UserSchema = z.object({
  email: z.string({
    required_error: "이메일은 ν•„μˆ˜μž…λ‹ˆλ‹€",
    invalid_type_error: "이메일은 λ¬Έμžμ—΄μ΄μ–΄μ•Ό ν•©λ‹ˆλ‹€",
  }).email("μœ νš¨ν•œ 이메일 ν˜•μ‹μ΄ μ•„λ‹™λ‹ˆλ‹€"),
  
  age: z.number({
    required_error: "λ‚˜μ΄λŠ” ν•„μˆ˜μž…λ‹ˆλ‹€",
    invalid_type_error: "λ‚˜μ΄λŠ” μˆ«μžμ—¬μ•Ό ν•©λ‹ˆλ‹€",
  }).min(18, "18μ„Έ 이상이어야 ν•©λ‹ˆλ‹€"),
});

μ£Όμ˜μ‚¬ν•­

Zod 검증 μ‚¬μš© μ‹œ μ£Όμ˜μ‚¬ν•­:
  1. parse()λŠ” 검증 μ‹€νŒ¨ μ‹œ μ˜ˆμ™Έ λ°œμƒ
  2. safeParse()λŠ” κ²°κ³Ό 객체 λ°˜ν™˜
  3. Transform은 검증 ν›„ 싀행됨
  4. refine()은 λ§ˆμ§€λ§‰μ— 싀행됨
  5. μ„±λŠ₯을 μœ„ν•΄ μŠ€ν‚€λ§ˆλ₯Ό μž¬μ‚¬μš©

λ‹€μŒ 단계