Zod 기본기: 스키마 정의와 타입 추론, 문자열·숫자·객체·배열 검증, union과 스키마 조합

이 글의 핵심

TypeScript 타입은 컴파일이 끝나면 사라지므로 API 응답, 폼 입력, 환경변수처럼 런타임에 들어오는 값은 따로 검증해야 합니다. Zod는 스키마 하나에서 검증과 타입을 함께 얻게 해 인터페이스와 검증 코드가 어긋나는 문제를 줄입니다. Zod 4에서 달라진 에러 API, Yup·Joi와의 차이, 스키마가 커질 때 lazy로 성능을 챙기는 법도 다룹니다.

이 글에서는 Zod의 기본 API부터 스키마 컴포지션, 고급 유니온·재귀, transform·preprocess, 에러 전략, 폼 라이브러리 비교, API 설계 패턴, 성능·lazy, Yup·Joi 비교까지 실무에서 바로 쓸 수 있는 수준으로 정리합니다. 아래 예제는 TypeScript 5.x 기준으로 작성했습니다.

설치

npm install zod

기본 사용법

import { z } from 'zod';

// 스키마 정의
const UserSchema = z.object({
  name: z.string(),
  age: z.number(),
  email: z.string().email(),
});

// 타입 추론
type User = z.infer<typeof UserSchema>;
// { name: string; age: number; email: string; }

// 검증
const user = UserSchema.parse({
  name: 'Alice',
  age: 30,
  email: '[email protected]',
});

// 안전한 검증 (에러 반환)
const result = UserSchema.safeParse({
  name: 'Bob',
  age: 'invalid',  // 타입 오류
  email: '[email protected]',
});

if (!result.success) {
  console.log(result.error.issues);
} else {
  console.log(result.data);
}

실무 팁: parse는 실패 시 예외를 던지므로 경계(HTTP 핸들러, 큐 consumer, main)에서만 쓰며, 라이브러리 내부·UI 이벤트에서는 safeParse로 흐름을 제어하는 편이 디버깅에 유리합니다.

Primitives

z.string()
z.number()
z.bigint()
z.boolean()
z.date()
z.symbol()
z.undefined()
z.null()
z.void()
z.any()
z.unknown()
z.never()

String 검증

z.string()
  .min(3)
  .max(100)
  .email()
  .url()
  .uuid()
  .regex(/^[A-Z]+$/)
  .trim()
  .toLowerCase()
  .toUpperCase()
  .datetime()  // ISO 8601
  .ip()  // IPv4 or IPv6

Number 검증

z.number()
  .min(0)
  .max(100)
  .int()
  .positive()
  .negative()
  .nonnegative()
  .nonpositive()
  .multipleOf(5)
  .finite()
  .safe()

Object

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  role: z.enum(['admin', 'user']),
  createdAt: z.date().optional(),
});

// Partial
const PartialUser = UserSchema.partial();
// 모든 필드 optional

// Pick
const UserIdName = UserSchema.pick({ id: true, name: true });

// Omit
const UserWithoutId = UserSchema.omit({ id: true });

// Extend
const ExtendedUser = UserSchema.extend({
  avatar: z.string().url(),
});

// Merge
const MergedSchema = UserSchema.merge(z.object({ extra: z.string() }));

Array

z.array(z.string())
  .min(1)
  .max(10)
  .length(5)
  .nonempty()

// Tuple
z.tuple([z.string(), z.number(), z.boolean()])

스키마 컴포지션과 재사용

작은 스키마를 쌓아 올리는 방식이 유지보수·테스트·문서화에 가장 잘 맞습니다. extend·merge·and(intersection 래퍼)로 필드 집합을 조합하며, 공통 조각은 별 const로 빼 한 곳만 수정하면 전역이 따라오게 합니다.

import { z } from 'zod';

const IdSchema = z.object({ id: z.string().uuid() });
const TimestampsSchema = z.object({
  createdAt: z.coerce.date(),
  updatedAt: z.coerce.date(),
});

// 공통: 이메일 + 표시명
const ProfileBase = z.object({
  email: z.string().email(),
  displayName: z.string().min(1).max(64),
});

// API별로 조합: 생성은 id·타임스탬프 없음, 응답은 전부
export const CreateUserInput = ProfileBase;
export const UserEntity = ProfileBase.merge(IdSchema).merge(TimestampsSchema);

// Brand로 동일 string이라도 용도 분리(실수 방지)
const UserId = z.string().uuid().brand<'UserId'>();
const Email = z.string().email().brand<'Email'>();
type UserId = z.infer<typeof UserId>;

brand는 런타임 값은 그대로 두고 TypeScript에만 태그를 붙이므로, id를 email 슬롯에 넣는 실수를 컴파일 타임에 막을 수 있습니다. (런타임 구분이 필요하면 별도 필드·discriminated union을 쓰는 편이 안전합니다.)

z.strictObject나 기본 z.object의 strip(기본) / strict / passthrough 동작은 OpenAPI·외부 API와 맞출 때 중요합니다. 알 수 없는 키를 거부하려면 strict를, 로깅용으로 남기려면 passthrough를 검토하세요.

고급 타입: union, discriminated union, recursive

Union

const StringOrNumber = z.union([z.string(), z.number()]);
// 또는
const StringOrNumber = z.string().or(z.number());

Discriminated union (태그 기반)

공통 리터럴 필드(type, kind 등)가 있을 때 discriminatedUnion이 에러 위치·타입 추론 모두에 유리합니다. 일반 union보다 실패 시 어떤 분기를 시도했는지 메시지가 명확해질 때가 많습니다.

const PaymentSchema = z.discriminatedUnion('method', [
  z.object({ method: z.literal('card'), last4: z.string().length(4) }),
  z.object({ method: z.literal('bank'), accountMask: z.string() }),
  z.object({ method: z.literal('point'), points: z.number().int().nonnegative() }),
]);

type Payment = z.infer<typeof PaymentSchema>;

Recursive (트리, 댓글, 카테고리)

자기 자신을 포함하는 구조는 z.lazy와 함께 z.ZodType<T>(또는 satisfies)로 명시적 재귀 타입을 주는 것이 일반적입니다.

type Category = {
  name: string;
  subcategories: Category[];
};

const CategorySchema: z.ZodType<Category> = z.lazy(() =>
  z.object({
    name: z.string(),
    subcategories: z.array(CategorySchema),
  })
);

실무 팁: 재귀 스키마는 JSON 직렬화 깊이·순환 참조(실제 데이터에 부모 id만 있고 중첩 객체는 없는 경우)와 설계가 어긋나기 쉽습니다. API가 flat list + parentId만 준다면 클라이언트에서 트리를 조립한 뒤 CategorySchema로 검증하거나, 서버 응답이 항상 중첩이면 그에 맞춰 스키마를 유지하세요.

Intersection (객체 병합)

const PersonSchema = z.object({ name: z.string() });
const EmployeeSchema = z.object({ id: z.number() });
const EmployeePerson = z.intersection(PersonSchema, EmployeeSchema);
// 또는
const EmployeePerson2 = PersonSchema.merge(EmployeeSchema);

merge는 키가 겹치면 나중에 merge한 객체의 키가 우선합니다. OpenAPI allOf와 비슷한 방식으로 쓰되, 같은 키에 서로 다른 refine이 있으면 디버깅이 어려우므로 한 객체 안에서 extend로 정리하는 편이 낫습니다.

Enum

z.enum(['admin', 'user', 'guest'])

// Native TypeScript Enum
enum Role {
  Admin = 'admin',
  User = 'user',
}

z.nativeEnum(Role)

Optional & Nullable & Default

z.string().optional()  // string | undefined
z.string().nullable()  // string | null
z.string().nullish()   // string | null | undefined

z.string().default('default value')

Transform과 Preprocess 활용

transform은 검증이 끝난 뒤 출력 타입을 바꿉니다. preprocess는 파싱 전에 unknown에 가깝게 들어온 값을 정리할 때 씁니다. 폼·쿼리스트링·JSON.parse 직후처럼 문자열로만 들어오는 숫자/불리언이 대표 사례입니다.

import { z } from 'zod';

// 문자열 → 숫자 (화이트리스트: 실패는 스키마 단계에서)
const PositiveInt = z
  .string()
  .regex(/^\d+$/)
  .transform((s) => parseInt(s, 10))
  .pipe(z.number().int().positive());

// boolean 쿼리 파라미터: "true" | "1" | true 등 흡수
const CoercedBool = z.preprocess((val) => {
  if (val === 'true' || val === '1' || val === 1) return true;
  if (val === 'false' || val === '0' || val === 0) return false;
  return val;
}, z.boolean());

// 날짜: ISO 문자열 → Date
const IsoDate = z
  .string()
  .datetime()
  .transform((s) => new Date(s));

pipe로 transform 뒤에 또 다른 ZodType을 이어 단계적 검증을 할 수 있습니다. 복잡한 도메인 규칙은 superRefine에서 여러 이슈를 한 번에 쌓는 방식이 읽기 좋습니다.

const PasswordPolicy = z
  .object({
    password: z.string(),
    confirm: z.string(),
  })
  .superRefine((val, ctx) => {
    if (val.password.length < 10) {
      ctx.addIssue({ code: 'custom', path: ['password'], message: '10자 이상' });
    }
    if (val.password !== val.confirm) {
      ctx.addIssue({ code: 'custom', path: ['confirm'], message: '비밀번호 불일치' });
    }
  });

Refine (커스텀 검증)

const PasswordSchema = z.string().refine(
  (val) => val.length >= 8 && /[A-Z]/.test(val) && /[0-9]/.test(val),
  { message: 'Password must contain uppercase and number' }
);

// 복잡한 검증
const UserSchema = z.object({
  password: z.string(),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: 'Passwords do not match',
  path: ['confirmPassword'],
});

Preprocess (기본)

const TrimmedString = z.preprocess((val) => {
  if (typeof val === 'string') return val.trim();
  return val;
}, z.string());

TrimmedString.parse('  hello  ');  // 'hello'

에러 핸들링 전략

  1. 경계에서만 parse: 서버의 경우 핸들러 입구에서 schema.safeParse(req.body) → 실패 시 400 + issues를 로그/클라이언트에 맞게 축약.
  2. 사용자 메시지: message per-field, _errors 뿐 아니라 path로 필드 매핑. RHF·Formik은 zodResolver가 path를 필드名으로 넘깁니다.
  3. 애플리케이션 코드: issue.code로 분기(invalid_type, too_small, unrecognized_keys 등). 다국어는 setErrorMap이나 i18n 레이어에서 issue 메타를 조합.
  4. 로깅: 프로덕션에서 원본 body 전체를 남기면 민감정보 이슈가 있으므로, 스키마 통과 전에는 해시/길이만, 실패 시는 issues만 structured logging 하는 패턴이 흔합니다.
import { z } from 'zod';

const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
  if (issue.code === z.ZodIssueCode.invalid_type) {
    return { message: '타입이 올바르지 않습니다' };
  }
  return { message: ctx.defaultError };
};

z.setErrorMap(customErrorMap);

// 필드별
const schema = z.object({
  email: z.string().email({ message: '올바른 이메일을 입력하세요' }),
  age: z.number({ invalid_type_error: '숫자를 입력하세요' })
    .min(18, { message: '18세 이상이어야 합니다' }),
});

flatten() / formErrors로 폼 전체를 한 번에 UI에 뿌리기도 합니다. tRPC·TRPC TRPCError에 ZodError를 cause로 싣으면, 상위에서 공통 formatter로 처리하기 좋습니다.

Zod 4에서 달라진 에러 API

위 예제는 Zod 3 문법입니다. 2025년에 나온 Zod 4는 파싱 속도와 타입 추론 비용을 크게 줄였지만, 에러 커스터마이징 쪽 API가 바뀌어서 업그레이드할 때 가장 많이 깨지는 부분이 여기입니다. message는 계속 동작하지만 통합 파라미터 error가 권장되고, invalid_type_error/required_error와 errorMap은 error 하나로 합쳐졌습니다. 전역 설정은 z.setErrorMap 대신 z.config({ customError })를 씁니다. 문자열 포맷 검사도 z.string().email()보다 최상위 z.email(), z.uuid()가 기본 형태가 됐습니다.

import { z } from 'zod'; // v4

const schema = z.object({
  email: z.email({ error: '올바른 이메일을 입력하세요' }),
  age: z.number({
    error: (iss) => (iss.input === undefined ? '나이는 필수입니다' : '숫자를 입력하세요'),
  }).min(18, { error: '18세 이상이어야 합니다' }),
});

const result = schema.safeParse(input);
if (!result.success) {
  const { fieldErrors } = z.flattenError(result.error); // 평면 폼용
  const tree = z.treeifyError(result.error);            // 중첩 객체용
}

.flatten()·.format() 메서드는 v4에서 deprecated이고 z.flattenError()·z.treeifyError()로 옮겨 갔습니다. 에러 메시지를 서버 400 응답에 그대로 싣는 팀이라면, 업그레이드 후 응답 본문 모양이 조용히 바뀌어 프론트엔드 폼 매핑이 깨지는 일이 생기기 쉬우니 에러 포맷팅을 헬퍼 한 곳으로 모아 두고 그 헬퍼에 대한 테스트를 먼저 작성해 두는 편이 안전합니다. @hookform/resolvers도 v4 스키마를 지원하는 버전으로 함께 올려야 합니다.

Form 라이브러리: React Hook Form vs Formik

구분React Hook Form + zodResolverFormik + 수동/어댑터
리렌더등록 기반, 필드 단위 갱신이 적음컨텍스트 기반, 큰 폼에서 리렌더 부담 가능
Zod 연동@hookform/resolvers 공식, 성숙커뮤니티 패키지·직접 validate
API 스타일register / ControllerField / useField
적합대부분의 신규 React 프로젝트, 성능 민감 폼레거시·기존 Formik 투자가 큰 팀

React Hook Form은 아래처럼 스키마 하나로 타입·검증이 맞습니다. valueAsNumber로 숫자 인풋을 맞추는 등 HTML과 타입이 어긋나는 지점만 주의하면 됩니다.

npm install react-hook-form @hookform/resolvers
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const schema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
  age: z.number().min(18),
});

type FormData = z.infer<typeof schema>;

function MyForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
    resolver: zodResolver(schema),
  });

  const onSubmit = (data: FormData) => {
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}
      <input type="password" {...register('password')} />
      {errors.password && <span>{errors.password.message}</span>}
      <input type="number" {...register('age', { valueAsNumber: true })} />
      {errors.age && <span>{errors.age.message}</span>}
      <button type="submit">Submit</button>
    </form>
  );
}

Formik을 쓴다면 validate에서 schema.safeParse(values)로 통일하거나, zodFormikAdapter 류 어댑터로 맞출 수 있습니다. 팀에 이미 Formik 투자가 있다면 점진적으로 공통 schema만 Zod로 두며, 마이그레이션 시기에 RHF로 옮기는 식이 리스크가 적습니다.

API 스키마 설계 패턴

  • Input / Output 분리: UserCreate는 비밀번호·약관, UserPublic은 id·createdAt만. 같은 테이블이라도 노출 스키마는 별도 z.object로 두어 과다 노출을 방지합니다.
  • 버전 필드: v: z.literal(1)로 클라이언트·서버가 동의한 페이로드 형태를 고정(회귀 방지).
  • 페이징·정렬: sortBy: z.enum(['createdAt', 'name']), order: z.enum(['asc', 'desc'])처럼 화이트리스트로만.
  • ID 타입: URL 파라미터는 문자열이므로 z.coerce.number() vs z.string().uuid()를 라우트별로 일관되게.
  • 내부 DTO는 넓게, 외부 API는 좁게: 내부에서만 쓰는 필드는 InternalUserSchema로, 공개 REST는 PublicUserResponse로 분리.
import { z } from 'zod';

export const PaginationQuery = z.object({
  page: z.coerce.number().int().min(1).default(1),
  pageSize: z.coerce.number().int().min(1).max(100).default(20),
});

export const CreatePostBody = z.object({
  title: z.string().min(1).max(200),
  body: z.string().max(50_000),
  tags: z.array(z.string().max(32)).max(20).default([]),
});

tRPC·Hono·Express 어디서든 같은 스키마를 import하면 클라이언트(zodios 등)·서버·OpenAPI 생성기까지 한 갈래로 맞출 수 있습니다.

tRPC 통합

import { z } from 'zod';
import { publicProcedure, router } from './trpc';

export const userRouter = router({
  create: publicProcedure
    .input(
      z.object({
        name: z.string().min(1),
        email: z.string().email(),
        age: z.number().min(18).optional(),
      })
    )
    .mutation(async ({ input }) => {
      // input은 자동 타입 추론 + 검증됨
      return db.user.create({ data: input });
    }),
});

성능 최적화와 lazy 스키마

  • 스키마는 모듈 최상위에 한 번만 정의하고 재사용하세요. parse마다 거대한 객체를 새로 만들 필요는 없습니다.
  • 대형 Union: 분기가 많으면 discriminatedUnion이 단순 union 나열보다 유리한 경우가 있습니다(내부 최적화·에러).
  • 큰 JSON: z.lazy로 실제로 필요한 서브트리만 검증하거나(드물게), 응답이 매우 크면 도메인별로 잘라 여러 스키마로 safeParse를 나누는 것이 메모리에 도움이 될 수 있습니다.
  • 빈번한 동일 페이로드 캐싱은 키 설계·무효화에 주의(보안·메모리). 대부분 앱은 캐시 없이도 충분합니다.
// lazy: 순환·대형 트리에 사용 (위 Category 예제 참고)
// 스키마 캐싱은 특수한 경우에만
const UserSchema = z.object({ id: z.string(), name: z.string() });
const parsedCache = new Map<string, z.infer<typeof UserSchema>>();

function parseWithCache(jsonKey: string, data: unknown) {
  if (parsedCache.has(jsonKey)) {
    return { success: true as const, data: parsedCache.get(jsonKey)! };
  }
  const result = UserSchema.safeParse(data);
  if (result.success) parsedCache.set(jsonKey, result.data);
  return result;
}

Yup·Joi와의 비교 (요약)

  • Yup: 검증 중심, InferType으로 타입을 뒤늦게 붙이는 느낌. React 레거시·폼 팀이 익숙한 경우多. oneOf·SchemaOf 생태는 성숙하나, TypeScript와의 ‘한 객체로 타입+검증’ 일체감은 Zod가 강합니다.
  • Joi: Node·서버 쪽에서 흔하고 스키마가 표현력이 좋으나, 번들·브라우저·TS 정적 타입 연동 측면에서 Zod가 유리한 경우가 많습니다. BFF·Lambda만 Joi를 쓰고 프론트는 Zod로 중복 정의가 생기지 않게 공유 패키지로 끌어올리는 팀도 있습니다.
  • Zod: z.infer로 동기화, discriminated union·brand·transform+pipe가 일상 API에 가깝으며, tRPC·RHF·Prisma 생태가 기본 examples를 제공합니다.

마이그레이션은 필드 단위로 yup 스키마와 z 스키마를 병행 테스트한 뒤 교체하는 방식이 안전합니다.

Async Validation

const UsernameSchema = z.string().refine(
  async (username) => {
    const exists = await checkUsernameExists(username);
    return !exists;
  },
  { message: 'Username already taken' }
);

const result = await UsernameSchema.parseAsync('alice');

실무 팁: 비동기 refine은 RHF mode: 'onBlur' / debounce와 함께 쓰지 않으면 요청 폭주가 날 수 있습니다. 서버 쪽 최종 검증은 반드시 별도.

환경변수 검증

import { z } from 'zod';

const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']),
  DATABASE_URL: z.string().url(),
  PORT: z.string().transform((val) => parseInt(val, 10)),
  API_KEY: z.string().min(1),
});

export const env = envSchema.parse(process.env);

JSON 파싱

const ConfigSchema = z.object({
  apiUrl: z.string().url(),
  timeout: z.number(),
  retries: z.number().default(3),
});

const rawConfig = JSON.parse(fs.readFileSync('config.json', 'utf-8'));
const config = ConfigSchema.parse(rawConfig);

API Response 검증

const UserResponseSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  createdAt: z.string().datetime(),
});

async function fetchUser(id: number) {
  const response = await fetch(`/api/users/${id}`);
  const data = await response.json();
  return UserResponseSchema.parse(data);
}

Prisma 통합 (참고)

import { z } from 'zod';
import type { User } from '@prisma/client';

const UserSchema: z.ZodType<User> = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  createdAt: z.date(),
});

실제 프로젝트 적용 경험담 (요약)

  • tRPC + Zod 한 베이스: procedure input과 프론트 폼 schema를 packages/validators에 두면 드리프트가 사라집니다. “API만 바꾸고 UI를 안 바꾼” 클래스의 버그를 줄였습니다.
  • 외부 API 응답 검증: 서드파티가 스키마를 바꿔도 safeParse 실패로 조기 감지·알람. parse로 멈추기보다, 배열 응답이면 itemSchema.safeParse로 건너뛰기 vs 전체 실패 정책을 팀이 정해야 합니다.
  • 폼에선 preprocess: 쿼리/모든 인풋이 string인 레거시와 맞출 때 coerce·preprocess를 쓰면, 타입스크립트 z.infer와 실제 런타입이 맞습니다.
  • 에러 UX: setErrorMap을 한곳에 두며, 제품 locale에 맞게 메시지를 중앙집중하면 디자이너·PM과 문구를 맞추기 쉽습니다.

트러블슈팅

순환 참조

  • z.lazy() 사용 (위 Category 예제).

타입 추론이 애매할 때

  • z.infer<typeof Schema>를 export해서 단일 출처로 쓰거나, satisfies z.ZodType<YourType>로 맞춤.

에러 메시지 한글화

  • message 옵션, setErrorMap, RHF errors에 매핑.

Union 좁히기

const result = UnionSchema.safeParse(data);
if (result.success) {
  // result.data는 올바른 판별 유니온
}

체크리스트

  • Zod 설치
  • 스키마 정의 + z.infer 타입 추론
  • safeParse vs parse 위치 (경계 vs UI)
  • 커스텀 에러 메시지 / setErrorMap
  • React Hook Form (zodResolver) 또는 Formik 어댑터
  • tRPC / REST input·output 스키마 분리
  • 환경변수·config 검증
  • API response 검증
  • async refine 남용 방지 (서버·디바운스)
  • lazy·discriminated union·brand 필요 시

마무리

Zod는 “TypeScript가 런타임 검증을 할 수 있다면?”에 대한 실용적인 답입니다. 스키마 컴포지션·discriminated union·transform·preprocess·에러 맵까지 한 흐름으로 묶을 수 있어, API·폼·환경을 같은 언어로 맞추기 좋습니다. Yup·Joi에서 이전하는 팀은 공유 모듈에 스키마를 모아두는 것부터 시작하면 효과를 빨리 느낄 수 있습니다.

자주 묻는 질문 (FAQ)

Q. 트리나 댓글처럼 자기 자신을 참조하는 스키마는 어떻게 정의하나요?

A. 스키마를 선언하는 도중에는 아직 변수가 초기화되지 않았기 때문에 자기 자신을 바로 참조할 수 없어서, 스키마 정의를 z.lazy(() => z.object({…}))로 감싸 실제 검증 시점에 평가되도록 해야 합니다. 이 경우 TypeScript가 타입을 추론하지 못하므로 Category 같은 재귀 타입을 먼저 직접 선언하고 z.ZodType로 스키마에 타입을 명시합니다. API가 중첩 객체 대신 parentId가 있는 평평한 목록을 준다면 클라이언트에서 트리를 조립한 뒤 검증하는 식으로 실제 데이터 모양에 맞춰야 합니다.

Q. React Hook Form과는 어떻게 통합하나요?

A. @hookform/resolvers 패키지의 zodResolver로 연결합니다. Zod 스키마 하나로 폼 검증, 타입, 에러 메시지를 함께 처리할 수 있습니다.


같이 보면 좋은 글