Zod란?
타입 안전 스키마
TypeScript 타입 자동 추론 별도 타입 정의 불필요
런타임 검증
실행 시 데이터 검증 잘못된 데이터 차단
상세한 에러
필드별 에러 메시지 디버깅 용이
변환 & 기본값
데이터 변환 및 기본값 유연한 처리
기본 사용법
Zod 스키마는 Entity에서 자동 생성되지만, 직접 정의할 수도 있습니다.스키마 정의
복사
import { z } from "zod";
// Entity로부터 자동 생성
export const User = z.object({
id: z.number().int(),
email: z.string().max(100),
age: z.number().int(),
role: z.enum(["admin", "normal"]),
});
export type User = z.infer<typeof User>;
데이터 검증
복사
import { User } from "./user.types";
try {
const user = User.parse({
id: 1,
email: "[email protected]",
age: 30,
role: "admin",
});
// ✅ 검증 성공, user는 User 타입
console.log(user.email);
} catch (error) {
// ❌ 검증 실패 시 ZodError
console.error(error);
}
parse vs safeParse:
parse(): 에러를 던집니다. try-catch로 처리safeParse(): 에러를 반환합니다. result.success로 체크
parse()를 사용하여 자동으로 400 에러를 반환하고, UI에서는 safeParse()로 안전하게 처리합니다.기본 타입 검증
Entity의 각 타입별 Zod 검증입니다.문자열 검증
복사
const email = z.string();
email.parse("[email protected]"); // ✅
const withLength = z.string().max(100);
withLength.parse("a".repeat(100)); // ✅
withLength.parse("a".repeat(101)); // ❌
숫자 검증
복사
const age = z.number().int();
age.parse(30); // ✅
age.parse(30.5); // ❌ Must be integer
날짜 검증
복사
const birthDate = z.date();
birthDate.parse(new Date()); // ✅
birthDate.parse("2024-01-01"); // ❌ Expected Date, received string
불린 검증
복사
const isActive = z.boolean();
isActive.parse(true); // ✅
isActive.parse(false); // ✅
isActive.parse("true"); // ❌ Expected boolean, received string
// 변환: 문자열 → 불린
const coerced = z.coerce.boolean();
coerced.parse("true"); // ✅ true
coerced.parse("false"); // ✅ false
coerced.parse(1); // ✅ true
coerced.parse(0); // ✅ false
복합 타입 검증
배열
복사
const tags = z.string().array();
tags.parse(["typescript", "nodejs"]); // ✅
tags.parse(["typescript", 123]); // ❌
객체
복사
const user = z.object({
name: z.string(),
profile: z.object({
bio: z.string(),
website: z.string().url().optional(),
}),
});
user.parse({
name: "John",
profile: {
bio: "Hello",
website: "https://john.com",
},
}); // ✅
Enum
복사
const role = z.enum(["admin", "moderator", "normal"]);
role.parse("admin"); // ✅
role.parse("guest"); // ❌ Invalid enum value
Union (여러 타입 중 하나)
복사
const idOrEmail = z.union([z.number().int(), z.string().email()]);
idOrEmail.parse(123); // ✅
idOrEmail.parse("[email protected]"); // ✅
idOrEmail.parse("not-an-email"); // ❌
고급 검증 패턴
조건부 검증
복사
const password = z.string()
.min(8)
.refine(
(val) => /[A-Z]/.test(val),
{ message: "대문자를 포함해야 합니다" }
)
.refine(
(val) => /[0-9]/.test(val),
{ message: "숫자를 포함해야 합니다" }
)
.refine(
(val) => /[!@#$%^&*]/.test(val),
{ message: "특수문자를 포함해야 합니다" }
);
password.parse("Abc12345!"); // ✅
password.parse("abc12345"); // ❌ 대문자 없음
데이터 변환
복사
const age = z.string().transform(val => parseInt(val, 10));
age.parse("30"); // ✅ 30 (number)
기본값 설정
복사
const settings = z.object({
theme: z.enum(["light", "dark"]).default("light"),
notifications: z.boolean().default(true),
});
settings.parse({}); // ✅ { theme: "light", notifications: true }
settings.parse({ theme: "dark" }); // ✅ { theme: "dark", notifications: true }
실전 검증 패턴
API 파라미터 검증
복사
export const UserListParams = z.object({
num: z.number().int().min(1).max(100).default(24),
page: z.number().int().min(1).default(1),
search: z.enum(["id", "email", "username"]).optional(),
keyword: z.string().trim().optional(),
orderBy: z.enum(["id-desc", "id-asc", "created_at-desc"]).optional(),
}).refine(
(data) => {
// keyword가 있으면 search도 필수
if (data.keyword && !data.search) {
return false;
}
return true;
},
{
message: "keyword와 search는 함께 사용해야 합니다",
path: ["search"],
}
);
비즈니스 로직 검증
복사
const dateRange = z.object({
startDate: z.date(),
endDate: z.date(),
}).refine(
(data) => data.endDate > data.startDate,
{
message: "종료일은 시작일보다 이후여야 합니다",
path: ["endDate"],
}
).refine(
(data) => {
const diff = data.endDate.getTime() - data.startDate.getTime();
const days = diff / (1000 * 60 * 60 * 24);
return days <= 365;
},
{
message: "기간은 최대 1년까지만 가능합니다",
path: ["endDate"],
}
);
Form 검증
복사
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { UserSaveParams } from "@/services/user/user.types";
function UserForm() {
const form = useForm({
resolver: zodResolver(UserSaveParams),
defaultValues: {
email: "",
username: "",
role: "normal",
},
});
const onSubmit = form.handleSubmit((data) => {
// data는 자동으로 UserSaveParams 타입
console.log(data);
});
return (
<form onSubmit={onSubmit}>
<input {...form.register("email")} />
{form.formState.errors.email && (
<span>{form.formState.errors.email.message}</span>
)}
<input {...form.register("username")} />
{form.formState.errors.username && (
<span>{form.formState.errors.username.message}</span>
)}
<button type="submit">저장</button>
</form>
);
}
에러 처리
ZodError 구조
복사
try {
User.parse(invalidData);
} catch (error) {
if (error instanceof z.ZodError) {
console.log(error.issues);
// [
// {
// code: "invalid_type",
// expected: "string",
// received: "number",
// path: ["email"],
// message: "Expected string, received number"
// },
// {
// code: "too_small",
// minimum: 8,
// type: "string",
// inclusive: true,
// path: ["password"],
// message: "String must contain at least 8 character(s)"
// }
// ]
}
}
에러 메시지 커스터마이징
복사
const user = z.object({
email: z.string().email("유효한 이메일 주소를 입력하세요"),
age: z.number()
.int("나이는 정수여야 합니다")
.min(0, "나이는 0 이상이어야 합니다")
.max(120, "나이는 120 이하여야 합니다"),
});
에러 포맷팅
복사
const result = User.safeParse(invalidData);
if (!result.success) {
// Flat 에러 (필드별)
const flatErrors = result.error.flatten();
console.log(flatErrors.fieldErrors);
// {
// email: ["Invalid email"],
// age: ["Number must be greater than 0"]
// }
// Form 에러 (React Hook Form 형식)
const formErrors = result.error.format();
console.log(formErrors.email?._errors); // ["Invalid email"]
}
성능 최적화
스키마 재사용
복사
// ❌ 매번 새로 생성 (느림)
function validate(data: unknown) {
const schema = z.object({
name: z.string(),
age: z.number(),
});
return schema.parse(data);
}
// ✅ 스키마 재사용 (빠름)
const schema = z.object({
name: z.string(),
age: z.number(),
});
function validate(data: unknown) {
return schema.parse(data);
}
부분 검증
복사
// 전체 검증 불필요 시 부분 검증
const user = z.object({
id: z.number(),
email: z.string().email(),
profile: z.object({
bio: z.string(),
website: z.string().url(),
}),
});
// 이메일만 검증
const emailOnly = user.pick({ email: true });
emailOnly.parse({ email: "[email protected]" }); // ✅ 빠름
// profile만 검증
const profileOnly = user.pick({ profile: true });
