테크

2024. 10. 24

실전! Zod와 TypeScript [1편]

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

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

비누랩스에서는 TypeScript를 사용해 서버를 작성하고 있습니다. TypeScript는 JavaScript에 컴파일 타임에 알 수 있는 타입 정보를 추가한 언어입니다. 코드 작성 시점에 타입 오류를 잡아낼 수 있어, 코드의 안전성과 유지보수성을 크게 높여줍니다. 이러한 기능 덕분에 TypeScript는 특히 대규모 프로젝트에서 개발자들이 더 편리하고 효율적으로 작업할 수 있도록 돕는 강력한 도구로 자리 잡았습니다.


하지만 TypeScript의 타입 안전성은 코드 내부에서만 유효합니다. 예를 들어, 외부에서 들어오는 데이터(예: 클라이언트가 서버에 요청하면서 전달하는 API 인자나 사용자 입력 데이터)는 컴파일 단계에서 TypeScript가 그 타입을 알 수 없습니다. 만약 외부에서 받은 데이터가 예상한 구조와 다르다면, 런타임에서 오류가 발생할 수 있는 겁니다. 그래서 실제 데이터가 예상한 타입에 맞는지 런타임에서 검증할 수 있는 도구가 필요합니다.


비누랩스 개발팀은 이 문제를 해결하기 위해 Zod 라이브러리를 사용하고 있습니다. Zod는 스키마(Schema)라고 불리는 검증 규칙을 간단하게 작성할 수 있는 도구입니다. 이 스키마를 통해 특정 데이터가 우리가 기대한 타입에 맞는지 검사할 수 있습니다. Zod를 사용하면, 런타임에서 API 인자의 타입을 검증하면서 TypeScript의 정적 타입 시스템과 자연스럽게 연결할 수 있습니다. 이를 통해 개발자는 타입 안전성을 유지하면서도 외부 데이터의 유효성을 보장할 수 있습니다. 한마디로, TypeScript와 Zod를 함께 사용하면 데이터의 신뢰성을 높이고 오류를 줄여 더 안전한 서버를 만들 수 있습니다.


이번 글에서는 에브리타임 서비스의 API를 예시로, Zod를 사용해 API 인자를 검증하고 타입 안전성을 유지하는 방법을 설명하겠습니다.


※ 아래 예제들은 독자들의 이해를 위해 일부 변형했으며, 실제 사용하는 코드와 다를 수 있습니다.



예제 1. 댓글 등록

import { z } from "zod";

const saveCommentSchema = z.object({
  articleId: z.number().int().positive(),
  text: z.string().trim().min(1).max(10_000)
});

const params = saveCommentSchema.parse(req.body);


이 코드는 댓글을 등록하는 API의 파라미터를 검증하기 위한 Zod 스키마를 정의하고 있습니다. saveCommentSchema라는 이름으로 스키마를 선언해 사용했습니다. API의 파라미터가 선언한 타입과 다르면 parse 함수가 에러를 발생시키지만, 검증이 성공하면 params{articleId: number; text: string} 타입의 객체가 되기 때문에 이후에는 타입에 대한 걱정 없이 params를 사용할 수 있습니다.


스키마를 선언하기 위해 가장 먼저 해야 할 일은 z.object 를 사용해 스키마가 객체임을 선언하는 것입니다. API는 일반적으로 여러 개의 파라미터를 받기 때문입니다. 이번 API의 파라미터로는 articleId, text가 있습니다. 여기서 articleId는 숫자 타입이고, text는 문자열 타입입니다. 또한, JavaScript의 기초 타입인 불리언, 심볼, null, undefined는 물론, 자주 사용되는 Date 타입이나 TypeScript의 unknown, never, any 같은 특수한 타입도 선언할 수 있습니다.


Zod는 단순히 데이터 타입을 추가하는 기능을 넘어, 입력 데이터의 정확성과 유효성을 보장하는 여러 가지 메서드를 제공합니다. 예를 들어, articleId는 숫자 타입으로 선언할 때 .int().positive()를 추가하여 이 값이 항상 양의 정수임을 보장합니다. 이로 인해 params.articleId의 타입은 여전히 숫자이지만, Zod의 parse 함수가 음수나 소수가 들어오면 에러를 발생시키므로, 개발자는 항상 articleId가 양의 정수라는 것을 확신할 수 있습니다. 이 외에도 양수를 나타내는 positive 외에, negative, nonnegative, nonpositive와 같은 메서드, 그리고 임의의 범위를 지정할 수 있는 lt, lte, gt, gte와 같은 메서드도 제공합니다.


text 프로퍼티 역시 비슷하게 처리됩니다. z.string()을 사용하여 문자열 타입으로 선언된 text.min(1).max(10_000)을 통해 최소 길이와 최대 길이를 검증합니다. 이때, 길이를 검증하는 대신 원하는 길이를 직접 지정할 수도 있습니다.


z.string().trim().min(1).parse(" "); // Error
z.string().min(1).trim().parse(" "); // OK


text프로퍼티에는 articleId와 다른 점이 하나 있습니다. 바로 .trim()입니다. trim은 문자열 앞뒤 공백을 제거하는 함수로, 검증만 수행하는 int, positive, min 등의 메서드와는 달리 실제 값을 변형합니다. 이때 주의할 점은 trim의 위치입니다. 위 코드를 보면, 첫 번째 문장에서는 공백을 지우고 길이를 검증하므로 parse 함수가 에러를 발생시키고, 반면 두 번째 문장에서는 길이를 먼저 검사한 후 공백을 제거하므로 정상적으로 빈 문자열을 반환합니다. 이처럼 Zod 함수는 호출 순서에 따라 실행되기 때문에, 메서드의 위치가 중요합니다.



예제 2. 프로필 업데이트

const updateUserProfileSchema = z.object({
  profileImage: z.string().url()
    .startsWith("<https://profile.everytime.kr/>")
    .endsWith(".jpg")
    .nullable()
    .optional(),
  email: z.string().email().optional(),
  nickname: z.string().refine(validateNickname).optional()
});


두 번째 예제는 사용자의 프로필을 업데이트하는 API입니다. 이 API는 프로필 이미지, 이메일, 닉네임 세 가지를 업데이트할 수 있습니다. 여기서 핵심은 ‘업데이트하고 싶은 것만’ 넘기면 된다는 겁니다. 아무 값도 넘기지 않는다면 기존 값 그대로 유지됩니다. 이메일과 닉네임은 삭제라는 개념이 없지만, 프로필 이미지는 다릅니다. 만약 프로필 사진을 삭제하고 기본 이미지로 되돌리고 싶다면, 명시적으로 null을 넘겨주면 됩니다.


스키마는 profileImage, email, nickname이라는 세 가지 문자열을 정의합니다. 중요한 점은, 이 프로퍼티들의 마지막에는 .optional() 함수가 붙어 있다는 겁니다. 이 파라미터들이 API의 필수 요소가 아니기 때문에, 말 그대로 ‘존재할 수도 있고, 존재하지 않을 수도 있다’는 뜻입니다. 다만, null을 허용하려면 .nullable() 를 써줘야 합니다. 예제로 사용한 위 코드처럼 .nullable().optional()을 같이 쓰고 싶다면, 간단하게 .nullish()를 쓸 수도 있습니다.


여기서 주의해야 할 점이 있습니다. Zod의 optional은 TypeScript의 Optional Property와 같은 의미가 아닙니다. TypeScript에서는 옵션 프로퍼티가 ‘이 프로퍼티가 있을 수도 있고, 없을 수도 있다’는 뜻이지만, Zod에서는 명시적으로 undefined 값을 받는 상황까지 포함합니다.

위 스키마로 파싱된 객체의 타입은 아래와 같습니다.


{
  profileImage?: string | null | undefined;
  email?: string | undefined;
  nickname?: string | undefined;
}


{nickname: "에브리타임"}{email: "[user@everytime.kr]()", profileImage: null} 같은 값은 API에 전달할 수 있지만, {nickname: null}은 전달이 안됩니다. 한 번 설정한 닉네임은 지울 수 없지만, 프로필 이미지는 삭제할 수 있는 이유입니다.


다시 두 번째 예제로 돌아가서 profileImageemail에 각각 붙어있는 urlemail 함수에 대해 이야기해 보겠습니다. 이 함수들은 입력된 값이 URL 형식인지, 이메일 형식인지를 검사합니다. Zod는 이 외에도 IP, UUID, DateTime 등 자주 사용하는 형식을 검증을 할 수 있는 다양한 기능을 제공합니다.


그리고 profileImagestartsWithendsWith도 눈여겨볼 부분입니다. 이 함수들은 이름 그대로 특정 문자열로 시작하거나 끝나지 않으면 에러를 발생시킵니다. 예를 들어, 프로필 이미지는 “https://profile.everytime.kr/“로 시작하고 “.jpg”로 끝나야 하는데, 그렇지 않으면 규칙에 어긋나기 때문에 거부합니다. 만약 문자열 중간에 특정 단어가 들어있는지 확인하고 싶다면 includes 를, 좀 더 복잡한 패턴 검사는 regex 로 해결할 수 있습니다.


nickname프로퍼티에 사용된 refine 함수는 조금 더 복잡한 상황에서 유용하게 쓰입니다. 다른 함수를 인자로 받아서 그 함수의 결과가 falsy 값이면 검증 실패로 처리합니다. 닉네임의 길이 제한이 있다면 그 조건에 맞는지 검사할 수 있습니다. 비동기 검증이 필요한 경우에는 Promise를 반환하는 함수도 사용할 수 있습니다.


하지만 비동기 검증을 사용하는 경우에는 두 가지 주의해야 할 점이 있습니다. 첫 번째는, refine 에 전달하는 함수가 반드시 Promise를 반환해야 한다는 것입니다. 예전에는 Promise 대신 다른 라이브러리를 쓰는 경우도 있었지만, 이제는 대부분의 시스템이 내장된 Promise를 사용합니다. 하지만 TypeScript에서는 구조적 서브타이핑을 기반으로 타입을 검증하기 때문에 겉모습만 Promise처럼 보이더라도 실제로는 그렇지 않을 수 있습니다. 그래서 Zod에서는 진짜 Promise인지 확인하고, 그렇지 않으면 검증을 제대로 처리하지 않습니다.


두 번째는 비동기 검증을 할 때는 parse 대신parseAsync 함수를 사용해야 한다는 것입니다. parse 함수를 사용하면, 검증 결과와 상관없이 런타임에 에러가 발생합니다. 에브리타임 서버에서는 이런 실수를 방지하기 위해 비동기 검증이 필요하면 Zod의 refine 대신, 데이터를 파싱한 이후 별도로 검증하는 코드를 추가하는 방법을 원칙으로 하고 있습니다.


사실, 단일 프로퍼티에 refine 함수를 써야 하는 경우는 드뭅니다. 대부분은 정규표현식이나 Zod의 기본 함수로 충분히 검증을 할 수 있습니다. refine 함수는 여러 파라미터 간의 관계를 검사할 때 더 유용합니다. 다음 예제를 통해 자세히 알아보도록 하겠습니다.



예제 3. 비밀번호 변경

const updatePasswordSchema = z.object({
  currentPassword: z.string(),
  newPassword: z.string().refine(validatePassword)
}).refine(params => {
  return params.currentPassword !== params.newPassword
}, {
  message: "password_reused",
  path: ["newPassword"]
});


이번 예제는 비밀번호를 변경하는 API의 파라미터를 검증하는 스키마입니다. 사용자는 기존 비밀번호와 새 비밀번호를 입력해야 합니다. 기존 비밀번호는 데이터베이스와 비교해야 실제로 맞는지 확인할 수 있으므로, 스키마 단계에서는 이를 검증하지 않습니다. 또한, 기존 비밀번호는 설정 시기에 따라 규칙이 다를 수 있어 별도로 규칙을 검사하지 않습니다.


반면, 새 비밀번호는 특정 규칙을 만족해야 하며, refine 함수를 사용해 이를 검증할 수 있습니다. 길이, 특수문자 포함 여부 등을 체크합니다.


또한, 이 스키마에는 두 번째 refine 함수도 포함되어 있습니다. 이 함수는 객체 전체를 검증하며, 새 비밀번호가 기존 비밀번호와 동일하지 않은지를 확인합니다. 이를 통해 사용자가 이전 비밀번호와 동일한 비밀번호로 변경하지 못하도록 합니다. 만약 두 비밀번호가 같으면 "password_reused"라는 오류 메시지를 반환하고, newPassword 필드에서 오류가 발생했음을 알려줍니다. 이처럼 Zod의 refine 함수는 특정 필드뿐만 아니라 전체 객체에서 필드 간의 관계를 검증할 수도 있습니다. 에브리타임에서는 이러한 기능을 통해 새 비밀번호에 대한 보안 규정을 준수하고 있습니다.



예제 4. 채팅 전송

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의 파라미터를 검증하는 스키마입니다. 이 API는 특정 채팅방 ID와 함께 문자나 이미지를 전송하는 데 사용됩니다. 중요한 점은 하나의 채팅 메시지에 문자와 이미지를 동시에 보낼 수 없다는 겁니다. 그래서 마지막 refine 함수는 이를 검사합니다. chatRoomId는 양의 정수만 허용하는 필드로 메시지를 보내 채팅방을 지정하고, text는 특정 길이 제한이 있는 문자열만을 허용합니다. images는 URL 형식의 문자열이 들어간다는 것을 제외하면 모두 처음 보는 함수들입니다.


images 필드의 .array().nonempty().max(20) 부분은 이미지 배열이 최소 하나의 URL을 포함하며, 최대 20개까지 가질 수 있음을 의미합니다. 배열에 대해 min이나 length 를 사용해 최소 개수나 길이를 설정할 수도 있지만, 이 경우에는 요구사항에 따라 비어있지 않고, 20개 이하의 이미지를 담은 배열이기 때문에 nonemptymax만을 사용했습니다.


Zod에서 배열을 표기하는 방법은 두 가지가 있습니다. 첫 번째는 원소의 타입을 먼저 선언하고 그 타입을 포함하는 배열로 선언하는 방법입니다. 두 번째는 배열을 선언할 때 array 함수의 인자로 배열의 원소 타입을 지정하는 방법입니다.


z.string().array()  // string[]
z.array(z.string()) // 마찬가지로 string[]


두 방식 모두 동일한 타입을 반환하기 때문에, 개인의 취향에 따라 원하는 방식을 선택할 수 있습니다. 예를 들어, image 필드는 다음과 같이도 작성할 수 있습니다.


z.array(
  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)
).nonempty()
.max(20)


다시 네 번째 예제로 돌아가서, images 배열의 원소 타입은 URL 형식의 문자열입니다. 여기에는 transform이라는 함수가 붙어 있는데, 이 함수는 값을 받아 다른 형태로 변환하는 역할을 합니다. 이 예시에서는 URL을 입력받아 허용된 도메인에 속하는 경우에만 그 URL의 경로(path) 부분을 반환하도록 설정했습니다. 즉, 이 함수를 거치고 나면 https://allowed.domain.com/path/name 이라는 값은 /path/name 으로 반환됩니다.


이 스키마를 사용하면 에브리타임 채팅 API의 입력값을 검증할 수 있습니다. 하지만 에브리타임 코드에 이런 타입은 존재하지 않습니다. 왜냐하면 스키마가 반환하는 타입이 TypeScript 코드에서 바로 사용할 수 있을 만큼 완벽하지 않기 때문입니다.


Zod는 타입이 없는 데이터와 TypeScript의 타입 시스템을 연결해 주는 매개체입니다. TypeScript로 서버를 개발할 때 필수적인 라이브러리입니다. 하지만 Zod의 기능을 제대로 이해하고 사용하지 않으면 그 장점을 충분히 활용하기 어렵습니다. 다음 글에서는 이 스키마의 문제점과 개선방법, 그리고 Zod 사용 시 유의해야 할 점들을 이야기해 보겠습니다.


🔗 실전! Zod와 TypeScript [2편]


Written by 김슬기