테크

2024. 10. 25

실전! Zod와 TypeScript [2편]

실전 예제로 알아보는 타입 검증법

실전! Zod와 TypeScript [2편]실전! Zod와 TypeScript [2편]

🔗 실전! Zod와 TypeScript [1편]


이제 우리는 Zod를 이용해 기본적인 타입 검증과 값의 제약 조건을 어떻게 확인할 수 있는지 알게되었습니다. 하지만 Zod를 사용하다 보면 몇 가지 함정에 빠질 수 있는데, 지난 글에서 다룬 채팅 API 예제가 좋은 예시입니다.


const saveMessageSchema = z.object({
  chatRoomId: z.number().int().positive(),
  text: z.string().trim().min(1).max(MESSAGE_MAX_LENGTH).optional(),
  images: z.string()
    .url()
    .transform(url => {
      const domain = ALLOWED_IMAGE_DOMAINS.find(domain => url.startsWith(domain));
      if (!domain) {
        return "";
      }
      return new URL(url).pathname;
    })
    .refine(validateImagePath)
    .array()
    .nonempty()
    .max(20).optional()
}).refine(params => {
  return !params.text !== !params.images;
});


위의 스키마는 채팅 API의 데이터를 검증하는 예시입니다. 하지만 이 코드는 실제로 사용하기엔 부족합니다. 스키마로 검증한 객체의 타입이 우리가 원하는 정보를 온전히 담고 있지 않기 때문입니다. refine 함수를 통해 textimages 둘 중 하나는 있어야 하고, 동시에 존재할 수 없음을 검증했지만, refine 은 타입을 변화시키지 않으므로 여전히 타입이 불완전한 상태입니다.


{
  chatRoomId: number;
  text?: string | undefined;
  images?: string[] | undefined;
}


이 타입의 객체는 여전히 chatRoomId만 존재하거나, textimages가 동시에 존재할 가능성을 갖고 있습니다. 만약 타입 시스템의 도움 없이 유효성 검사만으로 충분하다고 생각한다면 이 상태로 사용할 수도 있겠지만, 좋은 타입이 아닙니다. 좋은 타입은 불가능한 상태를 아예 표현할 수 없도록 만들어야 합니다. 이를 위해 첫 번째로 해야 할 일은 합타입을 사용해 원하는 조합을 명시하는 것입니다.


예제 4-1. 합타입을 이용한 채팅 전송

const commonProperties = {
  chatRoomId: z.number().int().positive()
};
const saveMessageSchema = z.union([
  z.object({
    text: z.string().trim().min(1).max(MESSAGE_MAX_LENGTH)
  }).extend(commonProperties),
  z.object({
    images: z.string()
      .url()
      .transform(url => {
        const domain = ALLOWED_IMAGE_DOMAINS.find(domain => url.startsWith(domain));
        if (!domain) {
          return "";
        }
        return new URL(url).pathname;
      })
      .refine(validateImagePath)
      .array()
      .nonempty()
      .max(20)
  }).extend(commonProperties)
]);


우리가 원하는 타입은 chatRoomIdtext가 함께 있는 타입, 혹은 chatRoomIdimages가 함께 있는 타입입니다. 이런 경우를 두타입의 ‘합타입’이라고 부릅니다. Zod에서는 이를 union으로 표현합니다. union은 다양한 Zod 타입을 인자로 받아, 그 중 어느 하나라도 일치하면 검증을 통과시킵니다.

위 예제에서는 union을 사용해 우리의 요구사항을 표현했습니다. 첫 번째 타입으로 text 속성이 있는 개체를, 두 번째 타입으로 images속성이 있는 객체를 전달했습니다. 두 타입 모두에 공통적으로 필요한 chatRoomIdextend를 사용해 추가했습니다. 이렇게 하면 공통 속성을 손쉽게 관리할 수 있습니다.


이제 문제없이 모든 요구사항을 만족한 것 같지만, 사실 이 스키마에도 문제가 있습니다. union을 사용했음에도 불구하고 textimages가 동시에 있는 객체를 제대로 걸러내지 못합니다. 이번 스키마가 반환한 타입은 다음과 같습니다.


{
  chatRoomId: number;
  text: string;
} | {
  chatRoomId: number;
  images: string[];
}


겉으로는 textimages 중 하나만 있을 것 같지만, 사실 그렇지 않습니다. 그 이유는 TypeScript가 구조적 타입 시스템을 사용하기 때문입니다. 낯선 용어같지만, 알고 보면 익숙한 개념입니다.


구조적 타입 시스템은 객체가 어떤 속성을 가지고 있는지로 타입을 판단합니다. 즉, 미리 정해진 상속 관계보다는 객체가 특정 속성을 포함하고 있느냐가 더 중요합니다. 예를 들어, { chatRoomId: number; text: string; images: unknown; }라는 객체는 chatRoomIdtext 속성을 포함하고 있기 때문에 { chatRoomId: number; text: string; } 타입에도 적합하다고 판단됩니다. 흔히 말하는 ‘덕 타이핑(duck typing)’을 컴파일 타임에 한다고 이해하면 쉽습니다.


이 때문에 TypeScript에서는 {chatRoomId: number; text: string;} 타입 변수에 images가 추가된 값을 할당할 수 있습니다. Zod도 이 원칙을 따르기 때문에 {chatRoomId: number; text: string; images: string[];}라는 객체가 들어오면, {chatRoomId: number; text: string;} 타입의 조건을 만족한다고 보고 에러 없이 통과시킵니다.


결과적으로, 이번 예제의 스키마 역시 textimages가 동시에 있는 객체를 걸러내지 못합니다. 이를 해결하기 위한 방안 중 하나가 아래에 제시한 예제 4-2입니다.


예제 4-2. strict를 이용한 채팅 전송

const commonProperties = {
  chatRoomId: z.number().int().positive()
};
const saveMessageSchema = z.union([
  z.object({
    text: z.string().trim().min(1).max(MESSAGE_MAX_LENGTH)
  }).extend(commonProperties).strict(),
  z.object({
    images: z.string()
      .url()
      .transform(url => {
        const domain = ALLOWED_IMAGE_DOMAINS.find(domain => url.startsWith(domain));
          if (!domain) {
            return "";
          }
          return new URL(url).pathname;
        })
      .refine(validateImagePath)
      .array()
      .nonempty()
      .max(20)
  }).extend(commonProperties).strict()
]);


TypeScript의 구조적 타입 시스템은 유연한 코드를 작성하는 데 도움을 주지만, 이번처럼 유연성이 오히려 문제가 될 수 있는 상황도 있습니다. 우리가 원하는 것은 정확한 타입입니다. 즉, textimages 속성 중 하나만 있어야 하고, 나머지 속성이 추가되면 에러가 발생해야 합니다.


이런 상황을 대비해 Zod 객체에서는 strict 함수를 제공합니다. 이 함수는 스키마에 정의된 속성 외에 다른 속성이 들어오면 검증에 실패하도록 만듭니다. 위 예제에서는 strict()를 사용해, textimages 중 하나만 있어야 한다는 조건을 더 엄격하게 만들었습니다.


이제 textimages가 동시에 존재하는 객체는 걸러지고, 둘 중 하나는 반드시 존재해야 합니다. 이로 인해 검증 단계뿐만 아니라 컴파일 단계에서도 제약 조건이 반영됩니다.


하지만 이 스키마에도 여전히 문제가 남아 있습니다. 그 문제는 strict 함수 때문이 아닙니다. 바로 이 스키마를 API의 파라미터 검증에 사용한다는 점입니다. API의 파라미터에는 우리가 정의한 textimages 외에도 다른 파라미터들이 종종 추가될 수 있습니다. 이런 파라미터들은 API의 동작을 처리하는 전이나 후에 사용되며, API 코드에서는 그 필요성을 알기 어렵습니다.


이런 파라미터들은 API의 처리와는 별개로 사용될 수 있고, API 코드에서는 그 필요성을 알기 어렵습니다. 따라서, API 내에서는 실제로 필요한 파라미터 외에 추가적인 파라미터가 들어오는 것을 완전히 배제할 수 없습니다.


예제 4-3: 원하지 않는 파라미터 명시

앞서 사용한 타입이 문제가 되었던 이유는 TypeScript의 구조적 서브타이핑 때문입니다. TypeScript는 타입의 구조만 같으면 자식 타입으로 간주하여, 원하지 않는 프로퍼티가 추가된 경우에도 통과시키는 유연성을 가지고 있습니다. 이로 인한 부작용을 막기 위해, 비누랩스에서는 다음과 같이 Zod 스키마를 작성합니다.


const commonProperties = {
  chatRoomId: z.number().int().positive()
};
const saveMessageSchema = z.union([
  z.object({
    text: z.string().trim().min(1).max(MESSAGE_MAX_LENGTH),
    images: z.undefined().optional()
  }).extend(commonProperties),
  z.object({
    text: z.undefined().optional(),
    images: z.string()
      .url()
      .transform(url => {
        const domain = ALLOWED_IMAGE_DOMAINS.find(domain => url.startsWith(domain));
        if (!domain) {
          return "";
        }
        return new URL(url).pathname;
      })
      .refine(validateImagePath)
      .array()
      .nonempty()
      .max(20)
  }).extend(commonProperties)
]);


합타입을 만들 때, 각 객체에서 존재하면 안되는 특정 프로퍼티를 undefined타입으로 명시하여, 불필요한 속성이 들어오지 않도록 강제했습니다. 이렇게 하면 우리가 예상하지 못한 값이 들어오는 상황을 방지할 수 있습니다.


다만, 이 방식은 TypeScript의 스타일과는 조금 다릅니다. TypeScript는 이런 경우에 사용할 수 있도록 bottom typenever타입을 제공합니다. bottom type은 모든 타입의 하위 타입으로, 다른 타입이 자식 타입이 될 수 없습니다. TypeScript에서 완벽한 타입은 아래와 같은 형태입니다.


{
  text: string;
  images?: never;
} | {
  text?: never;
  images?: string[];
}


그러나 아쉽게도 Zod에서는 never타입을 이러한 방식으로 직접 표현할 수 없습니다. Zod의 optional함수는 undefined 타입을 포함하기 때문에, images: z.never().optional() 을 사용해도 이는images?: never | undefined 을 의미합니다. never 타입은 undefined 의 자식 타입이므로, 결국 images?: undefined 와 동일한 의미를 갖게 됩니다. 따라서 undefined 함수를 사용하는 것과 크게 다르지 않으며, 취향에 따라 편리한 쪽을 선택하면 됩니다.


예제 5: 글 검색

const findArticleSchema = z.object({
  articleId: z.coerce.number().int().positive(),
  includeArticle: z.coerce.boolean().default(true),
  includeComment: z.coerce.boolean().default(true),
  commentLimit: z.coerce.number().int().positive().default(50),
  commentOffset: z.coerce.number().int().nonnegative().default(0)
});

const params = findArticleSchema.parse({...req.query, ...req.body});


이번 예제는 글 검색 API의 파라미터를 검증하는 코드입니다. 이 API는 POST 메소드와 GET 메소드를 모두 지원하는데, POST 메소드에서는 body에 파라미터를 담아 요청하는 반면, GET 메소드에서는 쿼리 파라미터를 이용해 데이터를 전달합니다. 코드에서 요청 데이터를 검증할 때, 두 가지 경우를 모두 처리하기 위해 body와 쿼리 파라미터를 합쳐서 parse 함수에 넘겨주게 됩니다.


문제는 쿼리 파라미터는 타입 정보가 없어서, 숫자나 불리언처럼 명확한 값이어야 할 데이터도 모두 문자열 형태로 들어온다는 것입니다. 예를 들어, /find/article?articleId=3&includeArticle= 라는 API가 호출됐다면, 쿼리 파라미터를 읽을 때 articleId3이라는 숫자 타입이 아니라 "3" 이라는 문자열이 전달되고, includeArticle 는 불리언 타입이 아니라 빈 문자열 ""로 전달됩니다.


이를 해결하기 위해, coerce를 사용해 입력된 값을 검증하기 전에 원하는 타입으로 변환합니다. z.coerce.number()은 문자열 "3" 을 숫자 타입 3 으로 변환하며, z.coerce.boolean()은 빈 문자열 "" 을 불리언 타입 false 로 바꿔줍니다. 하지만 여기에는 주의해야할 점이 있습니다.


z.coerce.boolean()"false"라는 문자열을 false로 반환하지 않습니다. 대신, 비어 있는 값이나 다른 falsy 값만 false로 처리합니다. /find/article?articleId=3&includeComment=includeCommentfalse로 변환되지만, /find/article?articleId=3&includeComment=0 이나 /find/article?articleId=3&includeComment=falseincludeCommenttrue로 처리됩니다. 만약 쿼리 파라미터에서 false0도 거짓으로 처리하고 싶다면, 아래와 같은 스키마를 사용해야 합니다.


z.boolean().or(
  z.string()
    .transform(value => !["0", "false", ""].includes(value))
)


예제 6: 글 검색 결과

const articleResponseSchema = z.object({
  writer: z.number().positive().int().optional(),
  title: z.string().min(1).optional(),
  text: z.string().min(1).max(10000),
  isWriterAnonymous: z.boolean(),
  attachements: z.object({
    id: z.number().positive().int(),
    original: z.string().url(),
    thumbnail: z.string().url()
  }).strict().array().optional(),
  comments: z.union([
    z.object({
      id: z.number().positive().int(),
      text: z.string().min(1).optional(),
      parentId: z.number().positive().int().optional(),
      writer: z.number().positive().int().optional(),
      isWriterAnonymous: z.boolean(),
      isDeleted: z.literal(false)
    }).strict(),
    z.object({
      id: z.number().positive().int(),
      parentId: z.undefined(),
      isDeleted: z.literal(true)
    }).strict()
  ]).array().optional()
}).strict();

const validateResult = articleResponseSchema.safeParse(result);
if (!validateResult.success) {
  reportBug(validateResult.error);
}
/*
{
  writer?: number | undefined;
  title?: string | undefined;
  text: string;
  isWriterAnonymous: boolean;
  attachements?: {id: number; original: string; thumbnail: string}[] | undefined;
  comments?: (
    | {id: number; text?: string | undefined; parentId?: number | undefined; writer?: number | undefined; isWriterAnonymous: boolean; isDeleted: false}
    | {id: number; parentId?: undefined; isDeleted: true}
  )[] | undefined;
}
*/
return result;


지금까지의 예제에서는 API 요청(Request) 단계에서 들어오는 데이터를 검증하고 타입을 부여하는 방법에 대해 설명했습니다. 하지만 이번 예제는 API 응답(Response) 데이터를 검증하는 데 사용됩니다.


서버로 들어오는 API의 인자뿐만 아니라, 서버에서 나가는 응답 결과도 API의 스펙에 포함됩니다. 따라서 응답도 정확히 정의되고 검증되어야 합니다. 예제 6에서 사용된 스키마는 API 응답 결과를 검증하여 예상치 못한 데이터 구조나 타입으로 인해 발생할 수 있는 오류를 방지합니다. 이를 통해 클라이언트는 항상 일관된 형태의 데이터를 받을 수 있습니다. 그러나 인자를 검증할 때와는 두 가지 중요한 차이점이 있습니다.


첫 번째는 모든 객체 타입에서 strict를 사용한다는 점입니다. 앞서 말한 것 처럼 API는 정의된 인자 외에 다른 인자도 수용할 수 있습니다. 이 때문에 인자 검증에서는 strict 함수를 사용할 수 없습니다. 그러나 응답 결과를 정의할 때는 항상 strict 함수를 사용하여, 예상치 못한 추가 속성이 포함되지 않도록 보장합니다. 응답 결과는 항상 명확하게 정의된 값만 반환해야 하며, 그 외의 값은 클라이언트에게 전달되어서는 안 됩니다.


두 번째는 응답 검증에 parse 함수 대신 safeParse함수를 사용한다는 점입니다. safeParse 함수는 예외를 발생시키지 않고, 데이터 검증 결과를 확인할 수 있게 해줍니다. 만약 입력 데이터가 정해진 스키마와 다르면, safeParse는 예외를 발생시키는 대신 검증 실패에 대한 결과와 그 이유를 반환합니다.


이렇게 함으로써, 응답 타입 검증에 실패하더라도 API의 오류로 간주되지 않고, 내부 트래킹 시스템에 보고만 하는 방식으로 처리할 수 있습니다. 이는 API가 실행 중에 비가역적인 변화를 일으킬 수 없기 때문에, 단순히 검증 실패를 API의 실패로 간주할 수 없기 때문입니다. 따라서, 이런 경우에는 내부 에러 트래킹 시스템을 통해 문제를 모니터링하고, 신속하게 수정될 수 있도록 하고 있습니다.



지금까지 비누랩스가 Zod를 사용하여 API 인자를 검증하고 TypeScript와 연결하는 방법을 알아보았습니다. Zod를 활용하면 코드와 외부 세계에서 발생할 수 있는 오류를 효과적으로 줄이고, 코드의 안전성과 유지보수성을 향상시킬 수 있습니다. 또한, Zod의 설계는 복잡한 검증 로직을 처리하는데 적합하며 API 인자를 검증하는 것 뿐 아니라 다양한 용도로 활용할 수 있습니다.


Zod의 동적 타입 검사는 TypeScript의 정적 타입 시스템과 부드럽게 연결되기 때문에, 개발자들은 다른 고민 없이 타입에만 집중할 수 있습니다. 만약 여러분이 TypeScript를 사용하여 서버를 만들 일이 있다면 적극적으로 도입해보기를 권장합니다. 이를 통해 더욱 신뢰할 수 있는 코드베이스를 구축하고, 개발과정에서 생길 수 있는 많은 오류를 미연에 방지할 수 있을 것입니다.


Written by 김슬기