🔥 타입 안전한 React Query 구현하기

1341자
17분

TypeScript를 사용하면 많은 이점을 얻을 수 있습니다. 타입 안전성은 초기 단계에서 버그를 잡아내고, 앱의 복잡성 일부를 타입 정의로 옮겨 개발자가 모든 것을 기억할 필요가 없게 해줍니다.

프로젝트마다 타입 안전성 수준은 크게 다를 수 있습니다. 모든 유효한 JavaScript 코드는 TypeScript 설정에 따라 유효한 TypeScript 코드가 될 수 있습니다. 또한 "타입이 있는 것"과 "타입 안전한 것" 사이에는 큰 차이가 있습니다.

TypeScript의 힘을 제대로 활용하려면 무엇보다 중요한 한 가지가 있습니다.

신뢰성

타입 정의를 신뢰할 수 있어야 합니다. 신뢰할 수 없다면 타입은 단순한 제안에 불과해지고, 정확성을 보장할 수 없게 됩니다. 따라서 타입을 신뢰할 수 있도록 다음과 같은 노력을 기울입니다:

  • 가장 엄격한 TypeScript 설정을 사용합니다.
  • typescript-eslint를 추가하여 any 타입과 ts-ignore 사용을 금지합니다.
  • 코드 리뷰에서 모든 타입 단언을 지적합니다.

그럼에도 불구하고 우리는 여전히 많은 거짓말을 하고 있을 가능성이 높습니다. 위의 모든 사항을 준수하더라도 말이죠.

제네릭

TypeScript에서 제네릭은 필수적입니다. 조금이라도 복잡한 기능을 구현하려면 제네릭을 사용해야 합니다. 특히 재사용 가능한 라이브러리를 작성할 때 더욱 그렇습니다.

하지만 라이브러리 사용자로서 제네릭에 대해 신경 쓸 필요가 없어야 이상적입니다. 제네릭은 구현 세부사항이기 때문입니다. 따라서 꺾쇠 괄호를 통해 함수에 제네릭을 "수동으로" 제공할 때마다 다음 두 가지 이유 중 하나로 인해 좋지 않은 상황이 됩니다:

불필요하거나, 스스로에게 거짓말을 하는 것입니다.

꺾쇠 괄호에 대하여

꺾쇠 괄호는 코드를 실제보다 더 복잡해 보이게 만듭니다. 예를 들어, useQuery를 사용할 때 흔히 볼 수 있는 방식을 살펴보겠습니다:

type Todo = { id: number; name: string; done: boolean }
 
const fetchTodo = async (id: number) => {
  const response = await axios.get(`/todos/${id}`)
  return response.data
}
 
const query = useQuery<Todo>({
  queryKey: ['todos', id],
  queryFn: fetchTodo,
})
 
query.data
//    ^?(property) data: Todo | undefined
 
typescript

여기서 주요 문제는 useQuery가 네 개의 제네릭을 가지고 있다는 점입니다. 수동으로 하나만 제공하면 나머지 세 개는 기본값으로 설정됩니다. 이것이 왜 좋지 않은지는 #6: React Query and TypeScript에서 자세히 다룹니다.

명확히 하자면, axios.getany를 반환합니다(fetch도 마찬가지지만, ky는 기본적으로 unknown을 반환하여 조금 더 나은 방식을 제공합니다). /todos/id 엔드포인트가 무엇을 반환할지 모르기 때문입니다. 그리고 data 속성이 any가 되는 것을 원하지 않기 때문에, 수동으로 제네릭을 "덮어쓰기" 해야 합니다. 하지만 정말 그래야 할까요?

더 나은 방법은 fetchTodo 함수 자체에 타입을 지정하는 것입니다:

type Todo = { id: number; name: string; done: boolean }
 
// ✅ fetchTodo의 반환값에 타입 지정
const fetchTodo = async (id: number): Promise<Todo> => {
  const response = await axios.get(`/todos/${id}`)
  return response.data
}
 
// ✅ useQuery에 제네릭 없음
const query = useQuery({
  queryKey: ['todos', id],
  queryFn: () => fetchTodo(id),
})
 
// 🙌 타입이 여전히 제대로 추론됨
query.data
//    ^?(property) data: Todo | undefined
 
typescript

이제 React Query가 queryFn의 결과에서 데이터의 타입을 제대로 추론할 수 있습니다. 수동으로 제네릭을 지정할 필요가 없어졌습니다. useQuery에 대한 입력이 충분히 타입화되어 있다면, 꺾쇠 괄호를 추가할 필요가 없습니다. 🎉

거짓말하는 꺾쇠 괄호

또는 데이터 가져오기 계층, 이 경우 axios에 기대하는 타입을 꺾쇠 괄호를 통해 제네릭으로 제공할 수도 있습니다:

const fetchTodo = async (id: number) => {
  const response = await axios.get<Todo>(`/todos/${id}`)
  return response.data
}
 
typescript

이제 원하지 않는다면 fetchTodo 함수에 타입을 지정할 필요도 없습니다. 타입 추론이 여기서도 작동하기 때문입니다. 이러한 제네릭은 불필요하지는 않지만, 제네릭의 황금률을 위반하기 때문에 거짓말이 됩니다.

제네릭의 황금률

이 규칙은 @danvdk의 훌륭한 책 Effective TypeScript에서 배웠습니다. 기본적으로 다음과 같이 말합니다:

제네릭이 유용하려면 최소한 두 번 이상 나타나야 합니다.

소위 "반환만 하는" 제네릭은 타입 단언을 위장한 것에 불과합니다. axios.get의 (약간 단순화된) 타입 서명은 다음과 같습니다:

function get<T = any>(url: string): Promise<{ data: T, status: number}>
 
typescript

타입 T는 한 곳에서만 나타납니다 - 반환 타입에서요. 따라서 이는 거짓말입니다! 다음과 같이 작성할 수도 있었습니다:

const fetchTodo = async (id: number) => {
  const response = await axios.get(`/todos/${id}`)
  return response.data as Todo
}
 
typescript

적어도 이 타입 단언(as Todo)은 명시적이며 숨겨져 있지 않습니다. 컴파일러를 우회하고 있다는 것, 즉 안전하지 않은 것을 가져와 신뢰할 수 있는 것으로 바꾸려 한다는 것을 보여줍니다.

다시 신뢰성에 대해

이제 다시 신뢰성 문제로 돌아왔습니다. 네트워크를 통해 받은 데이터가 실제로 특정 타입이라고 어떻게 신뢰할 수 있을까요? 사실 그럴 수 없습니다. 하지만 그래도 괜찮을 수 있습니다.

이전에는 이 상황을 "신뢰 경계"라고 불렀습니다. 백엔드가 합의된 대로 반환한다고 믿어야 합니다. 그렇지 않다면 이는 우리의 잘못이 아니라 백엔드 팀의 잘못입니다.

물론 고객은 이런 내부 사정을 신경 쓰지 않습니다. 그들이 보는 것은 "undefined의 name 속성을 읽을 수 없습니다" 같은 오류 메시지뿐입니다. 프론트엔드 개발자들은 문제 해결에 호출될 것이고, 네트워크를 통해 받은 데이터의 형태가 올바르지 않다는 것을 알아내는 데 상당한 시간이 걸릴 것입니다. 오류는 완전히 다른 곳에서 나타나기 때문입니다.

그렇다면 신뢰를 얻기 위해 할 수 있는 것이 있을까요?

zod

zod는 실행 시점에 검증할 수 있는 스키마를 정의할 수 있게 해주는 멋진 검증 라이브러리입니다. 게다가 검증된 데이터의 타입을 스키마에서 직접 추론합니다.

이는 기본적으로 타입 정의를 작성하고 무언가가 그 타입이라고 단언하는 대신, 스키마를 작성하고 입력이 그 스키마를 따르는지 검증한다는 것을 의미합니다. 검증이 성공하면 그 데이터는 해당 타입이 됩니다.

zod에 대해 처음 들었을 때는 폼 작업을 하면서였습니다. 사용자 입력을 검증하는 것은 매우 합리적입니다. 부수적인 효과로, 검증 후에는 입력이 올바르게 타입화됩니다. 하지만 사용자 입력만 검증할 수 있는 것은 아닙니다. URL 매개변수나 네트워크 응답 등 모든 것을 검증할 수 있습니다.

queryFn에서의 검증

import { z } from 'zod'
 
// 👀 스키마 정의
const todoSchema = z.object({
  id: z.number(),
  name: z.string(),
  done: z.boolean(),
})
 
const fetchTodo = async (id: number) => {
  const response = await axios.get(`/todos/${id}`)
  // 🎉 스키마에 대해 파싱
  return todoSchema.parse(response.data)
}
 
const query = useQuery({
  queryKey: ['todos', id],
  queryFn: () => fetchTodo(id),
})
 
typescript

이 방법은 이전보다 더 많은 코드를 요구하지 않습니다. 우리는 기본적으로 두 가지를 교환했습니다:

  • Todo 타입의 수동 정의를 todoSchema 정의로 바꾸었습니다.
  • 타입 단언을 스키마 파싱으로 대체했습니다.

이 방식은 React Query와 매우 잘 어울립니다. parse 함수는 문제가 발생하면 설명적인 Error를 던지고, 이는 React Query를 error 상태로 만듭니다 - 마치 네트워크 호출 자체가 실패한 것처럼 말이죠. 클라이언트 관점에서는 실제로 실패한 것입니다. 예상한 구조를 반환하지 않았기 때문입니다. 이제 우리는 어차피 처리해야 할 error 상태를 가지게 되었고, 사용자에게 예상치 못한 상황이 발생하지 않을 것입니다.

이는 제가 가진 또 다른 지침과도 잘 어울립니다:

TypeScript 코드가 JavaScript처럼 보일수록 더 좋습니다.

id: number 외에는 이 TypeScript 코드를 JavaScript 코드와 구별할 수 있는 것이 없습니다. 추가된 TypeScript 복잡성이 없습니다 - 우리는 단지 타입 안전성의 이점을 얻을 뿐입니다. 타입 추론이 우리의 코드를 따뜻한 칼이 버터를 가르듯 부드럽게 흐릅니다. 🤤

트레이드오프

스키마 파싱은 알아두면 좋은 개념이지만 공짜로 얻을 수 있는 것은 아닙니다. 우선, 스키마는 원하는 만큼 유연해야 합니다. 실행 시점에 선택적 속성이 null인지 undefined인지가 중요하지 않다면, 이런 사소한 이유로 쿼리를 실패시켜 사용자 경험을 저하시키는 일은 없어야 합니다. 따라서 스키마를 탄력적으로 설계해야 합니다.

또한 파싱은 추가적인 작업을 필요로 합니다. 데이터가 필요한 구조에 맞는지 실행 시점에 분석해야 하기 때문입니다. 그래서 이 기법을 모든 곳에 적용하는 것이 항상 좋은 선택은 아닐 수 있습니다.

getQueryData는 어떨까요?

queryClient.getQueryData도 같은 문제를 겪는다는 점을 눈치채셨을 겁니다. 이 함수는 반환 전용 제네릭을 포함하고 있으며, 제공하지 않으면 기본값으로 unknown을 사용합니다.

const todo = queryClient.getQueryData(['todos', 1])
//    ^? const todo: unknown
 
const todo = queryClient.getQueryData<Todo>(['todos', 1])
//    ^? const todo: Todo | undefined
 
typescript

React Query는 QueryCache에 무엇을 넣었는지 알 수 없기 때문에(전체적인 사전 정의된 스키마가 없으므로), 이것이 우리가 할 수 있는 최선입니다. 물론 getQueryData의 결과도 스키마로 파싱할 수 있지만, 캐시된 데이터를 이전에 검증했다면 이는 불필요합니다. 또한 QueryCache와의 직접적인 상호작용은 드물게 이루어져야 합니다.

react-query-kit와 같은 React Query 위에 구축된 도구들은 이 문제를 해결하는 데 큰 도움이 되지만, 그들도 한계가 있으며 기본적으로 거짓말을 조금 더 숨기는 역할을 합니다.

업데이트
v5는 쿼리 옵션을 정의하는 새로운 방법을 제공하며, 이를 통해 getQueryData도 타입 안전하게 만들 수 있습니다. 자세한 내용은 문서를 참조하세요.

종단간 타입 안전성

React Query가 이 측면에서 우리를 위해 더 많은 일을 할 수는 없지만, 다른 도구들이 도움을 줄 수 있습니다. 프론트엔드와 백엔드를 모두 관리하고 있고, 이들이 같은 모노레포에 있다면 tRPCzodios와 같은 도구를 고려해 보세요. 이 둘 모두 클라이언트 측 데이터 가져오기 솔루션으로 React Query를 기반으로 하지만, 진정한 타입 안전성을 확보하는 데 필요한 것을 갖추고 있습니다: 사전에 정의된 API / 라우터 정의입니다.

이를 통해 프론트엔드의 타입을 백엔드가 생성하는 것에서 추론할 수 있으며, 잘못될 가능성이 없습니다. 또한 이 둘 모두 스키마 정의에 zod를 사용합니다(tRPC는 검증 라이브러리에 구애받지 않지만, zod가 가장 인기 있습니다). 따라서 2023년에 배워야 할 목록에 zod 사용법을 추가하는 것도 좋은 선택이 될 것입니다. 🎊

이렇게 타입 안전한 React Query를 구현하면 개발 과정에서 많은 이점을 얻을 수 있습니다. 타입 오류를 조기에 발견하고, 코드의 자신감을 높이며, 유지보수성을 향상시킬 수 있습니다. 또한 이러한 접근 방식은 팀 전체의 생산성을 높이고, 더 안정적이고 예측 가능한 애플리케이션을 만드는 데 도움이 됩니다.