본문으로 건너뛰기

Schema Validation Layer

· 약 9분

평소 API 반환값을 추론하기 위해 어떤 방식을 사용하시나요?
Schema Validation Layer 를 추가하면 메인 로직을 깔끔하게 관리할 수 있습니다. (부제: zod 나 superstruct 를 사용해보아요.)

시작

Axios 를 사용하는 유저라면 반환값으로 예상하는 타입을 get 메서드 제너릭의 첫 번째 인자로 넣는 방식을 자주 사용하실 것입니다.

예를 들면 아래와 같을 거예요.

export default async function fetchPosts() {
const { data } = await axios.get<Post>('https://jsonplaceholder.typicode.com/posts');
return data;
}

export type Post = {
userId: number;
id: number;
title: string;
body: string;
};

그러나, 이러한 방식은 문제점이 있습니다. 반환값이 Post 라고 어떻게 확신하나요?

위 예제도 그렇습니다. 사실 해당 APIPost[] 를 반환하는데 Post 라고 타이핑을 했음에도 타입 에러가 잡히지 않습니다.

이는 전혀 Type-Safe 하지 않습니다!!

Generic 으로 타입을 넣어주는 방식은 Compile Time 에서 에러가 잡히지 않기 때문에 Runtime 에서 예상치 못한 문제가 터질 수 있습니다.

Schema Validation Layer 를 추가하면 이런 문제를 해결할 수 있습니다.

Validation Layer

Validation Layer 의 요지는 두 계층 사이에서 오고가는 데이터를 검증하는 계층을 두자는 것입니다.

앞선 예시의 문제는 원격 저장소와 데이터를 주고 받는 과정에서 데이터를 검증하는 로직이 없다는 점입니다.

물론, 검증 로직을 아래와 같이 추가할 수도 있겠습니다.

remotes/post.ts
// validation
function isPosts(data: unknown): data is Posts {
return Array.isArray(data);
}

export default async function fetchPosts() {
const { data } = await axios.get<Post>('https://jsonplaceholder.typicode.com/posts');

if (!isPosts(data)) {
throw new Error();
}

return data;
}

checkData 는 data 의 Array 여부를 체크함으로써 Validation Layer 의 목표를 일부 달성하긴 합니다.

그러나 생각해보세요.

  • 검증 후 predicate 하는 것은 과연 완벽하게 Type-Safe 한가? (실제로, Array.isArray(data) 분기문 만으로 Post[] 라고 보장할 수 없습니다. data 가 Post 임을 보장할 수 없기 때문이죠.)
  • 모든 조건을 검증하기 위해선 각 조건에 대한 분기 로직은 어느 정도로 작성해야 하는가?
  • 모든 조건을 커버하였다고 가정했을 때, 요구 조건이 변경되어서 Post 인터페이스가 달라진다면?

위 문제들을 해결하려고 로직들을 일일히 수정한다고 생각해보세요. 생각만해도 갑갑합니다.

이러한 귀찮은 작업들을 Schema 를 두는 것으로 해결할 수 있습니다.

Schema Validation

NPM 엔 Schema Validation 을 위한 패키지가 정말 많이 있습니다. 링크

다양한 도구가 저마다의 장단점을 갖고 있지만, 해당 문서에선 zod 로 예시를 들도록 하겠습니다.

위 예제를 zod 로 Schema Validaition 을 하면 아래와 같이 작성할 수 있습니다.

(Method 는 다음 섹션에서 가볍게 설명할테니, 지금은 흐름만 봐주세요.)

schema/post.ts
import axios from 'axios';
import { z } from 'zod';

export type Post = z.infer<typeof Post>;
export const Post = z.object({
userId: z.number(),
id: z.number(),
title: z.string(),
body: z.string(),
});

export type Posts = z.infer<typeof Post>;
export const Posts = z.array(Post);
remotes/post.ts
/* Type Inference: 
function fetchPosts(): Promise<{
userId: number;
id: number;
title: string;
body: string;
}[]>
*/
export default async function fetchPosts() {
const { data } = await axios.get('https://jsonplaceholder.typicode.com/posts');

return Posts.parse(data);
}

만약 잘못된 Schema 로 parse 를 시도한다면, ZodError 를 throw 합니다.

export default async function fetchPosts() {
const { data } = await axios.get('https://jsonplaceholder.typicode.com/posts');

+ return Post.parse(data);
- // return z.array(Post).parse(data);
}
zodError

zod 는 schema 단에서 데이터를 다룰 수 있는 매우 많은 method 를 제공해줍니다.

그리고, infer method 를 사용하면 schema 를 기반으로 추론한 타입을 사용할 수 있습니다.

다음 섹션에선 zod schema 에 대해 간단하게 소개하고, schema method parse, safeParse, preprocess, transform 을 설명하겠습니다.

소개할 메서드 이외에도 정말 많은 유용한 method 가 있으니, 관심 있는 분들은 링크 를 방문해보세요.

Zod Schema

zod 에서 Schema 는 단순한 string 타입부터 매우 복잡한 nested object 까지 모든 데이터 타입에 통용됩니다.

z 를 import 하면 다양한 타입을 위한 method 를 확인할 수 있습니다.

대부분 직관적인 네이밍을 가지고 있어 단순한 데이터 타입은 생략하고, 몇 가지 참고할 만한 메서드를 가볍게 소개하고 넘어갈까 합니다.

.optional

  body: z.string().optional(),

다음과 같이 사용하면 body?: string 의 의미를 갖습니다.

.nullable

  body: z.string().nullable(),

다음과 같이 사용하면 body: string | null 의 의미를 갖습니다.

이 경우, body 필드가 parse 대상에 없는 경우 Error 를 던집니다.

.nullish

  body: z.string().nullish(),

다음과 같이 사용하면 body?: string | null | undefined 의 의미를 갖습니다.

위 예시는 z.string().optional().nullabe() 과 동일합니다.

z.enum

const Fish = z.enum(['Salmon', 'Tuna', 'Trout']);
console.log(Fish.enum); // => {Salmon: "Salmon", Tuna: "Tuna", Trout: "Trout"};
console.log(Fish.enum.Salmon); // => "Salmon"
console.log(Fish.options); // => ["Salmon", "Tuna", "Trout"]

Zod 는 enum 을 위한 자체적인 메서드를 지원합니다. enum 으로 생성한 값은 .enum, .options 으로 다양한 값으로 사용할 수 있습니다.

z.nativeEnum

enum FishEnum {
Salmon = 'Salmon',
Tuna = 'Tuna',
Trout = 'Trout',
}

const Fish = z.nativeEnum(FishEnum);

앞서 소개한 z.enum() 이 enum 을 정의하거나 유효성을 검증하는 가장 추천되어지는 방식이지만, 이미 존재하는 enum 을 사용해야 하는 경우도 있습니다. 이럴 경우, z.nativeEnum 을 사용하여 스키마를 정의할 수 있습니다.

z.discriminatedUnion

const thing = z
.discriminatedUnion('type', [
z.object({ type: z.literal('human'), name: z.string() }),
z.object({ type: z.literal('fish'), species: z.array(z.string()) }),
])
.parse({ type: 'human', name: '박종호' });

if (thing.type === 'human') {
console.log(thing.name); // thing: { type: "human"; name: string; }
} else {
console.log(thing.species); // thing: { type: "fish"; species: string[]; }
}

zod 는 Discriminated Union 을 위한 메서드도 제공합니다.

첫 번째 인자로 타입을 좁힐 수 있는 대상을 지정하고, 다음 인자로 ZodSchema[] 를 넣어주면 parse 된 대상은 좁혀진 타입으로 추론됩니다.

z.infer

const Post = z.object({
userId: z.number(),
id: z.number(),
title: z.string(),
body: z.string(),
});
type Post = z.infer<typeof Post>;
// type Post = {
// id: number;
// userId: number;
// title: string;
// body: string;
// }

infer 를 사용하면 만들어 둔 스키마를 기반으로 타입 추론을 해줍니다.

Zod Method

앞서 언급했다시피, 아주 유용한 메서드인 parse, safeParse, preprocess, transform 에 대해 간략히 설명하겠습니다.

.parse

앞서 계속해서 나왔던 parse 메서드입니다.

ZodSchema.parse() 의 형태로 사용하며, 만든 Schema 가 인자에 들어오는 대상을 parse 할 수 있는지 검증하고, 유효하다면, 반환되는 값이 schema 타입으로 추론되도록 합니다.

유효하지 않다면 error 를 throw 합니다.

const name: unknown = '박종호';
const 박종호 = z.string().parse(name); // Type => 박종호: string

비단 앞에서 예시로 든 API 반환값을 parsing 하는 용도 외에도 다양한 방식으로 사용할 수 있습니다!

가령, payload 에 들어갈 값 중에 id 를 걸러줘야 하고, 해당 값을 넣으면 서버에서 오류를 뱉는다고 가정해봅시다. 그렇다면 다음과 같은 방식을 사용할 수 있을 겁니다.

function fetchSomething(payload: { id: string; name: string; content: string }) {
const newPayload = Object.fromEntries(Object.entries(payload).filter(([key, value]) => key !== 'id'));

// do something...
}

그러나, payload 에 또 다른 값이 영향을 주어서 filter 해야 하는 값이 또 추가된다면 해당 로직을 재수정 해야하고, 이는 매우 귀찮은 작업입니다.

이를 parse 로 해결할 수 있습니다.

type Something = z.infer<typeof Something>;
const Something = z.object({
name: z.string();
content: z.string();
})

function fetchSomething(payload: Something) {
const newPayload = Something.parse(payload);

// do something...
}

로직이 매우 간단해지고, 다른 필드가 추가적으로 들어오더라도 안전하게 payload 를 서버에 넘겨줄 수 있습니다.

.safeParse

앞서 소개한 parse 는 인자가 parsing 을 통과하지 못하면 Error 를 throw 합니다.

그러나, error 가 throw 되는 것을 원치 않는 경우도 있을 겁니다.

예를 들어, parsing 에 실패했을 때 다른 로직을 실행할 수도, 기존 값을 수정할 수도 있을 것입니다.

이러한 상황에서 safeParse 는 아주 적합한 메서드이며, 다음과 같은 타입을 가집니다.

.safeParse(data:unknown):
{ success: true; data: T; }
| { success: false; error: ZodError; }
let name: unknown = '박종호';
console.log(z.string().safeParse(name)); // => { success: true; data: "박종호" }

name = 1;
console.log(z.string().safeParse(name)); // => { success: false; error: ZodError }

const { success, data, error } = z.string().safeParse(name);

if (!success) {
// do something!!
}

.preprocess

때때로 Schema 에 통과하기 전 값을 변형시키고 싶은 경우가 있습니다.

예를 들어, 서버에서 넘겨주는 데이터 인터페이스가 바뀌었고 하위 호환성을 위해 기존 인터페이스를 유지 시켜줘야 할 필요가 있는 경우 preprocess 를 유용하게 사용할 수 있습니다.

const PostV2 = z.preprocess(
(input: any) => {
input.subtitle = input.version === 1 ? '' : input.subtitle;
// or input.subtitle ??= ""

return input;
},
z.object({
userId: z.number(),
id: z.number(),
title: z.string(),
body: z.string(),
subtitle: z.string(),
}),
);

.transform

transform 은 Schema 를 통과한 데이터를 변형시켜 줄 때 사용합니다.

서버에서 넘겨준 데이터와 클라이언트에서 렌더링을 위해 필요한 데이터의 인터페이스가 다른 경우가 흔히 있습니다.

이런 경우, 값을 가공하는 역할을 Schema Validation Layer 에 위임함으로써 주 로직을 보다 깔끔하게 관리할 수 있습니다.

export type Url = z.infer<typeof Url>; // Type Inference => Url: string
export const Url = z
.object({
protocol: z.string(),
host: z.string(),
pathname: z.string(),
})
.transform(({ protocol, host, pathname }) => {
return `${protocol}//${host}${pathname}`;
});

정리

  • Schema Validation Layer 를 추가하면 주 로직을 깔끔하게 관리할 수 있습니다.
  • Zod 에서 제공하는 메서드를 사용하면 Schema Validaition 을 손쉽게 할 수 있습니다.
  • FYI) superstruct 는 zod 에 비해 비교적 적은 번들 사이즈를 가집니다. 제공하는 기능도 비슷하니 참고해보시는 것도 좋습니다.