Logo

Zod를 통한 타입스크립트 친화적인 스키마 정의

이전 포스팅에서는 Zod를 사용하여 하나의 스키마로 유효성 검증과 타입 선언을 한 번에 해결하는 방법에 대해서 살펴보았는데요.

이번 포스팅에서는 Zod에 제공하는 타입스크립트 친화적인 검증자를 사용하여 스키마를 정의하는 다양한 방법에 대해서 알아보도록 하겠습니다.

자료형

스키마 정의는 자료형울 명시하는 것부터 시작하는데요. Zod는 자바스크립트의 기본 자료형이나 Date와 같은 내장 클래스에 대응하는 검증자(validator) 함수를 제공합니다.

예를 들어, 이메일, 나이, 활성화 여부, 생성 일자로 이루어진 사용자 객체를 나타내는 스키마를 Zod로 정의해보겠습니다.

import { z } from "zod";

const User = z.object({
  email: z.string(),
  age: z.number(),
  active: z.boolean(),
  createdAt: z.date(),
});

z.object()를 사용하여 User 스키마가 객체의 형태이고, z.string()으로 email 속성은 문자열로, age 속성은 z.number()로 숫자로, active 속성은 z.boolean()으로 불리언으로, z.date()createdAt 속성을 날짜 타입으로 정의하고 있습니다.

보시다 시피 API가 상당히 타입스크립트 친화적이면서도 굉장히 간단하고 명료하지 않나요?

Zod를 처음 사용하시는 분들도 이러한 직관적인 API 덕분에 큰 어려움 없이 배울 수 있답니다.

스키마에서 타입을 추출해보면 다음과 같은 타입이 추론이 되는데요.

type User = z.infer<typeof User>;
//   ^? { email: string; image: string; ips: string[]; createdAt: Date; }

여기까지만 보면 타입스크립트로 타입을 직접 선언하는 것과 큰 차이가 없다고 느껴질 수도 있는데요. 본 포스팅의 후반부로 갈수록 타입스크립트로는 어려운 검증이 부분이 Zod로는 가능하다는 것을 느끼실 게 될 거에요.

타입스크립트로 코드 작성하는 것과 무관하게 왜 자바스크립트 프로그램에서 유효성 검증이 필요한지에 대해서는 별도 포스팅에서 자세히 다루고 있으니 참고 바랍니다.

필수/선택

기본적으로 Zod 스키마에 포함된 모든 속성은 필수 입력인데요. optional() 검증자를 사용하면 필수 입력을 선택 입력으로 바꿀 수가 있습니다.

예를 들어, 위에서 작성한 스키마에서 active 속성을 선택 입력으로 한번 변경해볼까요?

import { z } from "zod";

const User = z.object({
  email: z.string(),
  age: z.number(),
  active: z.boolean().optional(),});

이 스키마에서 타입을 추론해보면 active 속성 뒤에 ?를 붙이고 타입을 boolean 또는 undefined로 선언한 것과 마찬가지라는 것을 알 수 있는데요.

type User = z.infer<typeof User>;
//   ^? { email: string; age: number; active?: boolean | undefined; }

따라서 다음 3가지 모두 유효성 검증을 모두 통과하게 됩니다.

// ✅ true
User.parse({
  email: "user@test.com",
  age: 35,
  active: true,
});

// ✅ undefined
User.parse({
  email: "user@test.com",
  age: 35,
  active: undefined,
});

// ✅ 누락
User.parse({
  email: "user@test.com",
  age: 35,
});

기본값

유효성 검증 과정에서 값이 누락되어 있는 속성에 기본값을 주고 싶다면 default() 검증자를 사용할 수 있습니다.

예를 들어, active 속성이 없으면 false으로 설정되도록 스키마를 변경해보겠습니다.

import { z } from "zod";

const User = z.object({
  email: z.string(),
  age: z.number(),
  active: z.boolean().default(false),});

이제 active 속성이 누락되어 있는 객체를 parse() 함수의 인자로 넘겨서 호출한 결과를 출력해볼까요?

const user = User.parse({
  email: "user@test.com",
  age: 35,
});

console.log(user);

그러면 active 속성 값을 지정해주지 않았지만 false로 자동 설정되는 것을 볼 수 있습니다.

콘솔
{
  email: 'user@test.com',
  age: 35,
  active: false
}

배열(array)

Zod로 배열 스키마를 정의할 때는 2가지 문법을 사용할 수 있는데요. 먼저 타입을 명시하고 .array()를 뒤에 붙여줄 수도 있고, z.string() 안에 타입을 인자로 넘겨줄 수도 있습니다.

import { z } from "zod";

const IPs = z.string().array(); // 첫 번째 방법
import { z } from "zod";

const IPs = z.array(z.string()); // 두 번째 방법

스키마로 부터 타입을 추출해보면 예상했던 것처럼 string[]이 나오는 것을 볼 수 있습니다.

type IPs = z.infer<typeof IPs>;
//   ^? type IPs = string[]

객체(object)

타입스크립트에 Record<Keys, Type> 유틸리티 타입이 있는 것처럼 Zod에도 z.record() 검증자가 있는데요. z.record()를 사용하면 키 이름에는 구애받지 않으면서 값만 타입을 제한할 수 있습니다.

예를 들어, 값으로 숫자만 사용할 수 있는 객체에 대한 스키마를 정의해볼까요?

const Numbers = z.record(z.number());

키 이름은 아무거나 자유롭게 쓸 수 있지만 값으로 숫자가 아닌 다른 자료형을 사용하면 검증이 실패하게 됩니다.

Prices.parse({ A: 1, B: 2 }); // ✅
Prices.parse({ C: 1, D: "2" }); // ❌ Expected number, received string

스카마로 부터 타입을 추출해보면 { [x: string]: number; }이 나오는 것을 볼 수 있습니다. 유틸리티 타입을 사용하면 Record<string, number>로도 표현할 수 있겠습니다.

type Numbers = z.infer<typeof Numbers>;
//   ^? type Numbers = { [x: string]: number; }

이넘(enum)

제한된 값 중에서 하나를 사용하도록 스키마를 정의하려면 어떻게 해야 할까요? 이럴 때는 z.enum() 검증자를 사용해여 사용 가능한 값을 나열해주면 되는데요.

예를 들어, GOLD, SILVER, BRONZE로 이뤄진 등급에 대한 스키마를 작성해보겠습니다.

import { z } from "zod";

const Level = z.enum(["GOLD", "SILVER", "BRONZE"]);

그러면 이 3가지 값 외에 다른 값을 사용할 시 유효성 검증이 실패하게 됩니다.

Level.parse("GOLD"); // ✅
Level.parse("SILVER"); // ✅
Level.parse("BRONZE"); // ✅

Level.parse("PLATINUM"); // ❌ Expected 'GOLD' | 'SILVER' | 'BRONZE', received 'PLATINUM'

스키마로 부터 타입을 추출해보면 유니온(union) 타입이 나오는 것이 확인됩니다.

type Level = z.infer<typeof Level>;
//   ^? type Level = "GOLD" | "SILVER" | "BRONZE"

고정값

자주는 아니지만 가끔 항상 동일한 값이 되야하는 속성이 있을 수 있는데요. 이 때는 z.literal() 검증자를 사용해서 해당 속성을 특정값으로 제한할 수 있습니다.

예를 들어, GOLD 등급 사용자의 스키마를 정의해볼까요?

import { z } from "zod";

const GoldUser = z.object({
  email: z.string().email(),
  level: z.literal("GOLD"),
});

이제 level 속성에는 GOLD 외에 다른 값은 허용되지 않는 것을 볼 수 있습니다.

GoldUser.parse({ email: "test@user.com", level: "GOLD" }); // ✅
GoldUser.parse({ email: "test@user.com", level: "SILVER" }); // ❌ Invalid literal value, expected "GOLD"

스키마로 부터 타입을 추출해보면 level 속성의 타입이 문자열 "GOLD"로 나옵니다.

type GoldUser = z.infer<typeof GoldUser>;
//   ^? type GoldUser = { email: string; level: "GOLD"; }

문자열 포맷

실제 프로젝트에서 문자열 스키마를 작성하다보면 z.string() 만으로는 적절한 유효성 검증이 힘든 경우가 많은데요.

예를 들어, 다음과 같이 사용자의 이메일, 사진, IP 주소를 나타내기 위한 스키마가 있다고 가정해보겠습니다.

import { z } from "zod";

const User = z.object({
  email: z.string(),
  image: z.string(),
  ips: z.string().array(),
});

그리고 아래처럼 빈 문자열이나 전혀 해당 포맷에 맞지 않은 값을 사용해도 아무 문제없이 유효성 검증이 통과하는 것을 볼 수 있는데요.

console.log(User.parse({
  email: "",
  image: "123",
  ips: ["A", "B"],
});

console.log(user);
콘솔
{ email: '', image: '123', ips: [ 'A', 'B' ] }

다행히도 이러한 문제를 방지할 수 있도록 Zod는 문자열에 특화된 검증자를 제공하고 있습니다.

따라서 z.string() 다음에 .email(), .url(), .ip()와 같은 검증자를 추가하여 특정 포맷에 맞는 값만 유효성 검증에 통과하도록 할 수 있습니다.

import { z } from "zod";

const User = z.object({
  email: z.string().email(),
  image: z.string().url(),
  ips: z.string().ip().array(),
});

위와 같이 추가 검증자를 사용하도록 스크마를 변경한 후에 동일한 객체를 parse() 함수에 넘겨보면 이번에는 유효성 검증이 실패하여 에러가 발생할 것입니다.

콘솔
ZodError: [
  {
    "validation": "email",
    "code": "invalid_string",
    "message": "Invalid email",
    "path": [
      "email"
    ]
  },
  {
    "validation": "url",
    "code": "invalid_string",
    "message": "Invalid url",
    "path": [
      "image"
    ]
  },
  {
    "validation": "ip",
    "code": "invalid_string",
    "message": "Invalid ip",
    "path": [
      "ips",
      0
    ]
  },
  {
    "validation": "ip",
    "code": "invalid_string",
    "message": "Invalid ip",
    "path": [
      "ips",
      1
    ]
  }

여기서 주의하실 부분은 하나 있는데요. 실제로 스키마에서 타입을 추출해보면 여전히 모든 속성이 문자열이라는 것을 알 수 있습니다.

type User = z.infer<typeof User>;
//   ^? { email: string; image: string; ips: string[]; }

다시 말해서 타입스크립트로 코드를 작성할 때는 여전히 일반 문자열을 사용할 수 있으며 컴파일(compile) 시에도 스키마에서 정의한 수준의 타입 검사는 일어나지 않습니다. 이러한 엄격한 유효성 검증은 순수하게 Zod에 의해서 실행 시점에서만 일어납니다.

숫자형 지정

자바스크립트에서는 정수형과 실수형이 구분되지 않기 때문에 z.number() 만으로는 부족한 경우가 종종 있는데요.

예를 들어, 나이를 단순히 숫자형으로 나타내면 정수를 사용하든 실수를 사용하든 문제가 되지 않습니다.

import { z } from "zod";

const Age = z.number();

Age.parse(12); // ✅
Age.parse(12.345); // ✅

만약에 이 값을 저정하는 데이터베이스에서 나이를 저장하는 칼럼이 정수형 타입으로 되어 있다면 큰 문제의 소지가 될 수 있겠죠?

이럴 때는 int() 검증자를 추가해주면 오직 정수만 사용할 수도 있도록 제한할 수 있습니다.

import { z } from "zod";

const Age = z.number().int(); // .int() 추가

Age.parse(12); // ✅
Age.parse(12.345); // ❌ Expected integer, received float

문자열 특화 검증자와 마찬가지로 타입스크립트 수준에서는 여전히 정수형과 실수형이 구분되지 않는 점 주의 바랍니다.

type Age = z.infer<typeof Age>;
//   ^? type Age = number

범위 제한

스키마를 정의할 때 값이 허용되는 범위를 지정해주면 도움이 될 때가 있는데요. 특히 데이터베이스에서 저장하는 값이 길이에 제한이 있는 경우 특히 유용합니다.

예를 들어, 아래와 같이 .min().max()를 사용하여 하한이나 상한을 지정해주면 그 범위가 넘어갈 시 유효성 검증이 실패하게 됩니다.

import { z } from "zod";

const Url = z.string().url().max(200);
const Age = z.number().int().min(18).max(80);

Url.parse("http://www.google.com"); // ✅ 200자 넘지 않음
Age.parse(900); // ❌ Number must be less than or equal to 80

역시 마찬가지로 추로된 타입에는 아무런 영향을 주지 않습니다.

type Url = z.infer<typeof Url>;
//   ^? type Url = string
type Age = z.infer<typeof Age>;
//   ^? type Age = number

실전 예제

지금까지 배운 스키마 정의 방법을 종합하여 실전에서 사용될 법한 사용자 스키마를 작성해보았습니다.

const User = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  age: z.number().int().min(18).max(80),
  level: z.enum(["GOLD", "SILVER", "BRONZE"]),
  image: z.string().url().min(1).max(200).optional(),
  ips: z.string().ip().array().optional(),
  active: z.boolean().default(false),
  createdAt: z.date().default(new Date()),
});

마치면서

이상으로 다양한 실습을 통해서 Zod로 어떻게 스키마를 정의하는지에 대해서 살펴보았습니다.

Zod는 본 포스팅에서 다룬 것보다 훨씬 더 많은 검증자를 제공하고 있지만 이 정도만 숙지하시면 나머지는 필요할 때 마다 공식 문서를 통해 충분히 찾아보실 수 있으실 것입니다.

Zod 관련 포스팅은 Zod 태그를 통해서 쉽게 만나보세요!