개인의 기록
  • 소개
  • 프로젝트
  • 글
  • 링크

© 2026 newgirok

← 글 목록

TanStack Query — 서버 상태를 캐시하고 동기화하는 라이브러리

2026년 6월 14일
React서버상태TanStack QueryReact Query

TanStack Query(구 React Query)는 서버 상태를 선언적으로 관리하는 라이브러리입니다. useEffect + useState 조합으로 데이터를 가져오던 방식 대신, 캐시·재요청·동기화를 자동으로 처리해 줍니다.

npm install @tanstack/react-query

설치 후 앱 최상위에 QueryClientProvider를 감쌉니다.

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient();

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <MyApp />
    </QueryClientProvider>
  );
}

1. Query

Query: 서버에서 데이터를 읽어오는 단일 비동기 작업 단위

useQuery 훅은 서버 데이터를 가져오고, 로딩·에러·성공 상태를 자동으로 관리합니다.

import { useQuery } from "@tanstack/react-query";

function UserProfile({ userId }: { userId: number }) {
  const { data, isPending, isError, error } = useQuery({
    queryKey: ["user", userId],
    queryFn: () => fetch(`/api/users/${userId}`).then((res) => res.json()),
  });

  if (isPending) return <p>로딩 중...</p>;
  if (isError) return <p>에러: {error.message}</p>;

  return <p>{data.name}</p>;
}

isPending, isFetching, isError, isSuccess 등의 상태 플래그를 통해 UI를 분기합니다. isFetching은 백그라운드 재요청 중에도 true가 되므로, 로딩 스피너와 구분해서 사용할 수 있습니다.


2. Query Key

Query Key는 캐시를 구분하는 기준입니다. 동일한 키로 useQuery를 여러 컴포넌트에서 호출하면, 네트워크 요청은 한 번만 발생하고 캐시된 데이터를 공유합니다.

// 정적 키: 항상 같은 데이터를 가리킴
queryKey: ["todos"]

// 동적 키: userId가 바뀌면 다른 캐시 항목으로 취급
queryKey: ["user", userId]

// 복잡한 키: 필터 조건까지 포함
queryKey: ["todos", { status: "active", page: 1 }]

키는 직렬화 가능한 값이면 무엇이든 사용할 수 있습니다. 키 배열의 앞부분을 기준으로 관련 쿼리를 묶어 일괄 무효화할 수도 있습니다.

// ["user"] 로 시작하는 모든 쿼리를 무효화
queryClient.invalidateQueries({ queryKey: ["user"] });

3. Query Function

queryFn은 Promise를 반환하는 함수입니다. 성공 시 데이터를 resolve하고, 실패 시 에러를 reject해야 합니다.

// fetch 사용 시 — 4xx/5xx는 자동으로 reject되지 않으므로 직접 처리
const queryFn = async () => {
  const res = await fetch("/api/todos");
  if (!res.ok) throw new Error("요청 실패");
  return res.json();
};

// axios 사용 시 — 4xx/5xx에서 자동으로 에러를 throw
const queryFn = () => axios.get("/api/todos").then((res) => res.data);

queryFn은 QueryFunctionContext를 인자로 받으며, 여기서 queryKey와 signal(AbortController)을 꺼낼 수 있습니다.

queryFn: async ({ queryKey, signal }) => {
  const [, userId] = queryKey;
  const res = await fetch(`/api/users/${userId}`, { signal });
  return res.json();
};

4. Mutation

Mutation: 서버 데이터를 생성·수정·삭제하는 비동기 작업 단위

데이터를 변경하는 작업에는 useMutation을 사용합니다. useQuery와 달리 자동으로 실행되지 않고, mutate 또는 mutateAsync를 호출할 때만 실행됩니다.

import { useMutation, useQueryClient } from "@tanstack/react-query";

function AddTodo() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: (newTodo: { title: string }) =>
      axios.post("/api/todos", newTodo).then((res) => res.data),
    onSuccess: () => {
      // 성공 후 todo 목록 캐시 무효화
      queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
    onError: (error) => {
      console.error("할 일 추가 실패:", error);
    },
  });

  return (
    <button
      onClick={() => mutation.mutate({ title: "새 할 일" })}
      disabled={mutation.isPending}
    >
      {mutation.isPending ? "추가 중..." : "추가"}
    </button>
  );
}

onSuccess, onError, onSettled 콜백으로 사이드 이펙트를 처리합니다.


5. Cache

TanStack Query는 내부적으로 QueryCache와 MutationCache를 가집니다. QueryClient가 이 캐시를 관리하며, 동일한 Key로 조회하면 네트워크 요청 없이 캐시에서 즉시 데이터를 반환합니다.

[QueryCache]
  "todos"            → { data: [...], updatedAt: ... }
  ["user", 1]        → { data: {...}, updatedAt: ... }
  ["user", 2]        → { data: {...}, updatedAt: ... }

캐시에서 특정 항목을 직접 읽거나 쓰는 것도 가능합니다.

// 캐시에서 읽기
const todos = queryClient.getQueryData(["todos"]);

// 캐시에 직접 쓰기 (네트워크 요청 없음)
queryClient.setQueryData(["todos"], (old) => [...old, newTodo]);

6. Stale Time

Stale Time: 캐시된 데이터를 "신선하다(fresh)"고 판단하는 유효 시간

데이터는 가져온 직후 fresh 상태입니다. staleTime이 지나면 stale 상태가 되며, 다음 조건 중 하나가 발생하면 자동으로 재요청합니다.

  • 컴포넌트가 마운트될 때 (refetchOnMount)
  • 브라우저 창이 포커스될 때 (refetchOnWindowFocus)
  • 네트워크가 재연결될 때 (refetchOnReconnect)
useQuery({
  queryKey: ["todos"],
  queryFn: fetchTodos,
  staleTime: 1000 * 60 * 5, // 5분 동안 신선한 상태 유지
});

staleTime: Infinity로 설정하면 수동으로 무효화하기 전까지 재요청하지 않습니다.

fresh 상태 (staleTime 이내)
  → 컴포넌트 마운트/포커스 → 재요청 안 함

stale 상태 (staleTime 경과)
  → 컴포넌트 마운트/포커스 → 백그라운드 재요청 발생

7. Cache Time (gcTime)

컴포넌트가 언마운트되면 해당 쿼리는 "비활성(inactive)" 상태가 됩니다. gcTime(기본값 5분) 이후에는 가비지 컬렉션 대상이 되어 캐시에서 제거됩니다.

useQuery({
  queryKey: ["todos"],
  queryFn: fetchTodos,
  gcTime: 1000 * 60 * 10, // 비활성 후 10분간 캐시 유지
});

staleTime과 gcTime의 관계를 정리하면 다음과 같습니다.

데이터 요청
  └─ staleTime 동안: fresh (재요청 안 함)
  └─ staleTime 이후: stale (조건 만족 시 재요청)
컴포넌트 언마운트
  └─ gcTime 동안: inactive (캐시 유지)
  └─ gcTime 이후: 캐시에서 제거

8. Invalidation

invalidateQueries를 호출하면 해당 키의 캐시를 stale로 표시하고, 활성 컴포넌트가 있으면 즉시 재요청합니다.

// 단일 쿼리 무효화
queryClient.invalidateQueries({ queryKey: ["todos"] });

// 접두사로 관련 쿼리 일괄 무효화
queryClient.invalidateQueries({ queryKey: ["user"] });
// ["user", 1], ["user", 2] 등 모두 무효화됨

// Mutation 성공 후 자동 무효화 패턴
const mutation = useMutation({
  mutationFn: updateTodo,
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ["todos"] });
  },
});

9. Prefetch

사용자가 특정 화면으로 이동하기 전에 데이터를 미리 가져오면 로딩 화면 없이 즉시 표시할 수 있습니다.

// 호버 시 데이터 미리 가져오기
function TodoList() {
  const queryClient = useQueryClient();

  const prefetchTodo = (id: number) => {
    queryClient.prefetchQuery({
      queryKey: ["todo", id],
      queryFn: () => fetchTodo(id),
      staleTime: 1000 * 60, // 1분 이내면 재요청 안 함
    });
  };

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id} onMouseEnter={() => prefetchTodo(todo.id)}>
          {todo.title}
        </li>
      ))}
    </ul>
  );
}

prefetchQuery는 이미 fresh한 캐시가 있으면 요청하지 않습니다.


10. Optimistic Update

Optimistic Update: 서버 응답을 기다리지 않고 UI를 먼저 변경한 뒤, 실패 시 이전 상태로 되돌리는 패턴

네트워크 지연 없이 즉각적인 피드백을 주기 위해 사용합니다. 성공 가능성이 높은 작업(좋아요, 체크박스 등)에 적합합니다.

const mutation = useMutation({
  mutationFn: toggleTodo,
  onMutate: async (todoId) => {
    // 진행 중인 재요청을 취소하여 낙관적 업데이트를 덮어쓰지 않도록 함
    await queryClient.cancelQueries({ queryKey: ["todos"] });

    // 현재 캐시 값을 저장 (롤백용)
    const previousTodos = queryClient.getQueryData(["todos"]);

    // 캐시를 낙관적으로 업데이트
    queryClient.setQueryData(["todos"], (old: Todo[]) =>
      old.map((todo) =>
        todo.id === todoId ? { ...todo, done: !todo.done } : todo
      )
    );

    return { previousTodos };
  },
  onError: (_err, _todoId, context) => {
    // 실패 시 이전 상태로 롤백
    queryClient.setQueryData(["todos"], context?.previousTodos);
  },
  onSettled: () => {
    // 성공/실패 여부와 관계없이 서버와 동기화
    queryClient.invalidateQueries({ queryKey: ["todos"] });
  },
});

11. Infinite Query

useInfiniteQuery는 각 페이지를 별도로 캐시하고, fetchNextPage를 호출할 때마다 다음 페이지를 가져와 기존 데이터에 추가합니다.

import { useInfiniteQuery } from "@tanstack/react-query";

function InfiniteTodos() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useInfiniteQuery({
      queryKey: ["todos"],
      queryFn: ({ pageParam }) =>
        fetch(`/api/todos?cursor=${pageParam}`).then((res) => res.json()),
      initialPageParam: 0,
      getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
    });

  const todos = data?.pages.flatMap((page) => page.items) ?? [];

  return (
    <>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage ? "로딩 중..." : "더 보기"}
      </button>
    </>
  );
}

getNextPageParam이 undefined를 반환하면 hasNextPage가 false가 되어 더 이상 로드하지 않습니다.


12. Hydration

Next.js 등 SSR 환경에서는 서버에서 데이터를 미리 가져와 HTML에 포함시키고, 클라이언트가 같은 데이터를 중복 요청하지 않도록 합니다.

// 서버 컴포넌트 (Next.js App Router 기준)
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query";

export default async function Page() {
  const queryClient = new QueryClient();

  await queryClient.prefetchQuery({
    queryKey: ["todos"],
    queryFn: fetchTodos,
  });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Todos />
    </HydrationBoundary>
  );
}

dehydrate로 서버 캐시를 직렬화하고, HydrationBoundary가 클라이언트의 QueryClient에 복원합니다. 클라이언트의 Todos 컴포넌트는 useQuery를 호출하더라도 이미 캐시된 데이터를 즉시 받습니다.


13. Devtools

npm install @tanstack/react-query-devtools
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <MyApp />
      {/* 개발 환경에서만 렌더링 */}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

화면 하단에 패널이 표시되며, 각 쿼리의 상태(fresh/stale/inactive/fetching), 캐시된 데이터, 마지막 업데이트 시각 등을 확인할 수 있습니다. 쿼리를 클릭해 수동으로 무효화하거나 제거하는 것도 가능합니다.

← 이전 글Tailwind CSS — Utility First CSS 프레임워크
다음 글 →Vite — 빠른 개발 서버와 번들링을 제공하는 빌드 도구