🔥 고급 서버 렌더링

2933자
36분

고급 서버 렌더링 가이드에 오신 것을 환영합니다! 이 가이드에서는 React Query를 스트리밍, 서버 컴포넌트, Next.js 앱 라우터와 함께 사용하는 방법을 배울 수 있습니다.

이 가이드를 읽기 전에 서버 렌더링 & 하이드레이션 가이드를 먼저 읽어보시는 것이 좋습니다. 해당 가이드에서는 React Query와 SSR을 함께 사용하는 기본적인 내용을 다룹니다. 또한 성능 & 리퀘스트 워터폴프리페칭 & 라우터 통합도 유용한 배경 지식이 될 것입니다.

시작하기 전에, SSR 가이드에서 설명한 initialData 접근 방식도 서버 컴포넌트와 함께 사용할 수 있지만, 이 가이드에서는 하이드레이션 API에 초점을 맞출 것입니다.

서버 컴포넌트 & Next.js 앱 라우터

여기서는 서버 컴포넌트에 대해 자세히 다루지는 않겠지만, 간단히 설명하자면 서버 컴포넌트는 초기 페이지 뷰와 페이지 전환 시에도 서버에서만 실행되는 것이 보장되는 컴포넌트입니다. 이는 Next.js의 getServerSideProps/getStaticProps와 Remix의 loader가 작동하는 방식과 유사합니다. 이들도 항상 서버에서 실행되지만, 데이터만 반환할 수 있는 반면 서버 컴포넌트는 훨씬 더 많은 작업을 수행할 수 있습니다. 하지만 React Query의 핵심은 데이터 부분이므로, 그 부분에 집중해 보겠습니다.

서버 렌더링 가이드에서 배운 프레임워크 로더에서 프리페치된 데이터를 앱에 전달하는 방법을 서버 컴포넌트와 Next.js 앱 라우터에 적용하려면 어떻게 해야 할까요? 이를 생각하기 좋은 방법은 서버 컴포넌트를 "그냥" 또 다른 프레임워크 로더로 간주하는 것입니다.

용어에 대한 간단한 설명

지금까지 이 가이드에서는 _서버_와 _클라이언트_에 대해 이야기해 왔습니다. 그런데 헷갈리게도 이는 _서버 컴포넌트_와 _클라이언트 컴포넌트_와 1:1로 일치하지 않습니다. 서버 컴포넌트는 항상 서버에서만 실행되지만, 클라이언트 컴포넌트는 실제로 두 곳 모두에서 실행될 수 있습니다. 그 이유는 초기 서버 렌더링 단계에서도 렌더링될 수 있기 때문입니다.

이를 이해하는 한 가지 방법은 서버 컴포넌트도 _렌더링_되지만, 항상 서버에서 발생하는 "로더 단계"에서 실행된다는 것입니다. 반면 클라이언트 컴포넌트는 "애플리케이션 단계"에서 실행됩니다. 해당 애플리케이션은 SSR 중에 서버에서 실행될 수도 있고, 예를 들어 브라우저에서 실행될 수도 있습니다. 애플리케이션이 정확히 어디에서 실행되고 SSR 중에 실행되는지 여부는 프레임워크마다 다를 수 있습니다.

초기 설정

React Query 설정의 첫 번째 단계는 항상 queryClient를 생성하고 QueryClientProvider로 애플리케이션을 감싸는 것입니다. 서버 컴포넌트를 사용할 때, 파일 이름 규칙을 제외하면 프레임워크 간에 대부분 동일하게 보입니다:

// Next.js에서는 이 파일의 이름이 app/providers.jsx가 됩니다.
'use client'
 
// QueryClientProvider는 내부적으로 useContext에 의존하므로, 맨 위에 'use client'를 추가해야 합니다.
import {
  isServer,
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query'
 
function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        // SSR을 사용할 때는 일반적으로 staleTime의 기본값을
        // 0보다 크게 설정하여 클라이언트에서 즉시 리페칭하는 것을 피합니다.
        staleTime: 60 * 1000,
      },
    },
  })
}
 
let browserQueryClient: QueryClient | undefined = undefined
 
function getQueryClient() {
  if (isServer) {
    // 서버: 항상 새 쿼리 클라이언트를 만듭니다.
    return makeQueryClient()
  } else {
    // 브라우저: 아직 쿼리 클라이언트가 없다면 새로 만듭니다.
    // 이는 매우 중요한데, 초기 렌더링 중 React가 suspend되더라도
    // 새로운 클라이언트를 다시 만들지 않기 때문입니다.
    // 쿼리 클라이언트 생성 아래에 서스펜스 바운더리가 있는 경우에는
    // 이 작업이 필요하지 않을 수도 있습니다.
    if (!browserQueryClient) browserQueryClient = makeQueryClient()
    return browserQueryClient
  }
}
 
export default function Providers({ children }) {
  // 참고: suspend될 수 있는 코드와 이 코드 사이에 서스펜스 바운더리가 없는 경우,
  //       쿼리 클라이언트를 초기화할 때 useState를 사용하지 마세요.
  //       초기 렌더링 시 suspend되고 바운더리가 없으면 React가 클라이언트를 버리기 때문입니다.
  const queryClient = getQueryClient()
 
  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  )
}
 
typescript
// Next.js에서는 이 파일의 이름이 app/layout.jsx가 됩니다.
import Providers from './providers'
 
export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <head />
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}
 
typescript

이 부분은 SSR 가이드에서 했던 것과 비슷합니다. 단지 두 개의 다른 파일로 분리해야 할 뿐입니다.

데이터 프리페칭과 하이드레이션/디하이드레이션

실제로 데이터를 프리페치하고 디하이드레이션하고 하이드레이션하는 방법을 살펴보겠습니다. 이는 Next.js 페이지 라우터를 사용할 때 다음과 같이 보입니다:

// pages/posts.jsx
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
  useQuery,
} from '@tanstack/react-query'
 
// 이는 getServerSideProps일 수도 있습니다.
export async function getStaticProps() {
  const queryClient = new QueryClient()
 
  await queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
  })
 
  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  }
}
 
function Posts() {
  // 이 useQuery는 <PostsRoute>의 더 깊은 자식에서 발생할 수도 있습니다.
  // 어느 쪽이든 데이터는 즉시 사용 가능합니다.
  //
  // useSuspenseQuery 대신 useQuery를 사용하고 있다는 점에 유의하세요.
  // 이 데이터는 이미 서버에서 프리페치되었으므로,
  // 컴포넌트 자체에서 suspend할 필요가 없습니다.
  // 프리페치를 잊어버리거나 제거하면, 대신 클라이언트에서 데이터를 가져오게 되는데,
  // useSuspenseQuery를 사용했다면 더 안 좋은 부작용이 발생했을 것입니다.
  const { data } = useQuery({ queryKey: ['posts'], queryFn: getPosts })
 
  // 이 쿼리는 서버에서 프리페치되지 않았으며,
  // 클라이언트에서 가져오기 시작할 때까지 시작되지 않습니다.
  // 두 패턴 모두 함께 사용해도 괜찮습니다.
  const { data: commentsData } = useQuery({
    queryKey: ['posts-comments'],
    queryFn: getComments,
  })
 
  // ...
}
 
export default function PostsRoute({ dehydratedState }) {
  return (
    <HydrationBoundary state={dehydratedState}>
      <Posts />
    </HydrationBoundary>
  )
}
 
typescript

이를 앱 라우터로 변환하는 것은 실제로 꽤 비슷해 보입니다. 우리는 단지 몇 가지 사항을 이동시키기만 하면 됩니다. 먼저, 프리페칭 부분을 처리할 서버 컴포넌트를 생성합니다:

// 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 (
    // 멋지죠! 이제 직렬화는 props를 전달하는 것만큼 쉬워졌습니다.
    // HydrationBoundary는 클라이언트 컴포넌트이므로, 하이드레이션은 여기서 발생합니다.
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Posts />
    </HydrationBoundary>
  )
}
 
typescript

다음으로, 클라이언트 컴포넌트 부분이 어떻게 보이는지 살펴보겠습니다:

// app/posts/posts.jsx
'use client'
 
export default function Posts() {
  // 이 useQuery는 <Posts>의 더 깊은 자식에서 발생할 수도 있습니다.
  // 어느 쪽이든 데이터는 즉시 사용 가능합니다.
  const { data } = useQuery({
    queryKey: ['posts'],
    queryFn: () => getPosts(),
  })
 
  // 이 쿼리는 서버에서 프리페치되지 않았으며,
  // 클라이언트에서 가져오기 시작할 때까지 시작되지 않습니다.
  // 두 패턴 모두 함께 사용해도 괜찮습니다.
  const { data: commentsData } = useQuery({
    queryKey: ['posts-comments'],
    queryFn: getComments,
  })
 
  // ...
}
 
typescript

위의 예제에서 멋진 점 중 하나는 파일 이름을 제외하면 Next.js에만 특화된 부분이 없다는 것입니다. 나머지 모든 부분은 서버 컴포넌트를 지원하는 다른 프레임워크에서도 동일하게 보일 것입니다.

SSR 가이드에서는 모든 라우트에 <HydrationBoundary>를 추가하는 보일러플레이트를 제거할 수 있다고 언급했습니다. 그러나 서버 컴포넌트에서는 이것이 불가능합니다.

참고: TypeScript 버전이 5.1.3 미만이고 @types/react 버전이 18.2.8 미만일 때 비동기 서버 컴포넌트를 사용하면서 타입 오류가 발생하는 경우, 두 버전 모두 최신 버전으로 업데이트하는 것이 좋습니다. 또는 다른 컴포넌트 내에서 이 컴포넌트를 호출할 때 {/\\* @ts-expect-error Server Component \\*/}를 추가하는 임시 해결책을 사용할 수 있습니다. 자세한 내용은 Next.js 13 문서의 비동기 서버 컴포넌트 TypeScript 오류를 참조하세요.

참고: Only plain objects, and a few built-ins, can be passed to Server Actions. Classes or null prototypes are not supported. 오류가 발생하는 경우, queryFn에 함수 참조를 전달하지 마세요. 대신 함수를 호출하세요. queryFn 인자에는 많은 속성이 있으며 모든 것이 직렬화할 수 있는 것은 아닙니다. Server Action은 queryFn이 참조가 아닐 때만 작동합니다를 참조하세요.

서버 컴포넌트 중첩

서버 컴포넌트의 좋은 점은 중첩될 수 있고 React 트리의 여러 수준에 존재할 수 있어서, 애플리케이션의 최상위 수준에서뿐만 아니라 실제로 사용되는 곳에 더 가까운 곳에서 데이터를 프리페치할 수 있다는 것입니다(Remix 로더와 유사). 이는 서버 컴포넌트가 다른 서버 컴포넌트를 렌더링하는 것만큼 간단할 수 있습니다(이 예제에서는 간결함을 위해 클라이언트 컴포넌트는 생략하겠습니다):

// app/posts/page.jsx
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from '@tanstack/react-query'
import Posts from './posts'
import CommentsServerComponent from './comments-server'
 
export default async function PostsPage() {
  const queryClient = new QueryClient()
 
  await queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
  })
 
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Posts />
      <CommentsServerComponent />
    </HydrationBoundary>
  )
}
 
// app/posts/comments-server.jsx
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from '@tanstack/react-query'
import Comments from './comments'
 
export default async function CommentsServerComponent() {
  const queryClient = new QueryClient()
 
  await queryClient.prefetchQuery({
    queryKey: ['posts-comments'],
    queryFn: getComments,
  })
 
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Comments />
    </HydrationBoundary>
  )
}
 
typescript

보시다시피, 여러 곳에서 <HydrationBoundary>를 사용하고 프리페칭을 위해 여러 queryClient를 생성하고 디하이드레이션하는 것은 전혀 문제가 되지 않습니다.

CommentsServerComponent를 렌더링하기 전에 getPosts를 기다리고 있기 때문에 이는 서버 측 워터폴로 이어질 것입니다:

1. |> getPosts()
2.   |> getComments()
text

데이터에 대한 서버 지연 시간이 짧다면 이는 큰 문제가 되지 않을 수 있지만, 여전히 언급할 가치가 있습니다.

Next.js에서는 page.tsx에서 데이터를 프리페치하는 것 외에도 layout.tsx와 병렬 라우트에서도 할 수 있습니다. 이들은 모두 라우팅의 일부이므로 Next.js는 이들을 모두 병렬로 가져오는 방법을 알고 있습니다. 따라서 위의 CommentsServerComponent가 대신 병렬 라우트로 표현된다면, 워터폴은 자동으로 평평해질 것입니다.

더 많은 프레임워크가 서버 컴포넌트를 지원하기 시작하면서 다른 라우팅 규칙을 가질 수 있습니다. 자세한 내용은 프레임워크 문서를 참조하세요.

대안: 프리페칭을 위해 단일 queryClient 사용

위의 예제에서는 데이터를 가져오는 각 서버 컴포넌트에 대해 새로운 queryClient를 생성합니다. 이는 권장되는 접근 방식이지만, 원한다면 모든 서버 컴포넌트에서 재사용되는 단일 queryClient를 생성할 수도 있습니다:

// app/getQueryClient.jsx
import { QueryClient } from '@tanstack/react-query'
import { cache } from 'react'
 
// cache()는 요청별로 범위가 지정되므로, 요청 간에 데이터가 누출되지 않습니다.
const getQueryClient = cache(() => new QueryClient())
export default getQueryClient
 
typescript

이렇게 하면 서버 컴포넌트에서 호출되는 유틸리티 함수를 포함하여 어디서든 getQueryClient()를 호출하여 이 클라이언트를 가져올 수 있습니다. 단점은 dehydrate(getQueryClient())를 호출할 때마다 이전에 이미 직렬화되었고 현재 서버 컴포넌트와 관련이 없는 쿼리를 포함하여 전체 queryClient를 직렬화한다는 것입니다. 이는 불필요한 오버헤드입니다.

Next.js는 이미 fetch()를 활용하는 요청을 중복 제거하지만, queryFn에서 다른 것을 사용하거나 이러한 요청을 자동으로 중복 제거하지 않는 프레임워크를 사용하는 경우, 위에서 설명한 것처럼 단일 queryClient를 사용하는 것이 중복 직렬화에도 불구하고 타당할 수 있습니다.

향후 개선 사항으로, dehydrateNew() 함수(이름은 임시)를 만들어 마지막 dehydrateNew() 호출 이후 새로운 쿼리만 디하이드레이션하는 것을 고려해 볼 수 있습니다. 이에 관심이 있고 도움을 주고 싶다면 언제든지 연락해 주세요!

데이터 소유권과 재검증

서버 컴포넌트에서는 데이터 소유권과 재검증에 대해 생각하는 것이 중요합니다. 그 이유를 설명하기 위해 위의 수정된 예제를 살펴보겠습니다:

// 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()
 
  // 이제 fetchQuery()를 사용하고 있다는 점에 유의하세요.
  const posts = await queryClient.fetchQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
  })
 
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      {/* 이 부분이 새로 추가되었습니다. */}
      <div>Nr of posts: {posts.length}</div>
      <Posts />
    </HydrationBoundary>
  )
}
 
typescript

이제 서버 컴포넌트와 클라이언트 컴포넌트 모두에서 getPosts 쿼리의 데이터를 렌더링하고 있습니다. 초기 페이지 렌더링에는 문제가 없겠지만, staleTime이 지나 어떤 이유로 쿼리가 클라이언트에서 재검증될 때는 어떻게 될까요?

React Query는 _서버 컴포넌트를 재검증하는 방법_을 모릅니다. 따라서 클라이언트에서 데이터를 다시 가져와 React가 게시물 목록을 다시 렌더링하게 되면, Nr of posts: {posts.length}는 결국 동기화되지 않은 상태가 됩니다.

staleTime: Infinity를 설정하여 React Query가 절대 재검증하지 않도록 하면 괜찮겠지만, 애초에 React Query를 사용하는 이유와는 거리가 멀 것입니다.

React Query를 서버 컴포넌트와 함께 사용하는 것은 다음과 같은 경우에 가장 적합합니다:

  • React Query를 사용하는 앱이 있고 모든 데이터 가져오기를 다시 작성하지 않고 서버 컴포넌트로 마이그레이션하려는 경우
  • 익숙한 프로그래밍 패러다임을 원하지만 여전히 서버 컴포넌트의 이점을 가장 적합한 곳에 적용하고 싶은 경우
  • React Query가 다루는 일부 유스 케이스가 있지만 선택한 프레임워크에서는 다루지 않는 경우

React Query를 서버 컴포넌트와 함께 사용하는 것이 언제 적합하고 그렇지 않은지에 대한 일반적인 조언을 주기는 어렵습니다. 새로운 서버 컴포넌트 앱을 시작하는 경우, 데이터 가져오기를 위해 프레임워크에서 제공하는 도구를 사용하고 실제로 필요할 때까지 React Query를 도입하지 않는 것이 좋습니다. 이는 결코 없을 수도 있고 그것은 괜찮습니다. 상황에 맞는 적절한 도구를 사용하세요!

React Query를 사용하는 경우, 오류를 잡아내기 위해 필요한 경우가 아니라면 queryClient.fetchQuery를 피하는 것이 좋습니다. 만약 사용한다면, 그 결과를 서버에서 렌더링하거나 클라이언트 컴포넌트라도 다른 컴포넌트에 전달하지 마세요.

React Query 관점에서 서버 컴포넌트는 데이터를 프리페치하는 곳일 뿐, 그 이상은 아닙니다.

물론 서버 컴포넌트가 일부 데이터를 소유하고 클라이언트 컴포넌트가 다른 데이터를 소유하는 것은 괜찮습니다. 단지 이 두 현실이 동기화되지 않도록 하세요.

서버 컴포넌트를 사용한 스트리밍

Next.js 앱 라우터는 애플리케이션 중 표시될 준비가 된 모든 부분을 가능한 한 빨리 브라우저로 자동 스트리밍하므로, 보류 중인 컨텐츠를 기다리지 않고도 완성된 컨텐츠를 즉시 표시할 수 있습니다. 이는 <Suspense> 경계를 따라 이루어집니다. loading.tsx 파일을 생성하면 자동으로 <Suspense> 경계가 생성된다는 점에 유의하세요.

위에서 설명한 프리페칭 패턴을 사용하면 React Query는 이러한 형태의 스트리밍과 완벽하게 호환됩니다. 각 Suspense 경계에 대한 데이터가 해결되면 Next.js는 완성된 컨텐츠를 렌더링하고 브라우저로 스트리밍할 수 있습니다. 프리페치를 기다리는 동안 실제로 suspend가 발생하기 때문에 위에서 설명한 대로 useQuery를 사용하더라도 이는 작동합니다.

React Query v5.40.0부터는 이 작업이 이루어지려면 모든 프리페치를 기다릴 필요가 없습니다. 보류 중인 쿼리도 디하이드레이션되어 클라이언트로 전송될 수 있기 때문입니다. 이를 통해 전체 Suspense 경계를 차단하지 않고도 가능한 한 빨리 프리페치를 시작할 수 있으며, 쿼리가 완료되면 _데이터_가 클라이언트로 스트리밍됩니다. 예를 들어 사용자 상호 작용 후에만 표시되는 일부 컨텐츠를 프리페치하거나, 무한 쿼리의 첫 페이지를 기다리고 렌더링하지만 렌더링을 차단하지 않고 2페이지를 프리페치하기 시작하려는 경우에 유용할 수 있습니다.

이를 작동시키려면 보류 중인 쿼리도 디하이드레이션하도록 queryClient에 지시해야 합니다. 이는 전역적으로 또는 hydrate에 해당 옵션을 직접 전달하여 수행할 수 있습니다.

또한 서버 컴포넌트에서 사용하고 클라이언트 프로바이더에서도 사용하려는 getQueryClient() 함수를 app/providers.jsx 파일에서 이동해야 합니다.

// app/get-query-client.ts
import { QueryClient, defaultShouldDehydrateQuery } from '@tanstack/react-query'
 
function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000,
      },
      dehydrate: {
        // 디하이드레이션에 보류 중인 쿼리 포함
        shouldDehydrateQuery: (query) =>
          defaultShouldDehydrateQuery(query) ||
          query.state.status === 'pending',
      },
    },
  })
}
 
let browserQueryClient: QueryClient | undefined = undefined
 
export function getQueryClient() {
  if (isServer) {
    // 서버: 항상 새로운 쿼리 클라이언트 생성
    return makeQueryClient()
  } else {
    // 브라우저: 아직 쿼리 클라이언트가 없다면 새로 생성
    // 초기 렌더링 중에 React가 suspend되더라도 쿼리 클라이언트를 다시 생성하지 않도록 하는 것이 매우 중요합니다.
    // 쿼리 클라이언트 생성 아래에 서스펜스 경계가 있는 경우에는 이 작업이 필요하지 않을 수 있습니다.
    if (!browserQueryClient) browserQueryClient = makeQueryClient()
    return browserQueryClient
  }
}
 
typescript

참고: NextJs와 서버 컴포넌트에서 이 작업이 가능한 이유는 클라이언트 컴포넌트로 Promise를 전달할 때 React가 Promise를 직렬화할 수 있기 때문입니다.

그런 다음 HydrationBoundary를 제공하기만 하면 됩니다. 하지만 더 이상 프리페치를 기다릴 필요는 없습니다:

// app/posts/page.jsx
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { getQueryClient } from './get-query-client'
import Posts from './posts'
 
// 아무것도 `await`하지 않기 때문에 함수는 `async`일 필요가 없습니다.
export default function PostsPage() {
  const queryClient = getQueryClient()
 
  // await가 없네요!
  queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
  })
 
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Posts />
    </HydrationBoundary>
  )
}
 
typescript

클라이언트에서는 Promise가 QueryCache에 자동으로 추가됩니다. 이는 서버에서 생성된 Promise를 "사용"하기 위해 Posts 컴포넌트 내에서 useSuspenseQuery를 호출할 수 있다는 것을 의미합니다:

// app/posts/posts.tsx
'use client'
 
export default function Posts() {
  const { data } = useSuspenseQuery({ queryKey: ['posts'], queryFn: getPosts })
 
  // ...
}
 
typescript

useQuery 대신 useSuspenseQuery를 사용할 수도 있고, Promise는 여전히 올바르게 처리될 것입니다. 그러나 NextJs는 이 경우 suspend하지 않고 보류 중인 상태로 컴포넌트를 렌더링하므로, 컨텐츠의 서버 렌더링도 옵트아웃하게 됩니다.

JSON이 아닌 데이터 타입을 사용하고 서버에서 쿼리 결과를 직렬화하는 경우, dehydrate.serializeDatahydrate.deserializeData 옵션을 지정하여 경계의 양쪽에서 데이터를 직렬화하고 역직렬화하여 캐시의 데이터가 서버와 클라이언트에서 동일한 형식인지 확인할 수 있습니다:

// app/get-query-client.ts
import { QueryClient, defaultShouldDehydrateQuery } from '@tanstack/react-query'
import { deserialize, serialize } from './transformer'
 
function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      // ...
      hydrate: {
        deserializeData: deserialize,
      },
      dehydrate: {
        serializeData: serialize,
      },
    },
  })
}
 
// ...
 
typescript
// app/posts/page.tsx
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from '@tanstack/react-query'
import { getQueryClient } from './get-query-client'
import { serialize } from './transformer'
import Posts from './posts'
 
export default function PostsPage() {
  const queryClient = getQueryClient()
 
  // await가 없네요!
  queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: () => getPosts().then(serialize), // <-- 서버에서 데이터 직렬화
  })
 
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Posts />
    </HydrationBoundary>
  )
}
 
typescript
// app/posts/posts.tsx
'use client'
 
export default function Posts() {
  const { data } = useSuspenseQuery({ queryKey: ['posts'], queryFn: getPosts })
 
  // ...
}
 
typescript

이제 getPosts 함수는 예를 들어 Temporal datetime 객체를 반환할 수 있고, transformer가 해당 데이터 타입을 직렬화하고 역직렬화할 수 있다고 가정하면 데이터는 클라이언트에서 직렬화되고 역직렬화됩니다.

자세한 내용은 프리페칭을 사용한 Next.js 앱 예제를 확인하세요.

Next.js에서 프리페칭 없이 실험적인 스트리밍

초기 페이지 로드 이후의 모든 페이지 탐색에서 요청 워터폴을 평평하게 만들기 때문에 위에서 설명한 프리페칭 솔루션을 권장하지만, 프리페칭을 완전히 건너뛰고 여전히 스트리밍 SSR이 작동하도록 하는 실험적인 방법이 있습니다: @tanstack/react-query-next-experimental

이 패키지를 사용하면 컴포넌트에서 useSuspenseQuery를 호출하는 것만으로 서버(클라이언트 컴포넌트)에서 데이터를 가져올 수 있습니다. 그러면 SuspenseBoundaries가 해결될 때 결과가 서버에서 클라이언트로 스트리밍됩니다. <Suspense> 경계로 감싸지 않고 useSuspenseQuery를 호출하면 가져오기가 해결될 때까지 HTML 응답이 시작되지 않습니다. 이는 상황에 따라 원하는 바일 수 있지만, 이는 TTFB에 영향을 미칠 것입니다.

이를 위해 앱을 ReactQueryStreamedHydration 컴포넌트로 감쌉니다:

// app/providers.tsx
'use client'
 
import {
  isServer,
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query'
import * as React from 'react'
import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental'
 
function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        // SSR을 사용할 때는 일반적으로 staleTime의 기본값을
        // 0보다 크게 설정하여 클라이언트에서 즉시 리페칭하는 것을 피합니다.
        staleTime: 60 * 1000,
      },
    },
  })
}
 
let browserQueryClient: QueryClient | undefined = undefined
 
function getQueryClient() {
  if (isServer) {
    // 서버: 항상 새로운 쿼리 클라이언트 생성
    return makeQueryClient()
  } else {
    // 브라우저: 아직 쿼리 클라이언트가 없다면 새로 생성
    // 초기 렌더링 중에 React가 suspend되더라도 쿼리 클라이언트를 다시 생성하지 않도록 하는 것이 매우 중요합니다.
    // 쿼리 클라이언트 생성 아래에 서스펜스 경계가 있는 경우에는 이 작업이 필요하지 않을 수 있습니다.
    if (!browserQueryClient) browserQueryClient = makeQueryClient()
    return browserQueryClient
  }
}
 
export function Providers(props: { children: React.ReactNode }) {
  // 참고: 서스펜스 경계 사이에 suspend될 수 있는 코드가 없는 경우,
  //       쿼리 클라이언트를 초기화할 때 useState를 사용하지 마세요.
  //       초기 렌더링 시 suspend되고 바운더리가 없으면 React가 클라이언트를 버리기 때문입니다.
  const queryClient = getQueryClient()
 
  return (
    <QueryClientProvider client={queryClient}>
      <ReactQueryStreamedHydration>
        {props.children}
      </ReactQueryStreamedHydration>
    </QueryClientProvider>
  )
}
 
typescript

자세한 내용은 NextJs Suspense 스트리밍 예제를 확인하세요.

큰 장점은 더 이상 SSR이 작동하도록 수동으로 쿼리를 프리페치할 필요가 없다는 것이며, 결과를 스트리밍하는 것조차 가능하다는 것입니다! 이는 여러분에게 훌륭한 DX와 낮은 코드 복잡성을 제공합니다.

단점은 성능 & 리퀘스트 워터폴 가이드의 복잡한 리퀘스트 워터폴 예제를 다시 보면 가장 쉽게 설명할 수 있습니다. 프리페칭을 사용하는 서버 컴포넌트는 초기 페이지 로드 이후의 모든 페이지 탐색에서 리퀘스트 워터폴을 효과적으로 제거합니다. 그러나 이 프리페치 없는 접근 방식은 초기 페이지 로드에서만 워터폴을 평평하게 만들고 페이지 탐색 시에는 원래 예제와 동일한 깊은 워터폴로 끝납니다:

1. |> <Feed>의 JS
2.   |> getFeed()
3.     |> <GraphFeedItem>의 JS
4.       |> getGraphDataById()
text

이는 getServerSideProps/getStaticProps보다 더 나쁜데, 이들을 사용하면 적어도 데이터와 코드 가져오기를 병렬화할 수 있었기 때문입니다.

성능보다 DX/반복/출시 속도를 중요하게 여기고 깊이 중첩된 쿼리가 없거나 useSuspenseQueries와 같은 도구를 사용하여 병렬 가져오기로 리퀘스트 워터폴을 잘 처리하고 있다면, 이는 좋은 절충안이 될 수 있습니다.

두 접근 방식을 결합하는 것도 가능할 수 있지만, 우리조차도 아직 시도해 보지 않았습니다. 만약 여러분이 이를 시도해 본다면, 발견한 내용을 알려주시거나 더 나아가 이 문서를 일부 팁으로 업데이트해 주시면 감사하겠습니다!

마지막 말

서버 컴포넌트와 스트리밍은 여전히 비교적 새로운 개념이며, 우리는 React Query가 어떻게 적합한지, 그리고 API를 어떻게 개선할 수 있을지 아직 파악 중입니다. 제안, 피드백, 버그 보고를 환영합니다!

마찬가지로, 이 새로운 패러다임의 모든 복잡성을 한 번에, 첫 번째 시도에서 모두 가르치는 것은 불가능할 것입니다. 여기에서 누락된 정보가 있거나 이 내용을 개선하는 방법에 대한 제안이 있다면, 연락해 주세요.