TypeScript에서 Zod로 런타임 타입 검증하기 — API 응답을 믿지 마라
백엔드가 스펙을 안 알려줄 때
2년 전 일이다. 백엔드팀이 갑자기 API 응답 포맷을 바꿨다. 아무도 프론트 팀한테 알려주지 않았다. 우리가 알게 된 건 프로덕션 배포 후 1시간이 지나서였다. 사용자들이 "로그인이 안 돼요"라고 신고했을 때다.
그 때 깨달았다. TypeScript의 정적 타입 체크만으로는 부족하다는 걸. 컴파일 시점엔 타입이 맞는데, 런타임에 서버가 다른 포맷을 보낼 수 있다. 그 뒤로 나는 Zod 없이는 프로덕션 코드를 짜지 않는다.
런타임 타입 검증이 필요한 이유
TypeScript 개발자들이 자주 하는 실수가 뭘까? 서버에서 받은 데이터가 타입과 일치한다고 가정하는 것이다. 하지만 현실은:
- 백엔드 개발자가 스펙을 바꿨는데 안 알려줬다
- 버전 관리가 안 되는 외부 API를 쓴다
- 데이터베이스에 이상한 데이터가 저장되어 있다
- 레거시 시스템에서 데이터가 넘어올 때 포맷이 이상하다
이런 상황에서 타입 검증 없으면 앱이 터진다. Zod는 이 문제를 우아하게 해결한다.
Zod 기본 사용법
<?xml version="1.0"?>
import { z } from 'zod'
// 간단한 스키마 정의
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
age: z.number().int().min(0).max(150).optional(),
})
// 타입 추출
type User = z.infer<typeof UserSchema>
// 검증
const data = {
id: 1,
name: 'John',
email: 'john@example.com',
age: 30,
}
const result = UserSchema.parse(data)
console.log(result) // 검증된 데이터
이게 기본이다. Zod 스키마를 정의하고, TypeScript 타입을 추출하고, 데이터를 검증한다. 만약 데이터가 스키마와 맞지 않으면 에러가 난다.
API 응답 검증
실제로 API를 호출할 때 Zod를 쓰는 패턴을 보자.
<?xml version="1.0"?>
const API_URL = 'https://api.example.com'
const UserResponseSchema = z.object({
data: z.object({
id: z.number(),
email: z.string().email(),
profile: z.object({
name: z.string(),
avatar: z.string().url().optional(),
}),
}),
status: z.enum(['success', 'error']),
})
type UserResponse = z.infer<typeof UserResponseSchema>
async function fetchUser(userId: number): Promise<UserResponse> {
const response = await fetch(`${API_URL}/users/${userId}`)
const data = await response.json()
// 백엔드가 뭘 보내든 우리 스키마와 검증한다
return UserResponseSchema.parse(data)
}
// 사용
try {
const user = await fetchUser(1)
console.log(user.data.profile.name) // 타입 안전
} catch (error) {
if (error instanceof z.ZodError) {
console.error('API 응답이 예상과 다릅니다:', error.errors)
}
}
이 패턴이 내 모든 API 호출의 기본이다. 서버에서 뭘 보내든 우리 스키마와 맞는지 확인한다. 맞지 않으면 에러를 던진다.
폼 입력 검증
폼 입력도 마찬가지다. 유저가 입력한 데이터는 신뢰할 수 없다.
<?xml version="1.0"?>
const LoginFormSchema = z.object({
email: z.string()
.email('유효한 이메일을 입력하세요')
.min(1, '이메일은 필수입니다'),
password: z.string()
.min(8, '비밀번호는 8자 이상이어야 합니다')
.regex(/[A-Z]/, '대문자를 포함해야 합니다')
.regex(/[0-9]/, '숫자를 포함해야 합니다'),
rememberMe: z.boolean().optional(),
})
type LoginFormData = z.infer<typeof LoginFormSchema>
function handleLogin(formData: FormData) {
const rawData = {
email: formData.get('email'),
password: formData.get('password'),
rememberMe: formData.get('rememberMe') === 'on',
}
const result = LoginFormSchema.safeParse(rawData)
if (!result.success) {
// 에러 메시지 출력
result.error.errors.forEach(err => {
console.error(`${err.path.join('.')}: ${err.message}`)
})
return
}
// 검증된 데이터 사용
submitLogin(result.data)
}
safeParse를 쓰면 에러를 던지지 않고 결과 객체를 반환한다. 폼 검증엔 이게 낫다. UI에서 각 필드의 에러를 보여줄 수 있으니까.
복잡한 스키마 구성
실제 프로젝트는 이것보다 훨씬 복잡하다. 여러 스키마를 조합하는 패턴을 보자.
<?xml version="1.0"?>
// 기본 스키마들
const IdSchema = z.number().int().positive()
const EmailSchema = z.string().email()
const TimestampSchema = z.coerce.date()
// 조합
const PostSchema = z.object({
id: IdSchema,
title: z.string().min(1).max(200),
content: z.string().min(10),
author: z.object({
id: IdSchema,
email: EmailSchema,
name: z.string(),
}),
tags: z.array(z.string()).default([]),
createdAt: TimestampSchema,
updatedAt: TimestampSchema,
status: z.enum(['draft', 'published', 'archived']),
metadata: z.record(z.unknown()).optional(),
})
// 변형
const CreatePostSchema = PostSchema.omit({ id: true, createdAt: true, updatedAt: true })
const UpdatePostSchema = PostSchema.partial()
type Post = z.infer<typeof PostSchema>
type CreatePost = z.infer<typeof CreatePostSchema>
type UpdatePost = z.infer<typeof UpdatePostSchema>
이렇게 하면 코드 중복을 줄일 수 있다. 기본 스키마를 정의하고, 필요에 따라 omit, partial 같은 메서드로 변형한다.
커스텀 에러 메시지
유저가 이해할 수 있는 에러 메시지가 중요하다.
<?xml version="1.0"?>
const AddressSchema = z.object({
street: z.string().min(1, '주소를 입력하세요'),
city: z.string().min(1, '도시를 입력하세요'),
zipcode: z.string()
.regex(/^\d{5}$/, '우편번호는 5자리 숫자여야 합니다'),
country: z.enum(['US', 'CA', 'MX'], {
errorMap: () => ({ message: '지원하는 국가가 아닙니다' }),
}),
}).refine((data) => {
// 커스텀 검증 로직
if (data.country === 'US' && !data.zipcode.match(/^\d{5}(-\d{4})?$/)) {
return false
}
return true
}, {
message: '미국 우편번호 형식이 잘못되었습니다',
path: ['zipcode'],
})
type Address = z.infer<typeof AddressSchema>
refine으로 복잡한 검증 로직을 추가할 수 있다. 여러 필드를 비교하거나, 외부 데이터에 기반해서 검증해야 할 때 유용하다.
React 폼 라이브러리와 통합
React Hook Form이나 Formik 같은 폼 라이브러리와 함께 쓰면 더욱 강력하다.
<?xml version="1.0"?>
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
const schema = z.object({
email: z.string().email('이메일 형식이 틀렸습니다'),
password: z.string().min(8, '8자 이상이어야 합니다'),
})
type FormData = z.infer<typeof schema>
export function LoginForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
})
const onSubmit = (data: FormData) => {
// data는 이미 검증된 타입
console.log(data)
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <p>{errors.email.message}</p>}
<input {...register('password')} type="password" />
{errors.password && <p>{errors.password.message}</p>}
<button type="submit">로그인</button>
</form>
)
}
이렇게 하면 폼 검증이 일관되고 타입 안전하다. React Hook Form이 Zod 스키마를 자동으로 검증해준다.
성능 고려사항
Zod는 강력하지만, 매번 검증하는 건 성능 비용이 있다. 특히 큰 배열이나 복잡한 객체를 자주 검증하면 느려질 수 있다.
<?xml version="1.0"?>
// 성능 최적화: 검증 결과 캐싱
const ValidationCache = new Map<string, any>()
function validateWithCache(data: unknown, schema: z.ZodSchema, cacheKey: string) {
if (ValidationCache.has(cacheKey)) {
return ValidationCache.get(cacheKey)
}
const result = schema.safeParse(data)
if (result.success) {
ValidationCache.set(cacheKey, result.data)
}
return result
}
// 또는 좀 더 정교한 방식: 빠른 검증 모드
const FastUserSchema = z.object({
id: z.number(),
email: z.string(),
// email 형식 검증 스킵
}).strict()
프로덕션에서 성능이 문제라면, 검증을 선택적으로 하는 것도 방법이다. 개발 환경에선 엄격하게, 프로덕션에선 필수적인 것만 검증한다.
마이그레이션 전략
기존 프로젝트에 Zod를 도입할 때는 점진적으로 하자.
<?xml version="1.0"?>
// 1단계: 중요한 API부터 시작
const CriticalAPIs = [
'/auth/login',
'/auth/refresh',
'/user/profile',
]
// 2단계: 스키마를 API 모듈 옆에 정의
// src/api/user.ts
const schemas = {
getUser: UserResponseSchema,
updateUser: UpdateUserSchema,
}
// 3단계: 모든 응답에서 검증
export async function getUser(id: number) {
const response = await fetch(`/api/users/${id}`)
const data = await response.json()
return schemas.getUser.parse(data)
}
// 4단계: 나머지 부분도 추가
급하게 모든 걸 바꿀 필요 없다. 중요한 부분부터 시작해서 점진적으로 확대해나가면 된다.
마무리
Zod 없이는 TypeScript를 제대로 쓴다고 할 수 없다. 정적 타입 체크는 빌드 타임이고, 실제 문제는 런타임에서 일어난다. API 응답, 폼 입력, 외부 데이터 — 모두 검증해야 한다.
처음엔 번거로워 보일 수 있지만, 디버깅 시간이 확 줄어든다. 문제를 빨리 발견할 수 있으니까. 그리고 팀원들도 "이 데이터는 안전하다"는 걸 알 수 있어서 코드 리뷰가 빨라진다.