Next.js 14에서 Tanstack Query를 사용하여 서버 상태 관리를 하고 있다. 이때, 컴포넌트 간 데이터 일관성을 유지하고 효율적인 데이터 캐싱을 구현하는 것이 중요하다. 기존 코드에서는 useQueryClient를 사용하지 않았으나, 전역 쿼리 클라이언트에 쉽게 접근하는데 사용하는 useQueryClient로 변경하려고 한다.
하지만 이 과정에서, prefetchInfiniteQuery를 사용하고 있는 서버 컴포넌트에서 useQueryClient를 사용하지 못하는 문제에 직면했다. 다음은 이 과정에서 직면한 문제와 해결 방법을 정리한 글이다.
useQueryClient를 사용하면 애플리케이션 최상위 레벨에서 생성된 단일 QueryClient 인스턴스를 공유하므로, 컴포넌트 간 데이터 일관성을 유지하고 효율적으로 상태를 관리할 수 있다.const queryClient = useQueryClient();
queryClient.invalidateQueries();
useQueryClient를 사용할 수 없다. 이는 useQueryClient가 클라이언트 전용 훅이기 때문에, 서버 컴포넌트에서 prefetchInfiniteQuery를 사용할 때는 new QueryClient()로 직접 인스턴스를 생성해야 한다.new QueryClient()를 사용하는 것은 피해야 함// Bad
const queryClient = new QueryClient();
queryClient.invalidateQueries();
컴포넌트 내부에서 매번 new QueryClient()를 생성하면 다음과 같은 문제가 발생한다.
1. 데이터 관리가 어려워짐
컴포넌트가 리렌더링될 때마다 새로운 QueryClient 인스턴스가 생성되므로, 동일한 데이터를 관리하는 여러 인스턴스가 존재하게 된다. 예를 들어, 특정 feed를 업데이트하는 useMutation을 실행한 후 invalidateQueries를 호출해도 변경 사항이 feed list에 반영되지 않을 수 있다. 이는 컴포넌트마다 QueryClient 인스턴스가 독립적으로 생성되어 데이터의 불일치가 발생하기 때문이다.
2. 비효율적 자원 관리 및 메모리 누수 가능성
Tanstack Query의 Query Client는 메모리 캐싱을 통해 상태를 관리하고, 이를 최적화하는 여러 기능을 제공한다. 하지만 컴포넌트 내부에서 새로 생성된 인스턴스는 캐싱의 이점을 활용하지 못하고 불필요한 메모리 사용을 초래하여 메모리 누수 가능성을 높인다.
애플리케이션의 최상위 레벨에서 QueryClient를 한 번만 생성하고, 이를 QueryClientProvider를 통해 앱 전체에 공급하는 것이 권장된다. 이 방식은 데이터 일관성을 유지하고 캐시된 데이터를 효율적으로 관리할 수 있게 한다.
queryClient를 export해서 사용하는 것은 오류가 있을 수 있음다음과 같이 QueryClientProvider에서 생성한 queryClient를 export하여 다른 컴포넌트에서 사용하고 있었다.
@/providers/TanstackQueryClientProvider"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
export const queryClient = new QueryClient();
const TanstackQueryClientProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
};
export default TanstackQueryClientProvider;
useMutation에서 queryClient.invalidateQueries()을 사용하기 위해 provider에서 따로 export하고 있는 queryClient을 불러다가 사용했다.
import { updateFeed } from "@/actions/feed-action";
import { useMutation } from "@tanstack/react-query";
import { queryClient } from "@/providers/TanstackQueryClientProvider";
import { FEED_KEY } from "../key";
export const useUpdateFeed = () => {
return useMutation({
mutationFn: updateFeed,
onSuccess: async () => {
queryClient.invalidateQueries({
queryKey: [FEED_KEY],
});
},
});
};
이렇게 하면 mutation에서는 queryClient.invalidateQueries()가 잘 동작된다.
그런데 queryClient.prefetchInfiniteQuery에서 new QueryClient()을 해서 사용하려 하니 에러가 발생했다.
import { FeedList, TopMenu } from "@/components/feed/list";
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query";
import { fetchFeeds } from "@/actions/feed-action";
import { FEED_KEY } from "@/lib/feed/key";
import { FEED_PAGE_SIZE } from "@/constants";
export default async function FeedListPage() {
const queryClient = new QueryClient();
await queryClient.prefetchInfiniteQuery({
queryKey: [FEED_KEY],
queryFn: ({ pageParam }) =>
fetchFeeds({ page: pageParam as number, pageSize: FEED_PAGE_SIZE }),
initialPageParam: 1,
staleTime: 60 * 1000,
});
const dehydratedState = dehydrate(queryClient);
return (
<div>
<TopMenu />
<HydrationBoundary state={dehydratedState}>
<FeedList />
</HydrationBoundary>
</div>
);
}
Error: Hydration failed because the initial UI does not match what was rendered on the server.
See more info here: https://nextjs.org/docs/messages/react-hydration-error

위에 오류는 서버에서 렌더링된 초기 UI와 클라이언트에서 렌더링된 UI가 불일치할 때 발생하는데, QueryClient 인스턴스가 잘못 관리하고 있어서 발생한 것이다.
QueryClient 인스턴스를 컴포넌트 외부에 선언할 경우, 서버와 클라이언트가 동일한 인스턴스를 공유하게 되어 Hydration 불일치가 발생할 수 있다. 이를 방지하기 위해, 컴포넌트 내부에서 new QueryClient()로 인스턴스를 선언해서 사용하면 오류가 해결된다.
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
const TanstackQueryClientProvider = ({ children }: { children: React.ReactNode }) => {
const queryClient = new QueryClient();
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
};
export default TanstackQueryClientProvider;
// Good
const queryClient = useQueryClient();
queryClient.invalidateQueries();
useQueryClient를 통해 필요한 곳에서 queryClient 인스턴스에 접근하고, 데이터를 효율적으로 관리할 수 있다.
import { updateFeed } from "@/actions/feed-action";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { FEED_KEY } from "../key";
export const useUpdateFeed = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateFeed,
onSuccess: async () => {
queryClient.invalidateQueries({
queryKey: [FEED_KEY],
});
},
});
};
이 방식으로 QueryClient 인스턴스를 최상위 레벨에서 생성하고, 컴포넌트 내에서 useQueryClient로 접근하면 데이터 일관성 및 효율적인 상태 관리를 확보할 수 있다.
서버 컴포넌트에서 prefetchInfiniteQuery를 실행할 때는, 클라이언트에서 사용하는 useQueryClient 훅을 사용할 수 없다.
prefetchInfiniteQuery 같은 사전 데이터를 패치하는 작업은 일반적으로 useQueryClient를 사용하는 것이 좋다. Tanstack Query 공식 문서에서는 prefetch 관련 작업을 useQueryClient를 통해 수행하도록 권장하고 있다.
function ShowDetailsButton() {
const queryClient = useQueryClient()
const prefetch = () => {
queryClient.prefetchQuery({
queryKey: ['details'],
queryFn: getDetailsData,
// Prefetch only fires when data is older than the staleTime,
// so in a case like this you definitely want to set one
staleTime: 60000,
})
}
return (
<button onMouseEnter={prefetch} onFocus={prefetch} onClick={...}>
Show Details
</button>
)
}
Next.js의 서버 컴포넌트에서 useQueryClient를 사용했더니 다음과 같은 에러가 발생했다.
import { fetchFeeds } from "@/actions/feed-action";
import { FeedList, TopMenu } from "@/components/feed/list";
import { FEED_PAGE_SIZE } from "@/constants";
import { FEED_KEY } from "@/lib/feed/key";
import {
dehydrate,
HydrationBoundary,
useQueryClient
} from "@tanstack/react-query";
export default async function FeedListPage() {
const queryClient = useQueryClient();
await queryClient.prefetchInfiniteQuery({
queryKey: [FEED_KEY],
queryFn: ({ pageParam }) =>
fetchFeeds({ page: pageParam as number, pageSize: FEED_PAGE_SIZE }),
initialPageParam: 1,
staleTime: 60 * 1000,
});
const dehydratedState = dehydrate(queryClient);
return (
<div>
<TopMenu />
<HydrationBoundary state={dehydratedState}>
<FeedList />
</HydrationBoundary>
</div>
);
}
클라이언트 훅인 useQueryClient를 서버 컴포넌트에서 사용하려고 시도할 경우 다음과 같은 에러가 발생했다.
Error: (0 , _tanstack_react_queryWEBPACK_IMPORTED_MODULE_5.useQueryClient) is not a function

그래서 서버 컴포넌트에서 QueryClient를 생성해 사용한다. 클라이언트 컴포넌트로 데이터가 전달될 때 불일치가 발생할 수 있으므로, dehydrate와 HydrationBoundary를 활용하여 서버에서 데이터를 미리 패치한 후 클라이언트로 전달하는 방식으로 처리하는 것이다.
prefetchInfiniteQuery를 실행하려면 new QueryClient()를 사용서버 컴포넌트에서 prefetchInfiniteQuery를 실행할 때는 new QueryClient()로 QueryClient 인스턴스를 생성하고, 데이터를 미리 패치한 후 dehydrate를 통해 직렬화하여 클라이언트로 전달한다.
import { fetchFeeds } from "@/actions/feed-action";
import { FeedList, TopMenu } from "@/components/feed/list";
import { FEED_PAGE_SIZE } from "@/constants";
import { FEED_KEY } from "@/lib/feed/key";
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query";
export default async function FeedListPage() {
const queryClient = new QueryClient(); // 서버 컴포넌트 내에서 QueryClient 직접 생성
await queryClient.prefetchInfiniteQuery({
queryKey: [FEED_KEY],
queryFn: ({ pageParam }) => fetchFeeds({ page: pageParam as number, pageSize: FEED_PAGE_SIZE }),
initialPageParam: 1,
staleTime: 60 * 1000,
});
const dehydratedState = dehydrate(queryClient);
return (
<div>
<TopMenu />
<HydrationBoundary state={dehydratedState}>
<FeedList />
</HydrationBoundary>
</div>
);
}
이렇게 함으로써 서버에서 미리 패치한 데이터를 클라이언트로 전달할 수 있으며, 클라이언트 컴포넌트에서 useQueryClient를 이용하여 데이터를 추가로 갱신하는 방식으로 구현할 수 있다.
Reference.
왜 컴포넌트 안에서 new QueryClient 사용을 지양해야 할까?
[React] React-query의 staleTime, cacheTime, invalidateQueries