카테고리 없음

Next.js 와 TanStack Query 사용하여 데이터 prefetch 하기

노엠디엔 2024. 10. 15. 18:21

이번에 사이드프로젝트를 하며 Next.js에서 Tanstack Query v5 버전을 사용하여 
서버에서 데이터를 prefetch(데이터 미리 가져오기) 하여 빠르게 ui표시할 수 있도록 해보았다.

 

클라이언트 렌더링 vs 서버 렌더링

클라이언트 렌더링에서는 다음과 같은 단계를 거친다고 한다.

  1. 서버에서 빈 HTML 마크업을 보냅니다 (콘텐츠 없이 기본 구조만 있음).
  2. 브라우저가 자바스크립트 파일을 다운로드 및 실행하여 애플리케이션을 실행시킵니다.
  3. 자바스크립트가 API 요청을 통해 데이터를 가져오고, 콘텐츠가 화면에 렌더링 됩니다.

사용자가 페이지를 처음 요청하면 빈 마크업만 받아서 화면이 비어 보이고,
자바스크립트와 데이터 요청이 모두 완료될 때까지 기다려야 한다
이렇게 최소 3번의 서버 요청이 이루어지며 그 후에야 화면에 콘텐츠가 표시된다.

 

 

  1. 서버에서 데이터가 포함된 HTML 마크업을 생성하여 사용자가
    페이지 로드 시 바로 콘텐츠를 볼 수 있음.
  2. 자바스크립트를 통해 페이지를 인터랙티브 하게 만들어 사용자 상호작용 가능.

즉, 서버에서 미리 데이터를 가져와 HTML에 포함시킴으로써, 첫 페이지 로드 시
빈 화면이 아닌 콘텐츠가 바로 표시되며, 사용자는 자바스크립트가 다운로드 및
실행되는 동안에도 콘텐츠를 확인할 수 있습니다.

React Query를 사용하면 서버와 클라이언트에서 데이터를 관리하고,
클라이언트 측에서 추가적인 데이터 요청을 피할 수 있다.

서버에서는 마크업을 생성하기 전에 데이터를 prefetch(미리 가져오기) 해야 하고
데이터를 마크업에 포함할 수 있는 직렬화 가능한 형식으로 dehydrate 시켜야 한다.
클라이언트에서는 해당 데이터를 react query 캐시로 hydrate 해야 한다.
그러면 클라이언트에서 새로운 데이터를 재요청하는 것을 막을 수 있다.

 

데이터 직렬화 (Dehydration)

서버에서 가져온 데이터를 JSON 형태로 변환하여 HTML에 포함시키는 과정이며
이 데이터를 클라이언트에서 사용할 수 있도록 React Query 캐시에 저장하게 된다.

React Query에서는 dehydrate 함수를 사용하여 데이터를 직렬화가 가능하며.

이를 HTML의 props로 전달하여 클라이언트가 페이지 로드 시 이를 사용할 수 있게 한다.


https://tanstack.com/query/latest/docs/framework/react/guides/ssr

 

TanStack | High Quality Open-Source Software for Web Developers

Headless, type-safe, powerful utilities for complex workflows like Data Management, Data Visualization, Charts, Tables, and UI Components.

tanstack.com

 

React Query에서 서버사이드 렌더링


React Query에서 SSR을 하기 위해서는 initialData를 props로 전달해서 사용하는 방법과
Hydration을 사용하는 방법 2가지가 있다.

 

 initialData를 props로 전달해서 사용하는 방법은 여러 가지 문제들이 있다고 하는데

export async function getServerSideProps() {
  const posts = await getPosts()
  return { props: { posts } }
}

function Posts(props) {
  // 서버에서 가져온 데이터를 initialData로 전달
  const { data } = useQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
    initialData: props.posts, // 서버에서 미리 받은 데이터를 클라이언트로 전달
  })

  // ...
}

initialData 방식의 문제점

  • props drilling 문제: useQuery가 트리 구조에서 깊은 컴포넌트 안에 있을 경우,
    initialData를 해당 컴포넌트까지 전달해야 한다
  • 여러 위치에서 동일한 쿼리를 호출할 때의 문제: 동일한 쿼리를 여러 곳에서 사용하면,
    한 곳에만 initialData를 전달하는 방식이 앱이 변경될 때 취약해질 수 있다.
  • 서버에서 쿼리가 언제 실행되었는지 알 수 없음: initialData는 서버에서 데이터를 가져온
    시점의 정보를 포함하지 않으므로, 클라이언트에서 쿼리가 실행된 시간을 기준으로
    데이터가 업데이트되었다고 간주한다.
    데이터 업데이트 시간이나 새로고침 필요 여부를 결정하기 어려움
  • 캐시 된 데이터 갱신 불가: 이미 클라이언트에 캐시 된 데이터가 있을 경우,
    initialData로 전달된 새로운 데이터가 더 최신이더라도 기존 데이터를 덮어쓰지 않는다.
    (getServerSideProps가 매번 호출되어 새 데이터를 가져오지만,
    initialData 옵션을 사용하기 때문에 클라이언트 캐시와 데이터는 절대 업데이트되지 않는다고 함.)

 Hydration Api 

그래서 공식문서에서는 Hydration Api를 사용하라고 한다.

Hydration Api에서는 dehydrate방식이 사용되는데 
react query의 상태를 서버에서 클라이언트로 전달할 수 있는 형태로 만들기 위해 사용되며
서버에서 데이터를 가져온 후 이를 직렬화하여 클라이언트로 전달한다.
이는 다시 클라이언트에서 hydrate를 통해 다시 react query 상태로 변환된다.

 

Dehydrate

  • dehydrate는 React Query의 캐시 상태를 직렬화(serialize)하여
    클라이언트에 전달할 수 있는 형태로 변환
  • 서버에서 데이터를 요청하고, 이 데이터는 queryClient의 캐시에 저장되며,
    이 데이터를 dehydrate 함수를 사용하여 직렬화하여 JSON 형태로 클라이언트로 전송된다.

Hydrate

  • hydrate는 클라이언트에서 받은 직렬화된 데이터를 React Query의 상태로 복원하는 과정
  • 클라이언트는 서버에서 전송된 JSON 데이터를 받습니다.
    이 데이터를 hydrate 함수를 사용하여 queryClient의 상태로 다시 변환
    즉, 클라이언트의 queryClient에 이 데이터를 다시 넣어 캐시 상태를 초기화한다.

https://tanstack.com/query/latest/docs/framework/react/guides/ssr#using-the-hydration-apis

 

TanStack | High Quality Open-Source Software for Web Developers

Headless, type-safe, powerful utilities for complex workflows like Data Management, Data Visualization, Charts, Tables, and UI Components.

tanstack.com

"use client";
import React, { ReactNode } from "react";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        // SSR에서는 클라이언트에서 즉시 refetch하는 것을 피하기 위해
        // staleTime을 0보다 크게 설정하는 것이 좋음.
        staleTime: 60 * 1000,
      },
    },
  });
}

let browserQueryClient: QueryClient | undefined = undefined;

function getQueryClient() {
  if (typeof window === "undefined") {
    return makeQueryClient();
  } else {
    if (!browserQueryClient) browserQueryClient = makeQueryClient();
    return browserQueryClient;
  }
}

export default function Providers({ children }: { children: ReactNode }) {

  const queryClient = getQueryClient();

  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`${pretendard.className} bg-grayscale-900 overflow-x-hidden`}
      >
        <QueryProviders>
          <Header />
          {children}
        </QueryProviders>
      </body>
    </html>
  );
}
// app/performances/layout.tsx
import { getPerformances } from "@/app/server/getPerformances";
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from "@tanstack/react-query";

export default async function Layout({ children }: React.PropsWithChildren) {
  const queryClient = new QueryClient();
  await queryClient.prefetchQuery({
    queryKey: ["performances"],
    queryFn: getPerformances,
    staleTime: 1000 * 60 * 5,
  });
  const dehydratedState = dehydrate(queryClient);
  // console.log(queryClient.getQueryState(["performances"]));

  return (
    <HydrationBoundary state={dehydratedState}>{children}</HydrationBoundary>
  );
}
// app/performances/page.tsx
"use client";
import Link from "next/link";
import Card from "@/components/shared/card/.";
import { useQuery } from "@tanstack/react-query";
import { getPerformances } from "@/app/server/getPerformances";
import { TPost } from "@/type/post";
import formatDate from "@/utils/formatDate";

export default function PerformancesList() {
  const { data, isLoading } = useQuery<TPost[]>({
    queryKey: ["performances"],
    queryFn: getPerformances,
  });

  if (isLoading) return <div>Loading...</div>;
  return (
    <>
      {data?.map(({ id, title, date, location, host, image }) => (
        <Link
          key={id}
          href={`/event/performances/${id}`}
          className="flex flex-col m-5 w-3/12"
        >
          <Card.Title>{title}</Card.Title>
          <Card.Img src={image} alt={"Card 이미지"} />
          <Card.Author
            date={formatDate(date)}
            name={host}
            location={location}
          />
        </Link>
      ))}
    </>
  );
}

 

새로고침시 클라이트 측으로 렌더링 되는 결과물은
loading상태값에 컴포넌트가 잠시 발생하지만

서버사이드 측으로 prefetch로 가져온 결과물은 바로 ui가 표시되는 것을 볼 수 있다.

 

React-query 클라이언트

React-query 서버사이드 prefetch후


참고블로그: https://sollogging.tistory.com/88

https://soobing.github.io/react/next-app-router-react-query/