TypeScript에서 Zod로 런타임 타입 검증하기 — API 응답을 믿지 마라

게시일: 2025년 5월 30일 · 13분 읽기

백엔드가 스펙을 안 알려줄 때

2년 전 일이다. 백엔드팀이 갑자기 API 응답 포맷을 바꿨다. 아무도 프론트 팀한테 알려주지 않았다. 우리가 알게 된 건 프로덕션 배포 후 1시간이 지나서였다. 사용자들이 "로그인이 안 돼요"라고 신고했을 때다.

그 때 깨달았다. TypeScript의 정적 타입 체크만으로는 부족하다는 걸. 컴파일 시점엔 타입이 맞는데, 런타임에 서버가 다른 포맷을 보낼 수 있다. 그 뒤로 나는 Zod 없이는 프로덕션 코드를 짜지 않는다.

런타임 타입 검증이 필요한 이유

TypeScript 개발자들이 자주 하는 실수가 뭘까? 서버에서 받은 데이터가 타입과 일치한다고 가정하는 것이다. 하지만 현실은:

이런 상황에서 타입 검증 없으면 앱이 터진다. 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 응답, 폼 입력, 외부 데이터 — 모두 검증해야 한다.

처음엔 번거로워 보일 수 있지만, 디버깅 시간이 확 줄어든다. 문제를 빨리 발견할 수 있으니까. 그리고 팀원들도 "이 데이터는 안전하다"는 걸 알 수 있어서 코드 리뷰가 빨라진다.

iL
ian.lab

실무 개발자입니다. 현장에서 겪은 문제와 해결 과정을 기록합니다. 오류 제보는 연락처로 보내주세요.