TIL

2024.03.13 TIL #tanstack_Query_SSR_QueryClient_Dehydration

inz1234 2024. 3. 14. 02:08

우선 Dehydration과 prefetching을 왜 궁금하게 되었는지부터 시작해야 한다.
SSG와 ISR, SSR의 공통점은 서버사이드 렌더링이다.
말 그대로 서버에서 렌더링을 하는 것 즉, 서버에서 초기 HTML을 생성하여 클라이언트로 전송하는 것을 말한다.

우리가 보통 Next.js에서 서버사이드 렌더링을 할 때
-> 서버컴포넌트에서 데이터를 fetch 하고
-> 그 데이터로 HTML을 만든 뒤에
-> 클라이언트로 전달한다.(+물론 SSG, ISR, SSR에 따라 fetch에 옵션을 다르게 준다).

이 때, 초기 컴포넌트 상태는 클라이언트와 서버 간에 공유되지 않으며,
클라이언트 측에서는 새로운 인스턴스가 생성된다.


그래서 룰루랄라 SSG를 만드려고 했는데, DB에서 받아온 데이터를 전역적으로 관리하기에는 tanstack-query가 편한디..

tanstack-query로는 서버사이드 렌더링을 할 수 없을까?

처음에는 말이 많았다.
ㅇㅇㅇ: "tanstack-query는 애초에 클라이언트 측 라이브러리이기 때문에 애초에 서버사이드 렌더링에 사용할 수 없을 걸"
ㅇㅇㅇ: "사용한다고 해도 tanstack-query에서 정의해둔 방법이 따로 있을 걸~"
이라는 말에 tanstack-query Docs를 찾아본 결과, tanstack-Query-v5 공식문서에 

Advanced Server Rendering

라는 카테고리가 있었다.
공식문서를 하나하나 해석해서 정리하기에는 너무 양이 많기 때문에 핵심 개념만 정리를 하고, 직접 읽어보는 것을 추천한다.공식문서: https://tanstack.com/query/latest/docs/framework/react/guides/advanced-ssr

 

Advanced Server Rendering | TanStack Query React Docs

Welcome to the Advanced Server Rendering guide, where you will learn all about using React Query with streaming, Server Components and the Next.js app router. Before we start, let's note that while the initialData approach outlined in the SSR guide also wo

tanstack.com


결론부터 말하자면 가능하다!
서버에서 초기 렌더링을 할 때,
1) prefetch로 서버에서 미리 데이터를 불러온 뒤 + 서버와 클라이언트 간에 초기상태를 공유(QueryClient 동기화)해서 
2) 그 데이터를 클라이언트에게 HTML에 끼워서(Dehydration) 준다.
3) 그럼 클라이언트 측에서는 서버로부터 받은 HTML을 기반으로 따로 데이터 fetching 할 필요없이
즉, 초기상태를 유지한 상태에서 hydration을 할 수 있기 때문에 렌더링 과정이 보다 수월해지고,
초기 로딩속도를 줄일 수 있다.

근데 Dehydration이 무엇인가?
처음에는 "그거 뭐 hydration의 반댓말 아냐? 뭐 대충 그런 거겠지" 라고 생각했다.

그럼 Hydration은 무엇인가?
Hydration이란,
(보통 React에서는 React.hydrate() 메서드를 사용)

서버에서 클라이언트로 초기에 전달한 HTML을 가지고
클라이언트 측에서 JavaScript(상태, 이벤트핸들러 등)로
User와 상호작용이 가능한 동적인 웹페이지로 변환하는 것이다.

그럼 반대로 Dehydration이란,
React 컴포넌트의 초기상태(앱의 레이아웃 및 특정 데이터 등)를
서버에서 만든 초기 HTML에 미리 포함시켜
클라이언트에게 보내는 것을 의미한다.

Dehydration의 이점이 무엇이냐 묻는다면
1. 클라이언트는 서버로부터 처음부터 초기상태가 끼워진 HTML을 받고 기억하고 있기 때문에
초기 로딩 속도가 빨라지고, 검색엔진 최적화(SEO)를 개선할 수 있다.
2. 사용자가 앱에서 상호작용하고 재방문할 때, 이전에 작업한 내용을 기억하고 이어서 진행할 수 있도록 도와준다.


사용법

아무튼 이러한 개념을 바탕으로 tanstack-Query에서 제공하는 코드를 보자면,

// In Next.js, this file would be called: app/providers.jsx
'use client'

// We can not useState or useRef in a server component, which is why we are
// extracting this part out into it's own file with 'use client' on top
import { useState } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        // With SSR, we usually want to set some default staleTime
        // above 0 to avoid refetching immediately on the client
        staleTime: 60 * 1000,
      },
    },
  })
}

let browserQueryClient: QueryClient | undefined = undefined

function getQueryClient() {
  if (typeof window === 'undefined') {
    // Server: always make a new query client
    return makeQueryClient()
  } else {
    // Browser: make a new query client if we don't already have one
    // This is very important so we don't re-make a new client if React
    // suspends during the initial render. This may not be needed if we
    // have a suspense boundary BELOW the creation of the query client
    if (!browserQueryClient) browserQueryClient = makeQueryClient()
    return browserQueryClient
  }
}

export default function Providers({ children }) {
  // NOTE: Avoid useState when initializing the query client if you don't
  //       have a suspense boundary between this and the code that may
  //       suspend because React will throw away the client on the initial
  //       render if it suspends and there is no boundary
  const queryClient = getQueryClient()

  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  )
}
// In Next.js, this file would be called: app/layout.jsx
import Providers from './providers'

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <head />
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

여기서 중요한 점은 새로운 QueryClient는 항상 서버에서 생성하고, 클라이언트는 이미 만들어진 QueryClient 를 재사용하는 게 중요하다고 한다.
왜냐면 그렇게 해야 서버에서 생성한 QueryClient를 클라이언트에게 전송함으로써 서버와 클라이언트 간 초기상태를 공유할 수 있기 때문이다.

어떻게 전송하냐면

// app/posts/page.jsx
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from '@tanstack/react-query'
import Posts from './posts'

export default async function PostsPage() {
  const queryClient = new QueryClient()

  await queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
  })

  return (
    // Neat! Serialization is now as easy as passing props.
    // HydrationBoundary is a Client Component, so hydration will happen there.
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Posts />
    </HydrationBoundary>
  )
}

위에 보면 Posts라는 클라이언트 컴포넌트에 dehydrate를 사용해서 하위 컴포넌트로 초기 queryClient를 전송하는 것을 알 수 있다.


조금 인상 깊었던 점
이전까지는 보통 최상위 Provider 컴포넌트에서 children으로 하위 컴포넌트를 감쌀 때, 
export const queryClient = new QueryClient()
이렇게 한번만 new QueryClient를 생성하고
하위 컴포넌트들에서의 만들었던 queryClient는 import 해오거나 useQueryClient() 이용해서 사용했다.

왜냐하면 하위 컴포넌트들에서도 새로운 new QueryClient를 만들면, queryClient1 , queryClient2 이렇게 여러개의 queryClient가 생기는 꼴이 되기 때문이다.

하지만 여기서는 위에서도 설명되어 있듯이 "Server: 항상 새로운 QueryClient를 생성한다" 여서 인지,

// app> report폴더 > page.tsx
import { TODOS_QUERY_KEY } from "(@/fns/fetchFns)";
import {
  HydrationBoundary,
  QueryClient,
  dehydrate,
} from "@tanstack/react-query";
import Report from "./Report";

export default async function ReportPage() {
  const queryClient = new QueryClient();  ---------------> here
  await queryClient.prefetchQuery({
    queryKey: TODOS_QUERY_KEY,
    queryFn: async () => {
      const response = await fetch("http://localhost:4000/todos", {
        next: { revalidate: 10 },
      });
      const data = await response.json();
      return data;
    },
  });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Report />
    </HydrationBoundary>
  );
}

여기서도 const queryClient = new QueryClient()를 생성했고, 또 다른 페이지인 AboutPage에서도

// app > about 폴더 > page.tsx
import { COMPANY_QUERY_KEY, fetchCompany } from "(@/fns/fetchFns)";
import {
  HydrationBoundary,
  QueryClient,
  dehydrate,
} from "@tanstack/react-query";
import About from "./About";

export default async function AboutPage() {
  const queryClient = new QueryClient(); --------------------> here
  await queryClient.prefetchQuery({
    queryKey: COMPANY_QUERY_KEY,
    queryFn: fetchCompany,
  });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <About />
    </HydrationBoundary>
  );
}

new QueryClient()를 생성했다.
둘 다 서버 측에서 생성되는 페이지이기 때문에 각 요청마다 새로운 QueryClient를 생성해야 돌아간다.


또, 궁금한 점
- Next.js에서 도대체 "use client"의 정확한 역할은 무엇인지 궁금하다.
그냥 주석이라는 썰도 있는데, 내 코드에서는 저걸 상단에 명시해주지 않으면 클라이언트 측 코드가 실행되지 않기 때문에 궁금했다. 알아봐야겠다.
- 또한 tanstack-query도 클라이언트 측 라이브러리임에도 이렇게 몇 가지 기능을 제공해주어서 서버사이드 렌더링이 되는데, 클라이언트 측 HTTP 메서드인 axios도 서버사이드 렌더링이 가능할까? 궁금했다.
하지만 axios는 기술 자체가 브라우저 환경에서만 지원이 되기 때문에 서버사이드 렌더링이 불가능 하다고 한다. 


아무튼 이렇게 클라이언트 측 라이브러리인 React-Query도, QueryClient 동기화와 Dehydrate 기능으로 서버사이드 렌더링이 되는 경우도 있다는 것을 알게 되었다.