Server Rendering & Hydration
Initial setup
// _app.tsx
import {
HydrationBoundary,
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
export default function MyApp({ Component, pageProps }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// 보통 SSR에서는 staleTime을 0 이상으로 해줌으로써
// 클라이언트 사이드에서 바로 다시 데이터를 refetch 하는 것을 피한다.
staleTime: 60 * 1000,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
<HydrationBoundary state={pageProps.dehydratedState}>
<Component {...pageProps} />
</HydrationBoundary>
</QueryClientProvider>
)
}
기존의 리액트 프로젝트와는 다르게 App 컴포넌트 안에 새로운 QueryClient를 useState()를 사용해 state로 선언해 줘야 한다. 바깥에 선언하면 쿼리 캐시가 다른 사용자들과 리퀘스트 간에 공유가 될 수 있고, Next.js에서는 페이지를 이동하면 App 컴포넌트부터 새롭게 렌더링되기 때문에 쿼리 클라이언트가 매번 새롭게 생성되는 것을 막기 위하여 state로 저장해준다.
+ 공식문서에서는 "각 페이지마다 HydrationBoundary를 선언하는 것은 boilerplate가 너무 많은 것처럼 보일 수 있다. 따라서 (각 페이지마다 HydrationBoundary를 선언하는 게 '문제'라는 것은 아니지만, 취향에 따라서는 이런 걸 원치 않을 수도 있으니) _App.tsx에서 딱 한 번만 선언하는 것으로 이러한 boilerplate를 줄일 수 있다"고 말하고 있다.
내 취향은 후자에 가깝기 때문에 다른 프로젝트에서도 이런 방식을 사용했다.
Hydration API
// pages/posts.jsx
import { dehydrate, HydrationBoundary, QueryClient, useQuery } from '@tanstack/react-query'
// This could also be getServerSideProps
export async function getStaticProps() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: getPosts,
})
return {
props: {
dehydratedState: dehydrate(queryClient),
},
}
}
function Posts() {
// 이 쿼리는 Posts의 더 깊은 하위 요소에서도 즉시 데이터를 사용할 수 있다.
const { data } = useQuery({ queryKey: ['posts'], queryFn: getPosts })
// 이 쿼리는 서버에서 prefetch하지 않는 데이터.
// prefetch하는 데이터와 아닌 데이터를 자유롭게 섞어서 활용할 수 있다.
const { data: commentsData } = useQuery({
queryKey: ['posts-comments'],
queryFn: getComments,
})
// ...
}
우리가 getServerSideProps를 통해 내려준 dehydratedState는 _App.tsx에서 pageProps.dehydratedState로 받아주게 된다. 이를 통해 우리는 원하는 컴포넌트 내에서 정해진 queryKey를 사용해 서버에서 prefetch된 쿼리를 가져다 사용할 수 있는 것이다.
여기서 조심해야 하는 점은 dehydrate 함수가 쿼리가 캐시해놓은 데이터를 글자 그대로 JSON 데이터로 '건조'시켜버린다는 점이다. 따라서 캐시에 dehydration 될 수 없는 무언가가 섞여있다면 서버 사이드에서 랜더링 되는 과정 중에 dehydration error가 발생하게 된다.
serverClientProvider
이렇게 서버사이드에서 미리 prefetch 해오니 참 빠르고 좋은데, 이를 적극적으로 사용하려다보니 동일한 prefetchQuery가 여러 페이지에 걸쳐 반복적으로 사용되고 있었다. 따라서 이런 코드들의 재사용성을 도모하기 위해 serverClientProvider라는 함수를 하나 만들었다.
user 정보와 같이 거의 모든 페이지에서 사용되는 서버 상태들을 한데 묶었더니 확실히 관리하기가 편해지기는 했다.
export const serverClientProvider = async (context: GetServerSidePropsContext) => {
const queryClient = new QueryClient();
const accessToken = getAccessToken(context) as string;
await queryClient.prefetchQuery({
queryKey: ["currentUser"],
queryFn: () => fetcher<User[]>({ method: "get", url: "/users", headers: { Authorization: accessToken } }),
});
return queryClient;
};
실제로 getServerSideProps에서 사용할 때는 아래와 같이 간단하게 처리할 수 있게 되는 것이다.
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const queryClient = await queryClientProvider(context);
return {
props: { dehydratedState: dehydrate(queryClient) },
};
};
queryCapsule
serverClientProvider를 사용해서 getServerSideProps에서의 prefetch를 일괄적으로 진행하는 일은 다양한 종류의 문제를 해결해주었다. 하지만 쓰다보니 금새 또 다른 문제점을 발견할 수 있었다.
// serverClientProvider.ts
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: getPosts,
})
// PostEmail.tsx
const { data } = useQuery({ queryKey: ['posts'], queryFn: getPosts })
// EmailBox.tsx
const { data } = useQuery({ queryKey: ['posts'], queryFn: getPosts })
서버 사이드에서 캐싱한 것을 불러오기 위해 클라이언트 쪽에서는 필요한 컴포넌트 구석 구석에서 useQuery를 사용하게 되었다. 문제는 queryKey와 queryFn을 직접 입력해주다보니 serverClientProvider에서는 queryKey를 ['posts']라고 해놓고, 막상 다른 곳에서 쓰다가 queryKey를 ['post']로 잘못 넣어서 에러가 나는 경우가 종종 있었다.
이런 휴먼 에러를 방지하기 위해 내가 ─ 잘 돌아가지 않는 머릿속의 돌맹이를 열심히 굴려서 ─ 마련한 대비책이 queryCapsule이다. 간단한 아이디어인데, 자주 사용하는 queryKey-queryFu 쌍을 하나의 캡슐로 감싸서 이를 리턴하게 만드는 것이다.
const postEmailQueryCapsule = () => {
return useQuery({ queryKey: ['posts'], queryFn: getPosts })
}
const { data } = postEmailQueryCapsule()
참고로 객체 지향의 캡슐화와는 (아마도) 아무런 상관이 없는 개념이다.
Server Side에서의 Dependant Queries
리액트 컴포넌트 안에서 useQuery를 사용할 때 어떤 쿼리가 다른 쿼리의 결과에 영향을 받는다면, 우리는 enabled라는 옵션을 사용하여 이를 처리했다. 그러나 prefetchQuery에는 enabled 옵션이 존재하지 않는다. 때문에 조금은 다른 방식으로 여기에 접근해야 한다.
가령 아래와 같은 경우가 있다고 해보자. A 쿼리는 email을 통해 특정 유저의 데이터를 가져오고, B 쿼리는 이 특정 유저 데이터 중 id 값을 사용해 해당 유저의 프로젝트 리스트를 가져온다. 이는 결국 B 쿼리의 동작이 A 쿼리의 결과에 영향을 받는다는 의미이다.
// A
await queryClient.prefetchQuery({
queryKey: ['user', email],
queryFn: getUserByEmail,
})
// B
await queryClient.prefetchQuery({
queryKey: ['projects', userId],
queryFn: getProjectsByUser,
})
이를 해결하는 방법은 queryClient의 다른 메소드를 사용하는 것이다. prefetchQuery는 아무것도 리턴하지 않는 void한 메소드이지만, fetchQuery는 API 통신 이후 그 결과를 리턴한다.
이러한 점을 활용하면 아래와 같이 조건문을 통해 원하는 쿼리를 모두 서버 사이드에서 prefetch해올 수 있다.
export async function getServerSideProps() {
const queryClient = new QueryClient()
const user = await queryClient.fetchQuery({
queryKey: ['user', email],
queryFn: getUserByEmail,
})
if (user?.userId) {
await queryClient.prefetchQuery({
queryKey: ['projects', userId],
queryFn: getProjectsByUser,
})
}
return { props: { dehydratedState: dehydrate(queryClient) } }
}
주의할 점?
매 요청에 대해 QueryClient를 생성하고, 이 캐시들은 gcTime 동안 메모리에 보관되게 된다. 때문에 요청이 많을 수록 서버의 부하도 늘어나 메모리 소비가 높아질 수 있다.
서버에서는 기본적으로 gcTime이 infinity로 설정되어있다. 이 말인 즉슨 react query가 정기적으로 진행하는 가비지 콜렉팅을 관두고, 요청이 완료될 때마다 메모리를 자동으로 치워낸다는 소리 같다. 따라서 기본값이 아닌 gcTime을 설정할 경우 you will be responsible for clearing the cache early라고 한다.
Hydration error가 발생할 수 있으므로 gcTime을 0으로 설정하는 미친 짓을 저질러서는 안 되겠다. infinity 대신 충분히 짧은 gcTime이 필요한 경우라면 2 * 1000으로 설정하는 것이 좋다고 한다.
메모리 소비를 줄이려면 요청이 처리되고 dehydratedState가 클라이언트로 전송된 후 queryClient.clear()를 호출할 수 있다는데, 실제로 어디서 어떻게 호출한다는 건지는 예시 코드가 따로 없는 거 같다.
블로그의 정보
Ayden's journal
Beard Weard Ayden