서버 렌더링 및 하이드레이션
이 가이드에서는 서버 렌더링과 함께 React Query를 사용하는 방법을 알아봅니다.
관련 배경은 프리페치 및 Router 통합 가이드를 참조하세요. 그전에 성능 및 요청 워터폴 가이드도 확인해 보는 것이 좋습니다.
하이드레이션 + 프리페치(code splitting 포함)에 관한 더 심층적인 예시는 종속 쿼리 및 Code Splitting 섹션을 참조하세요.
스트리밍, Server Components, 새로운 Next.js 앱 라우터와 같은 고급 서버 렌더링 패턴은 고급 서버 렌더링 가이드를 참조하세요.
코드만 보고 싶다면 아래의 전체 Next.js pages router 예제 또는 전체 Remix 예제로 바로 건너뛸 수 있습니다.
서버 렌더링 및 React Query
그렇다면 서버 렌더링이란 도대체 무엇일까요? 이 가이드의 나머지 부분에서는 이 개념에 익숙하다고 가정하지만, 이것이 React Query와 어떤 관련이 있는지 잠시 살펴보겠습니다. 서버 렌더링은 서버에서 초기 html을 생성하여 사용자가 페이지를 불러오자마자 볼 수 있는 콘텐츠를 제공하는 작업입니다. 이는 페이지가 요청될 때 필요에 따라 수행될 수 있습니다(SSR). 이전 요청이 캐시되었거나 빌드 시점이기 때문에 미리 수행될 수도 있습니다(SSG).
성능 및 요청 워터폴 가이드를 읽었다면 다음 내용을 기억할 수 있습니다:
1. |-> Markup (without content)
2. |-> JS
3. |-> Query
클라이언트에서 렌더링되는 애플리케이션에서는 사용자에게 화면의 콘텐츠를 표시하기 전에 최소 3회의 서버 왕복이 필요합니다. Server Rendering을 바라보는 한 가지 방식은 위 과정을 다음과 같이 바꾼다고 보는 것입니다:
1. |-> Markup (with content AND initial data)
2. |-> JS
**1.**이 완료되는 즉시 사용자는 콘텐츠를 볼 수 있으며, **2.**가 완료되면 페이지에서 상호작용하고 클릭할 수 있습니다. 마크업에도 필요한 초기 데이터가 포함되어 있으므로, 어떤 이유로 데이터를 재검증하려는 시점까지는 적어도 3. 단계를 클라이언트에서 전혀 실행할 필요가 없습니다.
이는 모두 클라이언트 관점의 내용입니다. 서버에서는 마크업을 생성하거나 렌더링하기 전에 해당 데이터를 프리페치해야 하고, 마크업에 삽입할 수 있는 직렬화 가능한 형식으로 해당 데이터를 디하이드레이션해야 하며, 클라이언트에서는 새로 가져오기를 수행하지 않도록 해당 데이터를 React Query 캐시에 하이드레이션해야 합니다.
이어서 React Query로 이 세 단계를 구현하는 방법을 알아봅니다.
Suspense에 관한 간단한 참고 사항
이 가이드에서는 일반 useQuery API를 사용합니다. 반드시 권장하는 방식은 아니지만, 항상 모든 쿼리를 프리페치한다면 이를 useSuspenseQuery로 대신 바꿀 수 있습니다. 장점은 클라이언트의 로딩 상태에 <Suspense>를 사용할 수 있다는 것입니다.
useSuspenseQuery를 사용할 때 쿼리 프리페치를 잊으면 그 결과는 사용 중인 framework에 따라 달라집니다. 어떤 경우에는 데이터가 일시 중단된 뒤 server에서 가져와지지만 client에는 전혀 하이드레이션되지 않아 client에서 다시 가져옵니다. 이 경우 server와 client가 서로 다른 내용을 렌더링하려 했기 때문에 markup 하이드레이션 불일치가 발생합니다.
초기 설정
React Query 사용의 첫 단계는 항상 queryClient를 생성하고 애플리케이션을 <QueryClientProvider>로 감싸는 것입니다. 서버 렌더링을 수행할 때는 queryClient 인스턴스를 앱 내부에서 React 상태로 생성하는 것이 중요합니다(인스턴스 참조도 문제없이 작동합니다). 이렇게 하면 서로 다른 사용자와 요청 간에 데이터가 공유되지 않도록 보장하면서도 컴포넌트 생명주기마다 queryClient를 한 번만 생성합니다.
Next.js 페이지 라우터:
// _app.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
// NEVER DO THIS:
// const queryClient = new QueryClient()
//
// Creating the queryClient at the file root level makes the cache shared
// between all requests and means _all_ data gets passed to _all_ users.
// Besides being bad for performance, this also leaks any sensitive data.
export default function MyApp({ Component, pageProps }) {
// Instead do this, which ensures each request has its own cache:
const [queryClient] = React.useState(
() =>
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,
},
},
}),
)
return (
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
)
}
Remix:
// app/root.tsx
import { Outlet } from '@remix-run/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
export default function MyApp() {
const [queryClient] = React.useState(
() =>
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,
},
},
}),
)
return (
<QueryClientProvider client={queryClient}>
<Outlet />
</QueryClientProvider>
)
}
initialData로 빠르게 시작하기
가장 빠르게 시작하는 방법은 프리페치에 React Query를 전혀 관여시키지 않고 dehydrate/hydrate API를 사용하지 않는 것입니다. 대신 원시 데이터를 useQuery의 initialData 옵션으로 전달합니다. getServerSideProps를 사용하여 Next.js pages router를 활용하는 예제를 살펴보겠습니다.
export async function getServerSideProps() {
const posts = await getPosts()
return { props: { posts } }
}
function Posts(props) {
const { data } = useQuery({
queryKey: ['posts'],
queryFn: getPosts,
initialData: props.posts,
})
// ...
}
이는 getStaticProps 또는 이전 버전인 getInitialProps에서도 작동하며, 동등한 함수가 있는 다른 모든 framework에도 같은 패턴을 적용할 수 있습니다. Remix을 사용한 동일한 예제는 다음과 같습니다:
export async function loader() {
const posts = await getPosts()
return json({ posts })
}
function Posts() {
const { posts } = useLoaderData<typeof loader>()
const { data } = useQuery({
queryKey: ['posts'],
queryFn: getPosts,
initialData: posts,
})
// ...
}
설정이 최소한이므로 일부 사례에서는 빠른 해결책이 될 수 있지만, 전체 접근 방식과 비교할 때 고려해야 할 몇 가지 장단점이 있습니다:
- 트리의 더 깊은 곳에 있는 컴포넌트에서
useQuery를 호출한다면initialData를 그 지점까지 전달해야 합니다 - 여러 위치에서 동일한 쿼리로
useQuery를 호출하는 경우, 그중 하나에만initialData를 전달하면 취약할 수 있으며 이후 앱이 변경될 때 작동하지 않을 수 있습니다.initialData가 지정된useQuery를 포함하는 컴포넌트를 제거하거나 이동하면, 더 깊이 중첩된useQuery에는 더 이상 데이터가 없을 수 있습니다. 이를 필요로 하는 모든 쿼리에initialData를 전달하는 것 역시 번거로울 수 있습니다. - 서버에서 쿼리를 가져온 시점을 알 방법이 없으므로, 대신
dataUpdatedAt및 쿼리를 다시 가져와야 하는지에 대한 판단은 페이지가 로드된 시점을 기준으로 합니다 - 쿼리에 대한 데이터가 캐시에 이미 있으면
initialData는 이 데이터를 절대 덮어쓰지 않습니다. 새 데이터가 기존 데이터보다 더 최신이더라도 마찬가지입니다.- 이것이 특히 문제가 되는 이유를 이해하려면 위의
getServerSideProps예시를 살펴보세요. 페이지를 여러 번 앞뒤로 이동하면 매번getServerSideProps가 호출되어 새 데이터를 가져오지만,initialData옵션을 사용하고 있으므로 클라이언트 캐시와 데이터는 절대 업데이트되지 않습니다.
- 이것이 특히 문제가 되는 이유를 이해하려면 위의
전체 하이드레이션 솔루션을 설정하는 것은 간단하며 이러한 단점이 없으므로, 나머지 문서에서는 이에 중점을 둡니다.
Hydration API 사용하기
설정을 조금만 더 하면 queryClient를 사용하여 preload 단계에서 쿼리를 프리페치하고, 해당 queryClient의 직렬화된 버전을 앱의 렌더링 부분에 전달한 뒤 그곳에서 재사용할 수 있습니다. 이렇게 하면 위의 단점을 피할 수 있습니다. 전체 Next.js pages router 및 Remix 예시로 바로 넘어가도 되지만, 일반적인 수준에서 추가 단계는 다음과 같습니다:
- 프레임워크 로더 함수에서
const queryClient = new QueryClient(options)를 생성합니다 - loader 함수에서 프리페치하려는 각 쿼리에 대해
await queryClient.query(...)를 수행하세요- 가능한 경우
await Promise.all(...)을 사용하여 쿼리를 병렬로 가져오려고 합니다 - 프리페치되지 않은 쿼리가 있어도 괜찮습니다. 이러한 쿼리는 서버에서 렌더링되지 않으며, 대신 애플리케이션이 상호작용 가능한 상태가 된 후 클라이언트에서 데이터를 가져옵니다. 사용자 상호작용 후에만 표시되거나 페이지의 훨씬 아래에 있어 더 중요한 콘텐츠를 차단하지 않아야 하는 콘텐츠에 매우 적합할 수 있습니다.
- 가능한 경우
- loader에서
dehydrate(queryClient)를 반환합니다. 이를 반환하는 정확한 구문은 프레임워크마다 다릅니다 - 프레임워크 로더에서 가져온
dehydratedState로<HydrationBoundary state={dehydratedState}>에서 트리를 래핑합니다.dehydratedState를 가져오는 방법도 프레임워크마다 다릅니다.- 각 route에서 수행하거나, 상용구를 피하기 위해 애플리케이션 최상단에서 수행할 수 있습니다. 예제를 참조하세요
흥미로운 점은 실제로 세 개의
queryClient가 관여한다는 것입니다. 프레임워크 로더는 렌더링 전에 진행되는 "미리 로드" 단계의 한 형태이며, 이 단계에는 프리페치를 수행하는 자체queryClient가 있습니다. 이 단계에서 디하이드레이션된 결과는 각각 자체queryClient를 가진 서버 렌더링 프로세스와 클라이언트 렌더링 프로세스 모두에, 즉 서버 렌더링 프로세스 및 클라이언트 렌더링 프로세스에 전달됩니다. 이를 통해 둘 다 동일한 데이터로 시작하므로 동일한 마크업을 반환할 수 있습니다.
Server Components는 또 다른 형태의 "미리 로드" 단계이며, React 컴포넌트 트리의 일부도 "미리 로드"(사전 렌더링)할 수 있습니다. 자세한 내용은 고급 서버 렌더링 가이드를 참조하세요.
전체 Next.js pages router 예제
app router 문서는 고급 서버 렌더링 가이드를 참조하세요.
초기 설정:
// _app.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
export default function MyApp({ Component, pageProps }) {
const [queryClient] = React.useState(
() =>
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,
},
},
}),
)
return (
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
)
}
각 라우트에서:
// 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
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>
)
}
전체 Remix 예제
초기 설정:
// app/root.tsx
import { Outlet } from '@remix-run/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
export default function MyApp() {
const [queryClient] = React.useState(
() =>
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,
},
},
}),
)
return (
<QueryClientProvider client={queryClient}>
<Outlet />
</QueryClientProvider>
)
}
각 라우트에서 중첩된 라우트에서도 이렇게 해도 괜찮다는 점에 유의하세요:
// app/routes/posts.tsx
import { json } from '@remix-run/node'
import {
dehydrate,
HydrationBoundary,
QueryClient,
useQuery,
} from '@tanstack/react-query'
export async function loader() {
const queryClient = new QueryClient()
await queryClient
.query({
queryKey: ['posts'],
queryFn: getPosts,
})
.catch(noop)
return json({ 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
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() {
const { dehydratedState } = useLoaderData<typeof loader>()
return (
<HydrationBoundary state={dehydratedState}>
<Posts />
</HydrationBoundary>
)
}
선택 사항 - 상용구 제거
모든 라우트에 이 부분을 포함하면 상용구 코드가 많아 보일 수 있습니다:
export default function PostsRoute({ dehydratedState }) {
return (
<HydrationBoundary state={dehydratedState}>
<Posts />
</HydrationBoundary>
)
}
이 접근 방식에 문제가 있는 것은 아니지만, 이 상용구를 제거하려면 Next.js에서 설정을 다음과 같이 수정할 수 있습니다:
// _app.tsx
import {
HydrationBoundary,
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
export default function MyApp({ Component, pageProps }) {
const [queryClient] = React.useState(() => new QueryClient())
return (
<QueryClientProvider client={queryClient}>
<HydrationBoundary state={pageProps.dehydratedState}>
<Component {...pageProps} />
</HydrationBoundary>
</QueryClientProvider>
)
}
// pages/posts.tsx
// Remove PostsRoute with the HydrationBoundary and instead export Posts directly:
export default function Posts() { ... }
Remix에서는 이 과정이 조금 더 복잡하므로 use-dehydrated-state 패키지를 살펴보는 것을 권장합니다.
종속 쿼리 프리페치하기
Prefetching 가이드에서는 종속 쿼리를 프리페치하는 방법을 알아보았습니다. 그렇다면 프레임워크 로더에서는 이를 어떻게 할까요? Dependent Queries 가이드에서 가져온 다음 코드를 살펴보세요:
// Get the user
const { data: user } = useQuery({
queryKey: ['user', email],
queryFn: getUserByEmail,
})
const userId = user?.id
// Then get the user's projects
const {
status,
fetchStatus,
data: projects,
} = useQuery({
queryKey: ['projects', userId],
queryFn: getProjectsByUser,
// The query will not execute until the userId exists
enabled: !!userId,
})
서버에서 렌더링할 수 있도록 이를 어떻게 프리페치할까요? 다음은 예시입니다:
// For Remix, rename this to loader instead
export async function getServerSideProps() {
const queryClient = new QueryClient()
const user = await queryClient.query({
queryKey: ['user', email],
queryFn: getUserByEmail,
})
if (user?.userId) {
await queryClient.query({
queryKey: ['projects', userId],
queryFn: getProjectsByUser,
})
}
// For Remix:
// return json({ dehydratedState: dehydrate(queryClient) })
return { props: { dehydratedState: dehydrate(queryClient) } }
}
물론 이 로직은 더 복잡해질 수 있지만, 이러한 loader 함수는 단지 JavaScript이므로 언어의 모든 기능을 활용해 로직을 구성할 수 있습니다. 서버에서 렌더링하려는 모든 쿼리를 반드시 프리페치해야 합니다.
오류 처리
React Query의 기본값은 점진적 성능 저하 전략입니다. 이는 다음을 의미합니다:
dehydrate(...)에는 성공한 쿼리만 포함되며 실패한 쿼리는 포함되지 않습니다void queryClient.query(...)에서 반환된 Promise를 의도적으로 무시하고.catch(noop)을 추가하여 모든 오류를 무시할 수 있으므로, 이를 둘러싼 loader 코드는 쿼리 오류를 감지하지 않습니다
이로 인해 실패한 모든 쿼리가 클라이언트에서 재시도되며, 서버에서 렌더링된 출력에는 전체 콘텐츠 대신 로딩 상태가 포함됩니다.
좋은 기본값이지만, 때로는 원하는 동작이 아닐 수 있습니다. 중요한 콘텐츠가 누락된 경우 상황에 따라 404 또는 500 상태 코드로 응답하는 것이 좋을 수 있습니다. 이러한 경우에는 noop catch 없이 await queryClient.query(...)를 사용합니다. 그러면 실패할 때 오류를 발생시켜 적절한 방식으로 처리할 수 있습니다.
let result
try {
result = await queryClient.query(...)
} catch (error) {
// Handle the error, refer to your framework documentation
}
// You might also want to check and handle any invalid `result` here
어떤 이유로든 재시도를 방지하기 위해 실패한 쿼리를 디하이드레이션된 상태에 포함하려면, shouldDehydrateQuery 옵션을 사용하여 기본 함수를 재정의하고 자체 로직을 구현할 수 있습니다:
dehydrate(queryClient, {
shouldDehydrateQuery: (query) => {
// This will include all queries, including failed ones,
// but you can also implement your own logic by inspecting `query`
return true
},
})
직렬화
Next.js에서 return { props: { dehydratedState: dehydrate(queryClient) } }를 수행하거나 Remix에서 return json({ dehydratedState: dehydrate(queryClient) })을 수행하면, 프레임워크가 queryClient의 dehydratedState 표현을 직렬화하여 마크업에 삽입하고 클라이언트로 전송할 수 있게 합니다.
기본적으로 이러한 프레임워크는 안전하게 직렬화/파싱할 수 있는 값의 반환만 지원하므로 undefined, Error, Date, Map, Set, BigInt, Infinity, NaN, -0, 정규 표현식 등을 지원하지 않습니다. 이는 쿼리에서도 이러한 값을 반환할 수 없다는 뜻입니다. 이러한 값을 반환하려면 superjson 또는 유사한 패키지를 살펴보세요.
사용자 지정 SSR 설정을 사용한다면 이 단계를 직접 처리해야 합니다. 처음에는 JSON.stringify(dehydratedState)를 사용하려고 생각할 수 있지만, 이는 기본적으로 <script>alert('Oh no..')</script> 같은 항목을 이스케이프하지 않으므로 애플리케이션에 XSS 취약점이 쉽게 발생할 수 있습니다. superjson도 값을 이스케이프하지 않으며, 사용자 지정 SSR 설정에서 단독으로 사용하기에는 안전하지 않습니다(출력을 이스케이프하는 추가 단계를 넣는 경우 제외). 대신 별도의 구성 없이도 XSS 삽입 공격에 안전한 Serialize JavaScript 또는 devalue 같은 라이브러리를 사용하는 것이 좋습니다.
요청 워터폴에 관한 참고 사항
성능 및 요청 워터폴 가이드에서 서버 렌더링이 더 복잡한 중첩 워터폴 중 하나를 어떻게 바꾸는지 다시 살펴보겠다고 언급했습니다. 구체적인 코드 예시를 다시 확인해 보세요. 복습하자면, <Feed> 컴포넌트 내부에 코드 분할된 <GraphFeedItem> 컴포넌트가 있습니다. 이는 피드에 그래프 항목이 포함된 경우에만 렌더링되며, 두 컴포넌트 모두 자체 데이터를 가져옵니다. 클라이언트 렌더링에서는 다음과 같은 요청 워터폴이 발생합니다:
1. |> Markup (without content)
2. |> JS for <Feed>
3. |> getFeed()
4. |> JS for <GraphFeedItem>
5. |> getGraphDataById()
서버 렌더링의 장점은 위 내용을 다음과 같이 바꿀 수 있다는 것입니다:
1. |> Markup (with content AND initial data)
2. |> JS for <Feed>
2. |> JS for <GraphFeedItem>
쿼리는 더 이상 client에서 가져오지 않으며, 대신 해당 데이터가 markup에 포함되었다는 점에 유의하세요. 이제 JS를 병렬로 로드할 수 있는 이유는 <GraphFeedItem>이 server에서 렌더링되었으므로 client에서도 이 JS가 필요하다는 것을 알고 있으며, 이 chunk를 위한 script-tag를 markup에 삽입할 수 있기 때문입니다. server에서는 여전히 다음과 같은 request waterfall이 발생합니다:
1. |> getFeed()
2. |> getGraphDataById()
피드를 가져오기 전에는 그래프 데이터도 가져와야 하는지 알 수 없으며, 이들은 종속 쿼리입니다. 이는 일반적으로 지연 시간이 더 짧고 안정적인 서버에서 발생하므로 대개 그다지 큰 문제가 되지 않습니다.
훌륭합니다. 워터폴을 거의 평탄화했습니다! 하지만 한 가지 문제가 있습니다. 이 페이지를 /feed 페이지라고 하고, /posts 같은 다른 페이지도 있다고 가정해 보겠습니다. URL 표시줄에 www.example.com/feed를 직접 입력하고 Enter를 누르면 이러한 훌륭한 서버 렌더링 이점을 모두 얻습니다. BUT, 대신 www.example.com/posts를 입력한 다음 /feed로 이동하는 링크를 클릭하면 다시 다음과 같은 상태로 돌아갑니다:
1. |> JS for <Feed>
2. |> getFeed()
3. |> JS for <GraphFeedItem>
4. |> getGraphDataById()
이는 SPA를 사용할 경우 서버 렌더링이 이후 탐색에서는 작동하지 않고 최초 페이지 로드에서만 작동하기 때문입니다.
최신 프레임워크는 초기 코드와 데이터를 병렬로 가져오는 방식으로 이 문제를 해결하려는 경우가 많으므로, 종속 쿼리를 프리페치하는 방법을 포함하여 이 가이드에서 설명한 프리페치 패턴과 함께 Next.js 또는 Remix을 사용한다면 실제로는 다음과 같은 형태가 됩니다:
1. |> JS for <Feed>
1. |> getFeed() + getGraphDataById()
2. |> JS for <GraphFeedItem>
이 방식이 훨씬 낫지만, 더 개선하려면 Server Components를 사용하여 이를 단일 왕복 요청으로 단순화할 수 있습니다. 방법은 고급 서버 렌더링 가이드에서 알아보세요.
팁, 요령 및 주의 사항
stale 상태는 서버에서 쿼리를 가져온 시점부터 측정됩니다
쿼리가 stale 상태로 간주되는지는 쿼리가 dataUpdatedAt된 시점에 따라 달라집니다. 여기서 주의할 점은 이 기능이 올바르게 작동하려면 서버의 시간이 정확해야 하지만, UTC 시간이 사용되므로 시간대는 영향을 주지 않는다는 것입니다.
staleTime의 기본값이 0이므로 기본적으로 페이지를 로드할 때 백그라운드에서 쿼리를 다시 가져옵니다. 특히 마크업을 캐시하지 않는 경우, 이러한 이중 가져오기를 방지하기 위해 더 높은 staleTime을 사용하는 것이 좋을 수 있습니다.
stale 상태인 쿼리를 이렇게 다시 가져오는 방식은 CDN에서 마크업을 캐시할 때 완벽하게 어울립니다! 서버에서 페이지를 다시 렌더링하지 않아도 되도록 페이지 자체의 캐시 시간을 적절히 길게 설정하면서, 사용자가 페이지를 방문하는 즉시 데이터가 백그라운드에서 다시 가져와지도록 쿼리의 staleTime은 더 짧게 구성할 수 있습니다. 페이지는 일주일 동안 캐시하되, 데이터가 하루보다 오래되었다면 페이지 로드 시 자동으로 다시 가져오도록 할 수도 있습니다.
서버의 높은 메모리 사용량
요청마다 QueryClient를 생성하는 경우, React Query는 이 클라이언트를 위한 격리된 캐시를 생성하며, 이 캐시는 gcTime 기간 동안 메모리에 유지됩니다. 해당 기간에 요청 수가 많으면 서버의 메모리 사용량이 높아질 수 있습니다.
서버에서 gcTime의 기본값은 Infinity이며, 이는 수동 가비지 컬렉션을 비활성화하고 요청이 완료되면 자동으로 메모리를 정리합니다. Infinity가 아닌 gcTime을 명시적으로 설정하면 캐시를 조기에 정리할 책임이 있습니다.
하이드레이션 오류가 발생할 수 있으므로 gcTime을 0로 설정하지 마세요. 이는 하이드레이션 경계가 렌더링에 필요한 데이터를 캐시에 배치하지만, 렌더링이 완료되기 전에 가비지 컬렉터가 데이터를 제거하면 문제가 발생할 수 있기 때문입니다. 더 짧은 gcTime이 필요하다면 앱이 데이터를 참조할 충분한 시간을 확보할 수 있도록 2 * 1000로 설정하는 것을 권장합니다.
더 이상 필요하지 않은 캐시를 비우고 메모리 사용량을 줄이려면 요청을 처리하고 디하이드레이션된 상태를 client로 보낸 후 queryClient.clear() 호출을 추가할 수 있습니다.
또는 더 작은 gcTime을 설정할 수 있습니다.
Next.js 재작성 시 주의 사항
Next.js의 rewrites 기능을 Automatic Static Optimization 또는 getStaticProps와 함께 사용할 때 주의할 점이 있습니다. 이 경우 React Query가 두 번째 하이드레이션을 수행합니다. 이는 Next.js가 client에서 rewrites를 파싱하고 하이드레이션 후 모든 매개변수를 수집하여 router.query에 제공할 수 있도록 보장해야 하기 때문입니다.
그 결과 모든 하이드레이션 데이터에서 참조 동등성이 사라지며, 예를 들어 데이터가 component의 props나 useEffects/useMemos의 dependency array에서 사용되는 모든 곳에서 트리거됩니다.