[React] React Query v5 (TanStack Query)로 마이그레이션 기록

@eunjios·2023년 12월 9일
1
post-thumbnail

TanStack Query 도입기

TanStack을 선택한 이유와 Quickstart 는 이전 포스트를 참고하자.

기존에는 HTTP 통신에 반복적으로 사용되는 작업을 재사용하기 위해 커스텀 훅을 만들어 사용했다.

React-Query를 도입하면서 HTTP 통신 관련 부분을 모두 마이그레이션 했는데, 그 중 가장 많은 부분이 변경된 Products 컴포넌트를 중심으로 기록할 예정이다.


👀 Products 컴포넌트 미리보기

서버에 있는 products 데이터는 paginated 되어 있다. '더 많은 상품 보기' 를 누르면 다음 페이지 데이터를 fetch 해온다.

참고 Pagination vs. Infinite Scroll vs. Load More Explained

미리보기


✏️ 기존 방식

검색어를 입력하면 API/products 에 있는 데이터를 다음과 같은 로직으로 fetch 해왔다. 해당 데이터는 paginated 된 데이터로 검색어 (keyword) 정보와 페이지 (page) 정보를 파라미터로 가진다.

  1. Context API를 사용해 현재 검색어 query 와 페이지 정보 page, fetch 해 온 데이터인 products 를 관리했다.
  2. 커스텀 훅 useAxios를 사용해 HTTP 통신에 필요한 state인 response isLoading error sendRequest 를 관리했다.
  3. useEffect를 사용해 query 의 변경을 감지하여 page=1 의 request를 보낸다.
  4. '더 많은 상품 보기' 버튼을 누르면 page + 1 의 request를 보낸다.
  5. useEffect를 사용해 isLoading 의 변경을 감지하여 fetch 해온 데이터를 products 에 추가한다.

💡 변경 방식

특정 위치에 도달했을 때 다음 페이지 데이터를 fetch 해오는 infinite scroll 방식은 아니지만, 버튼을 사용해 기존 데이터에 새로운 데이터를 fetch 해오기 때문에 react-query의 useInfiniteQuery 를 사용하였다.

기존에 이 부분을 직접 구현하면서 삽질을 꽤 했었다. 다른 페이지로 이동하고 다시 돌아왔을 때 중복으로 fetch가 되거나, StrictMode 일 때 중복으로 fetch 되는 등 Context API + useEffect 방식에 어려움을 겪었었다. 어찌 저찌 해서 해당 이슈들을 다 해결했지만 (위에서 언급한 기존 방식은 해결된 로직이다.) 왠지 모르게 잘못된 방식일 것 같다는 생각이 들었다.

아무튼 react-query의 useInfiniteQuery 를 사용하여 다음과 같이 구현했다.


(1) API 처리 함수 정의하기

interface 정의

export interface ProductsParamsModel {
  query: string;
  pageParam: number;
}

export interface ProductsResponseModel {
  products: ProductModel[];
  page: number;
  lastPage: number;
  total: number;
}

API 처리 함수 정의

const api = axios.create({
  baseURL: process.env.API_URL,
});

export const getProducts = async (params: ProductsParams) => {
  const response = await api('', {
    params: {
      keyword: params.query,
      page: params.pageParam
    },
  });
  const data: ProductsResponse = {
    ...response.data,
    lastPage: response.data.last_page,
  };
  return data;
}

(2) useInfiniteQuery

const { query } = useContext(ProductContext); // 검색어

const { 
  data, 
  isLoading, 
  isFetchingNextPage, 
  hasNextPage, 
  fetchNextPage 
} = useInfiniteQuery({
  queryKey: ['products', query],
  queryFn: ({ pageParam }) => getProducts({ pageParam, query }),
  initialPageParam: 1,
  getNextPageParam: (prevPage, pages) => {
    const isLastPage = 
      prevPage.products.length < 15 || prevPage.lastPage === prevPage.page;
    return isLastPage ? undefined : pages.length + 1;
  },
  enabled: query.trim().length > 0,
  staleTime: 300000,
});

useInfiniteQuery 옵션

  • queryFn (required) : data 요청에 사용될 함수로 반드시 Promise를 반환해야 한다.
  • initialPageParam (required) : 처음으로 fetch해 올 page 값
  • getNextPageParam (required) : infiniteScroll 의 마지막 페이지 데이터, 모든 페이지 데이터, pageParam 정보를 받는다. queryFn 의 파라미터로 넘길 값을 return 한다. return 값이 undefinednull 일 경우, 다음 페이지가 없음을 의미한다.
  • enabled : false 일 경우 queryFn 이 실행되지 않는다.
  • staleTime : refresh 시킬 시간

useInfiniteQuery가 반환하는 데이터

  • data.pages : 모든 페이지의 데이터 (배열)
  • data.pageParams : 모든 pageParams (배열)
  • isFetchingNextPage : fetchNextPage 로 다음 페이지 데이터를 fetch 해오고 있는지 여부
  • fetchNextPage : 다음 페이지 데이터 결과를 fetch 해오는 함수
  • hasNextPage : 다음 페이지를 fetch 해올 수 있는지 여부

자세한 내용은 공식 문서의 API Reference 를 확인하자.


마치며

좋았던 점 1. 캐싱 기능

캐싱 기능은 구현하지 않았었는데, 리액트 쿼리를 사용해서 쉽게 구현이 가능했다. 캐시를 사용해서 불필요한 request를 줄여 서버의 부담을 조금이나마 줄일 수 있었고 로딩을 기다릴 필요가 없으니 사용자 경험도 개선할 수 있었다.


좋았던 점 2. 전역에서 접근 가능한 데이터

import { queryClient } from '../../App';

const data = queryClient.getQueryData(queryKey)

위와 같이 QueryClient 를 사용해서 쿼리 키에 해당하는 데이터에 접근할 수 있다. 모든 컴포넌트에서 사용 가능하기 때문에 props drilling을 피할 수 있다.


좋았던 점 3. 만족스러운 DX

일단 코드가 많이 줄었고 직접 구현하지 않아도 되니 너무 편하다. 인터페이스를 사용해서 타입을 지정할 경우 접근할 수 있는 프로퍼티들도 뜨고.. 이건 타입스크립트의 편리함이긴 하지만.. 어쨌든 편하다..


해볼 것

React Router + React Query 도 살펴보긴 했는데, React Router (v6) 의 loader, action을 사용하는 것이 과연 좋은 것인가 라는 생각이 들었다. React Router의 기능은 최소한으로 사용하는 것이 좋다는 글을 봤어서 일단 이 부분은 더 고민이 필요할 것 같다.
우선적으로 해볼 것은 Context API가 아닌 다른 상태 관리 라이브러리를 도입하고 React Query와 함께 사용해 볼 생각이다.


References

profile
growth

0개의 댓글