프로젝트를 진행하면서 Next.js에서 별도의 상태 관리 라이브러리를 도입할 필요가 있을까? 라는 고민을 정말 많이 했습니다.
사실 Next.js는 fetch 하나만으로도 충분히 데이터 패칭이 가능하고, useState로 간단하게 상태도 관리할 수 있습니다. 그런데 문제가 하나씩 복잡해지기 시작하면서 이렇게 단순하게 가기엔 한계가 명확했습니다.
이런 걸 직접 하나하나 상태 관리로 짜려니까 너무 복잡해졌다.
그래서 결국 React Query를 도입했다.
결론부터 말하면, 지금은 "잘 썼다" 라는 생각이 든다.
npm install @tanstack/react-query
CSR/SSR 환경 구분해서 QueryClient를 생성해주는 유틸입니다.
서버에선 매번 새로 만들고, 브라우저에선 싱글톤으로 유지되게 처리.
import { QueryClient, defaultShouldDehydrateQuery, isServer } from '@tanstack/react-query';
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, //SSR 이후 클라이언트에서 즉시 refetch가 일어나는 걸 방지함
},
dehydrate: {
shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
},
},
});
}
let browserQueryClient: QueryClient | undefined = undefined;
export function getQueryClient() {
if (isServer) {
return makeQueryClient(); // SSR: 항상 새 인스턴스 생성
} else {
// CSR: 브라우저에서는 싱글톤 사용
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
}
클라이언트 전역에 QueryClientProvider를 붙여주는 용도입니다.
layout.tsx에서 감싸주면 끝!
'use client';
import { QueryClientProvider } from '@tanstack/react-query';
import { getQueryClient } from './get-query-client';
import type * as React from 'react';
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,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="ko">
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
서버에서 데이터를 먼저 가져오고, 클라이언트에서는 다시 요청하지 않고 바로 사용하는 구조입니다.
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query'
import TestPage from './testpage'
export default async function ServerPage() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: ['test'],
queryFn: getTest,
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<TestPage />
</HydrationBoundary>
)
}
'use client'
export default function TestPage() {
const { data } = useQuery({
queryKey: ['test'],
queryFn: () => getTest(),
})
....
}
React Query의 중심은 결국 queryKey라고 생각합니다.
이게 API의 주소이자 캐시의 식별자 역할을 하기 때문에,
처음부터 명확한 규칙을 갖고 설계하지 않으면 나중에 관리가 굉장히 어렵습니다.
특히 프로젝트가 커질수록...
이렇게 쿼리 키에 일관성이 깨지기 시작하면
invalidate나 refetch 시점에서 “이게 어디까지 영향을 미치지?” 고민하게 됩니다.
👉 그래서 초기에 queryKey 네이밍 컨벤션을 잘 정해놓는 게 진짜 중요합니다.!!
(저의 추천은 => 리소스 단위로 ['user', id], ['post', postId], ['search', filters])
React Query는 CSR(Client Side Rendering)만으로도 충분히 강력한 라이브러리입니다.
처음에는 그냥 클라이언트에서 useQuery()로 데이터만 잘 불러오고,
캐싱, 로딩 상태도 알아서 잘 처리되니까
진입장벽도 낮고, 일단 써보면 좋다(?)라는 생각을 합니다.
그리고 추후 SEO나 초기 로딩 최적화가 필요해지면?
그때 prefetchQuery() + dehydrate() 조합으로
SSR 구조로 확장하는것도 좋은 방법이라고 생각을 합니다.
무리하게 처음부터 SSR로 진입할 필요 없이,
단계적으로 CSR → SSR → hybrid로 발전시킬 수 있는 구조라는 게 정말 큰 장점입니다.