๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
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. ์„ฑ๋Šฅ์„ ์œ„ํ•ด ์Šคํ‚ค๋งˆ๋ฅผ ์žฌ์‚ฌ์šฉ

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