서버컴포넌트와 리액트쿼리
리액트쿼리로 서버 컴포넌트 사용하기
2025-01-22
이 문서는 원문을 번역한 글입니다. 사소한 오타, 생력된 부분, 이상한 번역이 있을 수 있습니다.
서버컴포넌트,app라우터
여기서는 서버 컴포넌트에 대해 자세히 다루지 않겠지만, 서버 컴포넌트는 서버에서 렌더링되는 것이 보장된 컴포넌트로, 초기 페이지 뷰와 페이지 전환 모두에서 해당됩니다.
이것은 Next.js의 getServerSideProps/ getStaticProps와 Remix의 로더가 작동하는 방식과 유사합니다. 모두 서버에서 실행되지만, Remix의 로더나 getServerSideProps 등은 데이터만 반환하는 반면, 서버 컴포넌트는 훨씬 더 많은 작업을 수행할 수 있습니다.
그러나 React-Query는 데이터 부분이므로, 그 부분에 집중해 보겠습니다.
서버 렌더링 가이드에서 프레임워크 로더를 통해 미리 가져온 데이터를 앱에 전달하는 방법을 배운 내용을 서버 컴포넌트와 Next.js 앱 라우터에 어떻게 적용할 수 있을까요? 이를 생각하는 가장 좋은 방법은 서버 컴포넌트를 "단지" 또 다른 프레임워크 로더로 간주하는 것입니다.
서버 컴포넌트와 클라이언트 컴포넌트는 1:1로 일치하지 않습니다. 서버 컴포넌트는 서버에서만 실행되도록 보장되지만, 클라이언트 컴포넌트는 서버,클라이언트 모두에서 실행돌 수 있습니다. 왜냐하면 클라이언트 컴포넌트가 초기 서버 렌더링에서도 렌더링될 수 있기 때문입니다.
리액트 서버 컴포넌트는, 기존 구조를 뒤바꾸진 않지만, 기존 코드가 실행되기 전, 새로운 레이어를 추가합니다.
리액트 서버-컴포넌트의 계층은 Remix의 loader를 떠오르게 하지만, React 컴포넌트의 형태로 제공됩니다.
서버 컴포넌트가 렌더링되긴 하지만, 이는 "로더단계"에서 발생합니다.(항상 서버에서 발생). 반면 클라이언트 컴포넌트는 "애플리케이션 단계"에서 실행됩니다. 이 애플리케이션은 SSR동안 서버에서 실행될 수 있으며, 브라우저에서도 실행될 수 있습니다. 애플리케이션이 정확히 어디에서 실행되는지 여부는 프레임워크에 따라 다를 수 있습니다.
이런 구분은, React의 서버컴포넌트와 클라이언트 컴포넌트간 역할과 동작 차이를 이해하는데 중요합니다.서버 컴포넌트는 데이터 로딩과 초기 렌더링을 담당하고, 클라이언트 컴포넌트는 사용자와의 상호작용을 처리하며, 동적인 UI를 관리합니다.
초기세팅
리액트쿼리를 세팅하는 가장 첫번째 단계는 queryClent를 만들고 애플리케이션을 QueryClientProvider로 감싸는 것입니다.
"use client";
//QueryClient가 useContext에 의존하기 때문에,use Clienet를 붙여야 합니다.
import {
isServer,
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
});
}
let browserQueryClient: QueryClient | undefined = undefined;
function getQueryClient() {
if (isServer) {
//항상 새로운 쿼리 클라이언트를 만듭니다.
return makeQueryClient();
} else {
//싱글톤의 쿼리 클라이언트를 사용합니다.
if (!browserQueryClient) {
browserQueryClient = makeQueryClient();
}
return browserQueryClient;
}
}
export default function Providers({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient();
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
import Providers from "./providers";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<head />
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
클라이언트 환경에서는 싱글톤을 유지해도 되지만 서버 환경에서는 싱글톤을 유지하면 안 됩니다.
같은 서버에서 모든 유저가 하나의 queryClient를 사용한다면 매우 큰일이 나기 때문에 매번 새로 생성하고, 클라이언트 환경에서 각 유저들은 매번 queryClient를 만들 필요가 없기 때문에 다음과 같이 작성되어 있습니다.
Prefeching/de-hydrationg
다음으로 실제 데이터를 미리가져오고, dehydrate,hydrate하는 방법을 살펴보겠습니다. 다음은 Next.js 페이지 라우터를 사용했을 떄의 모습입니다.
// pages/posts.tsx
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는 더 깊은 자식 컴포넌트에서도 발생할 수 있습니다.
//데이터는 어떤 경우든 즉시 사용할 수 있습니다.
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.tsx
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.tsx
"use client";
export default function Posts() {
// 이 useQuery는 <Posts>의 더 깊은 자식 컴포넌트에서도 발생할 수 있으며,
// 데이터는 어떤 경우든 즉시 사용 가능할 것입니다.
const { data } = useQuery({
queryKey: ["posts"],
queryFn: () => getPosts(),
});
// 이 쿼리는 서버에서 미리 가져오지 않았으며,
// 클라이언트에서 시작될 때까지 가져오기를 시작하지 않습니다. 두 가지 패턴을 혼합하는 것도 괜찮습니다.
const { data: commentsData } = useQuery({
queryKey: ["posts-comments"],
queryFn: getComments,
});
// ...
}
SSR 가이드에서는 모든 경로에 HydrationBoundary를 추가하는 보일러플레이트를 없앨 수 있다고 언급했습니다. 그러나 이는 서버 컴포넌트에서는 적용되지 않습니다.
참고: 비동기 서버 컴포넌트를 사용할 때 TypeScript 버전이 5.1.3 미만이고 @types/react 버전이 18.2.8 미만인 경우, 두 버전 모두 최신 버전으로 업데이트하는 것이 권장됩니다. 대안으로, 다른 컴포넌트 안에서 이 컴포넌트를 호출할 때 {/_ @ts-expect-error Server Component _/} 주석을 추가하여 오류를 무시할 수 있습니다. 더 많은 정보는 Next.js 13 문서의 비동기 서버 컴포넌트 TypeScript 오류 섹션을 참조하세요.
참고: "서버 액션에 전달할 수 있는 것은 일반 객체와 몇 가지 내장 객체뿐입니다. 클래스나 null 프로토타입은 지원되지 않습니다."라는 오류가 발생하는 경우, queryFn에 함수 참조를 전달하지 않도록 하세요. 대신, 함수를 직접 호출해야 합니다. queryFn의 인수에는 여러 속성이 포함되어 있으며, 그 중 모든 것이 직렬화 가능하지 않기 때문입니다. queryFn이 참조가 아닐 때만 서버 액션이 작동합니다.
중첩된서버컴포넌트
서버컴포넌트의 좋은 점 중 하나는, 중첩이 가능하고, React트리의 여러 레벨에 존재할 수 있다는 점입니다.
이를 통해 애플리케이션의 최상단에서만 데이터를 미리 가져오는 것이 아니라, 실제로 사용되는 곳에 더 가까운 위치에서 데이터를 미리 가져올 수 있습니다 .(Remix 로더와 유사하게).
이는 서버 컴포넌트가 다른 서버 컴포넌트를 렌더링하는 것만큼 간단할 수 있습니다 (간결함을 위해 이 예제에서는 클라이언트 컴포넌트는 제외하겠습니다).
// 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.prefetchQuery({
queryKey: ["posts"],
queryFn: getPosts,
});
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.prefetchQuery({
queryKey: ["posts-comments"],
queryFn: getComments,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Comments />
</HydrationBoundary>
);
}
위처럼 hydrationBoundary를 여러 곳에서 사용하는 것은 전혀 문제가 없고, 여러 개의 queryClient를 생성하는 것도 가능합니다.
그러나 comments-server를 렌더링하기 전에, getPosts를 기다리기 떄문에, 이는 워터폴 현상을 초래할 수 있습니다.
1. |> getPosts()
2. |> getComments()
서버의 데이터에 대한 대기 시간이 낮다면, 큰 문제가 아닐 수 있지만, 여전히 언급할 가치가 있습니다.
Next.js에서는 page.tsx에서 데이터를 미리 가져오는 것 외에도 layout.tsx와 병렬 라우트(parallel routes)에서도 데이터를 미리 가져올 수 있습니다.
이러한 모든 요소가 라우팅의 일부이기 때문에, Next.js는 이를 모두 병렬로 가져오는 방법을 알고 있습니다.
따라서 위의 CommentsServerComponent가 병렬 라우트(parallel routes)로 표현된다면, 워터폴 현상이 자동으로 평탄화(flattened)될 것입니다.
더 많은 프레임워크가 서버 컴포넌트를 지원하게 됨에 따라, 이들은 다른 라우팅 규칙을 가질 수 있습니다. 자세한 내용은 사용 중인 프레임워크의 문서를 참조하는 것이 좋습니다.
위의 예시에서, 우리는 매 서버컴포넌트마다 하나의 queryClient를 만들었고, 데이터를 가져왔습니다.
이것은 적절한 접근이나, 당신이 원한다면 모든 서버컴포넌트에서 재사용되고 공유되는 하나의 queryClient만 만들 수 있습니다.
import { QueryClient } from "@tanstack/react-query";
import { cache } from "react";
const getQueryClient = cache(() => new QueryClient());
export default getQueryClient;
그러나 이 패턴의 단점은 dehydrate(getQueryClient())를 호출할 때마다, 전체 queryClient가 직렬화되며, 이미 직렬화된 쿼리와, 관련없는 쿼리까지 포함해 직렬화를 하기 때문에, 불필요한 오버헤드가 발생할 수 있습니다.
데이터재검증
서버 컴포넌트를 사용할 때는 데이터 소유권과 재검증에 대해 생각하는 것이 중요합니다. 그 이유를 설명하기 위해, 위의 예제를 수정한 예를 살펴보겠습니다:
// 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();
// fetchQuery를 쓴 것에 주목해주세요
const posts = await queryClient.fetchQuery({
queryKey: ["posts"],
queryFn: getPosts,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<div>Nr of posts: {posts.length}</div>
<Posts />
</HydrationBoundary>
);
}
이제 서버 컴포넌트와 클라이언트 컴포넌트 모두에서 getPosts 쿼리의 데이터를 렌더링하고 있습니다.
이는 초기 페이지 렌더링에는 문제가 없지만, 만약 어떤 이유로 클라이언트에서 쿼리가 재검증되면 staleTime이 지나간 경우에는 어떻게 될까요?
React Query는 서버 컴포넌트를 재검증하는 방법을 알지 못하므로, 클라이언트에서 데이터를 다시 가져오게 되면 게시물 목록이 다시 렌더링되고, "게시물 수: {posts.length}"가 동기화되지 않게 됩니다.
staleTime을 Infinity로 설정하면 React Query가 재검증을 하지 않기 때문에 괜찮지만, React Query를 사용하는 이유가 아닐 가능성이 높습니다.
서버 컴포넌트와 함께 React Query를 사용하는 것이 가장 의미가 있는 경우는 다음과 같습니다:
- React Query를 사용하는 앱이 있고, 모든 데이터 가져오기를 다시 작성하지 않고 서버 컴포넌트로 마이그레이션하고 싶을 때
- 익숙한 프로그래밍 패러다임을 원하지만, 서버 컴포넌트의 이점을 적절히 활용하고 싶을 때
- React Query가 다루는 특정 사용 사례가 있지만, 선택한 프레임워크가 다루지 않는 경우
서버 컴포넌트와 React Query를 함께 사용할 때 언제가 적합한지에 대한 일반적인 조언을 주기는 어렵습니다.
새로운 서버 컴포넌트 앱을 시작하는 경우, 프레임워크에서 제공하는 데이터 가져오기 도구를 먼저 사용하고, 실제로 필요할 때까지 React Query를 도입하지 않는 것이 좋습니다. 필요하지 않을 수도 있으며, 그럴 경우에는 적절한 도구를 사용하는 것이 좋습니다.
서버컴포넌트와,스트리밍
Next.js 앱 라우터는 애플리케이션의 준비된 부분을 가능한 한 빨리 브라우저에 스트리밍하여, 완료된 콘텐츠를 대기 중인 콘텐츠를 기다리지 않고 즉시 표시할 수 있도록 자동으로 처리합니다.
loading.tsx 파일을 생성하면, 이로 인해 자동으로 Suspense경계가 뒤에서 생성됩니다.
위에서 설명한 프리패칭 패턴을 사용하면, React Query는 이러한 형태의 스트리밍과 완벽하게 호환됩니다.
각 Suspense 경계의 데이터가 해결되면, Next.js는 완료된 콘텐츠를 렌더링하고 브라우저로 스트리밍할 수 있습니다.
React Query v5.40.0부터는 모든 프리패치를 기다릴 필요가 없으며, 대기 중인 쿼리도 탈수(dehydrate)되어 클라이언트로 전송될 수 있습니다.
이를 통해 전체 Suspense 경계를 차단하지 않고 가능한 한 빨리 프리패치를 시작할 수 있으며, 쿼리가 완료되면 데이터를 클라이언트로 스트리밍할 수 있습니다.
이 기능을 작동시키기 위해서는 queryClient에 대기 중인 쿼리도 탈수하도록 지시해야 합니다. 이는 전역적으로 설정할 수 있으며, 또는 탈수할 때 해당 옵션을 직접 전달할 수 있습니다.
// app/get-query-client.ts
import {
isServer,
QueryClient,
defaultShouldDehydrateQuery,
} from "@tanstack/react-query";
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
dehydrate: {
//status가 pending인 쿼리도 dehydrate시킨다.
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === "pending",
},
},
});
}
let browserQueryClient: QueryClient | undefined = undefined;
export function getQueryClient() {
if (isServer) {
return makeQueryClient();
} else {
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
}
이렇게 하면 pending 상태인 쿼리까지 함께, dehydrate처리를 할 수 있습니다. HydrationBoundary에서, await prefetch를 더 이상 사용하지 않아도 됩니다.
// app/posts/page.tsx
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getQueryClient } from "./get-query-client";
import Posts from "./posts";
export default function PostsPage() {
const queryClient = getQueryClient();
// no await
queryClient.prefetchQuery({
queryKey: ["posts"],
queryFn: getPosts,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Posts />
</HydrationBoundary>
);
}
클라이언트에서 Promise가 QueryCache에 저장되고, Posts컴포넌트가 useSuspenseQuery를 호출하여, 그 Promise를 사용할 수 있습니다.
// app/posts/posts.tsx
"use client";
export default function Posts() {
const { data } = useSuspenseQuery({ queryKey: ["posts"], queryFn: getPosts });
}
마지막으로
데이터 소유권과 재검증에 대해 서버컴포넌트에서 react-query를 쓰는 것은 좋지 않을 것 같다. (애초에 이런 경우 fetch를 쓰는 것이 나을 것 같다.)
많은 것을 배워간 것 같다.특히 전역 query-provider에서 pending상태인 쿼리까지 dehydrate할 수 있다는 점이 신기했고, next,특히 서버 컴포넌트에서 react-query가 꼭 필요할까?의 질문을 계속 고민할 수 있으면 좋겠다..