Zod 완벽 가이드 — TypeScript schema validation, Yup·Joi 대체
이 글의 핵심
Zod는 "TypeScript-first schema validation"을 표방하는 라이브러리입니다. Yup·Joi가 런타임 검증 + 수동 타입 정의를 요구하는 반면, Zod는 스키마에서 TypeScript 타입을 자동 추론하고 tree-shakable·zero dependency로 번들 크기도 작습니다. 2020년 출시 후 tRPC·React Hook Form·T3 Stack의 기본 검증 라이브러리로 자리잡아 2026년 현재 GitHub Star 35k+입니다.
이 글에서는 Zod의 기본 API부터 스키마 컴포지션, 고급 유니온·재귀, transform·preprocess, 에러 전략, 폼 라이브러리 비교, API 설계 패턴, 성능·lazy, Yup·Joi 비교까지 실무에서 바로 쓸 수 있는 수준으로 정리합니다. 아래 예제는 TypeScript 5.x 기준으로 작성했습니다.
설치
npm install zod
기본 사용법
import { z } from 'zod';
// 스키마 정의
const UserSchema = z.object({
name: z.string(),
age: z.number(),
email: z.string().email(),
});
// 타입 추론
type User = z.infer<typeof UserSchema>;
// { name: string; age: number; email: string; }
// 검증
const user = UserSchema.parse({
name: 'Alice',
age: 30,
email: '[email protected]',
});
// 안전한 검증 (에러 반환)
const result = UserSchema.safeParse({
name: 'Bob',
age: 'invalid', // 타입 오류
email: '[email protected]',
});
if (!result.success) {
console.log(result.error.issues);
} else {
console.log(result.data);
}
실무 팁: parse는 실패 시 예외를 던지므로 경계(HTTP 핸들러, 큐 consumer, main)에서만 쓰고, 라이브러리 내부·UI 이벤트에서는 safeParse로 흐름을 제어하는 편이 디버깅에 유리합니다.
Primitives
z.string()
z.number()
z.bigint()
z.boolean()
z.date()
z.symbol()
z.undefined()
z.null()
z.void()
z.any()
z.unknown()
z.never()
String 검증
z.string()
.min(3)
.max(100)
.email()
.url()
.uuid()
.regex(/^[A-Z]+$/)
.trim()
.toLowerCase()
.toUpperCase()
.datetime() // ISO 8601
.ip() // IPv4 or IPv6
Number 검증
z.number()
.min(0)
.max(100)
.int()
.positive()
.negative()
.nonnegative()
.nonpositive()
.multipleOf(5)
.finite()
.safe()
Object
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
role: z.enum(['admin', 'user']),
createdAt: z.date().optional(),
});
// Partial
const PartialUser = UserSchema.partial();
// 모든 필드 optional
// Pick
const UserIdName = UserSchema.pick({ id: true, name: true });
// Omit
const UserWithoutId = UserSchema.omit({ id: true });
// Extend
const ExtendedUser = UserSchema.extend({
avatar: z.string().url(),
});
// Merge
const MergedSchema = UserSchema.merge(z.object({ extra: z.string() }));
Array
z.array(z.string())
.min(1)
.max(10)
.length(5)
.nonempty()
// Tuple
z.tuple([z.string(), z.number(), z.boolean()])
스키마 컴포지션과 재사용
작은 스키마를 쌓아 올리는 방식이 유지보수·테스트·문서화에 가장 잘 맞습니다. extend·merge·and(intersection 래퍼)로 필드 집합을 조합하고, 공통 조각은 별 const로 빼 한 곳만 수정하면 전역이 따라오게 합니다.
import { z } from 'zod';
const IdSchema = z.object({ id: z.string().uuid() });
const TimestampsSchema = z.object({
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});
// 공통: 이메일 + 표시명
const ProfileBase = z.object({
email: z.string().email(),
displayName: z.string().min(1).max(64),
});
// API별로 조합: 생성은 id·타임스탬프 없음, 응답은 전부
export const CreateUserInput = ProfileBase;
export const UserEntity = ProfileBase.merge(IdSchema).merge(TimestampsSchema);
// Brand로 동일 string이라도 용도 분리(실수 방지)
const UserId = z.string().uuid().brand<'UserId'>();
const Email = z.string().email().brand<'Email'>();
type UserId = z.infer<typeof UserId>;
brand는 런타임 값은 그대로 두고 TypeScript에만 태그를 붙이므로, id를 email 슬롯에 넣는 실수를 컴파일 타임에 막을 수 있습니다. (런타임 구분이 필요하면 별도 필드·discriminated union을 쓰는 편이 안전합니다.)
z.strictObject나 기본 z.object의 strip(기본) / strict / passthrough 동작은 OpenAPI·외부 API와 맞출 때 중요합니다. 알 수 없는 키를 거부하려면 strict를, 로깅용으로 남기려면 passthrough를 검토하세요.
고급 타입: union, discriminated union, recursive
Union
const StringOrNumber = z.union([z.string(), z.number()]);
// 또는
const StringOrNumber = z.string().or(z.number());
Discriminated union (태그 기반)
공통 리터럴 필드(type, kind 등)가 있을 때 discriminatedUnion이 에러 위치·타입 추론 모두에 유리합니다. 일반 union보다 실패 시 어떤 분기를 시도했는지 메시지가 명확해질 때가 많습니다.
const PaymentSchema = z.discriminatedUnion('method', [
z.object({ method: z.literal('card'), last4: z.string().length(4) }),
z.object({ method: z.literal('bank'), accountMask: z.string() }),
z.object({ method: z.literal('point'), points: z.number().int().nonnegative() }),
]);
type Payment = z.infer<typeof PaymentSchema>;
Recursive (트리, 댓글, 카테고리)
자기 자신을 포함하는 구조는 z.lazy와 함께 z.ZodType<T>(또는 satisfies)로 명시적 재귀 타입을 주는 것이 일반적입니다.
type Category = {
name: string;
subcategories: Category[];
};
const CategorySchema: z.ZodType<Category> = z.lazy(() =>
z.object({
name: z.string(),
subcategories: z.array(CategorySchema),
})
);
실무 팁: 재귀 스키마는 JSON 직렬화 깊이·순환 참조(실제 데이터에 부모 id만 있고 중첩 객체는 없는 경우)와 설계가 어긋나기 쉽습니다. API가 flat list + parentId만 준다면 클라이언트에서 트리를 조립한 뒤 CategorySchema로 검증하거나, 서버 응답이 항상 중첩이면 그에 맞춰 스키마를 유지하세요.
Intersection (객체 병합)
const PersonSchema = z.object({ name: z.string() });
const EmployeeSchema = z.object({ id: z.number() });
const EmployeePerson = z.intersection(PersonSchema, EmployeeSchema);
// 또는
const EmployeePerson2 = PersonSchema.merge(EmployeeSchema);
merge는 키가 겹치면 나중에 merge한 객체의 키가 우선합니다. OpenAPI allOf와 비슷한 느낌으로 쓰되, 같은 키에 서로 다른 refine이 있으면 디버깅이 어려우므로 한 객체 안에서 extend로 정리하는 편이 낫습니다.
Enum
z.enum(['admin', 'user', 'guest'])
// Native TypeScript Enum
enum Role {
Admin = 'admin',
User = 'user',
}
z.nativeEnum(Role)
Optional & Nullable & Default
z.string().optional() // string | undefined
z.string().nullable() // string | null
z.string().nullish() // string | null | undefined
z.string().default('default value')
Transform과 Preprocess 활용
transform은 검증이 끝난 뒤 출력 타입을 바꿉니다. preprocess는 파싱 전에 unknown에 가깝게 들어온 값을 정리할 때 씁니다. 폼·쿼리스트링·JSON.parse 직후처럼 문자열로만 들어오는 숫자/불리언이 대표 사례입니다.
import { z } from 'zod';
// 문자열 → 숫자 (화이트리스트: 실패는 스키마 단계에서)
const PositiveInt = z
.string()
.regex(/^\d+$/)
.transform((s) => parseInt(s, 10))
.pipe(z.number().int().positive());
// boolean 쿼리 파라미터: "true" | "1" | true 등 흡수
const CoercedBool = z.preprocess((val) => {
if (val === 'true' || val === '1' || val === 1) return true;
if (val === 'false' || val === '0' || val === 0) return false;
return val;
}, z.boolean());
// 날짜: ISO 문자열 → Date
const IsoDate = z
.string()
.datetime()
.transform((s) => new Date(s));
pipe로 transform 뒤에 또 다른 ZodType을 이어 단계적 검증을 할 수 있습니다. 복잡한 도메인 규칙은 superRefine에서 여러 이슈를 한 번에 쌓는 방식이 읽기 좋습니다.
const PasswordPolicy = z
.object({
password: z.string(),
confirm: z.string(),
})
.superRefine((val, ctx) => {
if (val.password.length < 10) {
ctx.addIssue({ code: 'custom', path: ['password'], message: '10자 이상' });
}
if (val.password !== val.confirm) {
ctx.addIssue({ code: 'custom', path: ['confirm'], message: '비밀번호 불일치' });
}
});
Refine (커스텀 검증)
const PasswordSchema = z.string().refine(
(val) => val.length >= 8 && /[A-Z]/.test(val) && /[0-9]/.test(val),
{ message: 'Password must contain uppercase and number' }
);
// 복잡한 검증
const UserSchema = z.object({
password: z.string(),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
Preprocess (기본)
const TrimmedString = z.preprocess((val) => {
if (typeof val === 'string') return val.trim();
return val;
}, z.string());
TrimmedString.parse(' hello '); // 'hello'
에러 핸들링 전략
- 경계에서만
parse: 서버의 경우 핸들러 입구에서schema.safeParse(req.body)→ 실패 시 400 +issues를 로그/클라이언트에 맞게 축약. - 사용자 메시지:
messageper-field,_errors뿐 아니라path로 필드 매핑. RHF·Formik은zodResolver가path를 필드名으로 넘깁니다. - 애플리케이션 코드:
issue.code로 분기(invalid_type,too_small,unrecognized_keys등). 다국어는setErrorMap이나 i18n 레이어에서issue메타를 조합. - 로깅: 프로덕션에서 원본
body전체를 남기면 민감정보 이슈가 있으므로, 스키마 통과 전에는 해시/길이만, 실패 시는issues만 structured logging 하는 패턴이 흔합니다.
import { z } from 'zod';
const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
if (issue.code === z.ZodIssueCode.invalid_type) {
return { message: '타입이 올바르지 않습니다' };
}
return { message: ctx.defaultError };
};
z.setErrorMap(customErrorMap);
// 필드별
const schema = z.object({
email: z.string().email({ message: '올바른 이메일을 입력하세요' }),
age: z.number({ invalid_type_error: '숫자를 입력하세요' })
.min(18, { message: '18세 이상이어야 합니다' }),
});
flatten() / formErrors로 폼 전체를 한 번에 UI에 뿌리기도 합니다. tRPC·TRPC TRPCError에 ZodError를 cause로 싣으면, 상위에서 공통 formatter로 처리하기 좋습니다.
Form 라이브러리: React Hook Form vs Formik
| 구분 | React Hook Form + zodResolver | Formik + 수동/어댑터 |
|---|---|---|
| 리렌더 | 등록 기반, 필드 단위 갱신이 적음 | 컨텍스트 기반, 큰 폼에서 리렌더 부담 가능 |
| Zod 연동 | @hookform/resolvers 공식, 성숙 | 커뮤니티 패키지·직접 validate |
| API 스타일 | register / Controller | Field / useField |
| 적합 | 대부분의 신규 React 프로젝트, 성능 민감 폼 | 레거시·기존 Formik 투자가 큰 팀 |
React Hook Form은 아래처럼 스키마 하나로 타입·검증이 맞습니다. valueAsNumber로 숫자 인풋을 맞추는 등 HTML과 타입이 어긋나는 지점만 주의하면 됩니다.
npm install react-hook-form @hookform/resolvers
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
age: z.number().min(18),
});
type FormData = z.infer<typeof schema>;
function MyForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
});
const onSubmit = (data: FormData) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<input type="number" {...register('age', { valueAsNumber: true })} />
{errors.age && <span>{errors.age.message}</span>}
<button type="submit">Submit</button>
</form>
);
}
Formik을 쓴다면 validate에서 schema.safeParse(values)로 통일하거나, zodFormikAdapter 류 어댑터로 맞출 수 있습니다. 팀에 이미 Formik 투자가 있다면 점진적으로 공통 schema만 Zod로 두고, 마이그레이션 시기에 RHF로 옮기는 식이 리스크가 적습니다.
API 스키마 설계 패턴
- Input / Output 분리:
UserCreate는 비밀번호·약관,UserPublic은id·createdAt만. 같은 테이블이라도 노출 스키마는 별도z.object로 두어 과다 노출을 방지합니다. - 버전 필드:
v: z.literal(1)로 클라이언트·서버가 동의한 페이로드 형태를 고정(회귀 방지). - 페이징·정렬:
sortBy: z.enum(['createdAt', 'name']),order: z.enum(['asc', 'desc'])처럼 화이트리스트로만. - ID 타입: URL 파라미터는 문자열이므로
z.coerce.number()vsz.string().uuid()를 라우트별로 일관되게. - 내부 DTO는 넓게, 외부 API는 좁게: 내부에서만 쓰는 필드는
InternalUserSchema로, 공개 REST는PublicUserResponse로 분리.
import { z } from 'zod';
export const PaginationQuery = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
});
export const CreatePostBody = z.object({
title: z.string().min(1).max(200),
body: z.string().max(50_000),
tags: z.array(z.string().max(32)).max(20).default([]),
});
tRPC·Hono·Express 어디서든 같은 스키마를 import하면 클라이언트(zodios 등)·서버·OpenAPI 생성기까지 한 갈래로 맞출 수 있습니다.
tRPC 통합
import { z } from 'zod';
import { publicProcedure, router } from './trpc';
export const userRouter = router({
create: publicProcedure
.input(
z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().min(18).optional(),
})
)
.mutation(async ({ input }) => {
// input은 자동 타입 추론 + 검증됨
return db.user.create({ data: input });
}),
});
성능 최적화와 lazy 스키마
- 스키마는 모듈 최상위에 한 번만 정의하고 재사용하세요.
parse마다 거대한 객체를 새로 만들 필요는 없습니다. - 대형 Union: 분기가 많으면
discriminatedUnion이 단순union나열보다 유리한 경우가 있습니다(내부 최적화·에러). - 큰 JSON:
z.lazy로 실제로 필요한 서브트리만 검증하거나(드물게), 응답이 매우 크면 도메인별로 잘라 여러 스키마로safeParse를 나누는 것이 메모리에 도움이 될 수 있습니다. - 빈번한 동일 페이로드 캐싱은 키 설계·무효화에 주의(보안·메모리). 대부분 앱은 캐시 없이도 충분합니다.
// lazy: 순환·대형 트리에 사용 (위 Category 예제 참고)
// 스키마 캐싱은 특수한 경우에만
const UserSchema = z.object({ id: z.string(), name: z.string() });
const parsedCache = new Map<string, z.infer<typeof UserSchema>>();
function parseWithCache(jsonKey: string, data: unknown) {
if (parsedCache.has(jsonKey)) {
return { success: true as const, data: parsedCache.get(jsonKey)! };
}
const result = UserSchema.safeParse(data);
if (result.success) parsedCache.set(jsonKey, result.data);
return result;
}
Yup·Joi와의 비교 (요약)
- Yup: 검증 중심,
InferType으로 타입을 뒤늦게 붙이는 느낌. React 레거시·폼 팀이 익숙한 경우多.oneOf·SchemaOf생태는 성숙하나, TypeScript와의 ‘한 객체로 타입+검증’ 일체감은 Zod가 강합니다. - Joi: Node·서버 쪽에서 흔하고 스키마가 표현력이 좋으나, 번들·브라우저·TS 정적 타입 연동 측면에서 Zod가 유리한 경우가 많습니다. BFF·Lambda만 Joi를 쓰고 프론트는 Zod로 중복 정의가 생기지 않게 공유 패키지로 끌어올리는 팀도 있습니다.
- Zod:
z.infer로 동기화, discriminated union·brand·transform+pipe가 일상 API에 가깝고, tRPC·RHF·Prisma 생태가 기본 examples를 제공합니다.
마이그레이션은 필드 단위로 yup 스키마와 z 스키마를 병행 테스트한 뒤 교체하는 방식이 안전합니다.
Async Validation
const UsernameSchema = z.string().refine(
async (username) => {
const exists = await checkUsernameExists(username);
return !exists;
},
{ message: 'Username already taken' }
);
const result = await UsernameSchema.parseAsync('alice');
실무 팁: 비동기 refine은 RHF mode: 'onBlur' / debounce와 함께 쓰지 않으면 요청 폭주가 날 수 있습니다. 서버 쪽 최종 검증은 반드시 별도.
환경변수 검증
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']),
DATABASE_URL: z.string().url(),
PORT: z.string().transform((val) => parseInt(val, 10)),
API_KEY: z.string().min(1),
});
export const env = envSchema.parse(process.env);
JSON 파싱
const ConfigSchema = z.object({
apiUrl: z.string().url(),
timeout: z.number(),
retries: z.number().default(3),
});
const rawConfig = JSON.parse(fs.readFileSync('config.json', 'utf-8'));
const config = ConfigSchema.parse(rawConfig);
API Response 검증
const UserResponseSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
createdAt: z.string().datetime(),
});
async function fetchUser(id: number) {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return UserResponseSchema.parse(data);
}
Prisma 통합 (참고)
import { z } from 'zod';
import type { User } from '@prisma/client';
const UserSchema: z.ZodType<User> = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
createdAt: z.date(),
});
실제 프로젝트 적용 경험담 (요약)
- tRPC + Zod 한 베이스: procedure
input과 프론트 폼schema를packages/validators에 두면 드리프트가 사라집니다. “API만 바꾸고 UI를 안 바꾼” 클래스의 버그를 줄였습니다. - 외부 API 응답 검증: 서드파티가 스키마를 바꿔도
safeParse실패로 조기 감지·알람.parse로 멈추기보다, 배열 응답이면itemSchema.safeParse로 건너뛰기 vs 전체 실패 정책을 팀이 정해야 합니다. - 폼에선
preprocess: 쿼리/모든 인풋이 string인 레거시와 맞출 때coerce·preprocess를 쓰면, 타입스크립트z.infer와 실제 런타입이 맞습니다. - 에러 UX:
setErrorMap을 한곳에 두고, 제품 locale에 맞게 메시지를 중앙집중하면 디자이너·PM과 문구를 맞추기 쉽습니다.
트러블슈팅
순환 참조
z.lazy()사용 (위 Category 예제).
타입 추론이 애매할 때
z.infer<typeof Schema>를 export해서 단일 출처로 쓰거나,satisfies z.ZodType<YourType>로 맞춤.
에러 메시지 한글화
message옵션,setErrorMap, RHFerrors에 매핑.
Union 좁히기
const result = UnionSchema.safeParse(data);
if (result.success) {
// result.data는 올바른 판별 유니온
}
체크리스트
- Zod 설치
- 스키마 정의 +
z.infer타입 추론 -
safeParsevsparse위치 (경계 vs UI) - 커스텀 에러 메시지 /
setErrorMap - React Hook Form (
zodResolver) 또는 Formik 어댑터 - tRPC / REST input·output 스키마 분리
- 환경변수·config 검증
- API response 검증
- async refine 남용 방지 (서버·디바운스)
- lazy·discriminated union·brand 필요 시
마무리
Zod는 “TypeScript가 런타임 검증을 할 수 있다면?”에 대한 실용적인 답입니다. 스키마 컴포지션·discriminated union·transform·preprocess·에러 맵까지 한 흐름으로 묶을 수 있어, API·폼·환경을 같은 언어로 맞추기 좋습니다. Yup·Joi에서 이전하는 팀은 공유 모듈에 스키마를 모아두는 것부터 시작하면 효과를 빨리 느낄 수 있습니다.
관련 글
- tRPC 완벽 가이드
- React Hook Form 가이드
- TypeScript 완벽 가이드
- Prisma 완벽 가이드
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. REST·tRPC 입력 검증, 환경 변수·config 로딩, React 폼(React Hook Form·Formik), 서드파티 JSON 응답 검증 등 경계가 있는 모든 입출력에 쓰면 됩니다. 위 체크리스트와 예제를 그대로 팀 가이드에 넣기 좋습니다.
Q. 선행으로 읽으면 좋은 글은?
A. 이 블로그의 tRPC, React Hook Form, TypeScript 가이드와 함께 읽으면 zodResolver·z.infer·procedure input이 한눈에 연결됩니다. 관련 글 링크를 참고하세요.
Q. 더 깊이 공부하려면?
A. Zod 공식 문서와 TypeScript 핸드북의 유니온·제네릭을 병행하면, brand·discriminatedUnion을 도메인 모델에 녹이기 쉽습니다.
이 글에서 다루는 키워드 (관련 검색어)
Zod, TypeScript, Validation, Schema, Type Safety, tRPC, React, React Hook Form, Formik, Yup, Joi 등으로 검색하시면 이 글이 도움이 됩니다.