본문 바로가기

카테고리 없음

[번역] Type-safe React Query

 

Type-safe React Query

About the difference between "having types" and "being type-safe"...

tkdodo.eu

 

타입스크립트를 사용하는 것이 좋은 생각이라는 것에 모두 동의할 것입니다. 타입 안전을 싫어하는 사람이 있을까요? 버그를 조기에 발견할 수 있는 좋은 방법이며, 앱의 일부 복잡성을 type 정의로 풀어낼 수 있으므로 머릿속에 영원히 보관할 필요가 없습니다. 

 

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

 

타입스크립트의 강력한 기능을 제대로 활용하려면 무엇보다도 필요한 것이 있습니다:

 

Trust

타입 정의를 신뢰할 수 있어야 합니다. 그렇지 않으면 타입은 단순한 제안에 불과할 뿐 정확하다고 믿을 수 없습니다. 그래서 우리는 타입을 신뢰할 수 있도록 그 이상의 노력을 기울입니다: 

 

- 우리는 엄격한 타입스크립트 설정을 활성화합니다.

- ts-ignore뿐만 아니라 모든 타입을 금지하는 typescript-eslint를 추가합니다.

- 코드 리뷰에서 모든 타입 어설션을 지적합니다. 

- 그럼에도 불구하고 거짓말을 하고 있을지도 모릅니다. 아주 많이요. 위의 모든 사항을 준수하더라도 말이죠.

 

Generics

제네릭은 타입스크립트에서 필수적입니다. 특히 재사용 가능한 라이브러리를 작성할 때는 원격으로 복잡한 것을 구현하고 싶을 때 제네릭을 사용해야 합니다. 

 

그러나 라이브러리 사용자로서 제네릭은 신경 쓸 필요가 없는 것이 이상적입니다. 제네릭은 구현 세부사항이기 때문입니다. 따라서 꺽쇠 괄호를 통해 함수에 제네릭을 "수동으로"  제공하는 것은 두 가지 이유 중 하나 때문에 좋지 않습니다. 

 

불필요한 정보이거나 스스로에게 거짓말을 하고 있는 것입니다. 

 

About angle brackets

꺽쇠 괄호는 코드를 실제보다 더 복잡하게 보이게 만듭니다. 예를 들어 자주 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에는 4가지의 제네릭이 있다는 것입니다. 그 중 하나만 수동으로 제공하면 나머지 세개는 기본값으로 들어값니다. 이것이 왜 나쁜지에 대해서는 #6: React Query와 타입스크립트에서 읽어보실 수 있습니다.

 

같은 맥락에서 axios.get은 any를 반환하지만(fetch와 마찬가지로), ky는 기본적으로 unknown을 반환하여 이 작업을 약간 더 잘 수행합니다. 이 함수로는 /todos/id 엔드포인트가 무엇을 반환할지 알지 못합니다. 그리고 데이터 속성이 any가 되지 않기를 원하기 때문에, 추론된 제네릭을 수동으로 제공하여 '재정의' 해줘야 합니다. 아니면 우리가 할까요?

Lying angle brackets

또는 데이터 fetch 레이어(이 경우 axios)에 괄호로 묶어 제네릭을 제공하여 예상 유형을 알려줄 수도 있습니다:

 

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

 

이제 type 추론이 다시 작동하므로 원하지 않는 경우 fetchTodo 함수를 입력할 필요도 없습니다. 이러한 제네릭은 불필요한 것은 아니지만 제네릭의 황금률을 위반하기 때문에 거짓말입니다. 

 

The golden rule of Generics

이 규칙은 @danvdk의 훌륭한 책인 Effective TypeScript에서 배웠습니다. 다음과 같이 명시되어 있습니다. 제네릭이 유용하려면, 최소한 두번 이상 표시되어야 합니다. 

 

소위 "return-only" 제네릭은 변장된 타입 어설션에 지나지 않습니다. axios.get에 대한 (약간 단순화된) 타입 서명은 다음과 같습니다. 

 

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

 

type T는 return type이라는 한 곳에서만 나타납니다. 그래서 거짓말입니다. 그냥 아래와 같이 쓸 수 도 있는 것입니다.

 

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

 

적어도 이 유형 type assertion은 명시적이고 숨겨져 있지 않습니다. 이는 컴파일러를 우회하여 안전하지 않은 것을 가져와서 신뢰할 수 있는 것으로 바꾸려고 한다는 것을 보여줍니다. 

 

Trust again

이제 우리는 다시 신뢰로 돌아갑니다. 우리가 다른 곳에서 온 정보가 실제로 어떤 유형인지 어떻게 믿을 수 있을까요? 그럴 수 없지면 어쩌면 괜찮을 수도 있습니다. 

 

저는 이 상황을 '신뢰할 수 있는 경계' (trusted boundary)라고 표현하곤 했습니다. 백엔드에서 반환하는 내용이 우리가 합의한 내용과 일치한다는 것을 믿어야 합니다. 그렇지 않다면 이는 우리의 잘못이 아니라 백엔드 팀의 잘못입니다. 

 

물론 고객은 신경 쓰지 않습니다. 고객에게 표시되는 것은 "cannot read property name of undefined" 또는 이와 유사한 메시지 뿐입니다. 프론트엔드 개발자가 호출되고, 오류가 완전히 다른 위치에 나타나기 때문에 실제로 유선을 통해 올바른 형태의 데이터를 얻지 못하고 있다는 사실을 파악하는데 상당한 시간이 걸립니다. 

 

그렇다면 우리가 신뢰를 줄 수 있는 방법이 있을까요?

 

Zod

zod는 런타임에 유효성을 검사할 수 있는 스키마를 정의할 수 있는 멋진 유효성 검사 라이브러리 입니다. 또한 스키마에서 직접 유효성 검사된 데이터의 유형을 추론합니다. 

 

이는 기본적으로 type 정의를 작성한 다음 어떤 것이 해당 유형이라고 주장하는 대신 스키마를 작성하고 입력이 해당 스키마를 준수하는지 검증하여 그 시점에 해당 type이 된다는 것을 의미합니다. 

 

form을 다룰 때 zod에 대해서 처음 들었다. 사용자 입력의 유효성을 검증하는 것은 당연한 일이다. 유효성 검사 후 입력이 올바르게 입력되는 것도 좋은 side effect이다. 하지만 사용자 입력의 유효성을 검사할 수 있을 뿐만 아니라 무엇이든 유효성을 검사할 수 있습니다. 예를 들면 URL params, 또는 네트워크 응답.

 

import { z } from 'zod'

// 👀 define the schema
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}`)
  // 🎉 parse against the schema
  return todoSchema.parse(response.data)
}

const query = useQuery({
  queryKey: ['todos', id],
  queryFn: () => fetchTodo(id),
})

 

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

 

- Todo type의 수동 타입 정의와 todoSchema에 대한 정의

- 그리고 스키마 구문 분석으로 type assertion을 대체했습니다.

 

파싱이 뭔가 잘못되었을 때 설명이 포함된 오류를 던지면 네트워크 호출 자체가 실패한 것처럼 React Query가 오류 상태가 되기 때문에 이것은 React Query와 함께 매우 잘 작동합니다. 그리고 클라이언트 관점에서 보면, 예상한 구조를 반환하지 않았기 때문에 실패한 것입니다. 

 

클라이언트 입장에서는 예상한 구조를 반환하지 않았기 때문에 실패한 것이죠. 이제 어쨋든 처리해야 하는 오류가 생겼으니 사용자에게는 놀랄 일이 없을 것입니다. 

 

이는 제 다른 가이드라인과도 잘 어울립니다. 타입스크립트 코드가 자바스크립트처럼 보일수록 더 좋습니다. 

 

TypeScript의 복잡성이 추가되지 않고 type 안정성의 이점을 누릴 수 있습니다. 타입 추론은 뜨거운 칼이 버터를 자르듯 코드를 통해 흐릅니다. 

 

Tradeoffs

스키마 parsing은 알아두면 좋은 개념입니다만 무료는 아닙니다. 우선 스키마는 원하는 만큼 탄력적이어야 합니다. 선태적 속성이 런타임에 null이거나 정의되지 않는 것이 중요하지 않다면, 그런 이유로 쿼리가 실패할 경우 사용자 경험이 엉망이 될 수 있습니다. 그러니 스키마를 탄력적으로 설계하세요.

 

또한 구문 분석은 런타임에 데이터를 분석하여 필요한 구조에 맞는지 확인해야 하므로 오버헤드가 발생합니다. 따라서 이 기술을 모든 곳에 적용하는 것은 오버헤드가 발생할 수 있다.

 

What about getQueryData

queryClient.getQueryData에도 반환 전용 제네릭이 포함되어 있으며, 이를 제공하지 않으면 기본값이 unknown으로 설정되는 동일한 문제가 있다는 것을 눈치채셨을 것입니다. 

 

const todo = queryClient.getQueryData(['todos', 1])
//    ^? const todo: unknown

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

 

미리 정의된 전체 스키마가 없기 때문에 React Query는 사용자가 Query Cache에 무엇을 넣었는지 알 수 없으므로, 이것이 우리가 할 수 있는 최선입니다. 물론 스키마로 getQueryData의 결과를 파싱할 수도 있지만, 이전에 캐시된 데이터의 유효성을 검사한적이 있다면 굳이 그럴 필요는 없습니다. 또한 쿼리 캐시와의 직접적인 상호작용은 신중하게 수행해야 합니다. 

 

End to End type Safety

이 점에서 React Query가 할 수 있는 일이 많지는 않지만, 다른 도구도 있습니다. 프론트엔드와 백엔드를 모두 제어할 수 있고, 심지어 같은 모노레포에 함께 있다면 tRPC나 zodios같은 도구를 고려해보세요. 두 도구 모두 클라이언트 측 데이터 불러오기 솔루션을 위해 React Query를 기반으로 구축되지만, 진정한 타입 안정성을 갖추기 위해 필요한 요소, 즉 업프론트 API/라우터 정의가 있습니다. 

 

이를 통해 프론트엔드의 type은 백엔드에서 생성되는 모든 것에서 틀릴 가능성 없이 추론할 수 있습니다. 또한 둘 다 스키마 정의에 zod를 사용하므로 (tRPC는 유효성 검사 라이브러리에 구애받지 않지만 zod가 가장 많이 사용됨), 2023년 학습 목록에 zod로 작업하는 방법을 배우는 것을 추가할 수 있습니다.