React Query

8. 타입 안전한 React Query

yoxxin 2024. 8. 6. 00:43

타입스크립트를 사용하는건 좋은 생각이다. 타입 안전을 싫어하는 사람이 있을까?

버그를 미리 알 수 있고, 복잡한 앱의 구조를 타입으로 정의하여 그 부분들은 우리 머리 속에 담아둘 필요가 없어진다.

 

"타입을 선언"한 것과 "타입 안전"한 것은 큰 차이가 있다.

타입스크립트의 진정한 힘을 이용하려면 한 가지가 더 필요하다.

신뢰하기

우리는 타입 정의를 신뢰할 수 있어야한다.

그렇지 않으면 타입은 단순 제안에 불과하고, 정확하다고 믿을 수 없다.

 

- 가장 엄격한 타입스크립트 설정을 활성화하자.

- typescript-eslint를 추가하여 `any`타입과 `ts-ignore`를 금지하자.

- 타입 단언을 지양하자.

제네릭

타입스크립트에서 제네릭은 필수적이다.

조금이라도 복잡한 걸 구현하려면 제네릭을 사용해야할 것이다. (라이브러리)

하지만 라이브러리 사용자로서 이상적인 것은 제네릭에 대해서 신경쓰지 않는 것이다.

제네릭은 그저 구현 세부사항이다.

제네릭을 수동으로 작성하는 것은 두 가지 이유 중 하나로 인해 좋지 않다.

1. 사실 불필요하다.

2. 스스로를 속이고 있는 것이다.

꺾쇠괄호에 대해(<>)

꺾쇠괄호는 우리 코드를 더 복잡해 보이게 만든다.

예를 들어, 우리는 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>({ // 제네릭으로 Todo 타입을 넣어주었다
  queryKey: ['todos', id],
  queryFn: fetchTodo,
})

query.data
//    ^?(property) data: Todo | undefined

useQuery가 네 개의 제네릭을 가지고 있다는 것이 문제가 된다.

수동으로 하나만 제공하면 나머지 세 개는 기본값으로 돌아간다. 이게 왜 나쁜지는 #6: React Query and TypeScript에 나와있다.

 

`axios.get`은 any를 반환한다.(`fetch`도 마찬가지고, `ky`는 기본적으로 unknown을 반환한다)

`/todos/id`에서 무엇을 반환할지 모르고, data 속성도 any가 되길 원하지 않기 때문에 제네릭을 이용해 타입을 덮어써야한다.

 

하지만 더 나은 방법은 `fetchTodo` 함수 자체에 타입을 지정하는 것이다.

type Todo = { id: number; name: string; done: boolean }

// ✅ typing the return value of fetchTodo
const fetchTodo = async (id: number): Promise<Todo> => {
  const response = await axios.get(`/todos/${id}`)
  return response.data
}
// 또는 axios.get에 제네릭을 바로 제공할 수 있다.
const fetchTodo = async (id: number) => {
  const response = await axios.get<Todo>(`/todos/${id}`)
  return response.data
}

// ✅ no generics on useQuery
const query = useQuery({
  queryKey: ['todos', id],
  queryFn: () => fetchTodo(id),
})

// 🙌 types are still properly inferred
query.data
//    ^?(property) data: Todo | undefined

이제 React Query는 queryFn의 결과의 data가 무엇일지 제대로 추론할 수 있다.

제네릭의 중요 규칙

Effective TypeScript에서 언급되는 golden rule of Generics 이 있다.

For a Generic to be useful, it must appear at least twice.

(제네릭은 유용하다, 다만 적어도 두 번 이상 쓰일때만)

 

즉 "return-only" 제네릭은 타입 어설션과 다를게 없다는 뜻이다.

다음은 `axios.get`의 예시이다.

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

제네릭 T가 리턴타입 Promise에만 사용되고 있다.

그럼 사실 이렇게 쓸 수도 있다.

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

적어도 타입 어셜션(`as Todo`)는 숨겨져 있지 않고 드러나있다.

다시, 신뢰하기

그리고 이제 다시 "신뢰"에 대해 생각해보자.

우리(프론트엔드)는 백엔드로부터 받는 정보가 실제로 그 타입인지 어떻게 믿을 수 있을까?

 

이 현상을 "신뢰 경계"라고 부르곤한다.

리턴 타입은 백엔드와 합의한 부분이기 때문에 믿고 작업할 수 밖에 없다.

만약 타입과 다른 데이터가 들어와서 문제가 생긴다면, 그건 우리(프론트엔드)의 문제가 아니다. - 문제는 백엔드 팀에게 있다.

 

물론 사용자는 신경쓰지 않는다.

그들은 단지 "cannot read property name of undefined"와 같은 에러 메시지를 볼 뿐이다.

프론트엔드 개발자로서 그 에러를 통해 백엔드로 부터 오는 데이터 타입이 잘못되었다는 사실을 알아채기는 꽤 오랜 시간이 걸린다.

그럼 백엔드에서 오는 데이터 타입에 대한 신뢰를 얻기위해 어떤 일을 할 수 있을까?

zod

zod는 런타임에 유효성 검사를 하는 스키마를 정의할 수 있는 검증 라이브러리이다.

또한 선언한 스키마로부터 타입을 추론(추출)할 수 있다.

타입을 선언한 다음, 데이터가 그 타입이라고 단언하는 대신

스키마를 작성하고 데이터가 그 스키마를 만족하는지 검증한다.

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

이전 코드와 비교해서 두 가지가 바뀌었다.

- Todo 타입 선언 -> todoSchema 스키마 선언

- 타입 단언 -> 스키마를 이용한 데이터 파싱

 

파싱(todoSchema.parse)에 문제가 생기면 zod에서 에러를 던진다.

그럼 우리는 그 에러를 처리해야하는 코드를 작성해야한다.

Tradeoffs

스키마 파싱은 데이터가 스키마와 일치하는지 런타임에 분석하기 때문에, 오버헤드가 발생한다.

따라서 이 방식을 모든 곳에 적용하는건 적절하지 않을 수 있다.


결론

useQuery의 제네릭을 쓰지 말고

queryFn에 넣을 fetch함수에 리턴 타입을 정의하자. (런타임에 타입 검증을 하고싶으면 zod를 쓰자)

더하여 queryOptions API를 함께 사용하면 queryClient.getQueryData와 같은 메서드의 리턴 타입 추론을 잘 할 수 있다.


이 글은 https://tkdodo.eu/blog/type-safe-react-query 를 번역 & 정리한 글입니다.