graphql 백엔드와 통신하면서 프론트 작업을 할 때에는 graphql-codegen 사용해서 graphql schema를 기준으로 types을 generate해서 사용했었습니다. 그래서 따로 parameters, body, 그리고 response에 대해서 type을 따로 신경쓸 필요 없었고 type-safe하게 작업할 수 있었습니다. 그런데 이번에 시작한 프로젝트에서는 graphql가 아닌 rest api로 구성되어있는 백엔드와 통신해야하는 상황이 생겼고, 직접 type들을 타이핑을 해야한다고 생각하니 그렇게 하기가 싫었습니다. (물론, Paste JSON as Code을 사용하면 조금 더 편하게 할 수 있긴 하지만 모든 문제를 해결해주는 것은 아니기 때문에)
예전에 최태건님이 FEConf에서 발표했던 OpenAPI Specification으로 타입-세이프하게 API 개발하기: 희망편 VS 절망편 영상을 보면서 백엔드에서 OpenApi 스펙을 제공해준다면 이 정보를 이용해서 typing을 만들어 낼 수 있다는 사실을 배웠던 것이 생각이 났고 새로운 프로젝트를 시작하는 김에 OpenApi를 사용해서 type을 generate하고 type-safe하게 react-query hook을 사용하고 싶었습니다. 그래서 개인적으로 어떻게 OpenApi 스펙과 몇 가지 라이브러리들을 활용해서 어떻게 react-query hook을 사용하고 있는지 정리해보려고합니다. 바로 그냥 코드만 보고 싶으신 분들은 아래 링크를 클릭하셔서 코드만 확인하실 수도 있습니다.
저는 다음 라이브러리들을 이용했습니다.
- react-query: server state를 요청하고 관리하기 위해서 사용합니다.
- openapi-typescript: 간단한 커맨드를 사용해서 OpenApi 스펙을 기준으로 typing을 생성해줍니다.
- ts-toolbelt: openapi-typescript 생성해주는 type들을 편하게 사용하기 위해서 typescript utils들을 제공해주는 라이브러리를 사용했습니다.
위의 라이브러리들을 설치를 하고나서 우선 처음 해야하는 작업은 OpenApi 스펙을 input으로 주고 openapi-typescript 라이브러리를 활용해서 typing을 생성해야 합니다. 우선 이 내용을 진행하기 위해서는 백엔드 서버에서 OpenApi 스펙이 정의된 JSON을 받을 수 있어야 합니다. 모든 분들이 이 정보를 가지고 있지 않으실 수도 있기 때문에 설명을 위해서 인터넷에 공개되어있는 openapi-generator OpenApi Specs를 사용해서 type을 만들고 react-query로 요청을 해본다고 가정해보겠습니다.
우선 type을 생성해봅시다. 아래 커맨드를 실행하면 따로 첨부한 types.ts 파일의 내용같은 type들이 generate됩니다.
# output parameter를 사용하면 어디에 type을 생성할지 결정할 수 있습니다.
npx openapi-typescript https://api.openapi-generator.tech/api-docs --output ./src/types/index.ts
결과물을 보면 몇 가지 interface들이 생성됩니다. 우선 각각의 interface가 어떤 값들을 의미하는 지에 대해서 이해가 필요합니다. paths와 operations를 제외하고 다른 type들도 생성이 되지만 저는 주로 paths와 operations만 참고해서 사용했습니다.
* paths interface
- 요청 api endpoint(ex. /api/gen/clients)를 키로하고 어떤 method들을 지원하고 각 method는 어떤 response를 return하는지에 대한 interface입니다.
* operations interface
- paths에서 각 method들의 parameters(path, query)와 response에 대한 type들을 모아두고 있는 interface입니다.
다음으로 react-query의 useQuery의 key값을 매번 직접 지정해주기 귀찮기 때문에 queryKey로 paths의 key로 사용하고, 그 값을 기준으로 params, body, response type, error type 등을 추론하도록 만들기 위해서는 util성의 타입들을 먼저 작업해야 했습니다. 이 utils성 타입들은 openapi-typescript가 생성해준 type들에서 필요한 key들만을 select하거나 parameters 혹은 response의 type를 추론하기 위해서 다음과 같이 작업했습니다.
import { O } from 'ts-toolbelt';
import { paths } from './types';
// 생성된 paths interface의 모든 키들의 type
export type OAIPathKeys = keyof paths;
// 주로 사용되는 api method들에 대한 type
export type OAIMethods = 'get' | 'put' | 'post' | 'delete' | 'patch';
// 특정 method가 존재하는 paths key들만 뽑기 위한 type
export type OAIMethodPathKeys<TMethod extends OAIMethods> = O.SelectKeys<
paths,
Record<TMethod, unknown>
>;
// 특정 paths, method에 해당되는 path parameters type (api params에 대한 type)
export type OAIPathParameters<
TPath extends OAIPathKeys,
TMethod extends OAIMethods,
> = O.Path<paths, [TPath, TMethod, 'parameters', 'path']>;
// 특정 paths, method에 해당되는 query parameters type (api querystring에 대한 type)
export type OAIQueryParameters<
TPath extends OAIPathKeys,
TMethod extends OAIMethods,
> = O.Path<paths, [TPath, TMethod, 'parameters', 'query']>;
// react-query의 variables로 params, querystring을 통합해서 받아서 사용하기 위한 type
export type OAIParameters<
TPath extends OAIPathKeys,
TMethod extends OAIMethods,
> = OAIPathParameters<TPath, TMethod> extends Record<string, unknown>
? OAIQueryParameters<TPath, TMethod> extends Record<string, unknown>
? O.Merge<
OAIPathParameters<TPath, TMethod>,
OAIQueryParameters<TPath, TMethod>
>
: OAIPathParameters<TPath, TMethod>
: OAIQueryParameters<TPath, TMethod> extends Record<string, unknown>
? OAIQueryParameters<TPath, TMethod>
: undefined;
// 특정 path, method의 statusCode 200에 대한 response type
export type OAIResponse<
TPath extends keyof paths,
TMethod extends OAIMethods,
> = O.Path<
paths,
// 아래의 path는 주어진 OpenApi Specs에 따라서 달라질 수 있습니다.
[TPath, TMethod, 'responses', 200, 'schema']
>;
다음으로, 위에 작성한 utils성 type들을 활용해서 react-query의 useQuery의 type을 확장해주는 useOAIQuery라는 hook을 만들었습니다.
import type { UseQueryOptions } from 'react-query';
import { useQuery } from 'react-query';
// BASE_URL은 요청해야하는 서버의 주소로 변경 필요
const BASE_URL = 'https://api.openapi-generator.tech';
// useQuery에 전달한 queryKey, variables를 requestUrl로 만들기 위한 utils
export const getRequestUrl = (
queryKey: string,
variables?: Record<string, unknown>,
) => {
let url = `${BASE_URL}${queryKey}`;
const paramKeys = (queryKey.match(/{[a-zA-z-]+}/g) ?? []).map((param) =>
param.replace(/[{}]/g, ''),
);
paramKeys.forEach((param) => {
url = url.replace(`{${param}}`, variables?.[param] as string);
});
const qs = new URLSearchParams(
Object.entries(variables ?? {}).reduce((current, [key, value]) => {
if (paramKeys.includes(key)) {
return current;
}
return {
...current,
[key]: value,
};
}, {}),
).toString();
if (qs) {
url = `${url}?${qs}`;
}
return url;
};
// 위에 생성한 types, utils을 활용하여 useQuery를 wrapping하고 있는 useOAIQuery
export function useOAIQuery<
TQueryKey extends OAIMethodPathKeys<'get'>,
TVariables extends OAIParameters<TQueryKey, 'get'>,
TData extends OAIResponse<TQueryKey, 'get'>,
>({
queryKey,
queryOptions,
variables,
}: {
queryKey: TQueryKey;
queryOptions?: Omit<
UseQueryOptions<
TData,
unknown,
TData,
(Record<string, unknown> | TQueryKey | undefined)[]
>,
'queryKey' | 'queryFn'
>;
} & (TVariables extends undefined
? {
variables?: undefined;
}
: O.RequiredKeys<NonNullable<TVariables>> extends never
? {
variables?: TVariables;
}
: {
variables: TVariables;
})) {
return useQuery(
[queryKey, variables],
async ({ signal }) => {
const response = await fetch(getRequestUrl(queryKey, variables), {
signal,
});
if (!response.ok) {
throw new Error('Response error');
}
const json = (await response.json()) as TData;
return json;
},
queryOptions,
);
}
위와 같이 util types, util 함수, useOAIQuery 등을 만들어 두고 실제로 사용한다면 매번 react-query의 key를 어떻게 지정해야할 지 고민하지 않아도 되고, type을 정확하게 사용할 수 있습니다. 완전한 코드 스니펫을 보고 싶으신 분은 아래 링크를 확인해주세요.
이 글에서 예시로 적은 코드들은 실제로 사용하고 있는 코드와는 조금 차이가 있습니다. 우선, 최대한 제가 전달하고자 하는 내용들을 설명하기에 부족함이 없고 작동하는 코드를 보여드리기 위해서 최대한 간결하게 정리하여서 공유했습니다 (여전히 제 실력이 부족하여 복잡하긴 합니다.). 제가 공유한 코드 스니펫들이 바로 사용할 수 있는 수준까지는 많은 보완이 필요할 수 있겠지만 rest api와 통신하는 작업을 진행하실 때에 type safe하게 server state를 관리하고 싶고 react-query나 swc를 사용할 때에 key를 매번 직접 적어주는 불편함을 느끼고 계신 분들에게 하나의 아이디어나 도움이 조금이라도 될 수 있으면 좋겠습니다.