iBetter Books
수정

Ch 04. Zod로 런타임 타입 검증하기

TypeScript의 타입은 컴파일 시점에만 존재합니다. 빌드가 끝나면 타입 정보는 사라지고, 실제로 실행되는 JavaScript에는 타입 검사가 없습니다. 그래서 API 응답이나 폼 입력처럼 외부에서 들어오는 데이터는 런타임에도 검증이 필요합니다.

Zod는 스키마를 한 번 정의하면 런타임 검증과 TypeScript 타입을 동시에 얻을 수 있는 라이브러리입니다. "두 번 쓰지 않는다"는 것이 Zod의 핵심 가치입니다.

설치

npm install zod@3

이 챕터는 Zod 3.x 기준으로 작성되었습니다. Zod 4.x는 API가 상당 부분 변경되었으므로, 학습 시에는 안정적인 3.x 버전을 사용합니다.

기본 스키마 정의

// 파일: src/zod/basic.tsimport * as z from "zod";// 원시 타입 스키마const nameSchema = z.string();const ageSchema = z.number();const activeSchema = z.boolean();// 객체 스키마const UserSchema = z.object({  id: z.number(),  name: z.string(),  email: z.string().email(),  age: z.number().min(0).max(150),  role: z.enum(["admin", "editor", "viewer"]),  createdAt: z.date().optional(),});// z.infer로 TypeScript 타입 자동 추출type User = z.infer<typeof UserSchema>;// 위와 동일:// type User = {//   id: number;//   name: string;//   email: string;//   age: number;//   role: "admin" | "editor" | "viewer";//   createdAt?: Date;// }

한 번 스키마를 정의하면 타입을 따로 선언할 필요가 없습니다. z.infer가 자동으로 TypeScript 타입을 만들어줍니다.

parse와 safeParse

Zod는 두 가지 방식으로 데이터를 검증합니다.

// 파일: src/zod/parse.tsimport * as z from "zod";const UserSchema = z.object({  name: z.string(),  age: z.number(),});// parse — 실패 시 예외 던짐try {  const user = UserSchema.parse({ name: "Alice", age: 30 });  console.log(user.name); // "Alice", 타입: string} catch (err) {  if (err instanceof z.ZodError) {    console.error(err.issues);    // [{ code: 'invalid_type', expected: 'number', received: 'string', path: ['age'], message: '...' }]  }}// safeParse — 실패해도 예외 없이 결과 반환const result = UserSchema.safeParse({ name: "Bob", age: "스물다섯" });if (result.success) {  console.log(result.data.name); // result.data는 User 타입} else {  console.log(result.error.issues); // 상세 에러 정보}

서버에서 응답을 받거나 외부 입력을 처리할 때는 safeParse가 적합합니다. 예외 처리 흐름을 섞지 않아도 됩니다.

자주 쓰는 검증 메서드

// 파일: src/zod/validators.tsimport * as z from "zod";const ProductSchema = z.object({  // 문자열 검증  name: z.string().min(1, "상품명을 입력하세요.").max(100),  slug: z.string().regex(/^[a-z0-9-]+$/, "소문자, 숫자, 하이픈만 허용됩니다."),  email: z.string().email("유효한 이메일 주소를 입력하세요."),  url: z.string().url("유효한 URL을 입력하세요.").optional(),  // 숫자 검증  price: z.number().positive("가격은 0보다 커야 합니다."),  stock: z.number().int("재고는 정수여야 합니다.").min(0),  discount: z.number().min(0).max(100).default(0),  // 배열  tags: z.array(z.string()).min(1).max(10),  // 열거형  status: z.enum(["draft", "published", "archived"]),  // 날짜  publishedAt: z.date().nullable(),});type Product = z.infer<typeof ProductSchema>;

폼 검증에 Zod 활용하기

React 폼 라이브러리와 함께 쓰는 패턴입니다. 여기서는 순수 Zod만으로 폼 검증 로직을 구현합니다.

// 파일: src/zod/form-validation.tsimport * as z from "zod";// 회원가입 폼 스키마const SignupSchema = z  .object({    username: z      .string()      .min(3, "사용자 이름은 3자 이상이어야 합니다.")      .max(20, "사용자 이름은 20자 이하여야 합니다.")      .regex(/^[a-zA-Z0-9_]+$/, "영문, 숫자, 밑줄만 사용할 수 있습니다."),    email: z.string().email("유효한 이메일 주소를 입력하세요."),    password: z      .string()      .min(8, "비밀번호는 8자 이상이어야 합니다.")      .regex(/[A-Z]/, "대문자를 포함해야 합니다.")      .regex(/[0-9]/, "숫자를 포함해야 합니다."),    confirmPassword: z.string(),  })  .refine((data) => data.password === data.confirmPassword, {    message: "비밀번호가 일치하지 않습니다.",    path: ["confirmPassword"],  });type SignupInput = z.infer<typeof SignupSchema>;function validateSignup(input: unknown): SignupInput | null {  const result = SignupSchema.safeParse(input);  if (!result.success) {    // 필드별 에러 메시지 추출    const fieldErrors = result.error.issues.reduce(      (acc, issue) => {        const field = issue.path[0] as string;        acc[field] = issue.message;        return acc;      },      {} as Record<string, string>    );    console.error("검증 실패:", fieldErrors);    return null;  }  return result.data;}// 사용 예const formData = {  username: "alice123",  email: "[email protected]",  password: "SecurePass1",  confirmPassword: "SecurePass1",};const validated = validateSignup(formData);if (validated) {  console.log("회원가입 처리:", validated.username);}

API 응답 검증

fetch로 받은 데이터를 Zod로 검증하면 런타임 안전성이 보장됩니다.

// 파일: src/zod/api-validation.tsimport * as z from "zod";// API 응답 스키마 정의const ApiUserSchema = z.object({  id: z.number(),  name: z.string(),  email: z.string().email(),  address: z.object({    city: z.string(),    country: z.string(),  }),});const ApiUsersSchema = z.array(ApiUserSchema);type ApiUser = z.infer<typeof ApiUserSchema>;// 공통 API 응답 래퍼const ApiResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) =>  z.object({    success: z.boolean(),    data: dataSchema,    message: z.string().optional(),  });async function fetchUser(id: number): Promise<ApiUser> {  const response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);  if (!response.ok) {    throw new Error(`HTTP 오류: ${response.status}`);  }  const json: unknown = await response.json();  // 런타임 검증 — API 응답이 예상 형식이 아니면 즉시 에러  const parsed = ApiUserSchema.safeParse(json);  if (!parsed.success) {    throw new Error(      `API 응답 형식 오류: ${parsed.error.issues.map((i) => i.message).join(", ")}`    );  }  return parsed.data;}async function fetchUsers(): Promise<ApiUser[]> {  const response = await fetch("https://jsonplaceholder.typicode.com/users");  const json: unknown = await response.json();  return ApiUsersSchema.parse(json);}

스키마 조합과 변환

기존 스키마를 재사용하고 변형하는 패턴입니다.

// 파일: src/zod/schema-composition.tsimport * as z from "zod";const BaseUserSchema = z.object({  id: z.number(),  name: z.string(),  email: z.string().email(),  createdAt: z.date(),});// 생성 시 — id, createdAt 제외const CreateUserSchema = BaseUserSchema.omit({ id: true, createdAt: true });// 수정 시 — 모든 필드 선택적 (id 제외)const UpdateUserSchema = BaseUserSchema.omit({ id: true, createdAt: true }).partial();// API 응답 — 비밀번호 필드 제외const PublicUserSchema = BaseUserSchema.omit({ createdAt: true });// 확장const AdminUserSchema = BaseUserSchema.extend({  role: z.literal("admin"),  permissions: z.array(z.string()),});type CreateUserInput = z.infer<typeof CreateUserSchema>;type UpdateUserInput = z.infer<typeof UpdateUserSchema>;type PublicUser = z.infer<typeof PublicUserSchema>;type AdminUser = z.infer<typeof AdminUserSchema>;

transform으로 데이터 변환하기

검증과 동시에 데이터를 원하는 형태로 변환할 수 있습니다.

// 파일: src/zod/transform.tsimport * as z from "zod";// 문자열로 들어온 날짜를 Date 객체로 변환const DateSchema = z.string().transform((val) => new Date(val));// 공백 제거 + 소문자 변환const NormalizedEmailSchema = z  .string()  .email()  .transform((val) => val.trim().toLowerCase());// API 응답 — 서버 필드명(snake_case)을 클라이언트 타입(camelCase)으로 변환const ServerUserSchema = z  .object({    user_id: z.number(),    full_name: z.string(),    created_at: z.string(),  })  .transform((data) => ({    id: data.user_id,    name: data.full_name,    createdAt: new Date(data.created_at),  }));type ClientUser = z.infer<typeof ServerUserSchema>;// { id: number; name: string; createdAt: Date }const raw = { user_id: 1, full_name: "Alice", created_at: "2024-01-01" };const user = ServerUserSchema.parse(raw);console.log(user.id);        // 1console.log(user.createdAt); // Date 객체

에러 메시지 커스터마이징

required_errorinvalid_type_error 옵션은 Zod 3.x에서 지원됩니다. Zod 4.x에서는 error 함수 패턴으로 변경되었으므로 버전에 주의합니다.

// 파일: src/zod/custom-errors.tsimport * as z from "zod";// Zod 3.x — required_error, invalid_type_error 사용const OrderSchema = z.object({  quantity: z.number({    required_error: "수량을 입력하세요.",    invalid_type_error: "수량은 숫자여야 합니다.",  }).int("수량은 정수여야 합니다.").positive("수량은 1 이상이어야 합니다."),  address: z.string({    required_error: "배송 주소를 입력하세요.",  }).min(5, "주소를 더 자세히 입력하세요."),});const result = OrderSchema.safeParse({ quantity: 0, address: "서울" });if (!result.success) {  // 첫 번째 에러 메시지  console.log(result.error.issues[0].message);  // "수량은 1 이상이어야 합니다."  // Zod 3.x — format()으로 필드별 에러 정리  const formatted = result.error.format();  console.log(formatted.quantity?._errors);  // ["수량은 1 이상이어야 합니다."]  console.log(formatted.address?._errors);  // ["주소를 더 자세히 입력하세요."]}

Zod를 쓰면 타입 정의와 런타임 검증 코드를 두 곳에 유지하는 번거로움이 사라집니다. 스키마 하나가 TypeScript 타입, 런타임 검증, 에러 메시지, 데이터 변환을 모두 담당합니다. API 응답을 처리하는 모든 지점, 폼 입력을 검증하는 모든 지점에 Zod를 두는 것이 현재 TypeScript 실무의 표준으로 자리잡고 있습니다.