TypeScript를 쓰면서 한 가지 불편한 진실이 있다. 타입은 컴파일 타임에만 존재하고, 런타임에서는 흔적도 없이 사라진다는 것.
API에서 내려오는 데이터를 as User로 단언하면 타입 에러는 안 뜬다. 그런데 서버가 스펙과 다른 응답을 보내면? user.email이 undefined인데 타입 시스템은 string이라고 말해준다. 런타임에서 터지기 전까지 아무도 모른다.
이 문제를 Zod로 해결했다.
타입 단언의 위험성
프로젝트에서 이런 코드가 흔했다:
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user';
}
const response = await fetch('/api/user');
const user = (await response.json()) as User; // 진짜 User인지 누가 보장하나?
as User는 "이건 User 타입이야"라고 TypeScript에게 말하는 거지, 실제로 검증하는 건 아니다. 서버 쪽에서 role 필드를 type으로 바꾸거나, email을 optional로 변경하면 프론트엔드는 조용히 깨진다. 타입 에러 없이.
실제로 이런 일이 있었다. 백엔드에서 사용자 목록 API의 응답 형태를 바꿨는데, 프론트엔드 타입 정의는 그대로였다. 배포 후 특정 페이지에서 Cannot read properties of undefined 에러가 Sentry에 쌓이기 시작했다. 타입이 맞다고 믿었으니 방어 코드도 없었다.
Zod 스키마 = 런타임 검증 + 타입 추론
Zod는 스키마를 정의하면 런타임 검증과 TypeScript 타입을 동시에 제공한다.
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
role: z.enum(['admin', 'user']),
});
type User = z.infer<typeof UserSchema>; // 타입이 자동으로 추론된다
interface를 직접 작성하지 않아도 된다. 스키마가 Single Source of Truth가 되어 타입과 검증 로직이 항상 동기화된다.
API 응답 검증에 적용하기
fetch 래퍼를 하나 만들었다:
async function fetchWithSchema<T>(
url: string,
schema: z.ZodType<T>
): Promise<T> {
const response = await fetch(url);
const data = await response.json();
return schema.parse(data); // 스키마에 맞지 않으면 여기서 에러
}
// 사용
const user = await fetchWithSchema('/api/user', UserSchema);
// user는 검증된 User 타입. 런타임에서도 확실하다.
schema.parse(data)가 핵심이다. 데이터가 스키마에 맞지 않으면 ZodError를 던진다. 어떤 필드가 왜 틀렸는지 상세한 에러 메시지를 준다.
에러가 무조건 던져지는 게 부담스러우면 safeParse를 쓰면 된다:
const result = UserSchema.safeParse(data);
if (!result.success) {
console.error('API 응답 스키마 불일치:', result.error.flatten());
// 에러 모니터링으로 보내기
return null;
}
return result.data; // 검증 완료된 데이터
프로덕션에서는 safeParse를 쓰고, 스키마 불일치를 에러 모니터링에 기록하되 앱이 크래시되지는 않게 했다. 개발 환경에서는 parse로 바로 터뜨려서 빠르게 잡았다.
React Hook Form과 통합
Zod의 진짜 매력은 같은 스키마로 폼 밸리데이션까지 된다는 거다. @hookform/resolvers의 zodResolver를 쓰면 된다.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const ProfileFormSchema = z.object({
name: z.string().min(2, '이름은 2자 이상이어야 합니다'),
email: z.string().email('올바른 이메일을 입력하세요'),
bio: z.string().max(200, '200자까지 입력 가능합니다').optional(),
});
type ProfileForm = z.infer<typeof ProfileFormSchema>;
function ProfileEditor() {
const { register, handleSubmit, formState: { errors } } = useForm<ProfileForm>({
resolver: zodResolver(ProfileFormSchema),
});
const onSubmit = (data: ProfileForm) => {
// data는 이미 검증 완료
updateProfile(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} />
{errors.name && <span>{errors.name.message}</span>}
{/* ... */}
</form>
);
}API 스키마와 폼 스키마가 같은 Zod 문법으로 관리되니까 일관성이 높다. 서버 응답 스키마에서 폼 스키마를 파생시킬 수도 있다:
// 서버 응답 스키마
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
bio: z.string().nullable(),
});
// 폼 스키마는 서버 스키마에서 필요한 필드만 pick
const ProfileFormSchema = UserSchema.pick({
name: true,
email: true,
bio: true,
}).extend({
name: z.string().min(2, '이름은 2자 이상이어야 합니다'),
});도입 후 달라진 점
타입 단언(as)을 쓰는 곳이 눈에 띄게 줄었다. API 경계에서 데이터가 검증되니까, 그 이후의 코드에서는 타입을 진짜로 믿을 수 있다. 방어적으로 넣던 if (user?.email) 같은 옵셔널 체이닝도 불필요한 곳에서는 제거했다.
백엔드 스키마 변경이 프론트엔드에서 조용히 깨지는 문제도 사라졌다. 변경이 생기면 Zod가 런타임에서 잡아내고, 개발 중이든 QA 중이든 즉시 알 수 있다.
번들 사이즈? Zod는 gzip 기준 약 13KB다. 런타임 타입 안전성의 대가로는 충분히 합리적이다. 더 가벼운 대안으로 Valibot이 있긴 한데(~1KB), Zod의 에코시스템과 문서가 압도적이라 현 시점에서는 Zod가 안전한 선택이다.
