junyeokk
Blog
React·2025. 12. 13

Zod 스키마 기반 유효성 검증

폼 데이터나 API 응답을 검증할 때, 보통 조건문을 나열한다.

typescript
if (!data.email || !data.email.includes('@')) {
  throw new Error('이메일이 올바르지 않습니다');
}
if (typeof data.age !== 'number' || data.age < 0) {
  throw new Error('나이가 올바르지 않습니다');
}

이 방식의 문제는 검증 로직이 타입 정보와 분리되어 있다는 것이다. TypeScript의 타입은 컴파일 타임에만 존재하고, 런타임에는 사라진다. 외부에서 들어오는 데이터(API 응답, 폼 입력, URL 파라미터)는 런타임에 검증해야 하는데, 타입과 검증 로직을 따로 관리하면 둘이 어긋나기 쉽다.

Zod는 스키마를 한 번 정의하면 런타임 검증과 TypeScript 타입을 동시에 얻는 라이브러리다. "스키마가 곧 타입"이라는 게 핵심 아이디어다.


스키마 정의

Zod에서 스키마는 데이터의 형태와 제약 조건을 코드로 표현한 것이다.

typescript
import { z } from 'zod';

const UserSchema = z.object({
  name: z.string().min(1, '이름은 필수입니다'),
  email: z.string().email('올바른 이메일을 입력하세요'),
  age: z.number().int().min(0).max(150),
  role: z.enum(['admin', 'user', 'guest']),
});

이 스키마에서 TypeScript 타입을 추출할 수 있다.

typescript
type User = z.infer<typeof UserSchema>;
// { name: string; email: string; age: number; role: 'admin' | 'user' | 'guest' }

z.infer가 핵심이다. 스키마를 정의하면 타입이 자동으로 따라오니까, 인터페이스를 따로 작성할 필요가 없다. 스키마를 수정하면 타입도 자동으로 바뀐다.


파싱과 검증

Zod는 "검증"이 아니라 "파싱"이라는 관점을 취한다. 입력 데이터를 스키마에 맞게 변환하거나, 맞지 않으면 에러를 던진다.

typescript
// parse: 실패 시 ZodError를 던짐
try {
  const user = UserSchema.parse(unknownData);
  // user는 User 타입으로 추론됨
} catch (e) {
  if (e instanceof z.ZodError) {
    console.log(e.errors);
    // [{ path: ['email'], message: '올바른 이메일을 입력하세요' }]
  }
}

// safeParse: 예외를 던지지 않고 결과 객체를 반환
const result = UserSchema.safeParse(unknownData);
if (result.success) {
  console.log(result.data); // User 타입
} else {
  console.log(result.error.errors);
}

parsesafeParse 두 가지 방식이 있다. safeParse는 try-catch 없이 성공/실패를 분기할 수 있어서 폼 검증처럼 에러가 정상 흐름인 경우에 편하다.


스키마 조합

Zod의 강점은 작은 스키마를 조합해서 복잡한 구조를 만들 수 있다는 것이다.

typescript
const AddressSchema = z.object({
  street: z.string(),
  city: z.string(),
  zipCode: z.string().regex(/^\d{5}$/),
});

// 기존 스키마 확장
const UserWithAddressSchema = UserSchema.extend({
  address: AddressSchema,
});

// 일부 필드만 선택
const UserPreviewSchema = UserSchema.pick({ name: true, role: true });

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

// 두 스키마 합치기
const MergedSchema = UserSchema.merge(AddressSchema);

extend, pick, omit, partial, merge 같은 메서드로 기존 스키마를 재사용한다. 이건 TypeScript의 Pick, Omit, Partial 유틸리티 타입과 대응되는데, 차이는 런타임에도 동작한다는 점이다.


변환(transform)

Zod 스키마는 검증만 하는 게 아니라 데이터를 변환할 수도 있다.

typescript
const StringToNumberSchema = z.string().transform((val) => parseInt(val, 10));

StringToNumberSchema.parse('42'); // 42 (number)

// 입력 타입과 출력 타입이 다를 수 있다
type Input = z.input<typeof StringToNumberSchema>;  // string
type Output = z.output<typeof StringToNumberSchema>; // number

URL의 쿼리 파라미터는 항상 문자열로 들어오는데, transform을 쓰면 파싱과 타입 변환을 한 번에 처리할 수 있다. z.inputz.output으로 변환 전후의 타입을 각각 추출할 수 있다.


class-validator와의 비교

NestJS 같은 프레임워크에서는 class-validator + class-transformer 조합을 많이 쓴다.

typescript
// class-validator 방식 (데코레이터)
class CreateUserDto {
  @IsString()
  @MinLength(1)
  name: string;

  @IsEmail()
  email: string;
}

// Zod 방식 (함수 체이닝)
const CreateUserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
});

class-validator는 클래스와 데코레이터 기반이라 NestJS의 파이프 시스템과 자연스럽게 통합된다. 반면 Zod는 순수 함수형이라 프레임워크에 의존하지 않는다. 프론트엔드에서는 Zod가 react-hook-form이나 tRPC와 잘 맞고, 백엔드에서는 class-validator가 NestJS 생태계와 잘 맞는 편이다.

핵심은 접근 방식의 차이다. class-validator는 "클래스에 제약 조건을 붙인다", Zod는 "스키마에서 타입이 나온다". Zod 쪽이 타입 안전성에서는 더 엄격하다. class-validator의 데코레이터는 타입 추론에 기여하지 않지만, Zod의 스키마는 그 자체가 타입의 원천(source of truth)이기 때문이다.


핵심 정리

Zod는 런타임 검증과 TypeScript 타입을 하나의 스키마로 통합하는 라이브러리다. "스키마를 정의하면 타입은 따라온다"는 원칙 덕분에 타입과 검증 로직이 어긋날 일이 없다. 외부 데이터의 경계(API 응답, 폼 입력, 환경 변수)에서 타입 안전성을 확보하는 데 특히 유용하다.


관련 문서