Orval 도입하기 1편
Why
API 명세가 부재하여 프론트엔드 개발자가 네트워크 응답을 보고 서버 DTO를 유추하는 비효율을 발견했다.
또한, 프론트엔드 프로젝트에 정의된 타입이 실제 응답 타입과 다른 경우가 많아서 런타임 안정성을 위해 타입을 정교화할 필요가 있었다.
백엔드팀에 Swagger 정상화를 요청했고, 하나의 엔드포인트에 대해 명세가 정확히 수정되었다.
(Nest.js 백엔드에서는 프론트에 내려주는 DTO를 any
타입으로 생성하도록 되어 있어, 모든 엔드포인트를 정확하게 반영하려면 많은 시간이 필요할 것이다.)
프론트 코드에서는 종종 &&
같은 방어 로직이 작성되어 있었는데, 해당 필드의 타입 정의를 보면 optional이나 nullable이 아니어서 방어 로직의 존재를 의아하게 여기며 제거해 보았다.
하지만, 서버 응답에서 해당 필드가 포함되지 않아서 런타임 에러가 발생하는 사례가 있었다.
따라서, 한 엔드포인트라도 타입을 안정화하고 점진적으로 개선하는 방안이 필요했다. 또한, 사람이 직접 Swagger를 보며 타입을 정의하는 것은 시간 소모가 크고, 실수가 발생할 수도 있다.
이에 따라, Orval을 도입하여 서버 DTO를 빠르게 typegen하고, 엔드포인트 호출 함수 및 React Query 훅까지 명령어 한 번으로 생성하도록 하였다.
Orval을 사용하면 API 명세가 Source of Truth로 관리되어 정확성을 확보하고, 프론트엔드 개발자의 부담도 크게 줄일 수 있다.
How
- 먼저 Orval 을 설치한다.
yarn add orval -D
- Orval 설정을 작성한다.
// orval.config.ts
import { defineConfig } from "orval";
export default defineConfig({
petstore: {
output: {
mode: "tags-split", // 태그별로 분리, Swagger 를 보면 DTO 별로 tags 를 확인할 수 있다.
target: "./src/api/generated.ts", // 생성될 파일 경로
schemas: "./src/api/models", // 생성될 모델 경로
client: "react-query", // 사용할 클라이언트. 이 옵션을 설정하면 엔드포인트별로 useQuery useMutation 등의 훅을 생성해준다! 얼마나 편리한가!
override: {
mutator: {
path: "./src/api/custom-axios.ts", // 팀에서 사용하고 있는 axiosInstance
name: "customInstance",
},
},
},
input: {
target: "https://petstore.swagger.io/v2/swagger.json", // Swagger json url을 입력한다. json 파일을 다운로드해서 사용할 수도 있지만, 쉽고 빠른 타입 자동화를 위해 url 사용을 권장한다.
filters: {
tags: [/picture/], // 태그를 필터링할 수도 있다. 이렇게 하면 orval 은 `picture` 태그가 포함된 엔드포인트만 추출한다.
},
},
},
});
- Custom Axios 를 설정한다.
// custom-instance.ts
import Axios, { AxiosRequestConfig } from "axios";
export const AXIOS_INSTANCE = Axios.create({ baseURL: "<BACKEND URL>" }); // use your own URL here or environment variable
// add a second `options` argument here if you want to pass extra options to each generated query
export const customInstance = <T>(
config: AxiosRequestConfig,
options?: AxiosRequestConfig
): Promise<T> => {
const source = Axios.CancelToken.source();
const promise = AXIOS_INSTANCE({
...config,
...options,
cancelToken: source.token,
}).then(({ data }) => data);
// @ts-ignore
promise.cancel = () => {
source.cancel("Query was cancelled");
};
return promise;
};
// In some case with react-query and swr you want to be able to override the return error type so you can also do it here like this
export type ErrorType<Error> = AxiosError<Error>;
export type BodyType<BodyData> = BodyData;
// Or, in case you want to wrap the body type (optional)
// (if the custom instance is processing data before sending it, like changing the case for example)
export type BodyType<BodyData> = CamelCase<BodyData>;
Custom Axios 를 설정하면 Orval 이 해당 axiosInstance 를 사용해서 엔드포인트 호출 함수를 만든다.
여기까지 잘 설정되었다면 orval 실행시 타입, 엔드포인트 호출함수, react-query 훅까지 한번에 생성된다!
yarn orval
예를 들어, 아래와 같다.
/**
* @summary 상품 상세 조회 // 이런 tsdoc 까지 자동으로 만들어준다.
*/
export const itemControllerGetItem = (
params: ItemControllerGetItemParams, // orval 이 생성해준 타입을 바로 사용한다.
signal?: AbortSignal
) => {
return customInstance<ItemControllerGetItem200>({
url: `/end-point`,
method: "GET",
params,
signal,
});
};
TroubleShooting
1. Orval 명령어가 실행되지만, 파일 생성이 되지 않음
- 에러 메시지:
Error: Duplicate schema names detected:
2x Picture
at writeSchemas
{
"plugins": [
{
"name": "@nestjs/swagger",
"options": {
"dtoFileNameSuffix": [".dto.ts", ".model.ts"],
"introspectComments": true
}
}
]
}
- 원인: Swagger 자동생성을 위해 nest-cli 에 플러그인이 추가되었는데, 기존에 수동으로 작성된 DTO와 충돌하여 중복이 발생하는 에러였다.
- 해결: Swagger 생성시 플러그인만 사용하도록 수정하니 해당 에러가 사라지고 Orval 이 정상 동작하였다.
2. DTO 를 어디까지 사용할까?
최상위 page.tsx 컴포넌트에서 orval 함수를 호출하면 반환값이 서버 DTO와 동기화된다.
해당 반환값을 하위 컴포넌트로 전달시 기존 프론트엔드에 정의되어있던 타입과 맞지 않아 대규모 TS 에러가 발생한다.
특히, 해당 엔드포인트가 반환하는 필드가 nested 구조까지 포함하면 50여 개가 넘어, 이를 사용하고 있는 모든 프론트엔드 코드에 빨간 줄이 가득했다. (...)
생각
서버 DTO는 정확히 검증해야 하지만, UI 모델에서는 프론트가 필요로 하는 필드만 사용하는 것이 효율적일 것이다.
예를 들어, 서버에서는 날짜 데이터를 string 으로 내려주지만, 프론트 UI 모델에서는 Date 타입으로 필요할 수 있다. 서버에서는 응답 필드를 20여개 내려주지만, 실제 프론트에서는 5개의 필드만 사용하고 있다면?
이런 생각을 통해 서버 DTO 사용과 프론트 모델 UI 가 구별될 필요가 있겠다고 느꼈다.
이외에도, 현재 프론트 코드베이스에 최소한으로 영향을 주면서, 백엔드와 프론트 타입을 자동으로 싱크하면서 유지할 수 있는 구조를 빠르게 구축하기 위해서도 아래 방법이 좋겠다고 생각했다.
해결 방안 (시도중)
서버 DTO 를 기존 프론트 소스코드에 UI friendly 하게 적용하기 위해 Zod 를 적극 활용해보려 한다.
- API 응답 검증
import { z } from "zod";
// === API DTO ===
type ApiMovie = components["schemas"]["Movie"];
// === Zod Schema ===
const ApiMovieSchema = z.object({
id: z.string(),
name: z.string().optional(),
posterUrl: z.string().url().optional(),
isLiked: z.boolean().optional(),
});
// === 런타임 parse ===
const safeMovie = ApiMovieSchema.parse(apiResponse);
- UI 모델로 변환 (이때 프론트에서 기존에 정의된 타입을 참고)
const UIMovieSchema = ApiMovieSchema.transform((movie) => ({
id: movie.id,
title: movie.name ?? "Untitled",
thumbnailUrl: movie.posterUrl ?? "/default.png",
isLiked: movie.isLiked ?? false,
}));
type UIMovie = z.infer<typeof UIMovieSchema>;
// UI 컴포넌트에서는 변환된 타입만 사용
const uiMovie: UIMovie = UIMovieSchema.parse(apiResponse);
이렇게 상위에서 먼저 프론트에 필요한 UI 모델로 transform 하고, 하위 UI 컴포넌트 레벨에서는 transform된 타입을 사용한다.
서버 DTO 신뢰성도 가지면서, 기존 프론트엔드 소스 코드에 변경점을 최소한으로 만들 수 있겠다고 생각하여 시도하고 있다.