Zod로 런타임 타입 검증 — 스키마, 폼, API 검증

Zod가 필요한 이유

TypeScript의 타입은 컴파일 타임에만 존재합니다. 빌드가 끝나면 JavaScript에는 타입 정보가 남지 않습니다. 사용자 입력, API 응답, 환경변수 등 외부에서 들어오는 데이터는 런타임에서 직접 검증해야 합니다.

Zod는 스키마를 정의하면 TypeScript 타입을 자동 추론해주는 런타임 검증 라이브러리입니다. 스키마 하나로 검증과 타입 정의를 동시에 처리할 수 있습니다.

설치와 기본 스키마

npm install zod

Zod 스키마는 z.object(), z.string(), z.number() 등 체이닝 메서드로 정의합니다.

import { z } from "zod";

// 기본 스키마 정의
const UserSchema = z.object({
  name: z.string().min(2, "이름은 2자 이상이어야 합니다"),
  email: z.string().email("올바른 이메일 형식이 아닙니다"),
  age: z.number().int().min(0).max(150),
  role: z.enum(["admin", "user", "guest"]),
  bio: z.string().optional(), // 선택적 필드
});

// 스키마에서 TypeScript 타입 자동 추론
type User = z.infer<typeof UserSchema>;
// { name: string; email: string; age: number; role: "admin" | "user" | "guest"; bio?: string }

// 유효한 데이터 — 성공
const validData = {
  name: "홍길동",
  email: "hong@example.com",
  age: 30,
  role: "admin" as const,
};

const result = UserSchema.safeParse(validData);
if (result.success) {
  console.log("검증 성공:", result.data);
  // 검증 성공: { name: "홍길동", email: "hong@example.com", age: 30, role: "admin" }
} else {
  console.log("검증 실패:", result.error.issues);
}

// 유효하지 않은 데이터 — 실패
const invalidData = {
  name: "김",        // 2자 미만
  email: "not-email", // 이메일 형식 아님
  age: -5,            // 음수
  role: "superuser",  // enum에 없음
};

const failResult = UserSchema.safeParse(invalidData);
if (!failResult.success) {
  failResult.error.issues.forEach((issue) => {
    console.log(`${issue.path.join(".")}: ${issue.message}`);
  });
  // name: 이름은 2자 이상이어야 합니다
  // email: 올바른 이메일 형식이 아닙니다
  // age: Number must be greater than or equal to 0
  // role: Invalid enum value. Expected 'admin' | 'user' | 'guest', received 'superuser'
}

safeParse는 예외를 던지지 않고 결과 객체를 반환합니다. parse는 실패 시 ZodError를 던집니다. 외부 입력에는 safeParse를 권장합니다.

고급 스키마 패턴

Zod는 변환, 정제(refine), 합성 등 다양한 고급 기능을 제공합니다.

import { z } from "zod";

// transform: 검증 후 값 변환
const TrimmedString = z.string().trim().toLowerCase();
console.log(TrimmedString.parse("  Hello World  ")); // "hello world"

// coerce: 입력값을 강제 변환 후 검증
const CoercedNumber = z.coerce.number();
console.log(CoercedNumber.parse("42")); // 42 (문자열 → 숫자)

const CoercedDate = z.coerce.date();
console.log(CoercedDate.parse("2026-01-15")); // Date 객체

// refine: 커스텀 검증 로직
const PasswordSchema = z
  .string()
  .min(8, "비밀번호는 8자 이상이어야 합니다")
  .refine((val) => /[A-Z]/.test(val), {
    message: "대문자를 최소 1개 포함해야 합니다",
  })
  .refine((val) => /[0-9]/.test(val), {
    message: "숫자를 최소 1개 포함해야 합니다",
  })
  .refine((val) => /[!@#$%^&*]/.test(val), {
    message: "특수문자를 최소 1개 포함해야 합니다",
  });

// superRefine: 여러 필드 간 교차 검증
const SignupSchema = z
  .object({
    password: z.string().min(8),
    confirmPassword: z.string(),
  })
  .superRefine((data, ctx) => {
    if (data.password !== data.confirmPassword) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "비밀번호가 일치하지 않습니다",
        path: ["confirmPassword"],
      });
    }
  });

// discriminatedUnion: 태그 기반 유니온 타입
const EventSchema = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("click"),
    x: z.number(),
    y: z.number(),
  }),
  z.object({
    type: z.literal("keypress"),
    key: z.string(),
    ctrlKey: z.boolean(),
  }),
  z.object({
    type: z.literal("scroll"),
    deltaY: z.number(),
  }),
]);

type AppEvent = z.infer<typeof EventSchema>;
// 타입에 따라 다른 필드가 자동 추론됨

const clickEvent = EventSchema.parse({ type: "click", x: 100, y: 200 });
console.log(clickEvent); // { type: "click", x: 100, y: 200 }

API 응답 검증

외부 API에서 받은 데이터를 Zod로 검증하면 타입 불일치로 인한 런타임 에러를 방지할 수 있습니다.

import { z } from "zod";

// API 응답 스키마 정의
const PostSchema = z.object({
  id: z.number(),
  title: z.string(),
  body: z.string(),
  userId: z.number(),
});

const PostListSchema = z.array(PostSchema);

// API 응답 타입 추론
type Post = z.infer<typeof PostSchema>;

// 안전한 API 호출 함수
async function fetchPosts(): Promise<Post[]> {
  const response = await fetch("https://jsonplaceholder.typicode.com/posts");
  const json: unknown = await response.json(); // unknown으로 받기

  // 런타임 검증: 스키마와 일치하지 않으면 에러
  const result = PostListSchema.safeParse(json);
  if (!result.success) {
    console.error("API 응답 형식 불일치:", result.error.flatten());
    throw new Error("API 응답 검증 실패");
  }

  return result.data; // Post[] 타입이 보장됨
}

// 환경변수 검증
const EnvSchema = z.object({
  DATABASE_URL: z.string().url(),
  API_KEY: z.string().min(10),
  PORT: z.coerce.number().default(3000),
  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
});

// 앱 시작 시 환경변수 검증
function validateEnv() {
  const result = EnvSchema.safeParse(process.env);
  if (!result.success) {
    console.error("환경변수 검증 실패:");
    result.error.issues.forEach((issue) => {
      console.error(`  ${issue.path.join(".")}: ${issue.message}`);
    });
    process.exit(1); // 환경변수 누락 시 즉시 종료
  }
  return result.data;
}

const env = validateEnv();
console.log(`서버 포트: ${env.PORT}`); // 타입 안전한 환경변수 접근

폼 검증 (React Hook Form 연동)

Zod는 @hookform/resolvers를 통해 React Hook Form과 자연스럽게 연동됩니다.

import { z } from "zod";

// 회원가입 폼 스키마
const RegisterFormSchema = z
  .object({
    username: z
      .string()
      .min(3, "사용자명은 3자 이상이어야 합니다")
      .max(20, "사용자명은 20자 이하여야 합니다")
      .regex(/^[a-zA-Z0-9_]+$/, "영문, 숫자, 밑줄만 사용 가능합니다"),
    email: z.string().email("올바른 이메일을 입력해주세요"),
    password: z
      .string()
      .min(8, "비밀번호는 8자 이상이어야 합니다")
      .regex(/[A-Z]/, "대문자를 1개 이상 포함해야 합니다")
      .regex(/[0-9]/, "숫자를 1개 이상 포함해야 합니다"),
    confirmPassword: z.string(),
    agreeTerms: z.literal(true, {
      errorMap: () => ({ message: "약관에 동의해야 합니다" }),
    }),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: "비밀번호가 일치하지 않습니다",
    path: ["confirmPassword"],
  });

// 폼 데이터 타입 추론
type RegisterForm = z.infer<typeof RegisterFormSchema>;

// React Hook Form과 연동 예시 (의사 코드)
// import { useForm } from "react-hook-form";
// import { zodResolver } from "@hookform/resolvers/zod";
//
// const { register, handleSubmit, formState: { errors } } = useForm<RegisterForm>({
//   resolver: zodResolver(RegisterFormSchema),
// });

// 수동 검증 예시
const formData = {
  username: "hong_gildong",
  email: "hong@example.com",
  password: "Secure1234",
  confirmPassword: "Secure1234",
  agreeTerms: true as const,
};

const formResult = RegisterFormSchema.safeParse(formData);
if (formResult.success) {
  console.log("폼 검증 통과:", formResult.data.username);
  // 폼 검증 통과: hong_gildong
} else {
  const fieldErrors = formResult.error.flatten().fieldErrors;
  console.log("필드별 에러:", fieldErrors);
}

정리

  • 스키마 = 타입 + 검증: z.infer로 타입을 추론하면 스키마와 타입이 항상 동기화됩니다.
  • safeParse 사용: 외부 입력에는 예외를 던지지 않는 safeParse를 사용합니다.
  • coerce로 형변환: 문자열을 숫자나 날짜로 변환할 때 z.coerce를 활용합니다.
  • refine/superRefine: 단일 필드 검증은 refine, 필드 간 교차 검증은 superRefine을 사용합니다.
  • API + 환경변수: 외부 데이터의 진입점에서 Zod 검증을 적용하면 런타임 안전성이 크게 향상됩니다.
  • 폼 라이브러리 연동: React Hook Form, Formik 등과 resolver를 통해 통합할 수 있습니다.

이 글이 도움이 되었나요?