Devlery
Blog/Zod

Zod: TypeScript 런타임 검증의 정석

TypeScript의 타입 안전성을 런타임까지 확장하는 스키마 검증 라이브러리 Zod의 기본 사용법부터 폼 검증, API 연동까지 실전 활용법을 알아봅니다.

TypeScript를 사용하면 컴파일 타임에 타입 오류를 잡아낼 수 있다. 하지만 외부에서 들어오는 데이터는 어떨까? API 응답, 폼 입력, 환경 변수, JSON 파싱 결과 등은 런타임에서야 실제 값을 알 수 있다. TypeScript의 타입 시스템은 컴파일 후 사라지기 때문에 이런 데이터는 보호할 수 없다.

Zod는 이 간극을 메워주는 라이브러리다.

Zod가 뭔가요?

Zod는 TypeScript 퍼스트 스키마 검증 라이브러리다. 스키마를 한 번 정의하면 두 가지를 동시에 얻을 수 있다.

  1. 런타임 검증 — 데이터가 스키마에 맞는지 실제로 확인
  2. 정적 타입 추론 — 스키마에서 TypeScript 타입을 자동 생성

즉, 타입 정의와 검증 로직을 따로 작성할 필요가 없다.

설치

npm install zod

의존성이 전혀 없고, TypeScript 5.5 이상을 지원한다.

기본 사용법

가장 단순한 예시부터 살펴보자.

import { z } from 'zod'

const UserSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  age: z.number().min(0).max(150),
})

// 스키마에서 타입 추출
type User = z.infer<typeof UserSchema>
// { name: string; email: string; age: number }

z.infer를 사용하면 스키마 정의에서 TypeScript 타입을 자동으로 추출할 수 있다. 인터페이스를 따로 만들어서 스키마와 동기화하는 수고가 사라진다.

데이터 파싱

정의한 스키마로 데이터를 검증하는 방법은 두 가지다.

// 1. parse — 실패 시 에러를 던짐
try {
  const user = UserSchema.parse({
    name: '홍길동',
    email: 'hong@example.com',
    age: 25,
  })
  console.log(user) // 타입이 User로 추론됨
} catch (e) {
  console.error(e) // ZodError
}

// 2. safeParse — 결과 객체를 반환
const result = UserSchema.safeParse(unknownData)

if (result.success) {
  console.log(result.data) // 검증 통과, 타입 안전
} else {
  console.log(result.error.issues) // 검증 실패 상세 정보
}

parse는 간단하지만 try-catch가 필요하고, safeParse는 에러를 던지지 않아서 분기 처리가 깔끔하다. 실무에서는 safeParse를 더 많이 쓰게 된다.

다양한 스키마 타입

Zod는 TypeScript의 거의 모든 타입을 커버한다.

// 기본 타입
z.string()
z.number()
z.boolean()
z.date()
z.undefined()
z.null()

// 문자열 검증
z.string().min(1)         // 빈 문자열 방지
z.string().max(100)       // 최대 길이
z.string().email()        // 이메일 형식
z.string().url()          // URL 형식
z.string().uuid()         // UUID 형식
z.string().regex(/^\d+$/) // 정규식

// 숫자 검증
z.number().int()          // 정수만
z.number().positive()     // 양수만
z.number().min(1).max(10) // 범위

// 배열과 객체
z.array(z.string())                // string[]
z.object({ key: z.string() })     // { key: string }
z.record(z.string(), z.number())  // Record<string, number>

// 유니온과 열거형
z.union([z.string(), z.number()]) // string | number
z.enum(['admin', 'user', 'guest']) // 'admin' | 'user' | 'guest'

실전 활용: API 응답 검증

외부 API 응답을 검증하는 패턴이다. 이 패턴이 없으면 as 단언으로 타입을 강제하게 되는데, 실제 데이터가 다를 때 런타임 에러가 발생한다.

const PostSchema = z.object({
  id: z.number(),
  title: z.string(),
  body: z.string(),
  userId: z.number(),
})

const PostListSchema = z.array(PostSchema)

async function fetchPosts() {
  const res = await fetch('https://api.example.com/posts')
  const json = await res.json()

  const result = PostListSchema.safeParse(json)

  if (!result.success) {
    console.error('API 응답 형식이 변경되었습니다:', result.error)
    throw new Error('Invalid API response')
  }

  return result.data // Post[] 타입으로 안전하게 사용
}

실전 활용: 환경 변수 검증

환경 변수는 항상 string | undefined 타입이다. Zod로 앱 시작 시점에 검증하면 이후 코드에서 안심하고 쓸 수 있다.

const EnvSchema = z.object({
  DATABASE_URL: z.string().url(),
  API_KEY: z.string().min(1),
  PORT: z.string().transform(Number).pipe(z.number().int().positive()),
  NODE_ENV: z.enum(['development', 'production', 'test']),
})

const env = EnvSchema.parse(process.env)
// env.PORT는 number 타입 (문자열에서 자동 변환)

.transform()을 사용하면 파싱 과정에서 값을 변환할 수도 있다. 위 예시에서는 문자열 PORT를 숫자로 변환하고 있다.

기존 도구와의 비교

항목ZodYupJoi
TypeScript 퍼스트O부분적X
타입 추론자동수동 정의 필요수동 정의 필요
번들 크기~13KB~40KB~140KB
의존성0여러 개여러 개
런타임브라우저 + Node.js브라우저 + Node.jsNode.js 중심

마무리

Zod는 TypeScript 생태계에서 사실상 표준 검증 라이브러리로 자리잡았다. React Hook Form, tRPC, Next.js 서버 액션 등 주요 도구들이 Zod 연동을 공식 지원하고 있을 정도다.

TypeScript를 쓰면서 as 단언이나 any 타입으로 외부 데이터를 처리하고 있다면, Zod 도입을 고려해보자. 스키마 하나로 타입 정의와 런타임 검증을 동시에 해결할 수 있다.