본문으로 건너뛰기 TanStack Query 완벽 가이드 | React Query·캐싱

TanStack Query 완벽 가이드 | React Query·캐싱

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로 데이터 가져오기

useQueryqueryKey + 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>
  );
}

의존적 쿼리 (enabled)

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으로 데이터 변경하기

바꾸는 건 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 Update로 체감 속도 올리기

Optimistic은 체감이 확 올라가요. onMutate에서 cancelQueriesgetQueryData / setQueryData → 실패 시 롤백 → onSettledinvalidate 묶는 그림. 처음엔 좀 겁나는데, 한 번 뜯어보면 “로컬 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>
  );
}

무한 스크롤과 Prefetch

긴 목록이면 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

리렌더를 줄이고 싶으면 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/SWR과 비교

Redux랑 비교하자면, 서버에서 온 건 Query, 폼/모달/테마는 다른 데서—이렇게 쪼개는 편이 저는 훨씬 낫다고 봅니다. Next App Router 쓰면 RSC / 클라이언트 경계는 따로 잡혀 있으니, “클라이언트에서만 useQuery” 같은 경계도 미리 팀에서 말을 맞춰두는 게 좋고요. SWR이 더 가볍다는 말은 있는데, 페이지네이션이랑 뮤테이션·도구 체인까지 생각하면 Query 쪽이 무난한 팀이 많아요.

TanStack Query, React Query, React, State Management, Caching, API, Frontend 쪽으로 찾아오시면 이 글과 맞닿는 주제일 거예요. React 18 / Next.js 15 / Zod 글은 relatedPosts에 걸어 둔 쪽이랑 같이 보면 읽기가 편해요.

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.