[번역] Type-safe React Query

cnsrn1874·2023년 1월 11일
1

해당 게시물은 원글을 번역한 글입니다. 일부 의역이 있으며, 오역 제보는 댓글로 부탁드립니다.


저는 타입스크립트 사용이 좋은 생각이라는 것에 모두 동의한다고 생각합니다. 누가 타입 안정성을 싫어할까요? 버그를 미리 발견할 수 있는 좋은 방법이며, 앱의 복잡성을 타입 정의에 맡김으로써 우리가 복잡성에 대해 영영 생각하지 않도록 해줍니다.

타입 안정성의 정도는 프로젝트마다 크게 다를 수 있습니다. 결국, 모든 타당한 자바스크립트 코드는 타입스크립트 설정에 따라 타당한 타입스크립트 코드가 될 수도 있습니다. 그리고 "타입이 정의된 것"과 "타입이 안정적인 것" 사이에는 큰 차이가 있습니다.

타입스크립트의 힘을 진정으로 활용하기 위해서는 무엇보다도 필요한 것이 하나 있습니다.

신뢰

우리의 타입 정의는 신뢰할만해야 합니다. 그렇지 않으면, 우리의 타입은 정확할 거라 기대할 수 없는 그저 제안에 불과합니다. 그래서 우리는 타입을 확실히 신뢰할 수 있도록 아래의 작업을 합니다.

  • 타입스크립트 설정 중 strict를 활성화합니다.
  • ts-ignoreany 타입을 금지하기 위해 typescript-eslint를 추가합니다.
  • 코드 리뷰를 할 때 모든 타입 단언을 지적합니다.

그럼에도 여전히 우리는 잘못하고 있을지도 모릅니다. 많이요. 위의 모든 것들을 지키더라도 말이죠.

제네릭

제네릭은 타입스크립트에서 매우 중요합니다. 약간 복잡한 것, 특히, 재사용 가능한 라이브러리를 구현하려는 즉시, 여러분은 제네릭을 찾게 될 겁니다.

하지만 라이브러리를 사용하는 입장에서는, 라이브러리의 제네릭에 신경 쓸 필요가 없어야 하는 게 이상적입니다. 제네릭은 구현할 때나 필요한 디테일입니다. 따라서 꺾쇠괄호로 함수에 직접 제네릭을 넣어주는 건, 두 가지 이유 중 하나로 나쁘다 할 수 있습니다.

그럴 필요가 없거나, 아니면 여러분 스스로를 속이고 있거나.

꺾쇠괄호(<>)에 대해

꺾쇠괄호는 코드를 필요 이상으로 "복잡"해 보이게 합니다. 예를 들어, useQuery가 어떻게 작성되는지 살펴보겠습니다.

// 꺾쇠 괄호를 사용한 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

여기서 문제는 useQuery가 제네릭 네 개를 가지고 있다는 겁니다. 이 중 하나만 직접 제공하면 나머지 세 개는 기본값으로 대체됩니다. 이게 나쁜 이유는 #6: 리액트 쿼리와 타입스크립트에서 읽을 수 있습니다.

이해를 돕기 위해 설명하자면, axios.getany를 반환합니다 (fetch도 any를 반환하지만, ky는 기본값으로 unknown을 반환하므로 좀 더 낫죠). axios.get/todos/id라는 엔드포인트가 무엇을 반환할지 알 수 없습니다. 그리고 우리는 data의 타입이 any가 아니었으면 하기 때문에 제네릭을 직접 넣어서, 추론된 제네릭을 "오버라이드" 해야 합니다. 정말 그래야 할까요?

더 좋은 방법은 fetchTodo 함수의 타입을 정의하는 겁니다.

// 타입이 정의된 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

이제 리액트 쿼리는 queryFn의 결과로부터 data가 무엇일지 적절히 추론할 수 있습니다. 제네릭을 직접 넣어줄 필요가 없어졌죠. useQuery에 입력되는 값의 타입이 충분히 정의되면, useQuery에 꺾쇠괄호를 추가할 필요가 없습니다. 🎉

잘못된 꺾쇠괄호

꺾쇠괄호로 제네릭을 넣어줌으로써, 데이터를 fetch 하는 계층(이 경우 axios)에게 예상되는 타입이 무엇인지 알려줄 수도 있습니다.

// 제네릭 넣어주기

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

타입 추론이 동작하기 때문에, 이제 원치 않으면 fetchTodo 함수의 타입을 정의할 필요도 없어졌습니다. 저런 제네릭 자체가 불필요한 것은 아니지만, 제네릭의 황금률을 위반하기 때문에 잘못된 제네릭입니다.

제네릭의 황금률

저는 이 황금률을 danvdk의 훌륭한 책 Effective TypeScript에서 배웠습니다. 그 책은 기본적으로 아래와 같이 말합니다.

제네릭이 유용하려면, 최소 두 번은 나타나야 한다.

소위 "반환 전용" 제네릭은 위장된 타입 단언에 불과합니다. axios.get의 (약간 단순화된) 타입 시그니처는 아래와 같습니다.

// axios.get의 타입 시그니처

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

타입 T는 반환값의 타입에서 한 번만 나타납니다. 그러니 잘못되었습니다! 우리는 아래와 같이 작성할 수도 있었습니다.

// 명시적 타입 단언

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

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

다시, 신뢰

그리고 이제 다시 신뢰를 회복해봅시다. 우리가 넘겨받은 것이 실제로 특정 타입에 해당하는지를 어떻게 신뢰할 수 있을까요? 그럴 수 없지만, 그래도 괜찮을 겁니다.

저는 이 상황을 "신뢰의 경계"라고 부르곤 했습니다. 우리는 백엔드가 우리와 서로 합의한 것을 반환할 것이라 믿어야 합니다. 만약 우리의 합의와 다른 걸 보낸다면, 이건 우리 잘못이 아니라 백엔드 팀 잘못입니다.

물론 고객은 누구 잘못인지 신경 쓰지 않습니다. 고객이 볼 수 있는 건 "cannot read property name of undefined"와 같은 것들뿐이죠. 고객 불만을 해결하기 위해 프런트엔드 개발자가 호출될 것이고, 오류가 완전히 다른 곳에서 나타날 것이기 때문에 올바른 형태의 데이터를 넘겨받지 못하고 있다는 것을 실제로 파악하는 데는 시간이 꽤 걸릴 겁니다.

그렇다면 우리가 우리의 잘못이 아니라는 믿음을 갖기 위해 스스로 할 수 있는 일이 있을까요?

zod

zod는 런타임에 검증되는 스키마를 정의할 수 있는 아름다운 유효성 검증 라이브러리입니다. 또한 유효성 검증된 데이터의 타입을 스키마에서 바로 추론합니다.

이건 기본적으로 타입을 정의한 뒤에 무언가가 어떤 타입이라고 단언하는 것이 아니라, 스키마를 작성하고 입력값이 그 스키마를 준수하는지 검증하는 걸 의미합니다.

저는 form 작업을 할 때, zod에 대해 처음 알게 됐습니다. 사용자 입력값의 유효성을 검증하는 건 전적으로 타당합니다. 유효성 검증 후엔 입력값의 타입도 올바를 것이라는 좋은 사이드 이펙트도 있습니다. 그리고 사용자 입력값뿐만 아니라 URL 매개변수나 네트워크 응답 등 무엇이든 검증할 수 있습니다.

queryFn에서의 검증

// zod로 파싱하기

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),
})

전보다 코드가 많지도 않습니다. 우리는 기본적으로 두 가지를 교환했습니다.

  • 직접적인 Todo 타입의 정의와 todoSchema 정의를 교환
  • 타입 단언과 스키마 파싱을 교환

이건 리액트 쿼리와 아주 잘 어울립니다. 왜냐하면 parse는 무언가 잘못되면 Error를 던지고, 리액트 쿼리는 마치 네트워크 호출 자체가 실패한 것처럼 error 상태가 되기 때문입니다. 기대한 구조의 응답값을 반환하지 않았으므로 클라이언트의 관점에선 호출 자체가 실패한 게 맞죠. 이제 우리에겐 어쨌거나 핸들링해야 하는 error 상태가 생겼으므로 사용자가 놀랄 일은 없을 겁니다.

그리고 이건 저의 또 다른 가이드라인과도 잘 어울립니다.

타입스크립트 코드가 자바스크립트처럼 보일수록 좋다.

id: number를 제외하면, 위의 타입스크립트 코드는 자바스크립트 코드와 차이가 없습니다. 타입스크립트의 복잡성은 더해지지 않았고, 타입 안정성이라는 이점만 얻었습니다. 타입 추론은 버터를 통과하며 가르는 뜨거운 칼처럼 우리 코드 속에 "흐릅니다". 🤤

트레이드 오프

스키마 파싱은 알아두어야 할 훌륭한 개념이지만, 공짜는 아닙니다. 우선, 여러분의 스키마는 여러분이 원하는 만큼 탄력적이어야 합니다. 런타임에 옵셔널 속성이 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

리액트 쿼리는 여러분이 QueryCache에 무엇을 넣었는지 알 수 없기 때문에(사전 정의된 전체 스키마가 없기 때문) 이게 최선입니다. 물론 getQueryData의 결과를 스키마로 파싱 할 수도 있지만, 캐시된 데이터의 유효성을 이전에 검증했다면 할 필요가 없습니다. 게다가 QueryCache와의 직접적인 상호 작용은 적게 해야 합니다.

react-query-kit과 같은 리액트 쿼리 기반의 툴은 이 고통을 완화하는 데 탁월하지만, 기본적으로 잘못을 조금 더 숨길 수 있을 뿐입니다.

종단간 타입 안정성

이와 관련해 리액트 쿼리가 우리에게 해줄 수 있는 것은 많지 않지만, 많은 걸 해주는 다른 툴은 있습니다. 여러분이 프런트엔드와 백엔드를 모두 컨트롤하는데다 그 둘이 모노레포에 같이 있다면 tRPCzodios와 같은 툴 사용을 고민해 보세요. 둘 다 클라이언트 사이드 데이터 fetching 솔루션인 리액트 쿼리를 기반으로 만들어졌지만, 진정한 타입 안정성을 위해 필요한 기능인 사전 API/라우터 정의를 갖추고 있습니다.

이를 통해, 프런트엔드에서는 백엔드가 생성하는 모든 것의 타입을 틀릴 가능성 없이 추론할 수 있습니다. 그리고 모두 스키마 정의에 zod를 사용합니다 (tRPC는 유효성 검증 라이브러리에 구애받지 않지만 zod가 가장 유명합니다). 그러니 zod 사용법은 여러분의 2023년 배워야 할 것들 목록에 확실히 올라갈 수 있습니다. 🎊

profile
프론트엔드 개발자

0개의 댓글