[번역] OpenAPI와 함께 타입 안전성을 갖춘 Tanstack Query

조영제
18 min readOct 10, 2024

--

원문: https://ruanmartinelli.com/blog/tanstack-query-openapi

TanStack Query(이전 명칭: React Query)는 리액트를 위한 비동기 상태 관리 라이브러리입니다. 주로 API와 같은 데이터 소스에서 데이터를 가져오도록 설정하면 캐싱, 재검증, 백그라운드 업데이트 등을 알아서 처리해 줍니다.

OpenAPI는 REST API를 문서화하기 위한 명세입니다. GitHub, Stripe, Discord 등 대형 기업들이 사용하는 이 명세는 API를 기계가 읽을 수 있는 형식으로 표현하는 가장 널리 쓰이는 표준입니다.

이 두 가지를 어떻게 결합할 수 있을지 한동안 고민해 왔습니다. 백엔드를 리팩토링하지 않고도 tRPC와 같은 개발 경험을 만들고 싶었습니다.

tRPC에서는 Prisma나 Drizzle(또는 다른 타입 안전 ORM) 스키마를 수정하면 타입이 자동으로 프런트엔드에 반영됩니다. 이는 전체 애플리케이션 스택을 아우르는 End to End 방식을 의미합니다.

하지만 우리는 여기서 조금 다르게 접근합니다. 백엔드는 건드리지 않고 OpenAPI 명세를 중심으로 타입 안전성을 확보합니다.

이 방식의 이름을 middle-to-end 타입 안전성이라고 부르겠습니다.

계획

우리는 API를 사용하는 리액트 용 타입 안전한 커스텀 TanStack Query 훅 쌍을 만들 것입니다. 이때 모든 타입은 OpenAPI 명세에서 자동으로 추론됩니다.

즉, TanStack Query의 `useQuery``useMutation` 훅을 래핑하고, 여기에 TypeScript의 마법 같은 기능들을 더할 예정입니다.

OpenAPI 스키마를 TypeScript 타입으로 변환하기 위해 openapi-typescript 패키지를 사용할 것입니다. 이와 더불어 openapi-typescript와 잘 연동되는 openapi-fetch도 사용할 예정입니다.

Step 1: OpenAPI 스펙으로부터 타입 생성하기

OpenAPI 스펙으로부터 타입스크립트 타입을 만들어 봅시다.

`openapi-typescript` 패키지를 설치하세요.

npm install -D openapi-typescript

더 나은 타입 안전성을 위해 tsconfig.json에 `noUncheckedIndexedAccess` 옵션도 켜는 것을 추천합니다.

{
"compilerOptions": {
// ...
"noUncheckedIndexedAccess": true
}
}

자 이제 타입을 생성해봅시다.

npx openapi-typescript <OPENAPI_URL> --output src/api/types.ts

이 글에서는 예시로 Github의 OpenAPI 명세를 사용하겠습니다. 아래 명령어로 타입을 생성하세요.

npx openapi-typescript https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json --output src/api/types.ts

ℹ️ API 타입 동기화 유지

이번 글에서는 OpenAPI 명세를 한 번만 불러와 타입을 생성했습니다.

그러나 실제 개발 환경에서는 OpenAPI 명세가 자주 변경될 수 있습니다. 이런 API와 클라이언트 간의 불일치를 방지하기 위해 이 과정을 자동화하는 것이 좋습니다.

이 작업은 복잡하지 않습니다. 간단한 스크립트와 **`setInterval`**을 사용하여 정기적으로 명세를 업데이트할 수 있습니다. 아래에 이를 시작하기 위한 예시가 있습니다.

import fs from 'node:fs'
import openapiTS from 'openapi-typescript'

const fetchSpec = async () => {
const types = await openapiTS('http://example.com/openapi.json')
fs.writeFileSync('./types.ts', types)
}

setInterval(fetchSpec, 10_000) // 10초마다 실행

`concurrently` 같은 패키지를 사용해서 개발 서버와 함께 이 스크립트를 실행할 수 있습니다.

{
// ...
"scripts": {
"start": "...",
"typegen": "tsx fetch-spec.ts",
"dev": "concurrently 'npm run start' 'npm run typegen'"
}
}

Step 2: API 클라이언트 세팅하기

타입을 만들었으니 `openapi-fetch` 를 사용해서 API 클라이언트를 세팅해 봅시다.

`openapi-fetch` 를 설치합시다.

npm install openapi-fetch

`client.ts` 파일 생성

import createClient from 'openapi-fetch'
import type { paths } from './types.ts'

const baseUrl = 'https://api.github.com'

const client = createClient<paths>({ baseUrl })

이 데모에서는 깃헙의 Gist를 가져와야 하므로 요청을 보내려면 API 토큰이 필요합니다.

이를 위해 `openapi-fetch` 에서는 각 요청전에 실행되는 미들웨어 함수를 정의할 수 있습니다.

import createClient, { Middleware } from "openapi-fetch";
import type { paths } from './types.ts'

// ⚠️ 데모 목적으로만 클라이언트 번들에 포함됩니다.
const githubToken = import.meta.env.VITEGITHUBTOKEN
const baseUrl = 'https://api.github.com'

const authMiddleware: Middleware = {
async onRequest(req, _options) {
req.headers.set('Authorization', `Bearer ${githubToken}`)
return req
},
}

const client = createClient<paths>({ baseUrl })

client.use(authMiddleware)

이제 Github API에 인증된 요청을 보낼수 있는 타입 안전한 API 클라이언트가 만들어졌습니다.

Step 3: 커스텀 TanStack Query 훅 만들기

이제 타입이 완벽하게 정의된 API 클라이언트를 구축했으니, 이를 활용해 TanStack Query의 커스텀 훅들을 만들어보겠습니다.

우리는 TanStack Query의 `useQuery``useMutation` 훅을 얇게 감싸서 사용할 것입니다. 목표는 `openapi-fetch` 와 동일한 타입 경험을 제공하면서도 TanStack Query 훅들을 사용하는 것입니다.

최종 API는 HTTP 메서드와 결합된 쿼리와 뮤테이션 형태로 구성될 것입니다. 예를 들어, `POST` 엔드포인트를 호출하려면 다음과 같은 방식이 될 것입니다.

const createGist = usePostMutation('/gists')

POST hook

새로운 `hooks.ts` 파일을 만들어봅시다.

타입 안전성이 없는 `useMutation` 훅을 구현해 보겠습니다.

import { client } from './client'
import { useMutation } from '@tanstack/react-query'

export function usePostMutation(path: string) {
return useMutation({
mutationFn: async (params: Record<string, any>) => {
const { data, error } = await client.POST(path, params)
if (error) throw error
return data
},
})
}

이제 타입을 추가해 봅시다. 우리는 이를 도와줄 유틸 패키지인 `openapi-typescript-helpers` 를 사용할 겁니다.

npm install -D openapi-typescript-helpers

`path` 매개변수는 OpenAPI 명세에서 POST 메서드를 가지는 경로만 추론되도록 설정하고자 합니다. 그런 다음, 해당 경로를 기반으로 해당 엔드포인트의 파라미터(예: body, params, querystring)와 반환 타입(response)을 자동으로 추론할 수 있어야 합니다.

아래에서는 이를 위해 `Paths``Params` 라는 두 가지 유틸리티 타입을 정의합니다. 그리고 `usePostMutation` 훅을 이 타입들을 확장하여 구현했습니다.

import { client } from './client'
import { useMutation } from '@tanstack/react-query'
import { HttpMethod, PathsWithMethod } from 'openapi-typescript-helpers'
import { FetchOptions } from 'openapi-fetch'

type Paths<M extends HttpMethod> = PathsWithMethod<paths, M>
type Params<M extends HttpMethod, P extends Paths<M>> = M extends keyof paths[P]
? FetchOptions<paths[P][M]>
: never

export function usePostMutation<P extends Paths<'post'>>(path: P) {
return useMutation({
mutationFn: async (params: Params<'post', P>) => {
const { data, error } = await client.POST(path, params)
if (error) throw error
return data
},
})
}

거의 다 왔습니다. 이제 남은 부분은 `useMutation` 훅에 추가 옵션(TanStack Query의 옵션)을 전달할 수 있는 방법을 구현하는 것입니다.

이 경우 TanStack Query의 `UseMutationOptions` 타입을 직접 사용하기가 다소 까다로울 수 있습니다. 그래서 필요한 옵션만 선택하여 사용할 수 있도록 새로운 타입을 정의했습니다.

import { UseMutationOptions as RQUseMutationOptions } from '@tanstack/react-query'

type UseMutationOptions = Pick<RQUseMutationOptions, 'retry'> // 필요하다면 더 많은 옵션을 추가하세요.

이제 이러한 옵션을 허용하도록 `usePostMutation` 함수를 업데이트할 수 있습니다.

import { client } from './client'
import { useMutation } from '@tanstack/react-query'
import { HttpMethod, PathsWithMethod } from 'openapi-typescript-helpers'
import { FetchOptions } from 'openapi-fetch'

type Paths<M extends HttpMethod> = PathsWithMethod<paths, M>
type Params<M extends HttpMethod, P extends Paths<M>> = M extends keyof paths[P]
? FetchOptions<paths[P][M]>
: never

type UseMutationOptions = Pick<RQUseMutationOptions, 'retry'>

export function usePostMutation<P extends Paths<'post'>>(path: P, options?: UseMutationOptions) {
return useMutation({
mutationFn: async (params: Params<'post', P>) => {
const { data, error } = await client.POST(path, params)
if (error) throw error
return data
},
...options,
})
}

GET hook

이제 `GET` 요청에 대해서도 비슷한 훅을 만들어 보겠습니다. `GET` 요청은 일반적으로 읽기에 사용되며 캐시해도 괜찮으므로 이번에는 `useQuery` 훅을 래핑 하겠습니다.

type UseQueryOptions = Pick<RQUseQueryOptions, 'enabled'> // 필요하다면 더 많은 옵션을 추가하세요.

export function useGetQuery<P extends Paths<'get'>>(
path: P,
params: Params<'get', P> & { rq?: UseQueryOptions }
) {
return useQuery({
queryKey: [path, params],
queryFn: async () => {
const { data, error } = await client.GET(path, params)
if (error) throw error
return data
},
...params?.rq,
})
}

이제 `UseQueryOptions` 라는 새로운 타입을 정의할 것입니다. 이는 이전의 `UseMutationOptions` 타입과 동일한 로직을 따르지만, 이번에는 `useQuery` 훅에 적용됩니다.

`params` 매개변수에는 추가적으로 `rq` 라는 새로운 속성을 추가하여 `useQuery` 훅에 React Query 옵션을 전달할 수 있도록 합니다.

`queryKey` 는 캐싱과 재검증 등을 제어하며, 경로(모든 GET 엔드포인트에서 고유한 값)와 요청 파라미터를 함께 받도록 설정합니다.

나머지 hook (PUT, DELETE, etc.)

PUT과 DELETE 엔드포인트는 보통 데이터를 변경(mutate)하는 작업을 수행하므로, POST 훅과 비슷한 방식으로 구현할 수 있습니다.

다음은 `usePutMutation``useDeleteMutation` 훅을 구현하는 방법입니다.

export function usePutMutation<P extends Paths<'put'>>(path: P, options?: UseMutationOptions) {
return useMutation({
mutationFn: async (params: Params<'put', P>) => {
const { data, error } = await client.PUT(path, params)
if (error) throw error
return data
},
...options,
})
}

export function useDeleteMutation<P extends Paths<'delete'>>(
path: P,
options?: UseMutationOptions
) {
return useMutation({
mutationFn: async (params: Params<'delete', P>) => {
const { data, error } = await client.DELETE(path, params)
if (error) throw error
return data
},
...options,
})
}

이제 GitHub의 OpenAPI 명세에 따라 완전히 타입이 적용된 커스텀 훅 쌍을 완성했습니다. 실제로 어떻게 동작하는지 확인해 봅시다.

Step 4: 리액트 컴포넌트 내에서 사용하기

마지막으로, 이러한 훅들을 실제 리액트 컴포넌트에서 어떻게 사용할 수 있는지 살펴보겠습니다. 아래는 GitHub의 Gist를 조회하고, 생성하고, 삭제하는 간단한 예제입니다. `useGetQuery`, `usePostMutation`, `useDeleteMutation` 훅들을 사용하는 모습을 확인할 수 있습니다.

import { FormEvent } from 'react'
import './api/client'
import { useDeleteMutation, useGetQuery, usePostMutation } from './api/hooks'

export default function App() {
// Mutations
const createGist = usePostMutation('/gists')
const removeGist = useDeleteMutation('/gists/{gist_id}')

// Queries
const gists = useGetQuery('/gists', { params: { query: { per_page: 5 } } })

const handleCreate = (e: FormEvent) => {
e.preventDefault()

const onSuccess = () => gists.refetch()
createGist.mutate(
{
body: {
description: new Date().toISOString(),
files: { 'greeting.txt': { content: 'hello, world' } },
},
},
{ onSuccess }
)
}

const handleDelete = (id: string) => {
if (!confirm('Are you sure?')) return

const onSuccess = () => gists.refetch()
removeGist.mutate({ params: { path: { gist_id: id } } }, { onSuccess })
}

return (
<>
<h1>Gists</h1>
<ul>
{gists.data?.map((gist) => (
<li key={gist.id}>
<strong>{gist.description || 'Untitled'}</strong>
<small>{new Date(gist.created_at).toLocaleTimeString()}</small>
<button onClick={() => handleDelete(gist.id)}>❌</button>
</li>
))}
</ul>

<form onSubmit={handleCreate}>
<button type="submit">Create Gist</button>
</form>
</>
)
}

주목할 만한 도구들

이 글을 통해 TanStack Query와 OpenAPI를 함께 통합하는 것이 생각보다 어렵지 않다는 것을 알게 되셨기를 바랍니다.

만약 이 글로 충분하지 않았다면, 이 작업을 더 쉽게 도와줄 수 있는 몇 가지 오픈 소스 도구들도 있습니다.

이들 각각은 고유한 기능과 장단점들이 있으므로 반드시 확인하시기 바랍니다.

결론

이번 글에서는 OpenAPI 명세를 기반으로 타입 안전한 TanStack Query 훅을 만드는 방법을 알아보았습니다.

`openapi-typescript``openapi-fetch` 를 활용하여 OpenAPI 명세에서 TypeScript 타입을 생성하고, 타입이 완전히 지원되는 API 클라이언트를 구축할 수 있었습니다.

그리고 TanStack Query의 `useQuery``useMutation` 훅을 감싸는 커스텀 훅 쌍을 만들어, OpenAPI 명세에 따라 타입이 안전하게 적용된 훅을 얻을 수 있었습니다.

최종적으로, OpenAPI 명세를 기반으로 완전히 타입이 정의된 훅 쌍을 구축하는 데 성공했습니다.

모든 코드 예시는 저장소에서 확인할 수 있습니다. 관심이 있다면 꼭 한 번 살펴보세요.

질문이나 피드백이 있으신가요? X / Twitter를 통해 언제든지 연락하세요! ✌️

Sign up to discover human stories that deepen your understanding of the world.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

--

--

조영제
조영제

Written by 조영제

두나무 NFT에서 웹 프론트엔드 개발자로 근무하고 있습니다. 일하는거랑 새로 무언가를 배우는것을 좋아합니다.

Responses (1)

Write a response