Zod가 필요한 이유
TypeScript의 타입은 컴파일 타임에만 존재합니다. 빌드가 끝나면 JavaScript에는 타입 정보가 남지 않습니다. 사용자 입력, API 응답, 환경변수 등 외부에서 들어오는 데이터는 런타임에서 직접 검증해야 합니다.
Zod는 스키마를 정의하면 TypeScript 타입을 자동 추론해주는 런타임 검증 라이브러리입니다. 스키마 하나로 검증과 타입 정의를 동시에 처리할 수 있습니다.
설치와 기본 스키마
npm install zod
Zod 스키마는 z.object(), z.string(), z.number() 등 체이닝 메서드로 정의합니다.
import { z } from "zod";
// 기본 스키마 정의
const UserSchema = z.object({
name: z.string().min(2, "이름은 2자 이상이어야 합니다"),
email: z.string().email("올바른 이메일 형식이 아닙니다"),
age: z.number().int().min(0).max(150),
role: z.enum(["admin", "user", "guest"]),
bio: z.string().optional(), // 선택적 필드
});
// 스키마에서 TypeScript 타입 자동 추론
type User = z.infer<typeof UserSchema>;
// { name: string; email: string; age: number; role: "admin" | "user" | "guest"; bio?: string }
// 유효한 데이터 — 성공
const validData = {
name: "홍길동",
email: "hong@example.com",
age: 30,
role: "admin" as const,
};
const result = UserSchema.safeParse(validData);
if (result.success) {
console.log("검증 성공:", result.data);
// 검증 성공: { name: "홍길동", email: "hong@example.com", age: 30, role: "admin" }
} else {
console.log("검증 실패:", result.error.issues);
}
// 유효하지 않은 데이터 — 실패
const invalidData = {
name: "김", // 2자 미만
email: "not-email", // 이메일 형식 아님
age: -5, // 음수
role: "superuser", // enum에 없음
};
const failResult = UserSchema.safeParse(invalidData);
if (!failResult.success) {
failResult.error.issues.forEach((issue) => {
console.log(`${issue.path.join(".")}: ${issue.message}`);
});
// name: 이름은 2자 이상이어야 합니다
// email: 올바른 이메일 형식이 아닙니다
// age: Number must be greater than or equal to 0
// role: Invalid enum value. Expected 'admin' | 'user' | 'guest', received 'superuser'
}
safeParse는 예외를 던지지 않고 결과 객체를 반환합니다. parse는 실패 시 ZodError를 던집니다. 외부 입력에는 safeParse를 권장합니다.
고급 스키마 패턴
Zod는 변환, 정제(refine), 합성 등 다양한 고급 기능을 제공합니다.
import { z } from "zod";
// transform: 검증 후 값 변환
const TrimmedString = z.string().trim().toLowerCase();
console.log(TrimmedString.parse(" Hello World ")); // "hello world"
// coerce: 입력값을 강제 변환 후 검증
const CoercedNumber = z.coerce.number();
console.log(CoercedNumber.parse("42")); // 42 (문자열 → 숫자)
const CoercedDate = z.coerce.date();
console.log(CoercedDate.parse("2026-01-15")); // Date 객체
// refine: 커스텀 검증 로직
const PasswordSchema = z
.string()
.min(8, "비밀번호는 8자 이상이어야 합니다")
.refine((val) => /[A-Z]/.test(val), {
message: "대문자를 최소 1개 포함해야 합니다",
})
.refine((val) => /[0-9]/.test(val), {
message: "숫자를 최소 1개 포함해야 합니다",
})
.refine((val) => /[!@#$%^&*]/.test(val), {
message: "특수문자를 최소 1개 포함해야 합니다",
});
// superRefine: 여러 필드 간 교차 검증
const SignupSchema = z
.object({
password: z.string().min(8),
confirmPassword: z.string(),
})
.superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "비밀번호가 일치하지 않습니다",
path: ["confirmPassword"],
});
}
});
// discriminatedUnion: 태그 기반 유니온 타입
const EventSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("click"),
x: z.number(),
y: z.number(),
}),
z.object({
type: z.literal("keypress"),
key: z.string(),
ctrlKey: z.boolean(),
}),
z.object({
type: z.literal("scroll"),
deltaY: z.number(),
}),
]);
type AppEvent = z.infer<typeof EventSchema>;
// 타입에 따라 다른 필드가 자동 추론됨
const clickEvent = EventSchema.parse({ type: "click", x: 100, y: 200 });
console.log(clickEvent); // { type: "click", x: 100, y: 200 }
API 응답 검증
외부 API에서 받은 데이터를 Zod로 검증하면 타입 불일치로 인한 런타임 에러를 방지할 수 있습니다.
import { z } from "zod";
// API 응답 스키마 정의
const PostSchema = z.object({
id: z.number(),
title: z.string(),
body: z.string(),
userId: z.number(),
});
const PostListSchema = z.array(PostSchema);
// API 응답 타입 추론
type Post = z.infer<typeof PostSchema>;
// 안전한 API 호출 함수
async function fetchPosts(): Promise<Post[]> {
const response = await fetch("https://jsonplaceholder.typicode.com/posts");
const json: unknown = await response.json(); // unknown으로 받기
// 런타임 검증: 스키마와 일치하지 않으면 에러
const result = PostListSchema.safeParse(json);
if (!result.success) {
console.error("API 응답 형식 불일치:", result.error.flatten());
throw new Error("API 응답 검증 실패");
}
return result.data; // Post[] 타입이 보장됨
}
// 환경변수 검증
const EnvSchema = z.object({
DATABASE_URL: z.string().url(),
API_KEY: z.string().min(10),
PORT: z.coerce.number().default(3000),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
});
// 앱 시작 시 환경변수 검증
function validateEnv() {
const result = EnvSchema.safeParse(process.env);
if (!result.success) {
console.error("환경변수 검증 실패:");
result.error.issues.forEach((issue) => {
console.error(` ${issue.path.join(".")}: ${issue.message}`);
});
process.exit(1); // 환경변수 누락 시 즉시 종료
}
return result.data;
}
const env = validateEnv();
console.log(`서버 포트: ${env.PORT}`); // 타입 안전한 환경변수 접근
폼 검증 (React Hook Form 연동)
Zod는 @hookform/resolvers를 통해 React Hook Form과 자연스럽게 연동됩니다.
import { z } from "zod";
// 회원가입 폼 스키마
const RegisterFormSchema = 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]/, "대문자를 1개 이상 포함해야 합니다")
.regex(/[0-9]/, "숫자를 1개 이상 포함해야 합니다"),
confirmPassword: z.string(),
agreeTerms: z.literal(true, {
errorMap: () => ({ message: "약관에 동의해야 합니다" }),
}),
})
.refine((data) => data.password === data.confirmPassword, {
message: "비밀번호가 일치하지 않습니다",
path: ["confirmPassword"],
});
// 폼 데이터 타입 추론
type RegisterForm = z.infer<typeof RegisterFormSchema>;
// React Hook Form과 연동 예시 (의사 코드)
// import { useForm } from "react-hook-form";
// import { zodResolver } from "@hookform/resolvers/zod";
//
// const { register, handleSubmit, formState: { errors } } = useForm<RegisterForm>({
// resolver: zodResolver(RegisterFormSchema),
// });
// 수동 검증 예시
const formData = {
username: "hong_gildong",
email: "hong@example.com",
password: "Secure1234",
confirmPassword: "Secure1234",
agreeTerms: true as const,
};
const formResult = RegisterFormSchema.safeParse(formData);
if (formResult.success) {
console.log("폼 검증 통과:", formResult.data.username);
// 폼 검증 통과: hong_gildong
} else {
const fieldErrors = formResult.error.flatten().fieldErrors;
console.log("필드별 에러:", fieldErrors);
}
정리
- 스키마 = 타입 + 검증:
z.infer로 타입을 추론하면 스키마와 타입이 항상 동기화됩니다. - safeParse 사용: 외부 입력에는 예외를 던지지 않는
safeParse를 사용합니다. - coerce로 형변환: 문자열을 숫자나 날짜로 변환할 때
z.coerce를 활용합니다. - refine/superRefine: 단일 필드 검증은
refine, 필드 간 교차 검증은superRefine을 사용합니다. - API + 환경변수: 외부 데이터의 진입점에서 Zod 검증을 적용하면 런타임 안전성이 크게 향상됩니다.
- 폼 라이브러리 연동: React Hook Form, Formik 등과 resolver를 통해 통합할 수 있습니다.