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_error와 invalid_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 실무의 표준으로 자리잡고 있습니다.