Zod 라이브러리: 스키마 선언과 유효성 검증을 간편하게

최근 새로운 프로젝트에서 외부 데이터를 받아와야 했는데, 스키마가 복잡해 데이터를 안전하게 처리하기 위해 Zod 라이브러리를 사용했다.

Typescript 자체도 자바스크립트보다 타입 관련해서 안정적인 처리가 가능하지만, 여기에 한 단계 더 추가된 안정성을 제공하는 것이 Zod다. (참고로 Zod는 타입스크립트와 찰떡궁합을 자랑하지만 자바스크립트에서도 사용할 수 있다)

타입스크립트는 컴파일 시점에 타입 추론을 하지만, 이 타입은 런타임에서 사라지기 때문에 실제 데이터가 올바른지 보장할 수 없다. 특히 사용자로부터 데이터를 받거나 외부에서 데이터를 받아오는 경우엔 더더욱 예상할 수 없는 데이터가 들어올 가능성이 높아지기 마련이다.

기본적인 유효성 검사

회원가입 form을 만드는 것을 생각해보자. 아이디, 비밀번호, 이메일 등에 대해 특정한 포맷을 요구하며, 사용자가 입력한 값이 이 포맷에 맞는지 유효성 검증을 하게 된다. Zod를 이용하면, 런타임 시점에서 데이터를 쉽고 직관적으로 검증할 수 있다.  문자열에 특화된 여러 가지 유효성 검사 기능을 제공하고 있어 email이나 url, datetime형식이 맞는지 등을 regex등을 통해 직접 구현할 필요가 없다. (물론 더 구체적이고 커스터마이즈된 검사가 필요하다면 regex를 사용할 수 있다)

import { z } from "zod";

const IdSchema = z.string().min(3, { message: "아이디는 최소 3자 이상이어야 합니다." });

const PasswordSchema = z.string().min(8, { message: "비밀번호는 최소 8자 이상이어야 합니다." });

const EmailSchema = z.string().email({ message: "유효한 이메일 주소를 입력해주세요." });

// 데이터 스키마 정의
const UserSchema = z.object({
  id: IdSchema,
  password: PasswordSchema,
  email: EmailSchema,
});

// 유효성 검사를 수행하는 함수
function validateUserData(data: unknown) {
  // zod의 safeParse 메소드 사용
  const result = UserSchema.safeParse(data);

  if (!result.success) {
    console.error("잘못된 입력 데이터:", result.error.format());
    return null;  
  } 

  console.log("유효한 사용자 데이터:", result.data);
  return result.data;  // 유효한 데이터만 반환
}


참고로 위 예시 코드에서의 safeParse를 사용하면 유효성 검증에 실패해도 예외를 던지지 않고, 오류에 대한 정보를 담은 결과 객체를 반환한다. 다음은 결과 객체 예시인데, 에러 메시지를 포맷팅하고 커스터마이즈할 수도 있다.

 [
      {
        "code": "invalid_type",
        "expected": "string",
        "received": "number",
        "path": [ "name" ],
        "message": "Expected string, received number"
      }
  ] 

Infer를 이용해 타입 간편하게 선언하기

z.infer타입과 스키마를 연결해주는 역할을 한다. Zod 스키마를 통해 데이터의 구조를 정의하면, z.infer를 사용해 스키마에서 타입을 자동으로 추론할 수 있다. 즉, 스키마에서 정의한 데이터 구조를 타입스크립트 타입으로 연결하여, 타입과 스키마가 일관되도록 보장해준다.

// Infer를 사용하지 않는 경우, 타입을 수동으로 정의해야 함
type User = {
  id: string;
  age: number;
  email: string;
};

// Infer를 사용하는 경우, zod로 정의한 스키마로부터 자동으로 타입을 추론
type User = z.infer<typeof UserSchema>;

자주 사용하는 타입 및 메소드

  • 기본 타입 : string, number, bigint, boolean, date, symbol, undefined, null, void, any, unknown, never
    • 이 타입들은 각각의 특성에 맞는 유효성 검사변환 메소드를 갖고 있다.
    • string 타입 예시 : ISO 8601기준에 맞는 날짜/시간 형식인지 검사하는 z.string().datetime(), 문자열 공백을 제거하는 z.string().trim()
    • number타입 예시 : 0보다 큰 숫자인지 검사하는 z.number().positive(), safe integer범위 내의 숫자인지 검사하는 z.number().safe()
  • 리터럴 타입 :  문자열이나 숫자에 특정한 값을 지정한 더 엄격한 타입 (예 : const gender = z.literal('Female').or(z.literal('Male')).or(z.literal('PreferNotToSay'));)
  • 타입 coercing : 데이터를 강제로 변환해 원하는 타입으로 만들 때 사용한다. 내 경우, 데이터를 파싱했을 때 숫자여야 하는 값들이 다 따옴표로 감싸져서 문자열로 들어오는 필드를 처리할 때 사용했다. (예: Repeat: z.coerce.number() )
  • optional / nullable
    • Optional로 설정하면 타입스크립트에서 optional 스키마를 추론할 때 undefined도 가능한 것으로 추론하고, Nullable로 설정하면 말 그대로 null이 가능한 것으로 추론한다.
// 이런 식으로 스키마를 감싸거나
const schema = z.optional(z.string());
const nullableString = z.nullable(z.string());

// 스키마에서 optional을 호출하는 식으로 설정 가능
const user = z.object({
  username: z.string().optional(),
});

type NewUser = z.infer; // { username?: string | undefined };

const E = z.string().nullable();
type E = z.infer; // string | null
  • 배열
    • optional이나 nullable처럼 배열 요소의 타입 스키마를 감싸거나, 스키마에서 .array()를 호출하는 식으로 배열 타입으로 만들어줄 수 있다. 스키마 뒤에 메소드를 호출하는 식으로 사용 시, 호출하는 순서에 주의하자!
// 감싸는 방식
const stringArray = z.array(z.string());

// 뒤쪽에 메소드 호출하는 방식
const stringArray = z.string().array();

// 호출 순서에 따라 스키마가 완전히 다르게 해석된다
z.string().optional().array(); // (string | undefined)[]
z.string().array().optional(); // string[] | undefined
    • 빈 배열이 아니어야 유효한 경우 z.string().array().nonempty() 로 체크할 수 있다. 그 외에도 배열의 최대/최소 길이 검사 등도 지원한다.
  • union type : 어떤 값이 여러 타입을 가질 수 있을 때 사용한다. (예: const stringOrNumber = z.union([z.string(), z.number()]);) - 숫자도 될 수 있고 문자도 될 수 있는 값

TypeScript-first schema validation with static type inference
TypeScript-first schema validation with static type inference

개발 문서도 라이브러리도 굉장히 직관적이라 (그리고 한국어 번역이 매우 잘 돼있다!!) 러닝 커브가 매우 낮은 느낌으로 업무에 빠르게 적용할 수 있었다.