TypeScript의 타입은 컴파일할 때만 존재하고 빌드가 끝나면 사라집니다. API 요청 바디, 외부 API 응답, .env 파일처럼 코드 밖에서 들어오는 값은 아무리 as { email: string }로 타입을 캐스팅해도 실제로 그 모양인지 런타임에 보장해주지 않습니다. 요청 바디에 email 필드가 숫자로 들어와도 캐스팅은 통과하고, 그 값을 문자열 메서드에 넘기는 순간에야 터집니다. Zod는 스키마 하나로 "이 값이 실제로 이 모양인지" 런타임에 검사하고, 그 스키마에서 TypeScript 타입까지 뽑아내는 라이브러리입니다.

Zod 공식 로고 — colinhacks/zod 저장소 README에 있는 워드마크
이미지: colinhacks/zod 공식 GitHub 저장소

핵심 개념

스키마를 정의하고 그걸로 데이터를 검사하는 두 단계로 나뉩니다.

import * as z from "zod";

const User = z.object({
  email: z.email(),
  age: z.number().int().min(0),
  nickname: z.string().min(2).max(20).optional(),
});

type User = z.infer<typeof User>;
// => { email: string; age: number; nickname?: string }

z.infer<typeof User>로 스키마에서 타입을 뽑아내므로, 같은 모양을 interface로 한 번 더 적을 필요가 없습니다. 스키마를 고치면 타입도 같이 바뀝니다.

검사는 .parse()와 .safeParse() 두 가지입니다. .parse()는 실패하면 ZodError를 던지고, .safeParse()는 예외 없이 { success, data } 또는 { success, error } 형태의 결과 객체를 돌려줍니다.

const result = User.safeParse({ email: "not-an-email", age: -1 });

if (!result.success) {
  result.error.issues;
  // [
  //   { code: "invalid_format", path: ["email"], message: "Invalid email address" },
  //   { code: "too_small", path: ["age"], message: "Too small: expected number to be >=0" }
  // ]
}

z.email(), z.uuid(), z.url()처럼 자주 쓰는 포맷은 최상위 함수로 따로 있고(v3에서는 z.string().email()처럼 체이닝이었는데 v4에서 바뀌었습니다), 문자열 길이·범위 같은 세부 규칙은 여전히 체이닝으로 붙입니다.

실전 예시

API 라우트에서 요청 바디를 검사하는 패턴입니다.

// app/api/users/route.ts
import * as z from "zod";
import { NextResponse } from "next/server";

const CreateUserBody = z.object({
  email: z.email(),
  age: z.number().int().min(0).max(150),
});

export async function POST(req: Request) {
  const body = await req.json();
  const result = CreateUserBody.safeParse(body);

  if (!result.success) {
    return NextResponse.json(
      { error: result.error.issues.map((i) => i.message) },
      { status: 400 }
    );
  }

  const { email, age } = result.data; // 여기선 완전히 타입 안전함
  // ... DB에 저장
  return NextResponse.json({ email, age }, { status: 201 });
}

환경변수도 같은 방식으로 검사할 수 있습니다. .env에 오타가 있어도 process.env.DATABASE_URL은 그냥 string | undefined라서 빌드는 통과하고, 실제로 그 값을 쓰는 코드가 실행될 때야 터집니다. 서버 시작 시점에 한 번 검사해서 미리 죽는 쪽이 디버깅하기 훨씬 편합니다.

// src/env.ts
import * as z from "zod";

const EnvSchema = z.object({
  DATABASE_URL: z.url(),
  PORT: z.coerce.number().int().default(3000),
  NODE_ENV: z.enum(["development", "production", "test"]),
});

export const env = EnvSchema.parse(process.env);
// 여기서 실패하면 서버가 뜨기 전에 바로 에러 메시지와 함께 종료됩니다.

z.coerce.number()가 유용했던 지점은 여기입니다. 환경변수는 전부 문자열이라 PORT도 "3000"으로 들어오는데, z.coerce.number()는 Number()로 먼저 변환한 다음 검사해서 스키마에 변환 로직을 따로 안 적어도 됩니다.

부딪힌 부분

중첩된 객체 배열을 검사할 때 에러 메시지가 path: ["items", 2, "price"]처럼 배열 인덱스까지 정확히 찍혀 나오는 건 디버깅할 때 꽤 도움이 됐습니다. 반대로 아쉬웠던 건 .refine()으로 필드 간 상호 검증(예: endDate가 startDate보다 늦어야 한다)을 걸 때인데, 에러가 기본적으로 스키마 전체의 path: []에 붙어서 어느 필드 옆에 메시지를 띄워야 할지 프런트엔드 쪽에서 따로 처리해야 했습니다. path 옵션을 직접 지정해서 해결했습니다.

const DateRange = z.object({
  startDate: z.coerce.date(),
  endDate: z.coerce.date(),
}).refine((data) => data.endDate > data.startDate, {
  error: "종료일은 시작일보다 늦어야 합니다",
  path: ["endDate"],
});

path를 안 적으면 이 에러가 endDate 입력창 옆이 아니라 폼 전체에 걸린 에러로 처리돼서, 폼 검증 로직에서 한참 찾았습니다.