개인의 기록
  • 소개
  • 프로젝트
  • 글
  • 링크

© 2026 newgirok

← 글 목록

Zod — 런타임 데이터 검증과 TypeScript 타입 추론

2026년 6월 19일
TypeScriptValidation스키마Zod

TypeScript는 컴파일 타임에 타입을 검사합니다. 그러나 외부 API 응답, 폼 입력값, 환경 변수처럼 런타임에 외부에서 들어오는 데이터는 TypeScript가 보증하지 못합니다. 컴파일이 끝나면 타입 정보는 사라지기 때문입니다.

Zod는 이 문제를 해결합니다. 스키마를 한 번 정의하면 런타임 검증과 TypeScript 타입 추론을 동시에 얻을 수 있습니다. 별도의 타입 선언이 필요 없으며, 스키마가 곧 타입의 단일 진실 공급원(Single Source of Truth)이 됩니다.

설치

npm install zod

TypeScript 4.5 이상, strict 모드 활성화가 권장됩니다.


1. Schema

Schema: 데이터의 형태와 제약 조건을 선언적으로 정의하는 Zod 객체

Zod의 모든 것은 스키마에서 시작합니다. z.string, z.number, z.object 등 빌트인 메서드로 스키마를 구성합니다.

import { z } from "zod";

// 원시 타입 스키마
const nameSchema = z.string();
const ageSchema = z.number().int().min(0).max(150);
const isActiveSchema = z.boolean();

// 객체 스키마
const userSchema = z.object({
  id: z.number(),
  name: z.string().min(1).max(50),
  email: z.string().email(),
  age: z.number().int().positive().optional(),
  createdAt: z.string().datetime(),
});

// 배열 스키마
const tagsSchema = z.array(z.string()).min(1);

// 중첩 스키마
const postSchema = z.object({
  title: z.string(),
  author: userSchema,
  tags: tagsSchema,
});

스키마는 체이닝으로 제약 조건을 추가합니다. z.string.email.min(5)처럼 메서드를 이어 붙이면 복수의 검증 규칙이 모두 적용됩니다.


2. Parse

.parse는 데이터를 검증합니다. 검증을 통과하면 타입이 좁혀진 데이터를 반환하고, 실패하면 ZodError를 던집니다.

const userSchema = z.object({
  name: z.string(),
  email: z.string().email(),
});

// 성공
const user = userSchema.parse({ name: "김철수", email: "chul@example.com" });
// user의 타입: { name: string; email: string }

// 실패 — ZodError 예외 발생
try {
  userSchema.parse({ name: "김철수", email: "이메일아님" });
} catch (err) {
  if (err instanceof z.ZodError) {
    console.log(err.errors);
    // [{ path: ["email"], message: "Invalid email", ... }]
  }
}

ZodError의 .errors 배열에는 어느 경로(path)에서 어떤 문제(message)가 발생했는지 상세히 담겨 있습니다. 여러 필드가 동시에 실패하면 모든 오류를 한 번에 반환합니다.


3. SafeParse

예외 처리가 번거로운 경우 .safeParse를 사용합니다. 결과 객체의 success 필드로 분기하면 타입 가드가 자동으로 작동합니다.

const result = userSchema.safeParse({ name: "김철수", email: "wrong" });

if (result.success) {
  // result.data의 타입이 자동으로 좁혀짐
  console.log(result.data.name);
} else {
  // result.error는 ZodError 인스턴스
  console.log(result.error.errors);
}

API 라우트나 폼 핸들러처럼 오류를 예외가 아닌 값으로 처리하고 싶을 때 적합합니다.

// 폼 제출 핸들러 예시
async function handleSubmit(formData: unknown) {
  const result = formSchema.safeParse(formData);

  if (!result.success) {
    return { errors: result.error.flatten().fieldErrors };
  }

  await saveUser(result.data); // 타입 안전
}

.flatten은 fieldErrors와 formErrors로 구분된 오류 객체를 반환하여 폼 라이브러리와의 연동에 편리합니다.


4. Infer

스키마를 작성했다면 별도의 타입 선언은 필요 없습니다. z.infer로 스키마에서 타입을 추론합니다.

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>;
// 동일한 결과:
// type User = {
//   id: number;
//   name: string;
//   email: string;
//   role: "admin" | "user";
// }

function processUser(user: User) {
  console.log(user.role); // "admin" | "user"
}

스키마를 수정하면 타입도 자동으로 업데이트됩니다. 타입과 스키마를 따로 관리하다가 불일치가 생기는 문제를 원천 차단합니다.


5. Transform

.transform은 검증 후 데이터를 가공합니다. 입력 타입과 출력 타입이 달라질 수 있습니다.

// 문자열을 숫자로 변환
const numericStringSchema = z.string().transform((val) => parseInt(val, 10));
// 입력: string, 출력: number

const result = numericStringSchema.parse("42"); // 42 (number)

// 날짜 문자열을 Date 객체로 변환
const dateSchema = z.string().datetime().transform((val) => new Date(val));
// 입력: string, 출력: Date

// 객체 필드 변환
const userSchema = z.object({
  firstName: z.string(),
  lastName: z.string(),
  birthYear: z.string().transform(Number),
}).transform((data) => ({
  fullName: `${data.firstName} ${data.lastName}`,
  birthYear: data.birthYear,
}));

type TransformedUser = z.infer<typeof userSchema>;
// { fullName: string; birthYear: number }

z.infer는 변환 후 타입을 추론합니다. 입력 타입이 필요하면 z.input<typeof schema>를 사용합니다.


6. Refine

비밀번호 일치 확인, 날짜 범위 검증처럼 복잡한 규칙은 .refine으로 구현합니다.

// 단일 필드 커스텀 검증
const passwordSchema = z.string()
  .min(8, "비밀번호는 8자 이상이어야 합니다")
  .refine(
    (val) => /[A-Z]/.test(val),
    "대문자를 하나 이상 포함해야 합니다"
  )
  .refine(
    (val) => /[0-9]/.test(val),
    "숫자를 하나 이상 포함해야 합니다"
  );

// 복수 필드 간 검증 — superRefine 또는 object().refine()
const signUpSchema = z.object({
  password: z.string().min(8),
  confirmPassword: z.string(),
}).refine(
  (data) => data.password === data.confirmPassword,
  {
    message: "비밀번호가 일치하지 않습니다",
    path: ["confirmPassword"], // 오류가 표시될 필드 경로
  }
);

// superRefine: 세밀한 오류 제어
const rangeSchema = z.object({
  start: z.number(),
  end: z.number(),
}).superRefine((data, ctx) => {
  if (data.start >= data.end) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "start는 end보다 작아야 합니다",
      path: ["start"],
    });
  }
});

.refine은 단일 오류, .superRefine은 복수 오류를 추가할 수 있습니다.


7. Union

z.union은 TypeScript의 유니온 타입(A | B)과 대응합니다. 나열된 스키마 중 하나라도 통과하면 검증에 성공합니다.

// 문자열 또는 숫자
const stringOrNumber = z.union([z.string(), z.number()]);

stringOrNumber.parse("hello"); // ok
stringOrNumber.parse(42);      // ok
stringOrNumber.parse(true);    // ZodError

// 복잡한 유니온
const responseSchema = z.union([
  z.object({ status: z.literal("success"), data: z.string() }),
  z.object({ status: z.literal("error"), message: z.string() }),
]);

type Response = z.infer<typeof responseSchema>;
// { status: "success"; data: string } | { status: "error"; message: string }

단축 표현으로 .or을 사용할 수도 있습니다.

const schema = z.string().or(z.number());
// z.union([z.string(), z.number()])과 동일

8. Discriminated Union

Discriminated Union: 공통 식별 필드를 기준으로 타입을 구분하는 최적화된 유니온 스키마

z.union은 각 스키마를 순서대로 시도합니다. 스키마가 많을수록 성능이 저하될 수 있습니다. z.discriminatedUnion은 식별자 필드를 먼저 확인하여 즉시 해당 스키마로 분기합니다.

// z.union() 방식 — 모든 스키마를 순서대로 시도
const shapeUnion = z.union([
  z.object({ kind: z.literal("circle"), radius: z.number() }),
  z.object({ kind: z.literal("rect"), width: z.number(), height: z.number() }),
  z.object({ kind: z.literal("triangle"), base: z.number(), height: z.number() }),
]);

// z.discriminatedUnion() 방식 — kind 필드로 즉시 분기
const shapeSchema = z.discriminatedUnion("kind", [
  z.object({ kind: z.literal("circle"), radius: z.number() }),
  z.object({ kind: z.literal("rect"), width: z.number(), height: z.number() }),
  z.object({ kind: z.literal("triangle"), base: z.number(), height: z.number() }),
]);

const shape = shapeSchema.parse({ kind: "circle", radius: 10 });

type Shape = z.infer<typeof shapeSchema>;
  Input data
      |
      v
  kind === "circle"  -->  circle schema
  kind === "rect"    -->  rect schema
  kind === "triangle"--> triangle schema
      |
      v
  No match: ZodError

Redux action, API 응답, 이벤트 시스템처럼 타입 식별자가 명확한 경우에 z.discriminatedUnion이 적합합니다. 잘못된 식별자 값을 입력하면 다른 스키마를 시도하지 않고 즉시 오류를 반환합니다.


실전 패턴: API 응답 검증

외부 API 응답을 그대로 믿는 것은 위험합니다. Zod로 경계에서 검증합니다.

const productSchema = z.object({
  id: z.number(),
  name: z.string(),
  price: z.number().nonnegative(),
  category: z.enum(["electronics", "clothing", "food"]),
  tags: z.array(z.string()).default([]),
});

type Product = z.infer<typeof productSchema>;

async function fetchProduct(id: number): Promise<Product> {
  const res = await fetch(`/api/products/${id}`);
  const json = await res.json();

  // 런타임 검증 — 이후 코드에서 Product 타입 보장
  return productSchema.parse(json);
}

.default([])처럼 기본값을 설정하면 누락된 필드를 안전하게 처리할 수 있습니다.


정리

Zod는 런타임과 컴파일 타임의 간극을 스키마 하나로 메웁니다.

메서드역할
z.*()스키마 정의
.parse검증 후 타입 반환, 실패 시 예외
.safeParse검증 후 결과 객체 반환, 예외 없음
z.infer<>스키마에서 TypeScript 타입 추론
.transform검증 후 데이터 변환
.refine커스텀 유효성 검사 추가
z.union여러 스키마 중 하나 선택
z.discriminatedUnion식별 필드 기반 최적화 유니온

스키마를 외부 데이터가 진입하는 경계(API 레이어, 폼 핸들러, 환경 변수 로딩)에 배치하면, 애플리케이션 내부는 타입 안전성이 보장된 상태로 유지됩니다.

← 이전 글Vitest — Vite 기반 테스트 프레임워크
다음 글 →Zustand — 간결한 전역 상태 관리 라이브러리