Published on

openapi 스펙을 활용해서 type-safe하게 react-query 사용하기

Authors

 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를 매번 직접 적어주는 불편함을 느끼고 계신 분들에게 하나의 아이디어나 도움이 조금이라도 될 수 있으면 좋겠습니다.