1. Tanstack Query가 Fetch API에 비해 가지는 이점


Next.js App Router에서 Fetch API가 가지는 이점을 활용해 데이터 페칭을 구현했습니다. 하지만 클라이언트 측에서는 Fetch API만을 활용하는 것보다 Tanstack Query와 결합해 사용할 때 가지는 이점이 있습니다.

<aside>

  1. 성공, 실패, 펜딩상태 자동 추적
  2. 에러 핸들링 간소화
  3. queryKey가 변경되면 자동으로 새로운 요청
  4. useMutation 훅 제공
  5. useInfiniteQuery 훅을 이용한 무한 스크롤, 페이지네이션 구현 용이성 </aside>

위 5가지는 Fetch API만 사용할 경우 개발자가 직접 구현하고 관리해야 하지만, Tanstack Query에서는 기능으로 제공해 직관적이고 편하게 사용할 수 있습니다. Fetch API와 다르게 반응형으로 작동하기 때문에 queryKey가 변경되었을 때 자동으로 새로운 요청을 보내는 것도 중요한 부분입니다.

또한 기본적으로 SSR을 적극 활용하기 때문에 클라이언트 측에서 useMutation을 통해 활용할 수 있는 여러 기능과 최적화도 유용합니다. 페이지네이션 구현에서도 useInfiniteQuery를 이용해 쉽고 안전하게 구현할 수 있습니다.

2. 구현 및 적용


  1. useQuery
function useGetArticle(articleId: number) {
  return useQuery({
    queryKey: [QUERY_KEYS.ARTICLE, 'useGetArticle', articleId] as const,
    queryFn: async () => {
      const headers = await getAuthorizationTokenHeader();
      return articleReaderServices.getArticle(articleId, headers);
    }
  });
}

function useGetGooglePlacesDetail(placeId: string) {
  return useQuery({
    queryKey: [QUERY_KEYS.GOOGLE_PLACES, 'useGetGooglePlacesDetail', placeId] as const,
    queryFn: () => googlePlacesServices.getDetail(placeId),
    enabled: !!placeId
  });
}

Next.js App Router에서의 Fetch API 태그 기반 캐시와 비슷하게 공통 태그와 고유 태그(함수 이름, 파라미터)로 queryKey를 구성했습니다. queryFn에는 Fetch API 기반 페칭 함수를 호출합니다. 토큰 쿠키 등을 포함한 헤더는 서버 액션을 통해 안전하게 가져옵니다. Google Places API와 같이 파라미터가 유효할 때에만 페칭하도록 해야 할 경우 enabled 옵션으로 제어합니다.

❗useSuspenseQuery를 사용하지 않은 이유?

Suspense 내부에서는 클라이언트 측에서 새로운 데이터 요청합니다. 서버 측에서 데이터를 페칭하는 환경에서 서버와 클라이언트 간 상태 불일치로 인한 하이드레이션 에러가 발생할 수 있습니다. 문제를 해결하기 위해서는 서버 측에서 prefetchQuery를 이용해 데이터를 프리페칭한 뒤 dehydrate로 직렬화하는 과정을 거쳐야 하는데, 이는 App Router의 Fetch API 확장 기능을 활용하고자 하는 의도와 상충합니다. 따라서 Suspense의 선언적 로딩 UI 구현을 포기하고 컴포넌트 내부에서 useQueryisPending으로 처리하는 것이 적절하다고 판단했습니다.

  1. useInfiniteQuery
function getPreviousPageParam(currentPage: number, isEmpty: boolean, isFirst: boolean) {
  if (isEmpty || isFirst) return null;
  return currentPage - 1;
}

function getNextPageParam(currentPage: number, isEmpty: boolean, isLast: boolean) {
  if (isEmpty || isLast) return null;
  return currentPage + 1;
}

function useGetSearchArticleList(params: GetSearchArticleListParams) {
  const fullParams = { ...DEFAULT_PARAMS, ...params };
  return useInfiniteQuery({
    queryKey: [QUERY_KEYS.ARTICLE, 'useGetSearchArticleList', fullParams] as const,
    queryFn: async ({ pageParam = fullParams.page || 0 }) => {
      const headers = await getAuthorizationTokenHeader();
      const currentParams: GetSearchArticleListParams = { ...fullParams, page: pageParam };
      return articleReaderServices.getSearchArticleList(headers, currentParams);
    },
    initialPageParam: 0,
    getPreviousPageParam: (res) => {
      if (!res.body.data) return;
      const { empty, first, pageable } = res.body.data;
      const { page_number } = pageable;
      return getPreviousPageParam(page_number, empty, first);
    },
    getNextPageParam: (res) => {
      if (!res.body.data) return;
      const { empty, last, pageable } = res.body.data;
      const { page_number } = pageable;
      return getNextPageParam(page_number, empty, last);
    }
  });
}

페이지네이션 구현을 위해 initialPageParam, getPreviousParam, getNextPageParam 옵션을 활용합니다. useInfiniteQuery를 사용하는 모든 훅에서 공통으로 활용할 수 있도록 getPreviousParam, getNextPageParam에 대응하는 함수를 작성해 적용했습니다.