Next.js | Tanstack Query로 prefetch 적용하기

dayannne·2024년 10월 13일
0
post-custom-banner

Next.js + Prisma로 DB를 조회하면서 API를 구현해 작업하고 있는 프로젝트에서,

개발 후 셀프 QA를 하면서 초기 페이지 렌더링 + 데이터 fetching 시간까지 더해 로딩이 무지하게 느려지는 부분이 아쉽게 느껴졌다.

방법을 고민하다 Tanstack Query(React Query)에서 제공하는 Hydration API 를 발견! 이 기능을 통해 Next.js의 서버 컴포넌트에 data prefetching을 사용할 수 있다고 하여 도입해 보기로 했다.

원리는 다음과 같다.

  1. 서버 컴포넌트에서 queryClient.prefetchQuery를 사용해 데이터를 불러오고 이를 dehydrate하여 하위 컴포넌트를 HydrationBoundary로 감싸 state를 넘겨 준다.
  2. 데이터를 사용하는 컴포넌트에서 useQuery로 동일한 데이터를 불러오면 해당 데이터는 prefetch 된 상태로 넘어와 이를 사용한다.

초기 세팅

  • useReactQuery.tsx

    'use client';
    
    import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
    import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
    
    export default function ReactQueryProviders({
      children,
    }: React.PropsWithChildren) {
      function makeQueryClient() {
        return new QueryClient({
          defaultOptions: {
            queries: {
              staleTime: 60 * 1000,
            },
          },
        });
      }
    
      let browserQueryClient: QueryClient | undefined = undefined;
    
      function getQueryClient() {
        if (typeof window === 'undefined') {
          // Server일 경우
          return makeQueryClient();
        } else {
          // Browser일 경우
          if (!browserQueryClient) browserQueryClient = makeQueryClient();
          return browserQueryClient;
        }
      }
    
      const queryClient = getQueryClient();
    
      return (
        <QueryClientProvider client={queryClient}>
      {children}
    {/* <ReactQueryDevtools initialIsOpen={false} /> */}
    </QueryClientProvider>
    );
    }
    
    function makeQueryClient() {
      return new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 60 * 1000,
          },
        },
      });
    }

    SSR에서 클라이언트에서 즉시 refetch하는 것을 피하기 위해 staleTime을 0보다 크게 설정하는 것이 좋다.
    실제로 staleTime을 설정하지 않았다가 query key 변경 사항이 없음에도 무한 refetch를 경험한 적이 있다..b

    function getQueryClient() {
      if (typeof window === 'undefined') {
        // Server
        return makeQueryClient();
      } else {
        // Browser
        if (!browserQueryClient) browserQueryClient = makeQueryClient();
        return browserQueryClient;
      }
    }
    • typeof window === 'undefined' (서버인 경우) : 매번 새로운 queryClient를 만든다.
    • (브라우저인 경우) : queryClient가 존재하지 않을 경우에만 새로운 queryClient를 만든다.

      기존에 React Query를 설정할 때는 클라이언트에서 QueryClient를 생성하여 지속적으로 사용하는 방식이다. 그러나 prefetching 기능을 사용할 때는 서버에서도 QueryClient를 생성해야 하므로, 서버에서 이미 생성된 경우에는 다시 생성하지 않도록 로더 함수에 조건문을 추가해 주는 방식이다.

  • app/layout.tsx

    import type { Metadata } from 'next';
    import Script from 'next/script';
    
    import { Noto_Sans_KR } from 'next/font/google';
    
    import '../styles/globals.css';
    
    import ReactQueryProviders from './_hooks/useReactQuery';
    
    export const metadata: Metadata = {
      title: '나의 산책 일기',
      description: '나의 산책 일기 - my walk log',
    };
    
    const font = Noto_Sans_KR({
      subsets: ['latin'],
    });
    export default async function RootLayout({
      children,
    }: Readonly<{
      children: React.ReactNode;
    }>) {
      return (
        <html lang='en'>
          <body className={`${font.className} text-sm lg:text-base`}>
            <Script
              async
              type='text/javascript'
              src={`//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.KAKAO_JS_KEY}&libraries=services,clusterer&autoload=false`}
            ></Script>
            <ReactQueryProviders>
              {children}
            </ReactQueryProviders>
          </body>
        </html>
      );
    }
    

    ReactQueryProviderschiildren에 싹 감싸준다~

prefetchQuery


기본 세팅 방법은 아래와 같다.

1. 먼저 서버 컴포넌트를 준비!

내 프로젝트에서는 레이아웃 컴포넌트를 모두 서버 컴포넌트로 분리해 두고 있기 때문에
페이지 컴포넌트에 GET 요청이 필요한 컴포넌트가 있는 경우 prefetch & dehydrate 를 적용해 주었다.

prefetch & dehydrate는 useQuery를 사용하는 컴포넌트 바로 상위에서 적용해 주는 것이 좋다.
한 번 상위에서 적용하면 모든 하위 컴포넌트에 전역적으로 적용이 가능하지만, 모든 컴포넌트에서 해당 쿼리 데이터를 미리 불러오게 되기 때문에 불필요한 데이터 로딩이 이뤄질 수 있기 때문이다.

import Header from '@/app/_component/common/Header';
import getQueryClient from '@/app/shared/utils/getQueryCLient';
import { getDiaryDetail } from '@/app/store/server/diary';
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';

export interface DiaryLayoutProps {
  children: React.ReactNode;

  params: { diaryId: number };
}

const DiaryLayout = async ({ children, params }: DiaryLayoutProps) => {
  const queryClient = getQueryClient();

  await queryClient.prefetchQuery({
    queryKey: ['diaryDetail', Number(params?.diaryId)],
    queryFn: () => getDiaryDetail(Number(params?.diaryId)),
  });

  return (
    <div className='flex basis-full flex-col overflow-y-auto'>
      <Header title='일기 상세' enableBackButton />
      <HydrationBoundary state={dehydrate(queryClient)}>
        {children}
      </HydrationBoundary>
    </div>
  );
};

export default DiaryLayout;
export const getDiaryDetail = async (diaryId: number) => {
  const response = await axios.get(
    `${process.env.NEXT_PUBLIC_DOMAIN}/api/diary/${diaryId}`,
  );

  return response.data.data;
};

여기서 주의할 점 ⚠️
보통 axios를 사용한다면

const response = await axios.get(`/api/diary/${diaryId}`,);

이렇게 위와 같이 도메인을 빼고 사용하는데, 서버에서 API를 요청할 때에는 도메인을 꼭 붙여 사용해야 한다.
이는 서버와 클라이언트 환경에서의 실행 방식 차이로, API 요청을 서버에서도 처리하기 위해서는 localhost나 로컬 환경의 도메인과 무관하게 외부 도메인을 포함한 URL이 필요 절대 경로가 필요하기 때문이다!
(이 부분을 모르고 세팅했다가 전혀 적용이 되지 않아 바보 고생을 했다는 이야기✌️)

2. 하위 클라이언트 컴포넌트

export const useGetDiaryDetail = (diaryId: number) =>
  queryOptions({
    queryKey: ['diaryDetail', diaryId],
    queryFn: () => getDiaryDetail(diaryId),
    enabled: !!diaryId,
  });
'use client';

import PlaceDetailModal from '@/app/_component/common/Modal/PlaceDetailModal';
import Commentform from '@/app/_component/diary/Commentform';
import CommentList from '@/app/_component/diary/CommentList';
import DiaryItem from '@/app/_component/diary/DiaryItem';
import { useModalStore } from '@/app/store/client/modal';
import { useUserStore } from '@/app/store/client/user';
import {
  useDiaryLike,
  useDeleteDiary,
  useGetDiaryDetail,
} from '@/app/store/server/diary';
import { useSuspenseQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';

export interface DiaryPageProps {}

const DiaryPage = ({ params }: { params: { diaryId: number } }) => {
  const { diaryId } = params;
 
  const queryOptions = useGetDiaryDetail(params.diaryId);
  const { data: diary, isLoading, error } = useSuspenseQuery(queryOptions);
 
  // ...나머지 코드
  
  return (
    <>
      <div className='flex basis-full flex-col overflow-y-scroll bg-white'>
        <DiaryItem
          diary={diary}
          onConfirm={handleConfirm}
          onClick={() => handleClick(diary?.id)}
        />
        {/* ...나머지 코드*/}
      </div>
      {/* ...나머지 코드*/}
    </>
  );
};

export default DiaryPage;

그리고 하위 컴포넌트인 Page 컴포넌트에서 useSuspenseQuery / useQuery를 적용해 주면 끝!
이렇게 사용하면 Next.js와 React Query를 함께 사용하여 SSR페이지를 만들 수 있다.

prefetchInfiniteQuery


이번엔 하위 컴포넌트에서 useInfiniteQuery를 사용하는 경우이다. 이때는 prefetchInfiniteQuery를 사용하면 된다.

1. 서버 컴포넌트

export const getFeed = async (pageParam = 1) => {
  const response = await axios.get(
    `${process.env.NEXT_PUBLIC_DOMAIN}/api/feed`,
    {
      params: { page: pageParam, size: 10 },
    },
  );
  return response.data;
};
import Header from '@/app/_component/common/Header';
import { IDiary } from '@/app/shared/types/diary';
import getQueryClient from '@/app/shared/utils/getQueryCLient';
import { getFeed } from '@/app/store/server/feed';
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';

export interface FeedLayoutProps {
  children: React.ReactNode;
}

interface FeedData {
  pages: IDiary[];
  pageParams: number[];
}

const FeedLayout = async ({ children }: FeedLayoutProps) => {
  const queryClient = getQueryClient();
  await queryClient.prefetchInfiniteQuery({
    queryKey: ['feed'],
    queryFn: () => getFeed(1),
    getNextPageParam: (lastPage: any) => {
      const { page, totalPages } = lastPage;
      return page < totalPages ? page + 1 : undefined;
    },
    initialPageParam: 1,
    retry: 1,
    staleTime: 60 * 1000,
  });

  queryClient.setQueryData(['feed'], {
    pages: (queryClient.getQueryData(['feed']) as FeedData)?.pages || [],
    pageParams: [0],
  });

  return (
    <div
      className={`sm-md:overflow-y-hidden relative z-20 flex w-full shrink-0 basis-full flex-col bg-white lg:flex lg:w-96 lg:min-w-96 lg:basis-auto`}
    >
      <div className='flex h-full w-full basis-full flex-col'>
        <Header title='피드' />
        <div className='flex basis-full flex-col overflow-y-scroll'>
          <HydrationBoundary
            state={JSON.parse(JSON.stringify(dehydrate(queryClient)))}
          >
            {children}
          </HydrationBoundary>
        </div>
      </div>
    </div>
  );
};

export default FeedLayout;

prefetchInfiniteQuery에서는 몇가지 에러를 방지하기 위한 추가 설정이 필요하다.

  • pageParam이 undefined로 반환되는 문제
    클라이언트에서 data fetching 시에는 잘 불러와 지는 데이터가 서버 prefetch 시 불러와 지지 않는 문제가 있었다.

      queryClient.setQueryData(['feed'], {
        pages: (queryClient.getQueryData(['feed']) as FeedData)?.pages || [],
        pageParams: [0],
      });
    

    queryClient.setQueryData를 적용해 초기 데이터 로딩 시 항상 첫 페이지를 불러오도록 pageparams [0]으로 명시해 준다.

  • 데이터 무결성 문제 방지

    <HydrationBoundary
      state={JSON.parse(JSON.stringify(dehydrate(queryClient)))}
      >
     {children}
    </HydrationBoundary>

    JSON.stringifyJSON.parse를 사용하여 불필요한 속성을 제거하고 불변성을 유지하기 위함인데, pageParam undefined 문제가 발생하지 않는다면 JSON.parse, stringify는 지워줘도 된다.

2. 하위 클라이언트 컴포넌트

export const useGetFeed = () =>
  infiniteQueryOptions({
    queryKey: ['feed'],
    queryFn: () => getFeed(1),
    getNextPageParam: (lastPage: any) => {
      const { page, totalPages } = lastPage;
      return page < totalPages ? page + 1 : undefined;
    },
    initialPageParam: 1,
    refetchOnWindowFocus: false,
    refetchOnMount: true,
    refetchOnReconnect: true,
    retry: 1,
  });
'use client';

import React, { useRef, useEffect } from 'react';
import { useDiaryLike } from '@/app/store/server/diary';
import { WEATHERS } from '@/app/shared/constant';
import { formatTimeAgo } from '@/app/shared/function/format';
import Link from 'next/link';
import Image from 'next/image';
import { useUserStore } from '@/app/store/client/user';
import useInfiniteScroll from '@/app/_hooks/useInfiniteScroll';
import { useGetFeed } from '@/app/store/server/feed';
import { useInfiniteQuery } from '@tanstack/react-query';
import { IDiary } from '@/app/shared/types/diary';

const FeedPage = () => {
  const { user } = useUserStore();
  const queryOptions = useGetFeed();
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useInfiniteQuery(queryOptions);

  // ... 나머지 코드
  
  return (
    <>
      <ul className='grid grid-cols-2 gap-2 bg-white p-4'>
        {diaries?.map((diary: IDiary) => (
          <li
            className='rounded-2xl'
            key={diary.id}
            style={{
              backgroundImage: `linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5)),url('${diary.diaryImages[0]}')`,
              backgroundSize: 'cover',
              backgroundPosition: 'center',
            }}
          >
          {/* ...나머지 코드 */}
          </li>
        ))}

      </ul>
    </>
  );
};

export default FeedPage;

결과


아래는 fetching 후 가장 페이지 내 마지막 데이터를 로딩하기까지의 시간을 비교한 결과이다.

  • prefetch 적용 전 (4~5초)

    이미지 기준 5.73초

  • prefetch 적용 후 (1초대)

    1.21초

    1.53초

profile
☁️
post-custom-banner

0개의 댓글