Advanced Server Rendering
고급 서버 렌더링 가이드에 오신 것을 환영합니다. 이 가이드에서는 React Query를 스트리밍과 Server Components, 그리고 Next.js 앱 라우터와 함께 사용하는 방법을 배울 수 있습니다.
기본적인 React Query와 SSR 사용법을 배우고 싶다면 서버 렌더링 및 하이드레이션 가이드를 먼저 읽어보세요. 또한, 성능 및 요청 워터폴과 프리패칭 및 통합 라우터도 유용한 배경 지식을 제공합니다.
시작하기 전에, 'initialData' 접근 방식이 Server Components에서도 작동하지만, 이 가이드에서는 하이드레이션 API에 중점을 둘 것입니다.
Server Components & Next.js app router
이 가이드에서는 Server Components를 깊게 다루지는 않지만, 간단히 말하면 Server Components는 초기 페이지 보기와 페이지 전환 시 서버에서만 실행되는 컴포넌트입니다. 이는 Next.js의 getServerSideProps
/getStaticProps
와 Remix의 loader
가 서버에서만 실행되는 것과 비슷합니다. 그러나 Server Components는 단순히 데이터를 반환하는 것 이상을 할 수 있습니다. React Query와 관련된 데이터 부분에 중점을 두어 보겠습니다.
서버 렌더링 가이드에서 프레임워크 로더에서 앱으로 프리패치된 데이터 전달하기를 배운 내용을 Server Components와 Next.js 앱 라우터에 적용하는 방법을 살펴보겠습니다. Server Components를 "단지" 다른 프레임워크 로더라고 생각하는 것이 좋습니다.
A quick note on terminology
지금까지 이 가이드에서는 서버와 클라이언트에 대해 이야기했습니다. 하지만, 이 용어들이 Server Components와 Client Components와 일대일로 매칭되는 것은 아닙니다. Server Components는 서버에서만 실행되지만, Client Components는 실제로 두 장소에서 모두 실행될 수 있습니다. 이는 Client Components가 초기 서버 렌더링 단계에서도 렌더링될 수 있기 때문입니다.
이것을 생각하는 한 가지 방법은 Server Components가 "렌더링"되긴 하지만, 이는 "로더 단계"에서 발생하고, Client Components는 "어플리케이션 단계"에서 실행된다는 것입니다. 이 어플리케이션은 SSR 동안 서버에서 실행될 수도 있고, 예를 들어 브라우저에서도 실행될 수 있습니다.
Initial setup
React Query 설정의 첫 단계는 항상 queryClient
를 생성하고 어플리케이션을 QueryClientProvider
로 감싸는 것입니다. Server Components에서는 이 과정이 프레임워크에 따라 거의 동일하며, 파일 이름 규칙만 다를 수 있습니다:
// 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가 초기 렌더링 중에 멈추는 경우, 새 클라이언트를 다시 만들지 않도록 합니다.
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
}
export default function Providers({ children }) {
// NOTE: React가 초기 렌더링 중에 멈추는 경우, useState를 사용하여
// 쿼리 클라이언트를 초기화하지 않는 것이 좋습니다. 이는 클라이언트가
// 초기 렌더링에서 사라질 수 있기 때문입니다.
const queryClient = getQueryClient();
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
// 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>
);
}
이 부분은 SSR 가이드와 비슷하며, 파일을 두 개로 나누는 것만 다릅니다.
Prefetching and de/hydrating data
다음으로, 데이터 프리패칭과 하이드레이션을 어떻게 하는지 살펴보겠습니다. 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는 더 깊은 자식에서 사용할 수 있습니다.
// 데이터는 즉시 사용할 수 있습니다.
//
// 서버에서 이미 프리패치된 데이터가 있기 때문에 useSuspenseQuery 대신 useQuery를 사용합니다.
// 만약 프리패치를 잊거나 제거하면, 클라이언트에서 데이터를 다시 요청할 수 있습니다.
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>
);
}
이 코드를 앱 라우터로 변환하는 것은 비슷하게 보입니다. 먼저, 서버 컴포넌트를 만들어서 프리패칭을 처리합니다:
// 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>
);
}
다음으로, 클라이언트 컴포넌트 부분은 다음과 같습니다:
// app/posts/posts.jsx
"use client";
export default function Posts() {
// 이 useQuery는 더 깊은 자식에서 사용할 수 있습니다.
// 데이터는 즉시 사용할 수 있습니다.
const { data } = useQuery({
queryKey: ["posts"],
queryFn: () => getPosts(),
});
// 이 쿼리는 서버에서 프리패치되지 않았으며 클라이언트에서 요청이 시작될 때까지 기다립니다.
// 두 가지 패턴을 혼합해도 괜찮습니다.
const { data: commentsData } = useQuery({
queryKey: ["posts-comments"],
queryFn: getComments,
});
// ...
}
위의 예제에서 한 가지 멋진 점은 Next.js에 특화된 부분은 파일 이름뿐이라는 것입니다. 다른 프레임워크에서도 Server Components가 지원된다면 거의 동일한 방식으로 작성할 수 있습니다.
SSR 가이드에서 <HydrationBoundary>
를 모든 라우트에 넣는 것의 보일러플레이트를 없앨 수 있다고 언급했습니다. 그러나 Server Components에서는 이 방법을 사용할 수 없습니다.
NOTE: TypeScript 버전이
5.1.3
미만이고@types/react
버전이18.2.8
미만일 경우, async Server Components와 관련된 타입 오류가 발생할 수 있습니다. 최신 버전으로 업데이트하거나 이 문제가 해결될 때까지 기다려야 합니다.
Final words
Server Components와 Next.js 앱 라우터와 함께 React Query를 사용하는 것은 서버 컴포넌트가 로더처럼 작동하고 하이드레이션이 발생하는 방식을 이해하는 것이 핵심입니다. 데이터를 미리 가져와서 HydrationBoundary
로 하이드레이션하면 클라이언트에서 데이터 요청을 피할 수 있습니다. 최상의 성능과 사용자 경험을 위해 데이터는 컴포넌트 트리에서 가능한 한 빨리 미리 가져오고, 데이터가 이미 있는 경우 불필요한 클라이언트 사이드 재요청을 피하세요.