Zod를 활용해서 선언적으로 유효성 체크를 해보자

이희제·2024년 9월 22일
post-thumbnail

Zod 도입 이유

업무를 진행하면서 유저가 데이터 입력 후 저장했을 때 각 데이터에 대해 유효성 체크를 하도록 개발을 했다.

단순히 값이 있고 없음을 판단하는 것이 아니라 여러 조건이 붙어 유저 입력 데이터에 대해 유효한 지 체크가 필요했는데 그러다 보니 코드가 복잡해졌다.

Zod는 사용해서 스키마를 생성하고 해당 스키마 내에서 유효성 검증 로직을 구현하여 로직을 분리시켰다.

이로 인해 코드의 복잡도가 줄었는데 어떻게 적용했고 어떤 문제가 있었는지 얘기해보고자 한다.

해당 글은 내가 주로 사용했던 Zod의 메서드에 대한 내용이 담겨져있다. 더 많은 메서드는 공식 문서를 통해 확인할 수 있다.

내가 Zod를 도입한 가장 큰 이유는 기존 명령형적인 유효성 체크 로직은 선언적으로 변경하여 가독성이 높아진다.

또한, 스키마를 구성하고 따로 분리함으로써 기존 함수 내에서의 코드의 복잡도가 줄었다. (함수 내부가 깔끔해졌다)

복잡한 유효성 체크 로직을 더 쉽게 파악할 수 있어 유지보수가 용이해 지고 유효성 체크할 데이터에 대해서도 쉽게 파악할 수 있게 되었다.

공통 스키마를 추출하고 재사용, 조합할 수 있는 이점도 있었다.


Zod란?

타입스크립트는 정적 타입 시스템을 통해 타입 오류를 검출한다. 하지만 API 응답이나 사용자 입력(폼)과 같은 외부 데이터는 정적 타입 검증이 불가능하므로, 런타임에서 이를 검증해야 한다.

Zod는 외부에서 들어오는 데이터를 런타임에서 효과적으로 검증할 수 있는 라이브러리이다.

공식문서를 보면 다음과 같이 소개 글이 있다.

TypeScript-first schema validation with static type inference

해석하면 정적 타입 추론과 함께 타입스크립트 우선 스키마 유효성 검증을 해주는 라이브러리이다.

여기서 정적 타입 추론z.infer<typeof UserSchema>를 사용하여 스키마로부터 TypeScript 타입을 자동으로 추론하는 것이고 스키마 유효성 검증schema.parse()를 통해 런타임에 데이터를 검증하는 것이다.

import { z } from "zod";

// 스키마 정의
const UserSchema = z.object({
  username: z.string().min(3).max(20),
  email: z.string().email(),
  age: z.number().min(18).optional()
});

// 위의 스키마로부터 TypeScript 타입 추론 가능
type User = z.infer<typeof UserSchema>;

// 스키마 기반 데이터 유효성 검증
function createUser(data: unknown): User {
  return UserSchema.parse(data);
}

// 사용 예시
try {
  const user = createUser({
    username: "kim",
    email: "kim@example.com",
    age: 25
  });
  console.log(user.username); // TypeScript가 'user'의 타입을 정확히 알고 있음
} catch (error) {
  console.error("Invalid data:", error);
}

Zod 적용하기

기존 코드와 Zod를 적용하고 난 후의 코드를 비교하며 보자.

기존 유효성 체크

기존에 데이터 저장 전에 유효성 체크를 위해 내가 구성했던 예시 코드이다.

switch (contentsType) {
  case CONTENTS_TYPE.IMAGE:
    if (!contentsImgPath) {
      curValidationMessage.push("Image path is required. Please provide a valid path.")
    } else {
      if (contentsImgPath.includes('http://')) {
        curValidationMessage.push("Image path must not contain insecure 'http://' links. Please use 'https://' instead.")
      }
      if (!isValidImageExtension(contentsImgPath)) {
        curValidationMessage.push("Invalid file format. Please use a supported image file format (e.g., .jpg, .png, .gif).")
      }
      if (contentsImgPath.length > INPUT_LIMIT.IMAGE) {
        curValidationMessage.push(`Image path exceeds the maximum allowed length of ${INPUT_LIMIT.IMAGE} characters. Please use a shorter file path.`)
      }
    }
    break

  case CONTENTS_TYPE.EDITOR:
    if (!contentsEditor) {
      curValidationMessage.push("Content is required. Please provide some content.")
    } else {
      if (contentsEditor.includes('http://')) {
        isContentsHaveHttp = true
      }
      if (contentsEditor.includes('not allowed domain')) {
        curValidationMessage.push("Content contains a not allowed domain. Please remove or replace it.")
      }
      if (contentsEditor.includes('not allowed domain2')) {
        curValidationMessage.push("Content contains not allowed domain2. Please remove or replace it.")
      }
      if (getByteLength(contentsEditor) > INPUT_LIMIT.EDITOR) {
        curValidationMessage.push(`Content exceeds the maximum allowed size of ${INPUT_LIMIT.EDITOR} bytes. Please reduce the content length.`)
      }
    }
    break

  case CONTENTS_TYPE.URL:
    if (!urlPath) {
      curValidationMessage.push("URL path is required. Please provide a valid API path.")
    }
    if (getByteLength(contentsEditor) > INPUT_LIMIT.EDITOR) {
      curValidationMessage.push(`Content exceeds the maximum allowed size of ${INPUT_LIMIT.EDITOR} bytes. Please reduce the content length.`)
    }
    break

  default:
    // Handle default case if necessary
}

위 로직이 저장 전에 수행되는 유효성 체크 코드인데 각 타입별로 유효성 체크 로직이 다르고 보여줘야 하는 메시지가 다르기 때문에 코드량이 생각보다 많다.

다른 비즈니스 로직과 같은 파일 내에 있다보니 복잡도가 증가했고 유효성 체크 로직이 한눈에 파악되지 않는다.

다음으로 Zod를 사용해서 스키마를 생성하고 이를 기반으로 한 유효성 체크 코드를 보자.

Zod 스키마 기반 유효성 체크

스키마를 생성하고 이를 기반으로 좀 더 선언적으로 유저 입력에 대한 데이터를 런타임에 체크할 수 있다.

명확하게 각 데이터 속성이 어떤 타입을 가지고 있어야 하고 어떤 유효성 체크 로직이 적용되는 지 확인할 수 있다.

const createValidationMessage = (dataProperty: string) => ({
  required_error: `The ${dataProperty} field is required. Please provide a valid value.`,
  invalid_type_error: `The ${dataProperty} field must be a valid Type.`
})

우선 런타임에 들어오는 데이터에 대해 아예 값이 없는 경우(undefined)나 타입에 맞지 않는 경우에 걸리게 되는 커스텀 에러 메시지를 생성하는 함수이다.

const baseContentsSchema = z.object({
  lang: z.string(),
  title: z.string().nullish() // string | null | undefined
})

가장 작은 단위의 스키마로 베이스가 되는 스키마이다. contents 데이터는 기본적으로 lang 값이 있어야 하고 title은 없어도 되고 있어도 된다.

.nullish를 사용했는데 비슷한 스키마 메서드는 다음과 같다.

  • .optional - undefined를 허용한다. 즉, 데이터 속성 자체가 없어도 된다.
  • .nullable - null을 허용한다.

const createImageTypeSchema = () => baseContentsSchema.extend({
  contentsType: z.literal(CONTENTS_TYPE.IMAGE),
  contentsImgPath: z
    .string(createValidationMessage('IMAGE'))
    .min(1, { message: "Image path cannot be empty. Please provide a valid path." })
  	.max(200, { message: "Image path is too long. It must not exceed 200 characters." })
    ..superRefine((value, ctx) => {
      if (value === '') return; // 빈 문자열이면 추가 검증을 수행하지 않음

      if (value.includes('http://')) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          message: 'The image path must not contain insecure "http://" links. Please use "https://" instead.'
        });
      }
      if (!isValidImageExtension(value)) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          message: 'The file format is not supported. Please use a valid image file format (e.g., .jpg, .png, .gif).'
        });
      }
});

contents 가 이미지 타입일 경우 적용되는 스키마이다. contentsImgPath가 빈 문자열이 아닐 경우에만 확장자, 프로토콜 유효성 체크를 타도록 했다.

.superRefine 내에 value 가 빈 문자열이면 유효성 체크 로직을 타지 못하도록 한 것을 확인할 수 있다. (참고)

여기서 .superRefine은 스키마에 사용자 정의 유효성 검사 로직을 추가하는 데 사용되는 메서드이다. .refine.superRefine의 문법적 설탕이다.

.superRefine를 사용하면 여러 유효성 체크 로직을 한번에 처리할 수 있다. 추가로 특정 유효성 체크 로직에 걸렸을 경우 그 다음 로직을 타지 않게 할 수 있다.(Abort Early)

그리고 앞서 기본 스키마인 baseContentsSchema.extend 메서드를 통해 스키마를 확장했다. 비슷한 메서드인 .merge도 있다.

  • .extend - 기존 스키마에 데이터 속성(fields)를 추가할 수 있다.

  • A.merge(B) - 2개의 스키마를 합친다. 공통된 데이터 속성이 있다면 B의 값이 오버라이드 된다.


const createEditorContentsSchema = (contentType: 'CONTENT' | 'URL') => {
  const baseSchema = contentType === 'CONTENT'
    ? z.string(createValidationMessage(contentType)).min(1, `The ${contentType} field cannot be empty. Please provide some content.`)
    : z.string().nullable();

  return baseSchema
    .refine((content) => !content?.includes('http://'), {
      message: `The ${contentType} must not contain insecure "http://" links. Please use "https://" instead.`
    })
    .refine((content) => !content?.includes('now allowed domain'), {
      message: `The ${contentType} must not contain references to "now allowed domain". Please remove or replace this domain.`
    })
    .refine((content) => !content?.includes('now allowed domain2'), {
      message: `The ${contentType} must not contain "now allowed domain" links.`
    })
    .refine((content) => !content || getByteLength(content) <= INPUT_LIMIT.EDITOR, {
      message: `The ${contentType} content exceeds the maximum allowed size. Please reduce the content to ${INPUT_LIMIT.EDITOR} bytes or less.`
    });
}

createEditorContentsSchemaEditor, Url 타입일 경우 공통적으로 적용되는 스키마이다.

다만 타입에 따라 분기처리가 필요하다. Url 타입일 경우는 에디터 내용이 null이여도 된다.

현재는 해당 스키마 내에서 2개의 타입에 따라 분기 처리를 해주고 있지만, 내용 타입이 추후에 점차 늘어나고 해당 스키마에 로직 추가가 필요하다면 아예 각 타입별로 스키마를 분리하는 게 좋을 것 같다.


const createEditorTypeSchema = () => baseContentsSchema.extend({
  contentsType: z.literal(CONTENTS_TYPE.EDITOR),
  contentsEditor: createEditorContentsSchema('CONTENT')
})

Editor 타입일 경우의 스키마이다. Url 타입일 경우에 contentsEditor 속성이 존재할 수도 있기 때문에 createEditorContentsSchema를 통해 유효성 체크 로직을 공통화시켰다.


const createUrlTypeSchema = () => baseContentsSchema.extend({
  contentsType: z.literal(CONTENTS_TYPE.URL),
  urlPath: z
    .string(createValidationMessage('URL'))
    .min(1, 'The URL path cannot be empty. Please provide a valid URL path.'),
  contentsEditor: createEditorContentsSchema('URL')
})

Url 타입일 경우의 스키마이다.


const contentsSchema = () => {
  return z.discriminatedUnion('contentsType', [
    createImageTypeSchema(),
    createEditorTypeSchema(),
    createUrlTypeSchema()
  ])
}

export default contentsSchema

마지막으로 contents 데이터에 대한 스키마이다. 여기서 contentsType 별로 각각 다른 스키마가 적용되도록 z.discriminatedUnion를 사용했다.

z.discriminatedUnion은 식별자 키(discriminator key)를 사용하여 적절한 스키마를 선택할 수 있게 해준다.

타입스크립트에서 구별된 유니온 타입과 동일하다고 보면 된다.


Zod 관련 트러블 슈팅

input에 required 옵션을 주었는데 빈 문자열도 통과가 되어 유저 입력 사항이 빈 값으로 저장되는 문제가 있었다.

아래와 같이 imageTypeSchema 스키마를 생성했다.

const imageTypeSchema = baseContentsSchema.extend({
    type: z.literal(CONTENTS_TYPE.IMAGE),
    imgPath: z
      .string({
        required_error: "Image path is required. Please provide a valid path.",
        invalid_type_error: "Image path must be a string. Please enter a properly formatted path."
      })
 })

required_error를 통해서 값이 없을 경우 에러 메시지가 리턴되도록 구현을 했었는데 빈 문자열일 때 해당 required_error에 걸리지 않았다.

아래 이슈 글을 보고 내가 "required"에 대해 잘못 이해한 것을 알게 되었다.

https://github.com/colinhacks/zod/issues/2466

Zod는 TypeScript의 정적 타입 검사를 런타임에서도 수행할 수 있게 해주는 도구이다. 그리고 z.string()은 TypeScript의 string 타입과 일치하는 값을 의미한다.

즉 빈 문자열인 "" 도 string 타입이기 때문에 유효성 검사에 정상 통과되는 것이다.

위의 스키마에서는 required_errorinvalid_type_error에 걸려 내가 커스텀한 메시지가 리턴되는 경우는 다음과 같다.

1. null이 입력될 경우

  • 노출되는 메시지: "Image path must be a string. Please enter a properly formatted path."
  • 설명: null은 JavaScript에서 객체로 취급되므로, Zod는 이를 문자열이 아닌 타입으로 간주한다. 따라서 invalid_type_error 메시지가 표시된다.

2. undefined가 입력될 경우

  • 노출되는 메시지: "Image path is required. Please provide a valid path."
  • 설명: 런타임에서 imgPath 속성 자체가 없을 경우 즉, undefined일 경우이기 때문에 Zod는 이를 필수 필드가 누락된 것으로 해석하여required_error 메시지가 노출된다.

imgPath 값을 필수 입력 항목이기 때문에 빈 문자열을 허용하면 안 된다. 따라서 다음과 같이 스키마를 수정했다.

const imageTypeSchema = baseContentsSchema.extend({
    type: z.literal(CONTENTS_TYPE.IMAGE),
    imgPath: z
      .string({
        required_error: "Image path is required. Please provide a valid path.",
        invalid_type_error: "Image path must be a string. Please enter a properly formatted path."
      })
  	  .trim()
      .min(1, { message: "Image path cannot be empty. Please provide a valid path." })
  	  .max(200, { message: "Image path is too long. It must not exceed 200 characters." })
 })

유저는 최소 한 글자 이상 입력해야 하고 최대 200자까지 입력이 가능하도록 적용했다.


적용 후 느낀점

Zod를 적용하면서 가장 좋았던 점은 따로 스키마를 생성하여 유효성 체크 로직을 외부로 분리할 수 있는 것이다.

스키마를 생성하니 데이터 구조를 확실하게 파악할 수도 있었고 유저의 입력에 대해 런타임에서도 확실히 타입 체킹도 가능하다.

기존에는 유효성 체크 로직이 공통화 되어 있지 않았는데 Zod의 스키마 기반으로 구성하다보니 공통된 부분을 추출할 수 있어서 코드 중복을 줄일 수 있었다.

기존 코드보다 좀 더 명확해졌으며, 추후 유지보수할 경우나 로직 추가될 경우에도 금방 파악할 수 있을 것 같다.

profile
그냥 하자

0개의 댓글