Next.js App Router에서 React Query를 어떻게 섞어야 할까
React Query를 어느 정도 익히고 나면 자연스럽게 다음 질문으로 넘어가게 됩니다.
Next.js App Router에서도 그대로 쓰면 될까?- 이미 서버 컴포넌트에서 fetch가 되는데 굳이 왜 필요할까?
- 서버에서 가져온 데이터와 클라이언트 캐시는 어떻게 이어져야 할까?
이 질문이 중요한 이유는 App Router 자체가 이미 데이터 패칭에 대한 강한 관점을 가지고 있기 때문입니다. 그래서 React Query를 무조건 추가한다고 좋아지는 것이 아니라, 어떤 데이터는 Next.js 기본 흐름으로 충분하고 어떤 데이터는 여전히 React Query가 더 잘 맞는지를 구분하는 것이 핵심입니다.
한눈에 보면
짧게 정리하면 이렇습니다.
App Router에서는 서버에서 바로 가져와도 되는 데이터가 많습니다.- 그래서 모든 fetch를
React Query로 감싸는 것은 오히려 과할 수 있습니다. - 반면 클라이언트 상호작용이 강하고, 재요청/캐시/동기화가 중요한 데이터는
React Query가 여전히 강합니다. - 핵심은
Server Component와Client Component사이에서 데이터 책임을 어떻게 나눌지입니다.
즉, 이 조합의 포인트는 "둘 중 하나만 쓰기"가 아니라 각 도구가 더 잘 푸는 문제를 구분해서 섞는 것입니다.
왜 App Router에서는 고민이 더 생길까?
기존 SPA에서는 보통 클라이언트에서 데이터를 가져오는 흐름이 기본이었습니다. 하지만 App Router에서는 서버 컴포넌트 안에서 바로 await fetch()를 할 수 있습니다.
export default async function Page() {
const response = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 },
});
const posts = await response.json();
return <PostList posts={posts} />;
}이 구조는 단순하고 강력합니다.
- 서버에서 데이터를 가져오고
- HTML과 함께 보낼 수 있고
revalidate같은 캐시 전략도 줄 수 있습니다
그러다 보니 "React Query가 꼭 필요한가?"라는 질문이 자연스럽게 생깁니다.
답은 "항상은 아니다"입니다.
먼저 Next.js 기본 fetch가 잘 맞는 경우
아래 같은 데이터는 굳이 React Query가 없어도 충분한 경우가 많습니다.
- SEO가 중요한 공개 페이지 데이터
- 블로그 포스트, 카테고리 목록처럼 비교적 정적인 콘텐츠
- 서버 렌더링으로 처음부터 완성된 화면을 주고 싶은 데이터
- 사용자 상호작용보다 페이지 진입 시점이 더 중요한 데이터
예를 들어 블로그 포스트 상세는 서버 컴포넌트에서 바로 읽는 편이 자연스럽습니다.
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug);
return <PostDetail post={post} />;
}이 경우 클라이언트에서 다시 같은 데이터를 React Query로 한 번 더 감쌀 이유는 크지 않습니다.
그래도 React Query가 여전히 강한 경우
반대로 아래 상황은 React Query가 여전히 잘 맞습니다.
- 사용자 액션에 따라 자주 바뀌는 데이터
- 필터, 정렬, 페이지네이션, 무한 스크롤이 있는 리스트
- mutation 이후 여러 화면을 다시 동기화해야 하는 데이터
- 포커스 복귀, 재시도, background refetch가 중요한 데이터
- 클라이언트 인터랙션 중심 대시보드/어드민 화면
예를 들어 검색 필터가 많은 관리 화면이라면 서버 컴포넌트 한 번으로 끝나는 문제가 아닙니다.
- 검색어가 바뀌고
- 정렬이 바뀌고
- 탭이 바뀌고
- mutation 뒤 리스트를 다시 최신화해야 할 수 있습니다
이럴 때는 React Query가 주는 client-side cache와 invalidation 모델이 훨씬 유용합니다.
가장 단순한 분리 기준
실무에서는 아래 기준으로 생각하면 꽤 정리가 됩니다.
서버에서 바로 읽는 편이 좋은 데이터
- 최초 렌더링 품질이 중요하다
- SEO가 중요하다
- 정적/준정적 데이터다
- 페이지 진입 시 한 번 읽으면 충분하다
클라이언트에서 React Query로 읽는 편이 좋은 데이터
- 사용자 상호작용에 따라 자주 바뀐다
- 같은 화면 안에서 여러 번 다시 가져와야 한다
- mutation 이후 동기화 규칙이 중요하다
- 로컬 UI 상태와 같이 움직이는 데이터다
즉, App Router와 React Query의 조합은 "서버냐 클라이언트냐"보다 이 데이터가 어느 쪽 생명주기에 더 가깝냐로 보는 편이 좋습니다.
기본 세팅은 어떻게 둘까?
React Query를 쓰려면 QueryClientProvider는 클라이언트 컴포넌트에 있어야 합니다.
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
export function ReactQueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}그리고 루트 레이아웃에서 감쌉니다.
import { ReactQueryProvider } from './react-query-provider';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>
<ReactQueryProvider>{children}</ReactQueryProvider>
</body>
</html>
);
}여기서 중요한 것은 QueryClient를 렌더링마다 새로 만들지 않는 것입니다.
Client Component에서 쓰는 가장 기본적인 예제
필터가 있는 게시글 리스트는 보통 client component가 자연스럽습니다.
'use client';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
function useDebouncedValue<T>(value: T, delay = 300) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
window.clearTimeout(timeoutId);
};
}, [delay, value]);
return debouncedValue;
}
async function getPosts(keyword: string) {
const response = await fetch(`/api/posts?keyword=${encodeURIComponent(keyword)}`);
if (!response.ok) {
throw new Error('게시글 목록을 불러오지 못했습니다.');
}
return response.json();
}
export function SearchablePostList() {
const [keyword, setKeyword] = useState('');
const debouncedKeyword = useDebouncedValue(keyword, 300);
const { data, isFetching } = useQuery({
queryKey: ['posts', { keyword: debouncedKeyword }],
queryFn: () => getPosts(debouncedKeyword),
enabled: debouncedKeyword.trim().length > 0,
});
return (
<div>
<input value={keyword} onChange={(event) => setKeyword(event.target.value)} />
{isFetching && <p>검색 중...</p>}
<ul>
{data?.items.map((post: { id: number; title: string }) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
}이 예제는 App Router라고 해서 특별히 다르지 않습니다. 다만 실무에서는 검색어를 매 타이핑마다 바로 query에 연결하기보다, 위처럼 debounce나 enabled 조건을 같이 두는 편이 훨씬 자연스럽습니다. 결국 이 컴포넌트가 왜 client component여야 하는지, 그리고 클라이언트 상호작용이 강한 데이터에 왜 React Query가 잘 맞는지가 더 분명해집니다.
Server Component와 Client Component를 어떻게 나눌까?
가장 많이 쓰게 되는 패턴 중 하나는:
- 서버 컴포넌트에서 페이지 기본 뼈대를 만들고
- 인터랙티브한 영역만 클라이언트 컴포넌트로 내려서
- 그 안에서
React Query를 사용하는 방식입니다
예를 들면:
import { DashboardHeader } from './dashboard-header';
import { DashboardFilters } from './dashboard-filters';
export default function DashboardPage() {
return (
<div>
<DashboardHeader />
<DashboardFilters />
</div>
);
}그리고 실제 필터/리스트 영역은 클라이언트로 둡니다.
'use client';
export function DashboardFilters() {
return <InteractiveReportList />;
}이 방식의 장점은:
- 페이지 전체를 불필요하게 클라이언트화하지 않고
- 필요한 인터랙션 구역만
React Query를 쓰게 만들 수 있다는 점입니다
서버에서 prefetch한 뒤 hydrate하는 패턴은 언제 좋을까?
App Router에서 자주 언급되는 고급 패턴이 바로 이 조합입니다.
- 서버에서 query를 미리 가져오고
- dehydrated state를 내려주고
- 클라이언트에서
HydrationBoundary로 이어받는 방식입니다
이 패턴은:
- 첫 렌더링 품질을 챙기고 싶고
- 이후 클라이언트 캐시도 유지하고 싶을 때
유용합니다.
간단한 형태는 이렇게 볼 수 있습니다.
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
export default async function Page() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: getPosts,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostListClient />
</HydrationBoundary>
);
}그리고 클라이언트 컴포넌트는 같은 key로 그냥 읽습니다.
'use client';
import { useQuery } from '@tanstack/react-query';
export function PostListClient() {
const { data } = useQuery({
queryKey: ['posts'],
queryFn: getPosts,
});
return (
<ul>
{data?.items.map((post: { id: number; title: string }) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}이 패턴은 첫 진입과 이후 클라이언트 캐시를 둘 다 챙기고 싶을 때 좋습니다. 다만 모든 페이지에 기계적으로 넣을 필요는 없습니다.
언제 hydrate까지 갈 필요가 없을까?
아래 경우는 굳이 복잡한 hydration 흐름까지 가지 않아도 되는 경우가 많습니다.
- 서버 컴포넌트 fetch만으로 충분한 공개 페이지
- 클라이언트 재상호작용이 거의 없는 데이터
- 최초 렌더 이후 같은 데이터를 다시 건드릴 일이 거의 없는 화면
즉, "Next.js라서 hydration을 꼭 해야 한다"가 아니라 이 데이터를 이후에도 React Query 캐시로 계속 관리할 가치가 있는가를 먼저 봐야 합니다.
mutation은 App Router에서 어떻게 생각하면 좋을까?
여기서 실무 고민이 더 커집니다.
- 서버 액션을 쓸까?
- API route를 쓸까?
- mutation 성공 후 어떤 캐시를 다시 맞출까?
예를 들어 게시글 생성이 서버 액션으로 일어난다고 해도, 클라이언트 리스트 캐시는 여전히 다시 맞춰야 할 수 있습니다.
const createPostMutation = useMutation({
mutationFn: createPost,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});즉, 쓰기 방식이 서버 액션이든 API route든, 클라이언트에서 이미 보고 있는 데이터가 있다면 React Query 캐시 동기화는 별도 문제로 남습니다.
App Router에서 자주 하는 실수
이 조합에서 자주 보게 되는 실수를 정리하면 이렇습니다.
- 서버에서 이미 가져온 데이터를 클라이언트에서 이유 없이 다시 fetch한다
- 모든 페이지를
'use client'로 바꿔버린다 - React Query를 도입했는데 Next.js fetch cache 전략은 전혀 고려하지 않는다
- hydration이 필요한지 아닌지 구분 없이 전부 prefetch한다
- mutation 이후 invalidate 규칙이 없다
이 문제들은 대부분 "두 도구를 같이 쓰는 원칙"이 없을 때 생깁니다.
실무 기준 추천 흐름
너무 복잡하게 시작하지 않으려면 아래 흐름이 좋습니다.
- 공개 콘텐츠와 초기 페이지 데이터는 서버 컴포넌트 fetch를 먼저 본다
- 인터랙션이 강한 클라이언트 영역만 React Query를 쓴다
- 서버 prefetch + hydration은 첫 화면 품질과 이후 캐시 재사용 가치가 큰 경우에만 넣는다
- mutation 이후 invalidate 규칙은 도메인 단위로 정한다
즉, App Router와 React Query를 잘 섞는다는 것은 둘 다 최대한 많이 쓰는 것이 아니라, 각자 더 잘하는 역할을 분명히 나누는 것입니다.
정리하면
Next.js App Router에서 React Query를 쓴다는 것은 단순히 fetch 도구를 하나 더 올리는 일이 아닙니다.
핵심은 이렇습니다.
- 서버에서 바로 읽는 편이 좋은 데이터가 있고
- 클라이언트 캐시와 동기화가 중요한 데이터가 있으며
- 둘 사이 경계를
Server Component와Client Component기준으로 나눠야 하고 - hydration은 필요한 화면에만 선택적으로 넣는 것이 좋습니다
결국 중요한 것은 "React Query를 쓸 수 있는가"가 아니라, 이 데이터가 서버 생명주기와 클라이언트 생명주기 중 어디에 더 가깝냐입니다.
다음 글에서는 여기서 한 단계 더 들어가서, Suspense, SSR, hydration 관점에서 React Query를 보면 무엇이 달라지는지 정리해보겠습니다.
