고급 서버 렌더링
고급 서버 렌더링 가이드에 오신 것을 환영합니다. 여기에서는 스트리밍, Server Components 및 Next.js app router와 함께 React Query를 사용하는 방법을 모두 알아봅니다.
이 가이드보다 먼저 서버 렌더링 및 하이드레이션 가이드를 읽어보는 것이 좋습니다. 이 가이드에서는 SSR과 함께 React Query를 사용하는 기본 사항을 설명하며, 성능 및 요청 워터폴과 프리페치 및 Router 통합에도 유용한 배경지식이 담겨 있습니다.
시작하기 전에 SSR 가이드에 설명된 initialData 접근 방식도 Server Components에서 작동하지만, 이 가이드에서는 하이드레이션 API에 중점을 둔다는 점을 알아두겠습니다.
Server Components 및 Next.js 앱 라우터
여기서는 Server Components를 자세히 다루지 않지만, 간단히 말하면 초기 페이지 표시와 페이지 전환 시에도 서버에서만 실행되도록 보장되는 컴포넌트입니다. 이는 Next.js getServerSideProps/getStaticProps 및 Remix loader의 작동 방식과 유사합니다. 이들도 항상 서버에서 실행되지만 데이터만 반환할 수 있는 반면, Server Components는 훨씬 더 많은 작업을 할 수 있습니다. 하지만 데이터 부분이 React Query의 핵심이므로 여기에 집중하겠습니다.
서버 렌더링 가이드에서 배운 프레임워크 로더에서 프리페치한 데이터를 앱에 전달하기를 Server Components와 Next.js 앱 라우터에 어떻게 적용할 수 있을까요? 이를 이해하기 시작하는 가장 좋은 방법은 Server Components를 "그저" 또 하나의 프레임워크 로더로 간주하는 것입니다.
용어에 관한 간단한 참고 사항
지금까지 이 가이드에서는 _서버_와 _클라이언트_에 관해 설명했습니다. 혼동하기 쉽지만, 이 구분은 Server Components 및 _Client Components_와 일대일로 일치하지 않는다는 점에 유의해야 합니다. Server Components는 서버에서만 실행되도록 보장되지만, Client Components는 실제로 양쪽 모두에서 실행될 수 있습니다. 이는 초기 서버 렌더링 과정에서도 렌더링할 수 있기 때문입니다.
이를 이해하는 한 가지 방법은 Server Components도 렌더링하지만, 이 렌더링은 "로더 단계"(항상 서버에서 발생함)에서 이루어지는 반면 Client Components는 "애플리케이션 단계"에서 실행된다고 생각하는 것입니다. 해당 애플리케이션은 SSR 중 서버에서도 실행될 수 있고, 예를 들어 브라우저에서도 실행될 수 있습니다. 애플리케이션이 정확히 어디에서 실행되는지와 SSR 중에 실행되는지 여부는 프레임워크마다 다를 수 있습니다.
초기 설정
모든 React Query 설정의 첫 단계는 항상 queryClient를 생성하고 애플리케이션을 QueryClientProvider로 감싸는 것입니다. Server Components에서는 프레임워크 전반에 걸쳐 거의 동일하지만, 한 가지 차이점은 파일 이름 규칙입니다.
// In Next.js, this file would be called: app/providers.tsx
'use client'
// Since QueryClientProvider relies on useContext under the hood, we have to put 'use client' on top
import {
environmentManager,
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 (environmentManager.isServer()) {
// 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 }: { children: React.ReactNode }) {
// 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.tsx
import Providers from './providers'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<head />
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
이 부분은 SSR 가이드에서 수행한 작업과 상당히 유사하며, 단지 두 개의 서로 다른 파일로 분리하면 됩니다.
데이터 프리페치 및 디하이드레이션/하이드레이션
다음으로 실제로 데이터를 프리페치한 후 디하이드레이션하고 하이드레이션하는 방법을 살펴보겠습니다. Next.js Pages Router를 사용하면 다음과 같습니다:
// pages/posts.tsx
import {
dehydrate,
HydrationBoundary,
QueryClient,
useQuery,
} from '@tanstack/react-query'
// This could also be getServerSideProps
export async function getStaticProps() {
const queryClient = new QueryClient()
await queryClient
.query({
queryKey: ['posts'],
queryFn: getPosts,
})
.catch(noop)
return {
props: {
dehydratedState: dehydrate(queryClient),
},
}
}
function Posts() {
// This useQuery could just as well happen in some deeper child to
// the <PostsRoute>, data will be available immediately either way
//
// Note that we are using useQuery here instead of useSuspenseQuery.
// Because this data has already been prefetched, there is no need to
// ever suspend in the component itself. If we forget or remove the
// prefetch, this will instead fetch the data on the client, while
// using useSuspenseQuery would have had worse side effects.
const { data } = useQuery({ queryKey: ['posts'], queryFn: getPosts })
// This query was not prefetched on the server and will not start
// fetching until on the client, both patterns are fine to mix
const { data: commentsData } = useQuery({
queryKey: ['posts-comments'],
queryFn: getComments,
})
// ...
}
export default function PostsRoute({ dehydratedState }) {
return (
<HydrationBoundary state={dehydratedState}>
<Posts />
</HydrationBoundary>
)
}
이를 app router로 변환하는 과정은 실제로 상당히 비슷하며, 몇 가지를 약간 옮기기만 하면 됩니다. 먼저 프리페치 부분을 처리할 Server Component를 생성합니다:
// app/posts/page.tsx
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query'
import Posts from './posts'
export default async function PostsPage() {
const queryClient = new QueryClient()
await queryClient
.query({
queryKey: ['posts'],
queryFn: getPosts,
})
.catch(noop)
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>
)
}
다음으로 Client Component 부분이 어떻게 구성되는지 살펴보겠습니다:
// app/posts/posts.tsx
'use client'
export default function Posts() {
// This useQuery could just as well happen in some deeper
// child to <Posts>, data will be available immediately either way
const { data } = useQuery({
queryKey: ['posts'],
queryFn: () => getPosts(),
})
// This query was not prefetched on the server and will not start
// fetching until on the client, both patterns are fine to mix.
const { data: commentsData } = useQuery({
queryKey: ['posts-comments'],
queryFn: getComments,
})
// ...
}
위 예시의 멋진 점 하나는 여기서 Next.js에만 해당하는 것은 파일 이름뿐이며, 그 밖의 모든 것은 Server Components를 지원하는 다른 어떤 프레임워크에서도 동일하게 보인다는 것입니다.
SSR 가이드에서는 모든 라우트에 <HydrationBoundary>를 두는 상용구를 제거할 수 있다고 언급했습니다. Server Components에서는 불가능합니다.
참고: TypeScript 버전이
5.1.3보다 낮고@types/react버전이18.2.8보다 낮은 환경에서 async Server Components를 사용하는 중에 타입 오류가 발생하면 둘 다 최신 버전으로 업데이트하는 것을 권장합니다. 또는 이 컴포넌트를 다른 컴포넌트 내부에서 호출할 때{/* @ts-expect-error Server Component */}을 추가하는 임시 해결 방법을 사용할 수 있습니다. 자세한 내용은 Next.js TypeScript 문서의 Async Server Component TypeScript 오류를 참조하세요.
경고:
queryFn에서 데이터를 가져오기 위해 Next.js Server Actions를 사용하는 것은 권장하지 않습니다. 클라이언트에서 호출하면 Server Actions는 병렬이 아닌 직렬로 실행되며, 이는 React Query가 쿼리를 가져오고 다시 가져오는 방식과 충돌합니다. 이로 인해 쿼리가 pending 상태에 멈추거나 액션이 아예 실행되지 않을 수 있습니다(#7934 참조). Server Action 참조를queryFn에 전달하는 것도Only plain objects, and a few built-ins, can be passed to Server Actions...와 함께 실패할 수 있습니다. 액션을 참조로 전달하는 대신 _호출_해야 하기 때문입니다(#6264 참조). 클라이언트에서 데이터를 가져오려면 대신 API route의fetch를 사용하거나 tRPC 같은 RPC 계층을 사용합니다. Server Actions는 여전히 뮤테이션(useMutation)에 적합합니다.
Server Component 중첩
Server Components의 좋은 점은 React 트리의 여러 수준에 중첩되어 존재할 수 있으므로, 애플리케이션 최상단에서만 데이터를 프리페치하지 않고 데이터가 실제로 사용되는 위치에 더 가깝게 프리페치할 수 있다는 것입니다(Remix 로더와 마찬가지입니다). 이는 Server Component가 다른 Server Component를 렌더링하는 것만큼 간단할 수 있습니다(간결성을 위해 이 예시에서는 Client Components를 제외하겠습니다):
// app/posts/page.tsx
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
.query({
queryKey: ['posts'],
queryFn: getPosts,
})
.catch(noop)
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Posts />
<CommentsServerComponent />
</HydrationBoundary>
)
}
// app/posts/comments-server.tsx
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query'
import Comments from './comments'
export default async function CommentsServerComponent() {
const queryClient = new QueryClient()
await queryClient
.query({
queryKey: ['posts-comments'],
queryFn: getComments,
})
.catch(noop)
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Comments />
</HydrationBoundary>
)
}
보시다시피 여러 위치에서 <HydrationBoundary>를 사용하고 프리페치를 위해 여러 queryClient를 생성하고 디하이드레이션해도 전혀 문제없습니다.
CommentsServerComponent를 렌더링하기 전에 getPosts를 기다리고 있으므로 서버 측 워터폴이 발생한다는 점에 유의하세요:
1. |> getPosts()
2. |> getComments()
데이터에 대한 서버 지연 시간이 짧다면 큰 문제가 아닐 수 있지만, 그래도 언급할 만합니다.
Next.js에서는 page.tsx에서 데이터를 프리페치하는 것 외에도 layout.tsx 및 병렬 라우트에서도 프리페치할 수 있습니다. 이들은 모두 라우팅의 일부이므로 Next.js는 이 모든 데이터를 병렬로 가져오는 방법을 알고 있습니다. 따라서 위의 CommentsServerComponent가 병렬 라우트로 표현되었다면 워터폴은 자동으로 평탄화됩니다.
더 많은 framework가 Server Components를 지원하기 시작하면 서로 다른 라우팅 규칙을 사용할 수도 있습니다. 자세한 내용은 사용 중인 framework의 문서를 읽어보세요.
대안: 프리페치에 단일 queryClient 사용하기
위 예제에서는 데이터를 가져오는 각 Server Component마다 새로운 queryClient를 생성합니다. 이것이 권장되는 접근 방식이지만, 원하는 경우 모든 Server Components에서 재사용되는 단일 인스턴스를 대신 생성할 수 있습니다:
// app/getQueryClient.tsx
import { QueryClient } from '@tanstack/react-query'
import { cache } from 'react'
// cache() is scoped per request, so we don't leak data between requests
const getQueryClient = cache(() => new QueryClient())
export default getQueryClient
이 방식의 이점은 utility function을 포함하여 Server Component에서 호출되는 어디서든 getQueryClient()를 호출해 이 client를 가져올 수 있다는 것입니다. 단점은 dehydrate(getQueryClient())를 호출할 때마다 이전에 이미 직렬화되었고 현재 Server Component와 관련 없는 쿼리까지 포함해 전체 queryClient를 직렬화하므로 불필요한 오버헤드가 발생한다는 것입니다.
Next.js는 이미 fetch()를 활용하는 요청을 중복 제거하지만, queryFn에서 다른 것을 사용하거나 이러한 요청을 자동으로 중복 제거하지 않는 프레임워크를 사용한다면 직렬화가 중복되더라도 위에서 설명한 단일 queryClient를 사용하는 것이 적절할 수 있습니다.
향후 개선 사항으로, 마지막으로
dehydrateNew()을 호출한 이후 새로 생긴 쿼리만 디하이드레이션하는dehydrateNew()함수(이름 미정)를 만드는 방안을 검토할 수 있습니다. 흥미롭게 들리고 직접 기여하고 싶다면 언제든지 연락해 주세요!
데이터 소유권 및 재검증
Server Components에서는 데이터 소유권과 재검증을 고려하는 것이 중요합니다. 그 이유를 설명하기 위해 위 예시를 수정한 다음 예시를 살펴보겠습니다:
// app/posts/page.tsx
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query'
import Posts from './posts'
export default async function PostsPage() {
const queryClient = new QueryClient()
// Note we are getting the result from query
const posts = await queryClient.query({
queryKey: ['posts'],
queryFn: getPosts,
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
{/* This is the new part */}
<div>Nr of posts: {posts.length}</div>
<Posts />
</HydrationBoundary>
)
}
이제 getPosts 쿼리의 데이터를 Server Component와 Client Component 모두에서 렌더링하고 있습니다. 초기 페이지 렌더링에는 문제가 없지만, staleTime이 전달된 상태에서 어떤 이유로 클라이언트의 쿼리가 재검증되면 어떻게 될까요?
React Query는 Server Component를 재검증하는 방법을 알지 못하므로, 클라이언트에서 데이터를 다시 가져와 React가 게시물 목록을 다시 렌더링하게 되면 Nr of posts: {posts.length}의 동기화가 어긋나게 됩니다.
React Query가 절대 재검증되지 않도록 staleTime: Infinity를 설정했다면 괜찮지만, 애초에 React Query를 사용하고 있다면 이는 아마도 원하는 동작이 아닐 것입니다.
다음 조건에서는 Server Components와 함께 React Query를 사용하는 것이 가장 적합합니다:
- React Query를 사용하는 앱이 있으며 모든 데이터 가져오기를 다시 작성하지 않고 Server Components로 마이그레이션하려고 합니다
- 익숙한 프로그래밍 패러다임을 원하지만, 가장 적합한 곳에는 Server Components의 이점을 계속 가미하고자 합니다
- React Query에서는 지원하지만 선택한 프레임워크에서는 지원하지 않는 사용 사례가 있습니다.
React Query를 Server Components와 함께 사용하는 것이 적절한 경우와 그렇지 않은 경우에 대한 일반적인 조언을 드리기는 어렵습니다. 새 Server Components 앱을 이제 막 시작한다면 프레임워크에서 제공하는 데이터 가져오기 도구로 시작하고, 실제로 필요해질 때까지 React Query를 도입하지 않는 것을 권장합니다. 끝내 필요하지 않을 수도 있으며, 그래도 괜찮습니다. 작업에 적합한 도구를 사용하세요!
이를 사용하는 경우, 경험칙상 queryClient.query의 결과를 서버에서 렌더링하거나 다른 컴포넌트에 전달하지 않는 것이 좋으며, 그 대상이 Client Component인 경우에도 마찬가지입니다.
React Query의 관점에서는 Server Components를 오직 데이터 프리페치 장소로만 취급합니다.
물론 Server Component가 일부 데이터를 소유하고 Client Component가 다른 데이터를 소유해도 괜찮지만, 이 두 상태가 서로 동기화되지 않는 일이 없도록 해야 합니다.
Server Components를 사용한 스트리밍
Next.js 앱 라우터는 표시할 준비가 된 애플리케이션의 모든 부분을 최대한 빨리 브라우저로 자동 스트리밍하므로, 아직 대기 중인 콘텐츠를 기다리지 않고 완료된 콘텐츠를 즉시 표시할 수 있습니다. 이 작업은 <Suspense> 경계를 따라 수행됩니다. loading.tsx 파일을 생성하면 내부적으로 <Suspense> 경계가 자동 생성된다는 점에 유의하세요.
위에서 설명한 프리페치 패턴을 사용하면 React Query는 이러한 형태의 스트리밍과 완벽하게 호환됩니다. 각 Suspense 경계의 데이터가 이행되면 Next.js는 완성된 콘텐츠를 렌더링하여 브라우저로 스트리밍할 수 있습니다. 실제 일시 중단은 프리페치를 await할 때 발생하므로, 위에서 설명한 대로 useQuery를 사용하는 경우에도 작동합니다.
React Query v5.40.0부터는 이 기능이 작동하도록 모든 프리페치를 await할 필요가 없습니다. pending 쿼리도 디하이드레이션하여 클라이언트로 보낼 수 있기 때문입니다. 이를 통해 전체 Suspense 경계를 차단하지 않으면서 가능한 한 일찍 프리페치를 시작할 수 있으며, 쿼리가 완료되는 대로 _데이터_를 클라이언트로 스트리밍합니다. 예를 들어 일부 사용자 상호작용 후에만 표시되는 콘텐츠를 프리페치하려는 경우나, 무한 쿼리의 첫 번째 페이지를 await하고 렌더링하되 렌더링을 차단하지 않고 2페이지 프리페치를 시작하려는 경우에 유용할 수 있습니다.
이 기능이 작동하게 하려면 queryClient에 대기 중인 Query도 dehydrate하도록 지시해야 합니다. 이를 전역으로 설정하거나 해당 옵션을 dehydrate에 직접 전달할 수 있습니다.
서버 컴포넌트와 클라이언트 provider에서 사용하려 하므로 getQueryClient() 함수도 app/providers.tsx 파일 밖으로 옮겨야 합니다.
// app/get-query-client.ts
import {
environmentManager,
QueryClient,
defaultShouldDehydrateQuery,
} from '@tanstack/react-query'
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
dehydrate: {
// include pending queries in dehydration
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === 'pending',
shouldRedactErrors: (error) => {
// We should not catch Next.js server errors
// as that's how Next.js detects dynamic pages
// so we cannot redact them.
// Next.js also automatically redacts errors for us
// with better digests.
return false
},
},
},
})
}
let browserQueryClient: QueryClient | undefined = undefined
export function getQueryClient() {
if (environmentManager.isServer()) {
// 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
}
}
참고: React는 Promise를 Client Components에 전달할 때 네트워크를 통해 직렬화할 수 있으므로, 이는 NextJs 및 Server Components에서 작동합니다.
그런 다음 HydrationBoundary를 제공하기만 하면 되며, 더 이상 프리페치를 await할 필요가 없습니다:
// app/posts/page.tsx
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { getQueryClient } from './get-query-client'
import Posts from './posts'
// the function doesn't need to be `async` because we don't `await` anything
export default function PostsPage() {
const queryClient = getQueryClient()
// look ma, no await
void queryClient
.query({
queryKey: ['posts'],
queryFn: getPosts,
})
.catch(noop)
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Posts />
</HydrationBoundary>
)
}
클라이언트에서는 Promise가 자동으로 QueryCache에 배치됩니다. 즉, 이제 Posts 컴포넌트 내부에서 useSuspenseQuery를 호출하여 Server에서 생성된 해당 Promise를 "사용"할 수 있습니다:
// app/posts/posts.tsx
'use client'
export default function Posts() {
const { data } = useSuspenseQuery({ queryKey: ['posts'], queryFn: getPosts })
// ...
}
useSuspenseQuery대신useQuery할 수도 있으며, 이 경우에도 Promise는 여전히 올바르게 인식된다는 점에 유의하세요. 그러나 이 경우 NextJs는 일시 중단되지 않고 컴포넌트가pending상태로 렌더링되며, 이로 인해 콘텐츠의 서버 렌더링도 사용하지 않게 됩니다.
JSON이 아닌 데이터 타입을 사용하고 서버에서 쿼리 결과를 직렬화하는 경우, 경계의 각 측에서 데이터를 직렬화하고 역직렬화하도록 dehydrate.serializeData 및 hydrate.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,
},
},
})
}
// ...
// 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()
// look ma, no await
void queryClient
.query({
queryKey: ['posts'],
queryFn: () => getPosts().then(serialize), // <-- serialize the data on the server
})
.catch(noop)
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Posts />
</HydrationBoundary>
)
}
// app/posts/posts.tsx
'use client'
export default function Posts() {
const { data } = useSuspenseQuery({ queryKey: ['posts'], queryFn: getPosts })
// ...
}
이제 getPosts 함수는 예를 들어 Temporal datetime 객체를 반환할 수 있으며, transformer가 해당 데이터 타입을 직렬화하고 역직렬화할 수 있다면 데이터는 클라이언트에서 직렬화 및 역직렬화됩니다.
자세한 내용은 Next.js 프리페치가 적용된 App 예제를 확인하세요.
스트리밍과 함께 Persist Adapter 사용
Server Components를 사용한 스트리밍 기능과 함께 영구 저장 어댑터를 사용하는 경우, Promise를 저장소에 저장하지 않도록 주의해야 합니다. pending 상태의 쿼리는 디하이드레이션되어 클라이언트로 스트리밍될 수 있으므로, 성공한 쿼리만 영구 저장하도록 persister를 구성해야 합니다:
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
// We don't want to save promises into the storage, so we only persist successful queries
dehydrateOptions: { shouldDehydrateQuery: defaultShouldDehydrateQuery },
}}
>
{children}
</PersistQueryClientProvider>
이렇게 하면 데이터가 성공적으로 이행된 쿼리만 저장소에 영구 저장되어, 대기 중인 Promise로 인한 직렬화 문제를 방지합니다.
Next.js에서 프리페치하지 않는 실험적 스트리밍
위에서 자세히 설명한 프리페치 솔루션은 초기 페이지 로드 와 이후의 모든 페이지 탐색에서 요청 워터폴을 평탄화하므로 이를 권장하지만, 프리페치를 완전히 건너뛰면서도 스트리밍 SSR이 작동하도록 하는 실험적인 방법이 있습니다: @tanstack/react-query-next-experimental
이 패키지를 사용하면 컴포넌트에서 useSuspenseQuery를 호출하기만 해도 서버의 Client Component에서 데이터를 가져올 수 있습니다. 그러면 SuspenseBoundaries가 이행될 때 결과가 서버에서 클라이언트로 스트리밍됩니다. <Suspense> 경계로 감싸지 않고 useSuspenseQuery를 호출하면 가져오기가 이행될 때까지 HTML 응답이 시작되지 않습니다. 상황에 따라 이것이 원하는 동작일 수 있지만, TTFB에 악영향을 준다는 점을 유의하세요.
이를 구현하려면 앱을 ReactQueryStreamedHydration 컴포넌트로 감쌉니다:
// app/providers.tsx
'use client'
import {
environmentManager,
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: {
// 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 (environmentManager.isServer()) {
// 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 function Providers(props: { children: React.ReactNode }) {
// 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}>
<ReactQueryStreamedHydration>
{props.children}
</ReactQueryStreamedHydration>
</QueryClientProvider>
)
}
자세한 내용은 NextJs Suspense 스트리밍 예제를 확인하세요.
가장 큰 장점은 SSR이 작동하도록 쿼리를 수동으로 프리페치할 필요가 더 이상 없으며, 결과를 여전히 스트리밍하기까지 한다는 것입니다! 이를 통해 탁월한 DX와 더 낮은 코드 복잡성을 얻을 수 있습니다.
단점은 성능 및 요청 워터폴 가이드의 복잡한 요청 워터폴 예시를 다시 살펴보면 가장 쉽게 설명할 수 있습니다. 프리페치를 사용하는 Server Components는 초기 페이지 로드 및 이후의 모든 탐색에서 요청 워터폴을 효과적으로 제거합니다. 하지만 프리페치를 사용하지 않는 이 접근 방식은 초기 페이지 로드에서만 워터폴을 평탄화하며, 페이지 탐색 시에는 결국 원래 예시와 동일한 깊은 워터폴이 발생합니다:
1. |> JS for <Feed>
2. |> getFeed()
3. |> JS for <GraphFeedItem>
4. |> getGraphDataById()
이는 getServerSideProps/getStaticProps보다도 더 좋지 않습니다. 후자의 경우 적어도 데이터 가져오기와 코드 가져오기를 병렬화할 수 있었기 때문입니다.
성능보다 코드 복잡도가 낮은 DX/반복/배포 속도를 중시하고, 깊게 중첩된 쿼리가 없거나, useSuspenseQueries 같은 도구를 사용한 병렬 가져오기로 요청 워터폴을 잘 관리하고 있다면 이는 좋은 절충안이 될 수 있습니다.
두 접근 방식을 결합할 수도 있지만, 저희도 아직 시도해 보지 않았습니다. 시도한다면 발견한 내용을 알려주시거나 몇 가지 팁으로 이 문서를 업데이트해 주세요!
마무리
Server Components와 스트리밍은 아직 비교적 새로운 개념이며, React Query가 어떻게 어우러지는지와 API에 어떤 개선을 적용할 수 있을지 계속 파악하고 있습니다. 제안, 피드백, 버그 보고를 환영합니다!
마찬가지로, 첫 시도에 단 하나의 가이드로 이 새로운 패러다임의 모든 세부 사항을 설명하는 것은 불가능합니다. 여기에 누락된 정보가 있거나 이 콘텐츠의 개선 방법에 관한 제안이 있다면 연락해 주세요. 더 좋은 방법은 아래의 "GitHub에서 편집" 버튼을 클릭하여 저희를 돕는 것입니다.
추가 자료
Server Components도 사용할 때 애플리케이션이 React Query의 이점을 얻을 수 있는지 알아보려면 React Query가 필요하지 않을 수도 있습니다 문서를 참조하세요.