TanStack Query 캐싱이 실제로 동작하는 방식: staleTime·gcTime, select로 리렌더 줄이기, 커스텀 훅
이 글의 핵심
TanStack Query의 캐시가 staleTime과 gcTime에 따라 언제 다시 요청하는지, 낙관적 업데이트와 롤백, 화면 로직을 커스텀 훅으로 묶는 법, select로 불필요한 리렌더를 막는 방법을 정리합니다.
서버에서 온 데이터는 Query에 맡기는 것이 맞습니다. 리덕스 스토어에 users를 넣고, Context로 또 감싸고, useEffect로 갱신 타이밍을 손으로 짜던 방식은 이제 끊는 편이 낫다고 봅니다. (UI 토글이나 모달 열림 같은 클라이언트 상태는 Zustand나 단순히 useState면 됩니다.) 이 글은 TanStack Query를 교과서식으로 나누기보다, 처음 붙일 때 겪는 캐싱 감각과 팀에 권하는 쪽의 의견 위주로 적었습니다. 예제 코드는 그대로 가져다 쓸 수 있게 남겨 두었습니다.
캐싱이 실제로 어떻게 동작하는가
useQuery를 처음 쓸 때 가장 헷갈리는 부분이 캐시입니다. 같은 페이지에 프로필 카드가 두 군데 있어서 useQuery({ queryKey: ['user', id], ... })를 두 번 썼다고 해 보겠습니다. fetch가 두 번 나갈 것 같지만, Query는 똑같은 키면 네트워크 요청을 합칩니다. 첫 구독이 fetch를 띄우고, 둘째는 같은 요청의 결과를 공유합니다. DevTools를 켜서 요청이 한 번만 나갔는지 확인해 보면 바로 체감됩니다. 반대로 키를 ['user', id]와 ['userProfile', id]처럼 어정쩡하게 나누면 캐시가 맞지 않아 의도치 않게 요청이 두 배로 나가는 경우가 생깁니다. 키 규칙은 하나로 정하고, 요청 파라미터는 모두 키에 넣는 편이 안전합니다.
staleTime과 gcTime(예전 이름은 cacheTime)은 각각 “언제부터 오래된 데이터로 볼지”와 “아무도 쓰지 않는 캐시를 언제 메모리에서 치울지”를 정합니다. 기본값은 staleTime 0, gcTime 5분이라, 기본 설정에서는 데이터를 받자마자 stale 상태가 되고 컴포넌트가 다시 마운트되거나 창에 포커스가 돌아올 때마다 백그라운드 refetch가 일어납니다. staleTime을 넉넉히 주면 이런 반복 요청이 줄고, 짧게 주면 “항상 최신에 가깝다” 쪽이 됩니다. refetchOnWindowFocus를 끄는 팀도 많고, 대시보드는 자동 갱신을 끄고 수동 invalidate에만 기대는 경우도 있습니다. 정답은 팀·도메인마다 다르지만, “서버 상태는 Query에 맡긴다”는 전제만 지키면 조절은 이 두 값으로 합니다.
설치와 기본 설정
설치는 다음과 같습니다. Devtools는 처음에는 거의 필수에 가깝습니다.
npm install @tanstack/react-query
npm install -D @tanstack/react-query-devtools
앱 루트에서는 QueryClient를 한 번 만들고 provider로 감쌉니다. Next.js라면 'use client'가 붙은 providers.tsx 같은 파일에 두는 구성이 흔합니다. useState의 초기화 함수로 만드는 이유는, 컴포넌트 본문에서 new QueryClient()를 직접 호출하면 리렌더될 때마다 새 클라이언트가 생겨 캐시가 통째로 날아가기 때문입니다.
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1분
refetchOnWindowFocus: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
useQuery로 데이터 가져오기
useQuery는 queryKey + queryFn이 뼈대입니다. isLoading / error / data로 상태를 나누고, 로딩 스켈레톤과 에러 UI만 제품 톤에 맞게 그리면 대부분 끝납니다. 단, queryFn은 실패 시 반드시 예외를 던지거나 reject된 Promise를 반환해야 합니다. fetch는 404나 500 응답에도 reject하지 않으므로, 아래처럼 response.ok를 확인하지 않으면 에러 응답 본문이 정상 데이터로 캐시에 들어가고 재시도도 일어나지 않습니다.
import { useQuery } from '@tanstack/react-query';
interface User {
id: number;
name: string;
email: string;
}
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error('Failed to fetch user');
return response.json();
}
export function UserProfile({ userId }: { userId: number }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!data) return null; // TS 입장에서 data는 User | undefined이므로 필요
return (
<div>
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
}
data.name을 바로 쓰기 전에 if (!data) return null이 왜 필요한지 짚고 넘어갈 만합니다 — isLoading과 error를 다 걸러냈어도, TypeScript 타입 시스템 입장에서 data의 타입은 여전히 User | undefined입니다(로딩도 아니고 에러도 아닌데 데이터가 없는 상태가 이론상 존재하기 때문). strict 모드에서는 이 체크 없이 data.name에 접근하면 컴파일 에러가 나므로, isLoading/error 체크만 하고 넘어가면 타입 좁히기(narrowing)가 끝나지 않았다는 걸 기억해두면 좋습니다.
의존적 쿼리 (enabled)
user가 있을 때만 posts를 가져오고 싶다면 enabled: !!user 패턴을 씁니다. 의존적 쿼리의 기본 방법입니다. 이때 v5에서는 enabled: false인 동안 쿼리 상태가 pending이지만 요청은 나가지 않으므로 isLoading(= pending이면서 fetching 중)은 false입니다. 로딩 표시를 isPending으로 걸면 user를 기다리는 동안 스피너가 계속 돌고, isLoading으로 걸면 아무것도 보이지 않는 차이가 있어 화면 요구에 맞게 골라야 합니다.
function UserPosts({ userId }: { userId: number }) {
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
const { data: posts } = useQuery({
queryKey: ['posts', userId],
queryFn: () => fetchPosts(userId),
enabled: !!user,
});
return <div>{/* ... */}</div>;
}
useMutation으로 데이터 변경하기
데이터를 바꿀 때는 useMutation을 쓰고, 성공하면 invalidateQueries로 관련 queryKey를 무효화하는 방식이 가장 무난합니다. “성공 직후 이 키를 쓰는 목록은 다시 가져와라”라는 뜻입니다. 아래 createUser처럼 response.ok를 확인하지 않으면 서버가 400을 돌려줘도 mutation은 성공으로 처리되어 onSuccess가 실행되므로, 실제 코드에서는 queryFn과 마찬가지로 실패 응답에서 예외를 던져야 합니다.
여기서 invalidateQueries({ queryKey: ['users'] })처럼 키를 완전히 다 안 적어도 되는 이유를 알아두면 편합니다 — TanStack Query는 부분 매칭(prefix matching)을 지원해서, ['users']만 넘기면 ['users'], ['users', 1], ['users', 1, 'posts']처럼 그 아래로 뻗어나가는 모든 쿼리를 한 번에 무효화합니다. 반대로 이걸 모르고 매번 정확한 전체 키를 다 나열하려다 보면, 리스트 페이지 하나 바뀔 때마다 무효화 코드가 쓸데없이 길어지는 경우를 종종 봅니다.
import { useMutation, useQueryClient } from '@tanstack/react-query';
async function createUser(user: { name: string; email: string }) {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user),
});
if (!response.ok) throw new Error(`Failed to create user: ${response.status}`);
return response.json();
}
export function CreateUserForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
mutation.mutate({
name: formData.get('name') as string,
email: formData.get('email') as string,
});
};
return (
<form onSubmit={handleSubmit}>
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create User'}
</button>
{mutation.isError && <p>Error: {mutation.error.message}</p>}
</form>
);
}
Optimistic Update로 체감 속도 올리기
낙관적 업데이트(Optimistic Update)는 체감 속도를 크게 올려 줍니다. onMutate에서 cancelQueries → getQueryData / setQueryData → 실패 시 롤백 → onSettled에서 invalidate로 이어지는 흐름입니다. 처음에는 복잡해 보이지만, 로컬 state로 하던 일과 본질은 같습니다. 서버 응답이 진실이고, UI는 잠깐 앞질러 갔다가 서버 결과에 맞춰집니다.
첫 줄의 cancelQueries를 빼먹으면 왜 문제가 생기는지도 짚을 만합니다 — mutation이 진행 중인데 마침 백그라운드 refetch가 끝나서 서버의 “이전” 데이터로 캐시를 덮어써버리면, 방금 낙관적으로 반영한 UI가 롤백도 아니고 갑자기 원래대로 되돌아가는 것처럼 깜빡입니다. cancelQueries로 진행 중인 refetch를 먼저 취소해야, 낙관적 업데이트가 나중에 도착한 stale한 응답에 덮어씌워지는 경쟁 조건을 막을 수 있습니다.
function TodoList() {
const queryClient = useQueryClient();
const { data: todos } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
const toggleMutation = useMutation({
mutationFn: (id: number) => toggleTodo(id),
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
// old가 undefined일 수 있습니다 — 이 mutation이 todos 쿼리가 아직
// 한 번도 성공적으로 fetch되기 전에 실행되면 old.map()에서 그대로
// 런타임 에러가 납니다. optional chaining으로 방어해야 합니다.
queryClient.setQueryData(['todos'], (old: Todo[] | undefined) =>
old?.map((todo) =>
todo.id === id ? { ...todo, done: !todo.done } : todo
)
);
return { previousTodos };
},
onError: (err, id, context) => {
queryClient.setQueryData(['todos'], context?.previousTodos);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
return (
<ul>
{todos?.map((todo) => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.done}
onChange={() => toggleMutation.mutate(todo.id)}
/>
<span>{todo.text}</span>
</li>
))}
</ul>
);
}
무한 스크롤과 Prefetch
긴 목록이라면 useInfiniteQuery로 페이지를 pages에 쌓는 패턴을 쓰고, 목록에서 상세로 넘어가기 전에 prefetchQuery로 상세 쿼리를 미리 채워 두는 것도 대기 시간을 줄이는 데 효과가 있습니다.
onMouseEnter에서 prefetchQuery를 거는 패턴은 공짜가 아니라는 것도 같이 알아두면 좋습니다 — 마우스가 목록을 훑고 지나가기만 해도 항목마다 요청이 나가므로, 리스트가 길고 사용자가 스크롤하면서 마우스를 이리저리 움직이면 실제로 클릭하지 않을 항목까지 다 프리페치하게 됩니다. prefetchQuery에도 staleTime을 짧게라도 지정해서 같은 항목에 대한 중복 프리페치를 눌러주거나, 진짜 트래픽이 걱정되면 mouseenter 대신 살짝의 debounce/딜레이를 끼워 넣는 팀도 있습니다.
import { useInfiniteQuery } from '@tanstack/react-query';
interface PostsResponse {
posts: Post[];
nextCursor: number | null;
}
async function fetchPosts({ pageParam = 0 }): Promise<PostsResponse> {
const response = await fetch(`/api/posts?cursor=${pageParam}&limit=10`);
return response.json();
}
export function InfinitePostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialPageParam: 0,
});
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.content}</p>
</article>
))}
</div>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? 'Loading more...'
: hasNextPage
? 'Load More'
: 'No more posts'}
</button>
</div>
);
}
import { useQueryClient } from '@tanstack/react-query';
export function PostList() {
const queryClient = useQueryClient();
// useInfiniteQuery가 쓰는 ['posts']와 다른 키를 써야 함 (데이터 모양이 달라 캐시가 깨짐)
const { data: posts } = useQuery({
queryKey: ['posts', 'list'],
queryFn: fetchPostList,
});
const handleMouseEnter= (postId: number) => {
queryClient.prefetchQuery({
queryKey: ['post', postId],
queryFn: () => fetchPost(postId),
});
};
return (
<ul>
{posts?.map((post) => (
<li key={post.id} onMouseEnter={() => handleMouseEnter(post.id)}>
<Link href={`/posts/${post.id}`}>{post.title}</Link>
</li>
))}
</ul>
);
}
무한 스크롤에서 흔히 부딪히는 문제는 같은 키를 일반 쿼리와 무한 쿼리가 함께 쓰는 것입니다. useInfiniteQuery의 캐시 데이터는 { pages: [...], pageParams: [...] } 모양이라, 같은 ['posts'] 키로 useQuery를 쓰는 컴포넌트가 있으면 서로의 데이터를 덮어써서 data.pages is undefined 같은 런타임 에러가 납니다. 위 PostList의 키를 ['posts', 'list']로 나눈 이유입니다. 또 무한 쿼리를 invalidateQueries로 무효화하면 지금까지 불러온 모든 페이지를 처음부터 순서대로 다시 요청합니다. 사용자가 20페이지까지 스크롤한 상태라면 요청 20개가 연달아 나가므로, 페이지가 많이 쌓이는 화면에서는 maxPages 옵션으로 보관할 페이지 수를 제한하는 것을 고려할 만합니다.
커스텀 훅으로 화면 로직 캡슐화하기
댓글이 달리는 화면 하나를 통째로 커스텀 훅으로 빼 두면, 팀이 커져도 queryKey: ['comments', postId] 규칙만 지키면 됩니다. 화면마다 흩어져 있던 useEffect 기반 동기화 코드도 조금씩 사라집니다.
이렇게 훅으로 캡슐화해두면 얻는 실질적인 이득은 “쿼리 키 오타”를 원천 차단한다는 겁니다. useComments, useCreateComment, useDeleteComment가 각자 다른 화면에서 ['comments', postId]를 직접 문자열로 타이핑했다면, 어느 한 곳에서 배열 순서나 postId 타입(문자열 vs 숫자)이 미묘하게 어긋나도 컴파일 에러 없이 그냥 캐시가 안 맞는 버그로 나타납니다. 훅 안에 키를 한 군데로 몰아두면 이런 종류의 “키가 어긋나서 캐시 무효화가 안 먹힌다”는 디버깅이 애초에 발생할 여지가 줄어듭니다.
// hooks/useComments.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
interface Comment {
id: number;
postId: number;
author: string;
content: string;
createdAt: string;
}
export function useComments(postId: number) {
return useQuery({
queryKey: ['comments', postId],
queryFn: async () => {
const response = await fetch(`/api/posts/${postId}/comments`);
return response.json() as Promise<Comment[]>;
},
});
}
export function useCreateComment(postId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (comment: { author: string; content: string }) => {
const response = await fetch(`/api/posts/${postId}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(comment),
});
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['comments', postId] });
},
});
}
export function useDeleteComment(postId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (commentId: number) => {
await fetch(`/api/comments/${commentId}`, { method: 'DELETE' });
},
onMutate: async (commentId) => {
await queryClient.cancelQueries({ queryKey: ['comments', postId] });
const previousComments = queryClient.getQueryData(['comments', postId]);
queryClient.setQueryData(['comments', postId], (old: Comment[] | undefined) =>
old?.filter((comment) => comment.id !== commentId)
);
return { previousComments };
},
onError: (err, commentId, context) => {
queryClient.setQueryData(['comments', postId], context?.previousComments);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['comments', postId] });
},
});
}
// components/CommentSection.tsx
export function CommentSection({ postId }: { postId: number }) {
const { data: comments, isLoading } = useComments(postId);
const createComment = useCreateComment(postId);
const deleteComment = useDeleteComment(postId);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
createComment.mutate({
author: formData.get('author') as string,
content: formData.get('content') as string,
});
e.currentTarget.reset();
};
if (isLoading) return <div>Loading comments...</div>;
return (
<div>
<h2>Comments ({comments?.length})</h2>
<form onSubmit={handleSubmit}>
<input name="author" placeholder="Your name" required />
<textarea name="content" placeholder="Your comment" required />
<button type="submit" disabled={createComment.isPending}>
{createComment.isPending ? 'Posting...' : 'Post Comment'}
</button>
</form>
<ul>
{comments?.map((comment) => (
<li key={comment.id}>
<strong>{comment.author}</strong>
<p>{comment.content}</p>
<small>{new Date(comment.createdAt).toLocaleString()}</small>
<button onClick={() => deleteComment.mutate(comment.id)}>
Delete
</button>
</li>
))}
</ul>
</div>
);
}
리렌더 줄이기: select와 쿼리별 staleTime
리렌더를 줄이고 싶다면 select로 필요한 필드만 구독하게 만들 수 있고, staleTime / gcTime을 쿼리마다 다르게 주어 “이 데이터는 5분은 그대로 둬도 된다” 같은 합의를 코드로 표현할 수도 있습니다.
select가 리렌더를 줄여 주는 원리는 단순합니다. 컴포넌트는 data 객체 전체가 아니라 select가 뽑아낸 결과값(여기서는 data.name 문자열)을 받고, Query는 그 결과가 이전과 같으면 컴포넌트를 다시 렌더하지 않습니다. 그래서 user 객체의 다른 필드(예: email)가 바뀌어도 이 컴포넌트는 리렌더되지 않습니다. 다만 select를 인라인 화살표 함수로 두면 함수 참조가 렌더마다 바뀌어 select 자체가 매번 다시 실행되므로, 목록 전체를 가공하는 무거운 select라면 컴포넌트 바깥 상수나 useCallback으로 참조를 고정해 두는 편이 좋습니다.
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
select: (data) => data.name,
});
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
});
키와 옵션을 한곳에: queryOptions와 키 팩토리
앞에서 “같은 키면 합쳐지고, 키가 어긋나면 두 번 요청한다”고 했는데, 앱이 커지면 이 약속을 사람의 기억에 맡기기 어렵습니다. v5의 queryOptions는 키·queryFn·staleTime을 한 객체로 묶어 두고 useQuery, prefetchQuery, getQueryData에 그대로 넘기게 해 줍니다. 장점은 편의보다 타입입니다. queryClient.getQueryData(postQueryOptions(1).queryKey)의 반환 타입이 queryFn 결과로 추론되므로, 캐시를 직접 만지는 코드에서 as Post 캐스팅이 사라집니다.
import { queryOptions } from "@tanstack/react-query"
export const postKeys = {
all: ["posts"] as const,
list: (filters?: PostFilters) => [...postKeys.all, "list", filters] as const,
detail: (id: number) => [...postKeys.all, "detail", id] as const,
}
export const postQueryOptions = (id: number) =>
queryOptions({
queryKey: postKeys.detail(id),
queryFn: () => fetchPost(id),
staleTime: 60_000,
})
// 어디서든 같은 정의를 재사용
useQuery(postQueryOptions(id))
await queryClient.prefetchQuery(postQueryOptions(id))
queryClient.invalidateQueries({ queryKey: postKeys.all }) // 목록·상세 전부 무효화
키를 ["posts", ...] 접두사로 계층화해 두는 이유는 invalidateQueries가 접두사 매칭이기 때문입니다. 글 하나를 수정한 뒤 postKeys.all만 무효화하면 목록과 상세가 함께 갱신되고, 상세만 갱신하고 싶으면 postKeys.detail(id)를 넘기면 됩니다.
Next.js App Router에서 서버 prefetch + hydration
서버 컴포넌트에서 미리 받아 둔 데이터를 클라이언트 캐시에 넣어 첫 화면의 로딩 스피너를 없애는 패턴입니다. 서버에서 요청마다 새 QueryClient로 prefetch하고, dehydrate한 캐시를 HydrationBoundary로 내려 보내면 클라이언트의 useQuery는 이미 채워진 캐시로 바로 렌더합니다.
// app/posts/[id]/page.tsx (서버 컴포넌트)
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query"
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const qc = new QueryClient()
await qc.prefetchQuery(postQueryOptions(Number(id)))
return (
<HydrationBoundary state={dehydrate(qc)}>
<PostDetail id={Number(id)} /> {/* "use client" 컴포넌트, 내부에서 useQuery(postQueryOptions(id)) */}
</HydrationBoundary>
)
}
여기서 자주 밟는 함정이 두 가지 있습니다. 첫째, 클라이언트 QueryClient의 staleTime이 기본값 0이면 hydration 직후 데이터가 곧바로 stale로 간주되어 같은 요청이 브라우저에서 한 번 더 나갑니다. SSR을 쓰는 앱이라면 기본 staleTime을 수십 초 이상으로 잡아 두는 것이 사실상 필수입니다. 둘째, 서버에서 prefetch한 키와 클라이언트 useQuery의 키가 조금이라도 다르면(id를 문자열로 넘겼다든가) 캐시가 맞지 않아 hydration 효과가 조용히 사라집니다. 위처럼 양쪽 모두 같은 queryOptions를 쓰면 이 실수가 구조적으로 막힙니다. 서버 쪽 QueryClient를 모듈 전역으로 만들어 재사용하면 다른 사용자의 데이터가 섞일 수 있으니 반드시 요청 단위로 생성합니다(여러 서버 컴포넌트에서 공유하려면 React cache()로 감싼 팩토리를 씁니다).
Suspense를 쓰는 화면이라면 useSuspenseQuery가 data를 non-null로 타입 보장해 주므로 data!나 로딩 분기가 사라집니다. 대신 에러는 가장 가까운 에러 바운더리로 던져지므로, QueryErrorResetBoundary의 reset을 에러 바운더리의 onReset에 연결해 “다시 시도”가 쿼리 재실행으로 이어지게 해 두어야 합니다.
마무리: Redux/SWR과 비교
Redux와 비교하자면, 서버에서 온 데이터는 Query, 폼·모달·테마는 다른 곳에 두는 식으로 나누는 편이 훨씬 낫다고 봅니다. Next.js App Router를 쓴다면 RSC와 클라이언트 경계가 따로 있으니, “useQuery는 클라이언트 컴포넌트에서만” 같은 경계도 팀에서 미리 맞춰 두는 것이 좋습니다. SWR이 더 가볍다는 평가도 있지만, 페이지네이션·뮤테이션·DevTools까지 생각하면 Query 쪽이 무난한 팀이 많습니다.