Zod는 스키마를 정의하면 런타임 검증과 TypeScript 타입이 동시에 생성 되는 라이브러리입니다. "한 번만 정의하면 된다"가 핵심입니다.

왜 Zod가 필요한가

TYPESCRIPT
// 문제: TypeScript 타입은 런타임에 사라진다
interface User {
  name: string;
  age: number;
  email: string;
}

// API에서 받은 데이터가 진짜 User인지 모른다
const data = await response.json(); // any 또는 unknown
// as User로 단언하면 런타임 검증 없이 그냥 믿는 것

TypeScript 타입은 컴파일 타임에만 존재하므로, **외부 데이터 **(API 응답, form 입력, 환경 변수)는 런타임 검증이 필요합니다.

기본 사용법

BASH
npm install zod
TYPESCRIPT
import { z } from 'zod';

// 스키마 정의 = 런타임 검증 + 타입 정의
const UserSchema = z.object({
  name: z.string().min(1, '이름은 필수입니다'),
  age: z.number().int().positive(),
  email: z.string().email('유효한 이메일을 입력해주세요'),
});

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

// 런타임 검증
const result = UserSchema.safeParse({
  name: '홍길동',
  age: 25,
  email: 'hong@test.com',
});

if (result.success) {
  console.log(result.data); // User 타입으로 안전하게 사용
} else {
  console.log(result.error.issues); // 검증 에러 상세
}

주요 스키마 타입

TYPESCRIPT
// 원시 타입
const str = z.string();
const num = z.number();
const bool = z.boolean();
const date = z.date();

// 리터럴
const role = z.literal('admin');
const zero = z.literal(0);

// enum
const Status = z.enum(['active', 'inactive', 'suspended']);
type Status = z.infer<typeof Status>; // 'active' | 'inactive' | 'suspended'

// 유니온
const StringOrNumber = z.union([z.string(), z.number()]);

// 배열
const Tags = z.array(z.string());

// 튜플
const Coord = z.tuple([z.number(), z.number()]);

// 선택적 / nullable
const OptionalName = z.string().optional(); // string | undefined
const NullableName = z.string().nullable(); // string | null

객체 스키마

TYPESCRIPT
const ProductSchema = z.object({
  id: z.number(),
  title: z.string().min(1).max(100),
  price: z.number().positive(),
  tags: z.array(z.string()).default([]),
  metadata: z.record(z.string(), z.unknown()).optional(),
});

type Product = z.infer<typeof ProductSchema>;

// 확장
const DetailedProductSchema = ProductSchema.extend({
  description: z.string(),
  images: z.array(z.string().url()),
});

// 일부만 선택
const ProductSummary = ProductSchema.pick({ id: true, title: true });

// 일부 제외
const CreateProduct = ProductSchema.omit({ id: true });

// 모든 필드를 선택적으로
const UpdateProduct = ProductSchema.partial();

변환과 파이프

TYPESCRIPT
// coerce — 문자열을 숫자로 자동 변환
const NumericString = z.coerce.number();
NumericString.parse('42'); // 42 (number)

// transform — 값을 변환
const TrimmedString = z.string().transform((s) => s.trim());
TrimmedString.parse('  hello  '); // 'hello'

// pipe — 변환 후 다시 검증
const StringToNumber = z.string()
  .transform((s) => parseInt(s, 10))
  .pipe(z.number().positive());

StringToNumber.parse('42');  // 42
// StringToNumber.parse('-1'); // Error: 양수가 아님

실전 패턴

API 응답 검증

TYPESCRIPT
const ApiResponseSchema = <T extends z.ZodType>(dataSchema: T) =>
  z.object({
    status: z.number(),
    data: dataSchema,
    message: z.string().optional(),
  });

const UsersResponseSchema = ApiResponseSchema(z.array(UserSchema));

async function fetchUsers(): Promise<z.infer<typeof UsersResponseSchema>> {
  const response = await fetch('/api/users');
  const json = await response.json();
  return UsersResponseSchema.parse(json); // 검증 실패 시 에러
}

환경 변수 검증

TYPESCRIPT
const EnvSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']),
  DATABASE_URL: z.string().url(),
  PORT: z.coerce.number().default(3000),
  API_KEY: z.string().min(1),
});

// 앱 시작 시 한 번 검증
const env = EnvSchema.parse(process.env);
// env.PORT: number — coerce로 string → number 변환됨

폼 검증

TYPESCRIPT
const LoginSchema = z.object({
  email: z.string().email('유효한 이메일을 입력해주세요'),
  password: z.string()
    .min(8, '비밀번호는 8자 이상이어야 합니다')
    .regex(/[A-Z]/, '대문자를 포함해야 합니다')
    .regex(/[0-9]/, '숫자를 포함해야 합니다'),
});

type LoginForm = z.infer<typeof LoginSchema>;

function validateLogin(data: unknown): LoginForm | null {
  const result = LoginSchema.safeParse(data);
  if (result.success) return result.data;
  console.log(result.error.flatten()); // 필드별 에러 메시지
  return null;
}

parse vs safeParse

TYPESCRIPT
// parse — 실패 시 ZodError를 던짐
try {
  const user = UserSchema.parse(data);
} catch (error) {
  if (error instanceof z.ZodError) {
    console.log(error.issues);
  }
}

// safeParse — 에러를 던지지 않고 Result 형태로 반환
const result = UserSchema.safeParse(data);
if (result.success) {
  console.log(result.data);
} else {
  console.log(result.error.issues);
}

공부하다 보니 safeParse가 Result 패턴과 거의 동일하더라고요. 에러를 던지지 않으므로 더 안전합니다.

정리

  • Zod는 스키마 정의 한 번으로 런타임 검증과 TypeScript 타입을 동시에 생성한다
  • z.infer<typeof Schema>로 스키마에서 타입을 추출한다
  • safeParse는 에러를 던지지 않고 Result 형태로 반환한다
  • API 응답, 환경 변수, 폼 입력 등 외부 데이터 검증에 필수적이다
  • coerce, transform, pipe로 데이터 변환도 처리할 수 있다
댓글 로딩 중...