Zod 완벽 가이드 | TypeScript 스키마 검증·타입 추론·Form 유효성 검사
이 글의 핵심
Zod는 TypeScript 우선 스키마 검증 라이브러리입니다. 기본 타입부터 복잡한 객체, 커스텀 검증, React Hook Form 통합까지 실전 예제로 정리했습니다.
실무 경험 공유: 대규모 폼 시스템을 Zod로 구축하면서, 런타임 에러를 90% 줄이고 타입 안전성을 100% 확보한 경험을 공유합니다.
들어가며: “폼 검증이 복잡해요”
실무 문제 시나리오
시나리오 1: 타입과 검증이 따로예요
TypeScript 타입과 런타임 검증 로직이 중복됩니다. Zod는 하나로 통합합니다.
시나리오 2: 에러 메시지 관리가 어려워요
각 필드마다 에러 메시지를 관리하기 복잡합니다. Zod는 자동 생성합니다.
시나리오 3: API 응답 검증이 없어요
API 응답을 검증하지 않아 런타임 에러가 발생합니다. Zod로 안전하게 검증합니다.
1. Zod란?
핵심 특징
Zod는 TypeScript 우선 스키마 선언 및 검증 라이브러리입니다.
주요 장점:
- 타입 추론: 스키마에서 TypeScript 타입 자동 생성
- 제로 의존성: 추가 라이브러리 없이 독립 실행
- 체이닝 API: 직관적인 메서드 체이닝
- 커스텀 에러: 상세한 에러 메시지
- 작은 번들: 8KB (minified + gzipped)
2. 설치 및 기본 사용
설치
npm install zod
기본 타입
import { z } from 'zod';
// 문자열
const nameSchema = z.string();
nameSchema.parse('John'); // ✅ 'John'
nameSchema.parse(123); // ❌ Error
// 숫자
const ageSchema = z.number();
ageSchema.parse(30); // ✅ 30
ageSchema.parse('30'); // ❌ Error
// 불리언
const isActiveSchema = z.boolean();
isActiveSchema.parse(true); // ✅ true
// 날짜
const dateSchema = z.date();
dateSchema.parse(new Date()); // ✅ Date
// 이메일
const emailSchema = z.string().email();
emailSchema.parse('[email protected]'); // ✅
emailSchema.parse('invalid'); // ❌ Error
3. 객체 스키마
기본 객체
const userSchema = z.object({
name: z.string(),
age: z.number(),
email: z.string().email(),
});
type User = z.infer<typeof userSchema>;
// type User = {
// name: string;
// age: number;
// email: string;
// }
const user = userSchema.parse({
name: 'John',
age: 30,
email: '[email protected]',
});
선택적 필드
const userSchema = z.object({
name: z.string(),
age: z.number().optional(),
bio: z.string().nullable(),
website: z.string().url().optional(),
});
type User = z.infer<typeof userSchema>;
// type User = {
// name: string;
// age?: number | undefined;
// bio: string | null;
// website?: string | undefined;
// }
기본값
const userSchema = z.object({
name: z.string(),
role: z.string().default('user'),
isActive: z.boolean().default(true),
});
const user = userSchema.parse({ name: 'John' });
// { name: 'John', role: 'user', isActive: true }
4. 배열 및 튜플
배열
const numbersSchema = z.array(z.number());
numbersSchema.parse([1, 2, 3]); // ✅
const usersSchema = z.array(
z.object({
name: z.string(),
age: z.number(),
})
);
const users = usersSchema.parse([
{ name: 'John', age: 30 },
{ name: 'Jane', age: 25 },
]);
튜플
const coordinatesSchema = z.tuple([z.number(), z.number()]);
coordinatesSchema.parse([10, 20]); // ✅
coordinatesSchema.parse([10]); // ❌ Error
const personSchema = z.tuple([
z.string(), // name
z.number(), // age
z.boolean(), // isActive
]);
5. 유니온 및 인터섹션
유니온
const stringOrNumberSchema = z.union([z.string(), z.number()]);
stringOrNumberSchema.parse('hello'); // ✅
stringOrNumberSchema.parse(123); // ✅
stringOrNumberSchema.parse(true); // ❌ Error
// 더 간단한 방법
const schema = z.string().or(z.number());
리터럴
const roleSchema = z.enum(['admin', 'user', 'guest']);
roleSchema.parse('admin'); // ✅
roleSchema.parse('invalid'); // ❌ Error
type Role = z.infer<typeof roleSchema>;
// type Role = 'admin' | 'user' | 'guest'
인터섹션
const baseUserSchema = z.object({
id: z.number(),
name: z.string(),
});
const adminSchema = baseUserSchema.and(
z.object({
role: z.literal('admin'),
permissions: z.array(z.string()),
})
);
6. 커스텀 검증
refine
const passwordSchema = z
.string()
.min(8, '비밀번호는 최소 8자 이상이어야 합니다')
.refine(
(password) => /[A-Z]/.test(password),
'대문자를 포함해야 합니다'
)
.refine(
(password) => /[0-9]/.test(password),
'숫자를 포함해야 합니다'
);
passwordSchema.parse('Password123'); // ✅
passwordSchema.parse('password'); // ❌ Error
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'],
});
}
});
7. React Hook Form 통합
설치
npm install react-hook-form @hookform/resolvers
기본 사용
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const signupSchema = z.object({
email: z.string().email('유효한 이메일을 입력하세요'),
password: z.string().min(8, '비밀번호는 최소 8자 이상이어야 합니다'),
name: z.string().min(2, '이름은 최소 2자 이상이어야 합니다'),
});
type SignupForm = z.infer<typeof signupSchema>;
export function SignupForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<SignupForm>({
resolver: zodResolver(signupSchema),
});
const onSubmit = (data: SignupForm) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input {...register('email')} placeholder="Email" />
{errors.email && <p>{errors.email.message}</p>}
</div>
<div>
<input
{...register('password')}
type="password"
placeholder="Password"
/>
{errors.password && <p>{errors.password.message}</p>}
</div>
<div>
<input {...register('name')} placeholder="Name" />
{errors.name && <p>{errors.name.message}</p>}
</div>
<button type="submit">Sign Up</button>
</form>
);
}
8. API 응답 검증
fetch 래퍼
import { z } from 'zod';
const userSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
async function fetchUser(id: number) {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
// 런타임 검증
const user = userSchema.parse(data);
return user;
}
// 타입 안전
const user = await fetchUser(1);
console.log(user.name); // ✅ TypeScript가 타입을 알고 있음
safeParse (에러 핸들링)
async function fetchUser(id: number) {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
const result = userSchema.safeParse(data);
if (!result.success) {
console.error('Validation failed:', result.error);
return null;
}
return result.data;
}
9. 실전 예제: 복잡한 폼
import { z } from 'zod';
const addressSchema = z.object({
street: z.string().min(1, '주소를 입력하세요'),
city: z.string().min(1, '도시를 입력하세요'),
zipCode: z.string().regex(/^\d{5}$/, '5자리 우편번호를 입력하세요'),
});
const profileSchema = z.object({
// 기본 정보
firstName: z.string().min(2, '이름은 최소 2자 이상이어야 합니다'),
lastName: z.string().min(2, '성은 최소 2자 이상이어야 합니다'),
email: z.string().email('유효한 이메일을 입력하세요'),
// 연락처
phone: z
.string()
.regex(/^01[0-9]-\d{4}-\d{4}$/, '올바른 전화번호 형식이 아닙니다')
.optional(),
// 주소
address: addressSchema,
// 생년월일
birthDate: z.date().max(new Date(), '미래 날짜는 선택할 수 없습니다'),
// 약관 동의
agreeToTerms: z.literal(true, {
errorMap: () => ({ message: '약관에 동의해야 합니다' }),
}),
// 선택사항
newsletter: z.boolean().default(false),
bio: z.string().max(500, '자기소개는 500자 이하여야 합니다').optional(),
});
type Profile = z.infer<typeof profileSchema>;
export function ProfileForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<Profile>({
resolver: zodResolver(profileSchema),
});
const onSubmit = async (data: Profile) => {
try {
const response = await fetch('/api/profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (response.ok) {
alert('프로필이 저장되었습니다');
}
} catch (error) {
console.error(error);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<h2>프로필 정보</h2>
<div>
<input {...register('firstName')} placeholder="이름" />
{errors.firstName && <p>{errors.firstName.message}</p>}
</div>
<div>
<input {...register('lastName')} placeholder="성" />
{errors.lastName && <p>{errors.lastName.message}</p>}
</div>
<div>
<input {...register('email')} type="email" placeholder="이메일" />
{errors.email && <p>{errors.email.message}</p>}
</div>
<h3>주소</h3>
<div>
<input {...register('address.street')} placeholder="주소" />
{errors.address?.street && <p>{errors.address.street.message}</p>}
</div>
<div>
<input {...register('address.city')} placeholder="도시" />
{errors.address?.city && <p>{errors.address.city.message}</p>}
</div>
<div>
<input {...register('address.zipCode')} placeholder="우편번호" />
{errors.address?.zipCode && <p>{errors.address.zipCode.message}</p>}
</div>
<div>
<label>
<input type="checkbox" {...register('agreeToTerms')} />
약관에 동의합니다
</label>
{errors.agreeToTerms && <p>{errors.agreeToTerms.message}</p>}
</div>
<button type="submit">저장</button>
</form>
);
}
10. 성능 최적화
부분 검증
const userSchema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number(),
});
// 일부 필드만 검증
const partialSchema = userSchema.partial();
partialSchema.parse({ name: 'John' }); // ✅
// 특정 필드만 선택
const pickSchema = userSchema.pick({ name: true, email: true });
pickSchema.parse({ name: 'John', email: '[email protected]' }); // ✅
정리 및 체크리스트
핵심 요약
- Zod: TypeScript 우선 스키마 검증 라이브러리
- 타입 추론: 스키마에서 TypeScript 타입 자동 생성
- 체이닝 API: 직관적인 메서드 체이닝
- React Hook Form: 완벽한 통합 지원
- API 검증: 런타임 타입 안전성 확보
구현 체크리스트
- Zod 설치
- 스키마 정의
- 타입 추론 활용
- 커스텀 검증 구현
- React Hook Form 통합
- API 응답 검증
같이 보면 좋은 글
- TypeScript 5 완벽 가이드
- React Hook Form 완벽 가이드
- tRPC 완벽 가이드
이 글에서 다루는 키워드
Zod, TypeScript, Validation, Schema, Form, Type Safety, React
자주 묻는 질문 (FAQ)
Q. Zod vs Yup, 어떤 게 나은가요?
A. Zod는 TypeScript 우선이며 타입 추론이 강력합니다. Yup은 더 오래되었고 생태계가 큽니다. TypeScript 프로젝트는 Zod를 권장합니다.
Q. 성능은 어떤가요?
A. Zod는 매우 빠릅니다. 복잡한 스키마도 밀리초 단위로 검증합니다. 프로덕션에서 사용해도 성능 문제가 없습니다.
Q. 서버에서도 사용할 수 있나요?
A. 네, Zod는 Node.js에서도 완벽하게 동작합니다. API 요청 검증에 많이 사용됩니다.
Q. 에러 메시지를 커스터마이징할 수 있나요?
A. 네, 각 검증 메서드에 에러 메시지를 전달하거나 errorMap을 사용하여 전역 설정할 수 있습니다.