뒤로가기
Zod로 런타임에서도 타입을 믿을 수 있게 만들기

January 22, 2024

typescriptfrontend

TypeScript를 쓰면서 한 가지 불편한 진실이 있다. 타입은 컴파일 타임에만 존재하고, 런타임에서는 흔적도 없이 사라진다는 것.

API에서 내려오는 데이터를 as User로 단언하면 타입 에러는 안 뜬다. 그런데 서버가 스펙과 다른 응답을 보내면? user.emailundefined인데 타입 시스템은 string이라고 말해준다. 런타임에서 터지기 전까지 아무도 모른다.

이 문제를 Zod로 해결했다.

타입 단언의 위험성#

프로젝트에서 이런 코드가 흔했다:

typescript
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 타입을 동시에 제공한다.

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 래퍼를 하나 만들었다:

typescript
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를 쓰면 된다:

typescript
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/resolverszodResolver를 쓰면 된다.

typescript
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 문법으로 관리되니까 일관성이 높다. 서버 응답 스키마에서 폼 스키마를 파생시킬 수도 있다:

typescript
// 서버 응답 스키마
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가 안전한 선택이다.