TanStack Query(구 React Query)를 2년 넘게 쓰고 있다. v5에서 바뀐 것들이 꽤 있고, 그동안 팀에서 쌓아온 패턴들도 있다. 공식 문서를 읽으면 기본 사용법은 알 수 있는데, 실무에서 마주치는 상황은 문서에 안 나오는 경우가 많다.
여기 정리한 건 실제 프로젝트에서 반복적으로 쓰는 패턴들이다.
queryKey factory
queryKey를 문자열로 그냥 쓰면 프로젝트가 커질수록 관리가 안 된다.
// 이렇게 하면 안 된다
useQuery({ queryKey: ['users'], ... });
useQuery({ queryKey: ['users', userId], ... });
useQuery({ queryKey: ['users', userId, 'posts'], ... });
useQuery({ queryKey: ['users', { page, limit }], ... });
// 어디선가 invalidate할 때
queryClient.invalidateQueries({ queryKey: ['users'] }); // 이게 뭘 invalidate하는 건지?
queryKey를 factory 함수로 관리한다.
// queries/users.ts
export const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: UserFilters) => [...userKeys.lists(), filters] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: string) => [...userKeys.details(), id] as const,
};
export const userQueries = {
list: (filters: UserFilters) => ({
queryKey: userKeys.list(filters),
queryFn: () => fetchUsers(filters),
}),
detail: (id: string) => ({
queryKey: userKeys.detail(id),
queryFn: () => fetchUser(id),
}),
};사용하는 쪽.
// 리스트 조회
const { data } = useQuery(userQueries.list({ page: 1, limit: 20 }));
// 상세 조회
const { data } = useQuery(userQueries.detail(userId));
// 모든 user 관련 캐시 무효화
queryClient.invalidateQueries({ queryKey: userKeys.all });
// user 리스트만 무효화
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
// 특정 user만 무효화
queryClient.invalidateQueries({ queryKey: userKeys.detail(userId) });queryKey가 계층적으로 구성되어 있어서 원하는 범위만 정확하게 invalidate할 수 있다. userKeys.all을 invalidate하면 하위의 list, detail이 전부 무효화된다.
v5에서 바뀐 것들
v5에서 몇 가지 중요한 변화가 있다.
// v4: 여러 형태의 인자
useQuery(['users'], fetchUsers);
useQuery(['users'], fetchUsers, { staleTime: 5000 });
// v5: 항상 단일 객체
useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
staleTime: 5000,
});단일 객체 형태로 통일됐다. 이게 queryFactory 패턴과 잘 맞는다. userQueries.list(filters)가 반환하는 객체를 그대로 useQuery에 넘기면 된다.
onSuccess, onError, onSettled 콜백이 useQuery에서 제거됐다. 이건 큰 변화다.
// v4에서 흔히 쓰던 패턴
useQuery({
queryKey: ['user'],
queryFn: fetchUser,
onSuccess: (data) => {
setFormData(data); // 이런 패턴이 문제였다
},
});
// v5: useEffect로 대체하거나, 아예 이 패턴을 피하기
const { data } = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
});
// 서버 데이터를 로컬 상태에 복사하는 패턴 자체를 재고해야 한다
Optimistic Updates
사용자가 좋아요를 누르면 서버 응답을 기다리지 않고 바로 UI를 업데이트하는 패턴.
import { useMutation, useQueryClient } from '@tanstack/react-query';
function useLikeMutation(postId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => likePost(postId),
onMutate: async () => {
// 진행 중인 쿼리 취소 (충돌 방지)
await queryClient.cancelQueries({ queryKey: ['posts', postId] });
// 이전 값 저장
const previousPost = queryClient.getQueryData<Post>(['posts', postId]);
// 낙관적 업데이트
queryClient.setQueryData<Post>(['posts', postId], (old) => {
if (!old) return old;
return {
...old,
likes: old.likes + 1,
isLiked: true,
};
});
return { previousPost };
},
onError: (_err, _vars, context) => {
// 에러 시 롤백
if (context?.previousPost) {
queryClient.setQueryData(['posts', postId], context.previousPost);
}
},
onSettled: () => {
// 성공이든 실패든 서버 데이터로 동기화
queryClient.invalidateQueries({ queryKey: ['posts', postId] });
},
});
}
핵심은 onMutate에서 이전 값을 저장하고, onError에서 롤백하고, onSettled에서 서버와 동기화하는 3단계다.
리스트에서의 optimistic update는 조금 더 복잡하다.
function useDeletePostMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (postId: string) => deletePost(postId),
onMutate: async (postId) => {
await queryClient.cancelQueries({ queryKey: ['posts'] });
// 리스트의 이전 값 저장
const previousPosts = queryClient.getQueryData<Post[]>(['posts']);
// 해당 항목을 리스트에서 제거
queryClient.setQueryData<Post[]>(['posts'], (old) =>
old?.filter((post) => post.id !== postId)
);
return { previousPosts };
},
onError: (_err, _vars, context) => {
if (context?.previousPosts) {
queryClient.setQueryData(['posts'], context.previousPosts);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
}
Infinite Scroll
무한 스크롤은 useInfiniteQuery로 구현한다.
import { useInfiniteQuery } from '@tanstack/react-query';
interface PostsResponse {
posts: Post[];
nextCursor: string | null;
}
function usePostsInfinite() {
return useInfiniteQuery({
queryKey: ['posts', 'infinite'],
queryFn: ({ pageParam }): Promise<PostsResponse> =>
fetchPosts({ cursor: pageParam, limit: 20 }),
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
}
function PostFeed() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = usePostsInfinite();
const observerRef = useRef<IntersectionObserver>();
const lastPostRef = useCallback(
(node: HTMLDivElement | null) => {
if (isFetchingNextPage) return;
if (observerRef.current) observerRef.current.disconnect();
observerRef.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasNextPage) {
fetchNextPage();
}
});
if (node) observerRef.current.observe(node);
},
[isFetchingNextPage, hasNextPage, fetchNextPage]
);
const allPosts = data?.pages.flatMap((page) => page.posts) ?? [];
return (
<div>
{allPosts.map((post, index) => (
<div
key={post.id}
ref={index= allPosts.length - 1 ? lastPostRef : undefined}
>
<PostCard post={post} />
</div>
))}
{isFetchingNextPage && <LoadingSpinner />}
</div>
);
}data.pages는 각 페이지의 응답을 배열로 가지고 있다. flatMap으로 펼쳐서 전체 포스트 목록을 만든다. 마지막 포스트에 Intersection Observer를 달아서 뷰포트에 들어오면 다음 페이지를 가져온다.
Prefetching
라우터 이동 전에 데이터를 미리 가져오는 패턴.
function PostList() {
const queryClient = useQueryClient();
const handleMouseEnter = (postId: string) => {
queryClient.prefetchQuery({
queryKey: ['posts', postId],
queryFn: () => fetchPost(postId),
staleTime: 60 * 1000, // 1분 이내면 다시 안 가져옴
});
};
return (
<ul>
{posts.map((post) => (
<li
key={post.id}
onMouseEnter={()=> handleMouseEnter(post.id)}
>
<Link href={`/posts/${post.id}`}>{post.title}</Link>
</li>
))}
</ul>
);
}마우스를 올리면 상세 데이터를 미리 가져온다. 실제로 클릭해서 페이지에 들어가면 이미 캐시에 데이터가 있어서 로딩 없이 바로 보인다. staleTime을 넣어서 이미 최신 데이터가 캐시에 있으면 다시 가져오지 않는다.
Next.js App Router에서는 서버 컴포넌트에서 prefetch할 수도 있다.
// app/posts/page.tsx (서버 컴포넌트)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
export default async function PostsPage() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostList />
</HydrationBoundary>
);
}서버에서 데이터를 미리 가져와서 hydration을 통해 클라이언트에 전달한다. 클라이언트에서 useQuery를 호출하면 이미 캐시에 있는 데이터를 바로 사용한다.
Dependent Queries
한 쿼리의 결과가 다른 쿼리의 파라미터가 되는 경우.
function UserDashboard({ userId }: { userId: string }) {
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
// user가 로드된 후에만 실행
const { data: projects } = useQuery({
queryKey: ['projects', user?.teamId],
queryFn: () => fetchTeamProjects(user!.teamId),
enabled: !!user?.teamId,
});
// ...
}enabled를 false로 두면 쿼리가 실행되지 않는다. user가 로드되고 teamId가 있을 때만 두 번째 쿼리가 실행된다.
패턴들을 정리하고 보니, TanStack Query를 잘 쓰려면 결국 캐시를 이해해야 한다는 생각이 든다. queryKey의 계층 구조, staleTime과 gcTime의 차이, invalidation의 범위. 이런 것들이 머릿속에 그려지면 어떤 상황에서 어떤 패턴을 써야 하는지 자연스럽게 판단이 된다. 반대로 캐시 동작을 모르고 쓰면 "데이터가 안 바뀌어요", "이전 데이터가 잠깐 보여요" 같은 문제에 계속 부딪힌다. 도구를 쓰는 것과 도구를 이해하고 쓰는 것의 차이가 여기서 갈리는 것 같다.
