본문 바로가기
source-code/React

useQuery data type 지정하기 (feat. 관심사 분리)

by mattew4483 2023. 8. 16.
728x90
반응형

현 서비스에서는 react-query를 통해 서버 데이터를 관리하고 있다.
https://tanstack.com/query/v4/docs/react/reference/useQuery

 

useQuery | TanStack Query Docs

const { data,

tanstack.com

get 요청을 통해 받아온 데이터는 useQuery hooks를 통해 관리한다.
이때 해당 useQuery는, useQuery를 반환하는 custom hooks를 작성해 재사용할 수 있도록 했다.

// useGetPetDetailQuery.tsx
export const useGetPetDetailQuery = (
  pet_id: number,
) =>
  useQuery(
    petQueryKey.detail(pet_id),
    async () => {
      const {data} = await getPetDetailApi(pet_id)
      return data
    }
  )
  

// getPetDetailApi.tsx
export const getPetDetailApi = (id: number) => {
  return axios.get(`URL/${id}`)
}

예컨대 id에 해당하는 반려동물 정보를 조회하고 해당 반환값을 관리하기 위해
1) getPetDetailApi라는 fetch 함수를 작성하고
(해당 함수는 단순히 end-point에 HTTP요청을 보내고, 그 결과인 AxiosResponse를 반환한다)
2) 해당 함수를 queryFn으로 한 useQuery를 작성한 후
3) 이를 반환하는 useGetPetDetailQuery이란 custom hooks를 작성해, 필요한 곳에서 호출하여 사용했다.
 
여기서 문제는, useQuery의 반환 데이터가 타입 추론이 되지 않는다는 점!

참으로 난감한 상황

해당 query data의 타입을 알고 있음에도 불구(반려동물 상세 데이터 DTO를 따를 테다),
컴파일러가 의도대로 타입 추론을 하지 못하는 상황.
→ 해당 데이터에서 필요한 값들 중 아무것에도 접근하지 못한다.
 
이를 해결하기 위해서는? 
타입을 지정해 주면 그만이다.

interface Pet = {id : number}
const {data} = useGetPetDetailQuery(petId) as {data: Pet}

가장 즉각적인 해결책이자, 최악의 방법.
당장의 타입 추론은 가능하지만
1) data가 Pet이 아닌 경우(아직 fetch가 이뤄지지 않았을 때, fetch가 실패했을 때는 모두 undefined),
 런타임에서 에러가 발생한다!
2) useGetPetDetailQuery를 호출할 때마다, 일일이 as 구문을 작성해야 한다.
는 문제점이 있고, 둘 다 말할 수 없이 치명적이다.
 
이보다는 useGetPetDetailQuery hooks가
자신의 관심사인 '반려동물 상세 정보 데이터' 타입을 내부적으로 추론하고, 이를 반환하는 것이 훨씬 적절한 방법일 테다.

// useGetPetDetailQuery.tsx
export const useGetPetDetailQuery = (pet_id: number) =>
  useQuery<Pet>(petQueryKey.detail(pet_id), async () => {
    const {data} = await getPetDetailApi(pet_id)
    return data
  })

useGetPetDetailQuery가 반환하는 useQuery에 Pet interface란 제네릭을 넘겨준 모습.
 

export declare function useQuery<TQueryFnData = unknown, TError = unknown, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey>(queryKey: TQueryKey, queryFn: QueryFunction<TQueryFnData, TQueryKey>, options?: Omit<UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>, 'queryKey' | 'queryFn' | 'initialData'> & {
    initialData?: () => undefined;
}): UseQueryResult<TData, TError>;

useQuery는 내부적으로 다음과 같이 구현되어 있기 때문에
첫 번째 제네릭, 즉 TQueryFnData의 타입이 해당 함수의 반환값인 UseQueryResult의 TData으로 추론된다.
 
그리고 UseQueryResult로부터 상속 관계를 타고 타고 올라가면

export interface QueryObserverRefetchErrorResult<TData = unknown, TError = unknown> extends QueryObserverBaseResult<TData, TError> {
    data: TData;
    error: TError;
    isError: true;
    isLoading: false;
    isLoadingError: false;
    isRefetchError: true;
    isSuccess: false;
    status: 'error';
}

해당 TData가 useQuery의 반환값 속 data라는 속성의 타입임을 확인할 수 있다.
→ useQuery hooks의 첫 번째 제네릭이, 바로 useQuery의 data 속성 타입으로 추론되는 것!

사용처에서도 react-query가 지정한 useQuery의 data 속성의 타입에 곧바로 접근할 수 있다.
 
즉 위에서 얘기한 두 가지 문제점이
1) 런타임 에러 → react-query에서 해당 값을 Pet | undefined로 추론하므로, 해당 케이스에 맞도록 코드를 작성해 줄 수 있다.
2) 타입 하드 코딩 → 사용처에서는 해당 hooks를 호출, 반환값을 사용하기만 하면 된다.
위와 같이 해결된 모습이다. 와!
 

하지만... 정말 이걸로 된 걸까?

다시 한번 위의 코드를 살펴보자.

// useGetPetDetailQuery.tsx
export const useGetPetDetailQuery = (pet_id: number) =>
  // useQuery가 현재 자신이 호출되는 곳에서 Pet이란 interface를 사용 중임을 알아야 하는가?
  useQuery<Pet>(petQueryKey.detail(pet_id), async () => {
    const {data} = await getPetDetailApi(pet_id)
    return data
  })

useGetPetDetailQuery는 특정 queryKey와 queryFn의 반복을 피하고, 이를 재사용하는데 중점을 둔 hooks다.


그렇다면 useGetPetDetailQuery이 반환하는 useQuery, 나아가 react-query의 관심사는 무엇인가?

https://tkdodo.eu/blog/thinking-in-react-query

 

Thinking in React Query

In this talk, we will learn how a different mindset can help us understand React Query and work with it efficiently.

tkdodo.eu

If React Query is no data fetching library, what is it? 
My answer to this question has always been: An Async State Manager. 


react-query는 비동기 상태 관리자(Async State Manager)다.
 즉 서버로부터 데이터를 받아올 당시의 snapshot를 어떻게 관리할 것인가 를 담당할 뿐이다!

데이터를 어떻게 받아오는지, 받아온 데이터의 타입이 무엇인지는 react-query와 아무런 관계가 없으며
useQuery hooks에 이에 대해 신경 쓸 이유도, 신경 쓸 필요도 없다.
(queryFn이 어디서, 어떻게, 무슨 데이터를 받아오는가는 아무 상관이 없다. 그저 queryFn은 Promise를 반환하면 될 뿐!)

엥? 그렇다면 '서버로부터 받아온 데이터 타입이 무엇인가'는 누구의 관심사인 걸까?
잠시 위에 작성한 글로 되돌아가보자.

사실 이 모든 일은 
아무런 타입 지정을 하지 않았을 경우, 해당 query data의 타입을 알고 있는데도 적절한 추론이 이뤄지지 않는 것에서 시작되었다.(!)
 
그런데 여기서, 해당 query data의 타입을 어떻게 알 수 있는 것일까?
→ 간단하다. 백엔드 개발자와 합의되었기 때문이다!

// getPetDetailApi.tsx
export const getPetDetailApi = (id: number) => {
  return axios.get(`URL/${id}`) 
  // 반려동물 상세 조회 api는 status 200 - {id: number, name:string}을 반환합니다.
  // 그 외 에러 상황은 ~~~
}

useQuery의 queryFn에 사용된, HTTP 요청을 보내는 비동기 함수.
위 함수를 통해 `URL/${id}`로 요청을 보냈을 때, 반환값의 타입은 백엔드 개발자에 의해 정해진다.
 
즉 우리는 분명히(그리고 반드시) 해당 api를 사용하기 전,
백엔드 개발자와 api의 인자와 반환값에 대해 충분히 논의를 진행한 상태였을 테며
합의와 다른 반환값이 넘어온다면 → 이는 백엔드 개발자의 몫인 것이다.
 
아하..! 서버로부터 받아온 데이터의 타입이 무엇인가'가 누구의 관심사인지, 이제야 명확히 대답할 수 있겠다.

// getPetDetailApi.tsx
export const getPetDetailApi = (id: number) => {
  return axios.get<Pet>(`URL/${id}`)
}

반려동물 상세 정보 api를 호출하고, 그 결과를 반환하는 이 비동기 함수.
이 함수야말로 Pet이라는 interface에 직접적으로 관심을 가졌어야 했던 것!

// 사용처.tsx
const {data} = useGetPetDetailQuery(petId)

// useGetPetDetailQuery.tsx
export const useGetPetDetailQuery = (
  pet_id: number,
) =>
  useQuery(
    petQueryKey.detail(pet_id),
    async () => {
      const {data} = await getPetDetailApi(pet_id)
      return data
    }
  )
  

// getPetDetailApi.tsx
export const getPetDetailApi = (id: number) => {
  return axios.get<Pet>(`URL/${id}`)
}

useGetPetDetailQuery custom hooks에서도, useQuery에서도, 사용처에서도, Pet interface에 대해서는 알 필요가 사라진 모습.
→ Pet interface는 오직 해당 인터페이스와 직접적으로 연관된 서버와 가장 가까운 HTTP 요청 함수에서만 사용된다!


부끄럽지만, 이전에는 대부분의 코드를 두 번째 방식(useQuery에 제네릭으로 타입 지정)으로 작성하고 있었다.
 
그런데... 사실 가장 처음 타입스크립트로 react-query를 작성했을 때 오늘과 같은 물음을 던졌었다.
하지만 그때는 api 함수들에서 타입을 import 해 사용한다는 것에 대한 근거 없는 거부감이 들었었고,
+ 왜 api 함수에서 타입을 지정해야 하는가? 에 대해 스스로 명확한 대답을 하지 못했었다.
 
관심사의 분리에 대해 고민을 시작한 이후부터 코드를 작성할 때마다
해당 함수, 클래스, 컴포넌트를 어떻게 추상화할 것이며, 각 요소들의 관심사를 어떻게 정의할 것인가 에 집중하기 시작했고,
이를 통해 더 이해하기 쉽고 유지보수 하기 좋은 코드를 짤 수 있게 된 것 같다.
→ 앞으로도 더 나은 방향을 계속해서 고민하고, 찾아갈 수 있었으면 한다! 
 

728x90
반응형