메인 콘텐츠로 건너뛰기
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 UserModel extends BaseModelClass {
  modelName = "User";
  
  @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 UserModel extends BaseModelClass {
  @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 AuthModel extends BaseModelClass {
  @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 UserModel extends BaseModelClass {
  @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 ReportModel extends BaseModelClass {
  @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 UserModel extends BaseModelClass {
  @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. 성능을 위해 스키마를 재사용

다음 단계