본문으로 건너뛰기
Previous
Next
Zod 완벽 가이드 | TypeScript 스키마 검증·타입 추론·Form 유효성 검사

Zod 완벽 가이드 | TypeScript 스키마 검증·타입 추론·Form 유효성 검사

Zod 완벽 가이드 | TypeScript 스키마 검증·타입 추론·Form 유효성 검사

이 글의 핵심

Zod 완벽 가이드에 대해 정리한 개발 블로그 글입니다. Zod는 TypeScript 우선 스키마 검증 라이브러리입니다. 기본 타입부터 복잡한 객체, 커스텀 검증, React Hook Form 통합까지 다루고, 파서 조합·에러/변환 파이프라인·Input/Output… 개념과 예제 코드를 단계적으로 다루며, 실무·학습에 참고할 수 있도록 구성했습니다. 관련 키워드: Zod,…

런타임 검증을 생략했다가 망한 경험, 한 번쯤은 있을 것이다. 백엔드가 갑자기 price를 문자열로 돌려줬는데 프론트는 number라고 믿고 total * 1.1만 적용했다가 NaN이 되고, 배포 이후에야 Sentry에서 원인을 찾는 상황이다. TypeScript의 interface는 컴파일 시점에는 멀쩡해 보이지만, fetch로 들어온 JSON은 런타임에 any에 가깝다. 그 경계(HTTP, 환경 변수, localStorage, 웹훅)를 메우기 위해 Zod 같은 스키마 검증 라이브러리를 쓴다. 이 글에서는 Zod의 핵심 개념부터 React Hook Form·tRPC 연동, 성능·실무 팁까지 한 번에 정리한다.

Zod의 핵심 개념과 타입 추론

Zod는 스키마 선언 → 파싱(parse/safeParse) → 타입 추론이 한 줄로 이어진다. 수동으로 type User = { ... }를 유지보수할 필요가 줄고, 스키마가 곧 단일 진실 공급원(single source of truth)이 된다.

  • z.infer<typeof schema>: 스키마를 통과한 값의 TypeScript 타입. transform·default가 있으면 출력(output) 기준이다.
  • z.input<typeof schema>: parse에 넣을 수 있는 입력 타입. 문자열로 온 숫자를 z.coerce.number()로 받는 경우 등 입력과 출력이 갈라질 때 유용하다.
  • z.output<typeof schema>: z.infer와 동일한 의미로 기억해도 된다. API 핸들러에서는 z.input으로 raw body, 서비스 레이어에는 z.output만 넘기는 식으로 경계를 나눌 수 있다.

설치는 다음과 같다.

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

const nameSchema = z.string();
nameSchema.parse('John'); // OK
// nameSchema.parse(123); // ZodError

const emailSchema = z.string().email();
emailSchema.parse('[email protected]');

스키마 정의 패턴: 기본 타입, 객체, 배열

기본 타입과 제약

문자열·숫자·불리언 외에도 z.coerce로 쿼리스트링·폼 입력처럼 문자열로 온 값을 숫자로 맞추는 패턴이 자주 쓰인다. min/max/regex 등은 체이닝으로 붙인다.

import { z } from 'zod';

const pageQuery = z.object({
  page: z.coerce.number().int().min(1).default(1),
  q: z.string().trim().max(200).optional(),
});

객체·배열·레코드

z.object는 키가 고정된 DTO에, z.record는 동적 키 맵에, z.array는 리스트에 쓴다. strict/passthrough/strip으로 알 수 없는 키를 어떻게 다룰지 정할 수 있다(보안상 서버 응답에는 strict나 이후 transform으로 키 제거를 검토할 가치가 있다).

import { z } from 'zod';

const tagList = z.array(z.string().min(1)).max(20);

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

type User = z.infer<typeof userSchema>;

const user = userSchema.parse({
  name: 'John',
  age: 30,
  email: '[email protected]',
  tags: ['ts', 'zod'],
});

optional, nullable, default는 모두 체이닝으로 표현한다. z.union, z.discriminatedUnion, z.enum은 API가 여러 형태를 줄 때 타입을 안전하게 좁힌다.

복잡한 유효성 규칙: custom, refine, superRefine

필드 하나로 끝나지 않는 규칙은 refine(간단한 참/거짓)과 superRefine(여러 이슈·경로 지정)으로 나눈다. 비밀번호 확인, 날짜 범위, 크로스 필드 제약은 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'],
      });
    }
  });

z.string().refine()에 비동기 검사(예: 서버에 닉네임 중복 확인)를 붙일 수는 있지만, 폼·API 설계에 따라 클라이언트는 포맷만, 서버에서 최종 확인으로 나누는 편이 운영에 유리한 경우가 많다.

에러 메시지 커스터마이징

필드별 메시지는 스키마에 직접 넣거나, errorMap/setErrorMap으로 팀 공통 문구를 한곳에서 맞출 수 있다. 다국어(i18n) 키를 메시지에 실어 UI에서 치환하는 팀도 있다.

import { z } from 'zod';

const schema = z.object({
  email: z.string().email({ message: '유효한 이메일 형식이 아닙니다' }),
  age: z.number().min(0, { message: '0 이상이어야 합니다' }),
});

// issues: ZodError['issues'] — path, message, code로 구성
try {
  schema.parse({ email: 'bad', age: -1 });
} catch (e) {
  if (e instanceof z.ZodError) {
    const flat = e.flatten();
    console.log(flat.fieldErrors);
  }
}

safeParse 실패 시 result.error.format()·flatten()으로 React Hook Form이나 API 400 응답 본문에 맞게 가공한다. 운영 로그에는 전체 페이로드 대신 issues 요약과 요청 ID만 남기는 습관이 좋다.

TypeScript 타입과의 통합

라이브러리가 이미 interface를 요구하면, z.infer로 뽑은 타입을 그대로 넘기거나, 반대로 좁은 타입이 있을 때 satisfies로 스키마와의 정합성을 점검할 수 있다. 모노레포에서는 packages/shared-schemas에 Zod만 두고 프론트·백엔드가 같은 패키지를 import하는 구조가 흔하다. 이때 빌드 타깃(module/exports)만 맞추면 타입과 런타임이 한 번에 배포된다.

Branded type이 필요하면 z.string().uuid()처럼 좁힌 뒤 transform으로 도메인 타입으로 매핑하거나, z.custom<PetId>()을 쓰는 방식도 있다(팀 컨벤션에 맞출 것).

React Hook Form 통합 실전 예제

폼 레이어에서는 zodResolver가 Zod 에러를 필드 단위로 매핑해 준다. 스키마 변경이 곧 폼 타입 변경이므로 리팩터링 비용이 줄어든다.

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

const signupSchema = z.object({
  email: z.string().email('유효한 이메일을 입력하세요'),
  password: z.string().min(8, '비밀번호는 최소 8자 이상이어야 합니다'),
  name: z.string().min(2, '이름은 최소 2자 이상이어야 합니다'),
});

type SignupForm = z.infer<typeof signupSchema>;

export function SignupForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<SignupForm>({
    resolver: zodResolver(signupSchema),
    mode: 'onBlur', // 또는 onChange — UX/성능 트레이드오프
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <input {...register('email')} placeholder="Email" />
      {errors.email && <p>{errors.email.message}</p>}
      <input {...register('password')} type="password" />
      {errors.password && <p>{errors.password.message}</p>}
      <input {...register('name')} placeholder="Name" />
      {errors.name && <p>{errors.name.message}</p>}
      <button type="submit">Sign Up</button>
    </form>
  );
}

modeonChange로 두면 즉시 피드백은 좋지만 렌더 횟수가 늘 수 있으므로, 필드 수가 많은 화면에서는 onBluronSubmit 위주로 시작해 본다.

tRPC·API 엔드포인트 유효성 검증

tRPC에서는 routerinputz.object를 넣는 패턴이 표준에 가깝다. REST라면 핸들러 진입 직후에 동일 스키마로 parse하거나 safeParse 후 400을 반환하면 된다.

import { initTRPC } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.create();

const createPostInput = z.object({
  title: z.string().min(1).max(200),
  body: z.string().max(50_000),
});

export const appRouter = t.router({
  createPost: t.procedure.input(createPostInput).mutation(({ input }) => {
    // input은 이미 narrow됨
    return { id: '...', ...input };
  }),
});

클라이언트에서만 검증하지 말고, 서버에서 반드시 재검증한다. 브라우저 요청은 언제든 조작될 수 있기 때문이다.

transformpreprocess는 JSON에서 숫자가 문자열로 오는 등 외부 세계의 끈적임을 정리할 때 유효하다. 단계를 나누려면 pipe로 쪼개 두면 단위 테스트도 쓰기 쉽다.

import { z } from 'zod';

const money = z
  .string()
  .transform((s) => s.replace(/,/g, ''))
  .pipe(z.coerce.number().nonnegative());

성능 고려사항

Zod는 일반적으로 충분히 가볍지만, 초당 수천 건을 한 프로세스에서 스키마 전체로 검증할 때는 비용이 누적될 수 있다. 실무에서는 (1) 경계에서만 꼼꼼히 검사하고 내부는 이미 좁혀진 타입만 흐르게 하고, (2) 동일 요청에 대해 동일 스키마 인스턴스를 재사용하며, (3) 리스트가 크면 아이템 스키마는 재사용·불필요한 superRefine 남용을 피하는 식으로 튜닝한다. safeParse는 성공/실패 분기에 유리하고, parse는 “실패하면 예외”가 맞는 내부 경계에 쓰면 코드가 짧아질 수 있다. 병목이 의심되면 실제 서비스 부하에 가깝게 벤치마크를 찍어 보는 것이 안전하다.

API 응답: parse와 safeParse

서버 응답이 항상 계약을 지키지 않는다면 safeParse로 분기하는 편이 낫다. 실패 시 로그·폴백·재시도를 한곳에서 처리할 수 있다.

import { z } from 'zod';

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

async function fetchUser(id: number) {
  const response = await fetch(`/api/users/${id}`);
  const data: unknown = await response.json();
  const result = userSchema.safeParse(data);
  if (!result.success) {
    console.error('Validation failed:', result.error.flatten());
    return null;
  }
  return result.data;
}

json() 결과를 unknown으로 두고 곧바로 스키마에 넣는 습관이 타입 안전에 도움이 된다.

실제 프로젝트 적용 경험

경험상 효과가 컸던 것은 세 가지다. 첫째, 환경 변수를 앱 기동 시 한 번 z.object로 검사해, 프로덕션에서만 터지는 undefined API 키를 배포 전에 잡는 것. 둘째, 백엔드와 프론트가 다른 저장소일 때도 스키마 패키지를 npm workspace로 공유해, “배포 후 알게 된 필드 누락”을 줄인 것. 셋째, 오류를 한국어로 통일하되 코드/경로는 그대로 두어 Sentry·로그에서 원인을 역추적하기 쉽게 유지한 것이다.

Yup·Joi에서 넘어온 팀에 Zod는 TypeScript와의 동기화가 가장 큰 체감 이점이었다. 타입만으로는 부족한 지점, 즉 fetch·process.env·사용자 입력 같은 불신할 경계는 Zod(또는 Valibot, io-ts 등)로 한 번 통과시키는 습관을 권장한다.