글 목록으로 이동

봄가을 블로그

기술2024년 11월 10일--views

타입스크립트 국룰 Validation 라이브러리 Zod에 대해 알아보자

TypeScript에서 자주 사용되는 검증 라이브러리인 Zod가 인기를 얻게 된 배경을 알아보고, 간단한 사용법도 알아봅니다.

검증하는 사진
Photo: Unsplash from Kelly Sikkema

Validation이 필요한 이유와 Zod의 포지셔닝

자바스크립트는 브라우저나 Node.js만 있으면 아주 잘 돌아가는 스크립트 언어입니다. 자바스크립트는 어떠한 컴파일 과정을 거쳐 바이너리 형태로 있는 것이 아니라 .js 파일에 평문(Plain Text) 데이터로 들어가 있습니다. 우린 그냥 내용을 열어서 볼 수 있죠. 자바스크립트 엔진은 그때그때 코드를 돌리면서 변수에 적절한 타입을 부여합니다. 타입마다 할 수 있는 행동이 정해져 있고, 올바르지 않은 행동을 하면 에러를 일으킵니다.

const value = 10;
value(); // Uncaught TypeError: a is not a function

그러나 자바스크립트 코드를 작성하는 시점에는 변수에 어떤 값이 들어가있을지 예측하기가 힘듭니다.

function request(callback) {
callback();
}

위에서 callback이 function 타입인지 아닌지 보장할 수 없습니다. 쉽게 예측할 수 없다면 프로그래머는 발생할 수 있는 오류를 계속해서 머릿속으로 떠올리고 있어야 합니다. 머리에 과부하가 옵니다. 과부하를 줄이기 위해 코드를 방어적으로 짜게 됩니다. 점차 코드의 가독성이 떨어지고 생산성도 떨어집니다.

그래서 코드를 좀 더 예측할 수 있는 형태로 만들기 위해 타입스크립트가 등장했죠. 타입스크립트는 자바스크립트가 실제로 돌기 전에 이상한 것들을 잡아줍니다. 실제로 돌기 전이라 함은 개발할 때를 이야기합니다. 바로 VSCode나 Webstorm과 같은 에디터에서요. 브라우저나 Node.js에 코드가 실제로 실행되기 전에 우리는 미리 에러를 알고 대비할 수 있습니다.

value 는 Number 타입이고, 이 타입은 호출할 수 없는 타입입니다.
인자는 Function을 받으므로 Number를 넘길 수 없습니다.

이러한 타입스크립트의 타입 시스템은 아주 좋아 보이지만, 한계도 명백하죠. 바로 타입스크립트의 경계를 벗어나는 순간 타입 검사는 무용지물이 되어버립니다. 가장 흔한 사례라고 한다면 네트워크 통신입니다. 한 프로그램의 입장에서 외부로의 요청은 도무지 알 수 없는 영역입니다. 형식에 맞춰 요청을 보낸다고 한들 어떤 응답이 올지 알 수 없습니다. 브라우저에서 fetch 요청을 날린 후 json()으로 응답을 가져오려면 기본적으로 Promise<any>이라는 타입입니다. 응답은 무엇이든 될 수 있다는 말이지요. 타입스크립트로만 이루어진 내부 시스템끼리의 신뢰성만 보장되는 수준으로 만족해야 할까요?

json 함수의 리턴 타입은 Promise<any> 입니다.

브라우저와 서버가 하나로 통합되어 있다면 이야기가 조금은 달라질 수 있습니다. Next.js라는 프레임워크는 브라우저에서 돌아가는 React 뿐만 아니라 서버 역할까지 수행할 수 있습니다. 그래서 컴포넌트에서 Server Actions 와 같은 것을 사용하면서 실제로는 네트워크 요청이 이루어진다 해도 타입이 무사히 보존될 수 있도록 하죠. 물론 네트워크라는 본질적인 한계 때문에 전달되는 값들은 제약사항이 따르지만, 이정도만 해도 어딥니까. tRPC라는 프레임워크도 통합 환경을 비슷한 원리로 제공합니다.

이런 얘기들은 서버와 클라이언트가 통합되어 있다는 특수한 상황이고, 좀 더 일반적인 해법이 필요합니다.


외부 시스템으로부터의 데이터는 신뢰할 수 없다. 이는 사실 유구한 문제입니다. C++이나 Java 등의 정적 타입 기반 컴파일 언어에서도 마찬가지입니다. 자바스크립트에서도 같은 문제가 당연히 일찌감치 있었습니다. 즉, 외부로부터 온 데이터가 일정한 형식을 갖추고 있냐를 검증(Validation)하는 건 일반적인 패턴이고, 타입스크립트가 끌기 전부터에도 이미 수많은 검증 라이브러리들이 있었습니다. 예를 들면 2013년 8월에 1.0.0이 릴리즈된 Joi 같은 라이브러리가 있겠네요.

이러한 검증 라이브러리의 목표는 데이터가 어떤 스키마(데이터 형태)를 만족하는지 아닌지를 판별합니다. 보통은 다음처럼 두 가지 과정으로 나뉩니다.

  1. 정의: 스키마를 먼저 정합니다. 예를 들어 "age라는 필드에 number 타입이 오는 JSON 객체여야 한다"가 될 수 있습니다. 그런 규약을 가진 어떤 객체로 만듭니다. 예를 들어 Joi에서는 const schema = Joi.object({...}) 로 정의할 수 있습니다. 스키마 뿐만 아니라 에러 메시지 등도 커스텀할 수 있습니다.
  2. 검증: 정의한 스키마 객체를 들고 다니면서 parse 혹은 validate 와 같은 메서드를 호출합니다. 호출하면서 인자 값으로 미지의 데이터를 집어넣습니다. 결과는 True/False로 되거나 예외발생/발생안함 등으로 처리될 것입니다.

예시를 들었던 Joi라는 라이브러리는 유용합니다. 실제 브라우저에서 코드가 동작할 때 네트워크 응답이 특정한 스키마를 만족하는지 판별할 수는 있습니다. 하지만 코드가 돌기 전 에디터에서 해당 변수가 어떤 데이터를 가지고 있을지는 예측할 수 없습니다. 즉 타입스크립트 상의 타입은 알 수 없습니다. Joi의 검증 결과는 자바스크립트 때처럼 해당 데이터가 어떤 값을 들고 있을지 미리 알 수 없다는 거죠.


Zod라는 검증 라이브러리는 타입스크립트 지원을 아주 중요하게 생각하며, 그래서 빠르게 인기있는 라이브러리가 되었습니다. 에디터에서 보장된 타입을 제공해줄 뿐만 아니라 실제 코드가 돌아갈 때에도 데이터를 잘 검증합니다.

fetch 응답의 json() 메소드의 결과는 Promise<any>임을 기억하시나요? 이 결과를 다루는 네 가지 방법은 아래와 같습니다.

Validation 기능 (런타임)타입 정보 제공 (개발)
그냥 그대로 쓰기 (Promise<any>)XX
Type Assertion (as)XO
joiOX
zodOO

Zod의 빠른 포지셔닝은 기가 막혔고 사람들은 이를 알아보고 적극적으로 기용했죠.

빠른 속도로 인기를 얻는 Zod

정확한 라이브러리간 비교는 문서의 Comparison 부분을 참조하면 좋습니다.

개인적으로는 Zod가 쓰기 편하다는 점도 인기의 큰 요인이었다는 생각입니다.

Zod 사용법: 기본

상당히 선언적으로 깔끔하게 스키마를 작성할 수 있습니다.

import { z } from "zod";
const todoSchema = z.object({
userId: z.number(),
id: z.number(),
title: z.string(),
completed: z.boolean(),
});

위 스키마는 아래와 같은 데이터가 오는지를 검사합니다.

{
"userId": 13,
"id": 2,
"title": "오늘 블로그 글 쓰기",
"completed": false
}

실제로 데이터가 잘 검증되는지 확인해봅시다.

const res = await fetch("https://jsonplaceholder.typicode.com/todos/1");
const rawData = await res.json();
const todo = todoSchema.parse(rawData);
console.log(todo);

아래 내용이 출력되는 걸 확인할 수 있습니다.

{ userId: 1, id: 1, title: 'delectus aut autem', completed: false }

Zod 사용법: 에러 핸들링

이제는 일부러 에러를 내보겠습니다. 아래처럼 스키마를 수정해봅니다.

const todoSchema = z.object({
id: z.string(),
title: z.string(),
subtitle: z.string(),
});

변경점은 다음과 같습니다.

  • id의 타입을 number 에서 string으로 변경했습니다.
  • userIdcompleted를 뺐습니다.
  • subtitle을 추가했습니다.

스키마와 데이터가 일치하지 않아서 에러가 발생할 것입니다. 다만, 이제 에러가 어떻게 뜰지를 유의깊게 봅시다.

ZodError: [
{
"code": "invalid_type",
"expected": "string",
"received": "number",
"path": [
"id"
],
"message": "Expected string, received number"
},
{
"code": "invalid_type",
"expected": "string",
"received": "undefined",
"path": [
"subtitle"
],
"message": "Required"
}
]

나오는 에러를 통해 아래 사실을 알 수 있습니다.

  • 모든 에러를 한번에 알 수 있습니다: 에러는 여러 개 모아서 배열로 나옵니다. 이로써 내부 구현을 어느정도 알 수 있습니다. 조건들을 하나하나 검사하면서 옳지 않은 순간 예외를 발생시키는 게 아니라, 모든 Validation 작업을 다 한 다음에 실패한 것만 뽑아낸 거죠. 그래서 우리는 알맞지 않은 부분을 한 번의 parse 실행으로 모두 알 수 있습니다.
  • 잉여 필드는 검사되지 않습니다: 검사 대상 데이터엔 여전히 userIdcompleted 필드가 있음에도 불구하고 에러가 일어나지 않습니다. 불필요한 필드가 들어가있으니 에러가 일어나야 하지 않을까요? 그런데 이는 의도된 부분입니다. 우리는 보통 어떤 데이터를 사용하기 위해 그게 존재하는지, 적합한 형식을 갖추는지를 검증합니다. 다른 필드는 우리의 관심이 없습니다. 어차피 사용하지 않을 거니 있든 없든 상관이 없는 거죠. 그러나 간혹 상관이 있을 때도 있는데요, 이는 아래에서 추가로 설명합니다.

에러가 잘 구조화되어 있다 보니 Zod를 쓰는 쪽에서는 에러메시지 등을 커스터마이징 하기가 좋습니다. 이제 실제로 Medusa라는 오픈소스 시스템에서 에러를 포맷하는 함수를 만들어 쓰는데요,

https://github.com/medusajs/medusa/blob/develop/packages/core/framework/src/zod/zod-helpers.ts

위 에러는 아래와 같은 문자열로 변환됩니다.

Expected type: 'string' for field 'id', got: 'number'; Field 'subTitle' is required

파싱 전략 세 가지

위에서 기본적으로 잉여 필드는 검사되지 않는다고 했죠? 이 행동은 세 가지 파싱 전략 중 하나입니다. 파싱할 때에는 .parse 또는 .parseAsync 함수로 하는데요, 중요한 건 파싱 전략을 함수의 옵션으로 넣는 게 아니라, 스키마의 설정으로 미리 결정한다는 점입니다. 상당히 선언적이라 할 수 있습니다.

  • strip: 기본값입니다. 인식되지 않는 키를 허용하되 파싱 결과에서 인식되지 않은 키를 제거합니다.
  • strict: 엄격하게 검사합니다. .strict()로 활성화할 수 있습니다. 인식되지 않은 키가 있다면 오류를 냅니다. 예를 들어 서버에서 API의 쿼리를 Zod로 검사한다고 했을 때, 불필요한 쿼리를 에러로 처리함으로써 명확성을 확보할 수 있습니다.
  • passthrough: .passthrough()로 활성화할 수 있습니다. 인식하지 않는 키를 허용하고 입력된 데이터 그대로 출력합니다(인식되지 않는 키를 제거하지 않습니다). Raw Data를 온전히 보존할 필요가 있을 때 사용합니다.

실제 사용례 - TanStack Query와 함께 쓰기

TanStack Query는 React 쪽에서 API 호출을 편하게 해주는 라이브러리입니다. 다만 요청 행위는 직접 짜줘야 하는데요, 아래 예시에서는 axios를 사용하고 그 결과를 zod로 validation 하는 과정입니다. 리턴 타입은 TanStack Query가 알아서 만들어주니 걱정이 없습니다.

import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { z } from "zod";
const getTodoIdSchema = z.object({
userId: z.number(),
id: z.number(),
title: z.string(),
completed: z.boolean(),
});
export type Todo = z.infer<typeof getTodoIdSchema>;
export const useTodoQueryKey = "TodoQueryKey";
export const useTodoQuery = ({ todoId }: { todoId: number }) => {
return useQuery({
queryKey: [useTodoQueryKey, todoId],
queryFn: async () => {
const res = await axios.get(
`https://jsonplaceholder.typicode.com/todos/${todoId}`,
);
return getTodoIdSchema.parse(res.data);
},
});
};

useTodoQuery를 사용한 결과 그 data는 아래와 같이 타입이 잘 잡힌다는 걸 확인할 수 있습니다.

타입이 잘 잡히는 모습

참고로 z.infer는 어떤 schema의 parse 결과 타입을 뽑아내기 위한 유틸리티 타입입니다.

추가: 에러를 보기 좋게 만들어주는 zod-validation-error

위에서 Medusa 의 사례를 살펴봤는데요, 좀 더 보니까 더욱 유연하고 많은 곳에서 사용하고 있는 라이브러리가 있더라구요. 간단하게 소개합니다.

Input (from Zod)

[
{
code: "too_small",
inclusive: false,
message: "Number must be greater than 0",
minimum: 0,
path: ["id"],
type: "number",
},
{
code: "invalid_string",
message: "Invalid email",
path: ["email"],
validation: "email",
},
];

Code

import { fromError } from 'zod-validation-error';
try { ... } catch (err) {
const validationError = fromError(err);
// the error is now readable by the user
// you may print it to console
console.log(validationError.toString());
// or return it as an actual error
return validationError;
}

Output

Validation error: Number must be greater than 0 at "id"; Invalid email at "email"

마치며

Zod는 타입스크립트의 세계에서 “데이터를 검증”한다는 역할을 잘 수행해냈습니다. Zod는 사용하기 편하고 강력하며 대체제가 없다고 느껴집니다. 이런 틈새시장을 타이밍 좋게 잘 만드는 사람은 정말 천재인 것 같습니다. 천재들의 노고 콩고물을 열심히 줏어 먹읍시다~