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>
);
}
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가 되므로, 로딩 스피너와 구분해서 사용할 수 있습니다.
Query Key는 캐시를 구분하는 기준입니다. 동일한 키로 useQuery를 여러 컴포넌트에서 호출하면, 네트워크 요청은 한 번만 발생하고 캐시된 데이터를 공유합니다.
// 정적 키: 항상 같은 데이터를 가리킴
queryKey: ["todos"]
// 동적 키: userId가 바뀌면 다른 캐시 항목으로 취급
queryKey: ["user", userId]
// 복잡한 키: 필터 조건까지 포함
queryKey: ["todos", { status: "active", page: 1 }]
키는 직렬화 가능한 값이면 무엇이든 사용할 수 있습니다. 키 배열의 앞부분을 기준으로 관련 쿼리를 묶어 일괄 무효화할 수도 있습니다.
// ["user"] 로 시작하는 모든 쿼리를 무효화
queryClient.invalidateQueries({ queryKey: ["user"] });
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();
};
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 콜백으로 사이드 이펙트를 처리합니다.
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]);
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 경과)
→ 컴포넌트 마운트/포커스 → 백그라운드 재요청 발생
컴포넌트가 언마운트되면 해당 쿼리는 "비활성(inactive)" 상태가 됩니다. gcTime(기본값 5분) 이후에는 가비지 컬렉션 대상이 되어 캐시에서 제거됩니다.
useQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
gcTime: 1000 * 60 * 10, // 비활성 후 10분간 캐시 유지
});
staleTime과 gcTime의 관계를 정리하면 다음과 같습니다.
데이터 요청
└─ staleTime 동안: fresh (재요청 안 함)
└─ staleTime 이후: stale (조건 만족 시 재요청)
컴포넌트 언마운트
└─ gcTime 동안: inactive (캐시 유지)
└─ gcTime 이후: 캐시에서 제거
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"] });
},
});
사용자가 특정 화면으로 이동하기 전에 데이터를 미리 가져오면 로딩 화면 없이 즉시 표시할 수 있습니다.
// 호버 시 데이터 미리 가져오기
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한 캐시가 있으면 요청하지 않습니다.
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"] });
},
});
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가 되어 더 이상 로드하지 않습니다.
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를 호출하더라도 이미 캐시된 데이터를 즉시 받습니다.
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), 캐시된 데이터, 마지막 업데이트 시각 등을 확인할 수 있습니다. 쿼리를 클릭해 수동으로 무효화하거나 제거하는 것도 가능합니다.