React Query SSR
react-query
react-query는 클라이언트에서 server-state를 관리하기 위해 만들어진 라이브러리입니다. 서버 데이터를 효율적으로 케싱하는 방법, 불필요한 api 요청을 최소화하는 방법, client-state와 server-state를 분리해서 클라이언트의 data와 서버에 전달해야 하는 데이터, 서버에서 받아온 데이터를 효율적으로 분리해서 사용할 수 있습니다.
하지만 최근의 관심은 CSR이 아닌, 다시금 SSR 방법을 고민하고 있습니다. 즉 서버에서 미리 HTML을 생성하고 이를 클라이언트에 제공하는 방식이 각광받고 있습니다. 이때 예전의 웹페이지와 같이 정적인 페이지만을 제공하지 않기 위해 SSR과 CSR을 혼합해서 사용하고 있습니다.
Next.js의 경우 최초 url을 통한 페이지 접근은 SSR로 이루어집니다. 이후 페이지 렌더링을 효율적으로 진행하기 위해서 페이지 이동이 필요할 경우 SSR이 아닌, CSR 방법을 활용합니다. 이를 통해 깜빡임 없이 부드러운 페이지 전환을 제공합니다.
또한 interactive한 앱을 만들기 위해서 hydration 개념을 적용합니다. 초기 정적 페이지는 서버에서 렌더링 한 후, 이후에 자바스크립트 코드를 통해 client 측에서 이벤트를 연결하는 작업을 진행합니다. 이를 통해 초기 렌더링의 속도를 향상시킬 뿐만 아니라 SPA,CSR 조합으로 경험한 다이나믹한 UX,UI도 똑같이 활용할 수 있습니다.
Next.js는 express와 react를 결합해서 만든 프로젝트입니다. 따라서 SSR에서 react-query를 활용하기 위해서는 서버측에서 react query를 설정해야 합니다. 서버렌더링(SSR)은 초기 HTML을 서버에서 생성하고 client에 제공합니다. 이후 생성된 HTML에 JS와 query를 입히는 작업이 필요합니다. 즉 클라이언트에서 렌더링된 화면을 보기 위해서는 서버에서 아래와 같은 3번의 실행이 필요합니다.
1. -> Maekup
2. -> JS
3. -> Query
위의 3번의 과정을 2번의 과정으로 축약할 수 있습니다.
1. -> Maekup(with content AND initial data)
2. -> JS
1번 과정에서 HTML에 필효한 데이터를 받아서 초기 HTML을 생성합니다. 이후 2번에서 자바스크립트 파일을 실행하여 반응형 페이지가 됩니다. 따라서 클라이언트 측에서 다시 데이터를 받아오는 과정이 필요하지 않습니다. 따라서 서버 측에서는 prefetch - dehydrate - hydrate가 필요합니다.
-
prefetch에서 HTML markup 데이터 생성에 필요한 데이터를 미리 만듭니다.
-
dehydrate에서는 형성된 데이터를 적절한 방식으로 HTML에 삽입합니다.
-
hydrate는 클라이언트 측에서 수행이 되어 인터렉티브 기능을 추가합니다. 이때 이미 데이터가 HTML에 삽입되어 있기 때문에, 다시금 데이터를 불러오지 않습니다. hydrate 과정에서는 react-query에서 캐싱된 데이터를 활용합니다. 이를 통해 클라이언트 측에서 새로운 fetch가 실행되지 않도록 합니다.
환경
{
"dependencies": {
"@tanstack/react-query": "^5.17.5",
"next": "14.0.4",
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.0.4",
"typescript": "^5"
}
}
Provider 생성하기
Next.js에서 react-query를 사용하기 위해서는 CSR에서 하는 방식과 동일하게 queryClient
와 QueryClientProvider
를 설정해야 합니다.
// src/QueryProvider.tsx
'use client';
import { useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
export default function QueryProvider({
children,
}: {
children: React.ReactNode;
}) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// SSR을 위해서는 staleTime을 설정해야 합니다.
/*
staleTime이 0으로 설정할 경우 서버에서 prefetch이후
클라이언트에서 hydrate하는 과정에서
한번 더 fetch가 발생하고 이는 잠재적인 불일치를 야기합니다.
*/
staleTime: 60 * 1000,
},
},
}),
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
QueryClientProvider를 통해 Provider를 생성한 후 이를 layout에 연결합니다.
// src/app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import QueryProvider from '@/QueryProvider';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<QueryProvider>{children}</QueryProvider>
</body>
</html>
);
}
Prefetching && dehydrate
이제는 실제 서버에서 데이터를 prefetching 하고, hydrating 하는 과정을 설명합니다.
이를 위해서 react-query의 dehydrate
,HydrationBoundary
, QueryClient
를 활용합니다.
아래의 코드는 prefetch와 dehydrate 과정을 진행합니다.
// src/page.tsx
import {
QueryClient,
HydrationBoundary,
dehydrate,
} from '@tanstack/react-query';
import getCat from '@/query/getCat';
import CatPost from '@/component/CatPost';
// 해당 방식은 pageRputer의 getServerSideProps와 동일한 과정입니다.
export default async function Home() {
const queryClient = new QueryClient();
// prefetch를 통해 서버에서 생성할 HTML에 필요한 데이터를 미리 생성합니다.
await queryClient.prefetchQuery({
queryKey: ['cat'],
queryFn: getCat,
});
return (
/* HydrationBoundary 는 client component입니다.
hydration은 이곳에서 실행이 됩니다. */
<HydrationBoundary state={dehydrate(queryClient)}>
<CatPost />
</HydrationBoundary>
);
}
아래의 코드는 실제 서버에서 prefetch와 dehydrate가 된 데이터를 클라이언트 컴포넌트 측에서 활용하는 방법입니다. 이는 기존은 react-query 활용 방식과 동일합니다.
// src/component/CatPost.tsx
'use client';
import Image from 'next/image';
import { useQuery } from '@tanstack/react-query';
import getCat from '@/query/getCat';
export default function CatPost() {
const { data } = useQuery({ queryKey: ['cat'], queryFn: getCat });
return (
<main>
<Image
src={data![0].url}
width={1000}
height={600}
alt="cat image"
priority={false}
/>
</main>
);
}
해당 방식을 적용하였을때, 서버에서 데이터를 미리 생성하고 클라이언트 측에서 사용할 수 있습니다. prefetch된 데이터는 즉시 사용할 수 있습니다.
이를 활용하면 SSR과 CSR을 혼합해서 사용할 수도 있습니다.
// src/component/CatPost.tsx
'use client';
import Image from 'next/image';
import { useQuery } from '@tanstack/react-query';
import { getCat, getCats } from '@/query/getCat';
export default function CatPost() {
const { data } = useQuery({ queryKey: ['cat'], queryFn: getCat });
/*
해당 쿼리는 prefetch와 dehydrate가 적용되지 않습니다.
따라서 hydrate 과정에서 최초로 실행이 됩니다.
*/
const { data: catImages, isLoading } = useQuery({
queryKey: ['cats'],
queryFn: getCats,
});
return (
<main>
<Image
src={data!.url}
width={1000}
height={600}
alt="cat image"
priority={false}
/>
<div>
{isLoading ? (
<div>Loading...</div>
) : (
catImages!.map(cat => (
<Image
key={cat.id}
src={cat.url}
width={cat.width}
height={cat.heigth || '300'}
priority={false}
alt="cat"
/>
))
)}
</div>
</main>
);
}
queryKey가 cats
인 경우는 prefetch와 dehydration이 진행되지 않습니다. 따라서 기존의 react-query와 같이 클라이언트 측에서 데이터를 가지고 오고 실행이 됩니다. 위 두가지 방식 모두 Next.js에서 사용할 수 있습니다. 해당 페이지를 실행한 결과는 아래와 같습니다.

해당 SSR 방식으로 계층적으로 필요한 data를 활용할 수 있습니다. 애플리케이션의 맨 위가 아닌 데이터가 실제로 사용되는 위치에 더 가깝게 데이터를 프리페치할 수 있다는 것입니다.
// src/app/page.tsx
import {
QueryClient,
HydrationBoundary,
dehydrate,
} from '@tanstack/react-query';
import { getCat } from '@/query/getCat';
import CatPost from '@/component/CatPost';
import RenderManyCats from '@/component/RenderManyCats';
export default async function Home() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ['cat'],
queryFn: getCat,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<CatPost />
<RenderManyCats />
</HydrationBoundary>
);
}
CatPost
는 기존과 같이 하나의 cat 이미지를 받아옵니다. RenderManyCats
또한 서버사이드 렌더링을 실행하는데, 고양이 이미지 10개를 가지고 옵니다. 내부 코드는 다음과 같습니다.
// src/app/RenderManyCats.tsx
import {
QueryClient,
HydrationBoundary,
dehydrate,
} from '@tanstack/react-query';
import { getCats } from '@/query/getCat';
import CatsPost from '@/component/CatsPost';
export default async function RenderManyCats() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ['cats'],
queryFn: getCats,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<CatsPost />
</HydrationBoundary>
);
}
따라서 렌더링 과정은 아래와 같이 진행됩니다. await를 사용하기 때문에 waterfall 현상이 발생합니다. getCat이 실행된 이후에 getCats가 실행이 됩니다.
1. getCat()
2. getCats()
prefetch와 dehydrate에서 매번 새롭게 QueryClient를 정의하는 이유?
위의 방법에서는 모두 Server Component에서 fetch가 필요할 경우 QueryClient를 매번 새롭게 정의해서 사용하고 있습니다. 이는 react-query에서 추천하는 방식입니다. 이와 같이 매법 새롭게 QueryClient를 생성하지 않고 사용할 수 있습니다.
// app/getQueryClient.jsx
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;
위와 같이 하나의 QueryClient를 사용할 경우 장점은, 유틸리티 함수를 포함하여 서버 컴포넌트에서 호출되는 모든 곳에서 getQueryClient()를 호출하여 재활용할 수 있습니다.
단점은 dehydrate(getQueryClient())
를 실행할 때마다 이전에 이미 직렬화되어 현재 서버 컴포넌트와 관련이 없는 쿼리를 포함하여 전체 쿼리클라이언트를 직렬화하므로 불필요한 오버헤드가 발생합니다.
즉 매번 새롭게 queryClient를 정의하는 이유는 메모리 낭비가 있을 수 있지만, hydrate와 prefetch에서 필요한 데이터만을 실행해서 렌더링 속도를 최적화 하기 위함입니다.
Reference
- react-query
- - 환경
- Provider 생성하기
- Prefetching && dehydrate
- prefetch와 dehydrate에서 매번 새롭게 QueryClient를 정의하는 이유?
- Reference