TanStack Query 완벽 가이드 | React Query·캐싱
이 글의 핵심
TanStack Query(React Query)로 서버 상태를 관리하는 완벽 가이드. 캐싱, Mutation, Optimistic Updates, Infinite Scroll, Prefetch까지 실전 예제로 정리.
서버에서 온 데이터는 Query에 맡기세요. 리덕스 스토어에 users를 넣고, Context로 또 싸고, useEffect로 갱신 타이밍을 손으로 짜던 시절은 이제 끊는 게 맞다고 봅니다. (UI 토글이랑 모달 열림 같은 클라이언트 상태는 Zustand나 그냥 useState로 가고요.) 이 글은 TanStack Query를 “교과서 절”로 나누기보다, 처음 붙일 때 겪는 캐싱 감각이랑, 제가 팀에 밀어붙이는 쪽의 의견 위주로 적었어요. 예제 코드는 그대로 가져다 쓸 수 있게 남겨뒀습니다.
useQuery 처음 쓸 때 솔직히 헷갈려요. 같은 페이지에 프로필 카드가 두 군데 있어서 useQuery({ queryKey: ['user', id], ... })를 두 번 썼다고 칩시다. 예전엔 fetch가 두 번 나갈 거라고 상상하는데, Query는 똑같은 키면 네트워크를 합쳐요. 첫 구독이 fetch를 뜨이고, 둘째는 캐시에 있는 JSON을 씁니다. DevTools 켜서 보면 “한 번만 나갔네?”가 포인트예요. 반대로 키를 ['user', id]랑 ['userProfile', id]처럼 어정쩡하게 쪼개면 캐시가 안 맞아서 의도치 않게 두 배로 때리는 팀도 봤습니다. 약속은 하나로, 파라미터는 키에 박는 쪽이 마음이 편해요.
staleTime이랑 gcTime(전에 이름이 cacheTime이었죠)은 “곧 썩는다/메모리에서 치운다” 정도로 이해하면 됩니다. staleTime을 넉넉히 주면 화면에 다시 열릴 때마다 refetch하는 스팸이 줄고, 짧게 주면 “항상 최신에 가깝다” 쪽이에요. refetchOnWindowFocus 끄는 팀도 많고, 대시보드는 기본 끄고 수동 invalidate에만 기대는 경우도 있어요. 정답은 팀·도메인마다 있는데, “서버 상태는 Query에 맡긴다”는 전제만 지키면 조절은 여기서 합니다.
설치는 이렇게요. Devtools는 처음엔 거의 필수에 가깝습니다.
npm install @tanstack/react-query
npm install -D @tanstack/react-query-devtools
앱 루트는 QueryClient를 한 번 만들고 provider로 감쌉니다. Next면 'use client' 달려 있는 providers.tsx 류에 두는 그림이 흔해요.
// 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는 queryKey + queryFn이 뼈대입니다. isLoading / error / data는 그냥 믿고 쓰면 되고, 로딩 스켈레톤이랑 에러 UI만 프로덕트 톤에 맞게 그리면 끝에 가깝죠.
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>;
return (
<div>
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
}
user가 있을 때만 posts를 끌고 싶으면 enabled: !!user 패턴. 의존성 쿼리의 정석 루트죠.
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이고, 성공하면 invalidateQueries로 관련 queryKey를 한 번 쓸어주는 식이 제일 무난해요. “성공 직후 네가 알아서 리스트 쿼리를 다시 가져와”라는 말이에요.
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),
});
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은 체감이 확 올라가요. onMutate에서 cancelQueries → getQueryData / setQueryData → 실패 시 롤백 → onSettled에 invalidate 묶는 그림. 처음엔 좀 겁나는데, 한 번 뜯어보면 “로컬 state로 똑같이 하던 것”이랑 본질은 같아요. 서버 응답이 진실이고, UI는 잠깐 앞질러 갔다가 맞춰주는 거죠.
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']);
queryClient.setQueryData(['todos'], (old: Todo[]) =>
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>
);
}
긴 목록이면 useInfiniteQuery로 페이지를 pages에 쌓는 패턴, 리스트에서 상세로 넘기기 전에 prefetchQuery로 상세 쿼리를 미리 채우는 것도 “UX 비용” 줄이는 데 잘 먹혀요.
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();
const { data: posts } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
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>
);
}
댓글이 달리는 화면 하나를 통째로 커스텀 훅으로 빼면, 팀이 늘었을 때도 queryKey: ['comments', postId]만 지키면 됩니다. “이 화면만의 useEffect” 지옥이 조금씩 사라져요.
// 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[]) =>
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 / gcTime을 쿼리마다 다르게 주는 것도 “이 데이터는 5분은 그대로 둬도 된다” 같은 합의랑 잘 맞아요.
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,
});
Redux랑 비교하자면, 서버에서 온 건 Query, 폼/모달/테마는 다른 데—이렇게 쪼개는 편이 저는 훨씬 낫다고 봅니다. Next App Router 쓰면 RSC / 클라이언트 경계는 따로 잡혀 있으니, “클라이언트에서만 useQuery” 같은 경계도 미리 팀에서 말을 맞춰두는 게 좋고요. SWR이 더 가볍다는 말은 있는데, 페이지네이션이랑 뮤테이션·도구 체인까지 생각하면 Query 쪽이 무난한 팀이 많아요. 배포하실 땐 git add → git commit → git push 하시고 npm run deploy 돌리시면 됩니다.
TanStack Query, React Query, React, State Management, Caching, API, Frontend 쪽으로 찾아오시면 이 글과 맞닿는 주제일 거예요. React 18 / Next.js 15 / Zod 글은 relatedPosts에 걸어 둔 쪽이랑 같이 보면 읽기가 편해요.