React 애플리케이션의 상태는 관리하는 데이터의 특성에 따라 지역 상태(Local State)와 전역 상태(Global State)로 나눌 수 있다.
<input>의 입력값 등한편, 프론트엔드에서는 비동기 API 요청으로부터 얻은 데이터에 관한 정보도 상태로 관리할 일이 많다. React 개발에서 데이터 페칭 후 데이터를 화면에 표시하기까지의 과정을 생각해보자. 사용자에게 매끄러운 화면 동작을 제공하기 위해서, useEffect 훅에서 수행한 API 요청으로 얻은 데이터로부터 로딩 상태 처리/성공 및 실패 유무에 따른 UI 표시/에러 처리/캐싱과 같은 작업을 진행해야 할 것이다. 이를 위해서 결과에 따라 조건부 JSX 렌더링을 해주어야 하며 캐싱을 위한 작업을 별도로 수행해야 한다.
매번 이런 동일한 패턴의 작업을 수행하기에는 코드의 중복, 상태 관리의 복잡성, 관심사 분리 등 개발자가 신경써야 할 부분이 많이 생겨날 듯하다. 이때 이러한 상태들을 쉽게 관리해 줄 필요가 생겼고, ‘서버 상태(Server State)’라는 개념이 등장했다.
Tanstack Query(구 리액트 쿼리)는 React 진영의 대표적인 서버 상태 관리 라이브러리로, 웹 애플리케이션의 서버에서 가져온 데이터 작업을 용이하게 한다.
npm i @tanstack/react-query
# 관리되는 캐시를 쉽게 확인할 수 있게 해주는 개발 편의툴
npm i @tanstack/react-query-devtoolsdefaultOptions 설정 시 모든 query와 mutation에 기본 옵션을 추가한다. 다양한 메서드를 가지고 있어 상황에 맞게 캐시를 조작할 수 있다. <QueryClientProvider>는 QueryClient를 애플리케이션에 연결하고 제공하는 역할을 한다. 최상단에 감싼 후, 생성한 QueryClient 인스턴스를 client prop에 주입한다.import { RouterProvider } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import router from "./router";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
});
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<ReactQueryDevtools />
</QueryClientProvider>
);
}Client State: 어플리케이션에서만 필요한 데이터 ex) 모달 오픈 여부, 테마, 언어 등
Server State: 서버에서 받아오는 데이터 ex) DB 상품 정보

| 상태 | 설명 |
|---|---|
| fetching | 최초로 데이터를 불러오는 중인 상태 |
| fresh | 데이터가 신선한 상태 |
| stale | 데이터가 상한 상태 |
| inactive | 캐시 데이터가 현재 사용되지 않아 메모리에 기록된 상태 |
| deleted | 가비지 컬렉팅에 의해 메모리 상에서도 삭제된 상태 |
특정 조건일 때 stale 상태의 데이터를 fresh 상태로 전이하기 위해 데이터를 다시 불러오는 행위인 refetching을 수행하게 설정 가능하다.




데이터를 캐싱하고 관리하는 데 중요한 두 가지 설정값이다.
staleTime : 첫 데이터 페칭 후 얼마 동안 네트워크 요청 없이 캐시된 데이터를 사용할지 정하는 시간이다. ‘유통기한’을 떠올리면 이해하기 쉬울 것이다. (기본값: 0)stale은 ‘신선하지 않은, 탁한’이란 의미를 지닌 단어이다.
gcTime : 메모리 상에서 사용되지 않거나 inactive 상태의 캐시 데이터가 남아있는 시간이다. 즉, 캐시를 사용하는 쿼리를 수행하는 컴포넌트가 모두 언마운트된 이후 캐시 데이터를 메모리 상에 얼마 동안 유지할지 결정한다. 이 시간이 지나면 해당 캐시 데이터는 가비지 컬렉팅된다. (기본값: 5분, SSR 중에는 Infinity)API 옵션은 queryClient 의 defaultOptions 로 전역적으로 설정 가능하나, 개별 쿼리에서 별도 옵션을 주어야 하는 경우 해당 쿼리에서만 전역 옵션을 덮어쓰기 할 수 있다.
refetchOnWindowFocus사용자가 애플리케이션을 떠난 후 다시 돌아왔을때, 데이터가 stale(신선하지 않은)한 데이터가 존재한다면 리액트 쿼리는 백그라운드 상에서 자동적으로 fresh(새로운)데이터를 요청한다.
retryuseQuery 쿼리가 실패(쿼리 함수가 에러를 던질 경우)했을 때, 리액트 쿼리는 쿼리의 요청이 연속적인 재시도(default: 3회)의 한계점에 다다르기 직전까지 자동적으로 재시도를 수행한다.
queryClient의 메서드로 쿼리를 무효화(stale 상태로 만듦)하고, 최신화(refetch: 새로운 데이터를 fetch)하는 기능이다. invalidateQueries에 옵션이 없는 경우에는 캐시 내의 모든 쿼리를 무효화한다. 만약, 옵션에 queryKey가 있으면 해당 키를 가진 모든 쿼리를 무효화한다.
import { createTodo } from "@/api/create-todo";
import { useMutation, useQueryClient } from "@tanstack/react-query";
export function useCreateTodoMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createTodo,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["todos"],
});
},
});
}
쿼리 무효화 시, 동일한 쿼리키를 포함하는 다른 키까지 무효화되는 비효율이 생길 수 있다. 또한 쿼리키를 중복으로 정의하여 예기치 못한 에러를 발생시킬 여지가 있다.
Tanstack Query 공식문서에는 쿼리 무효화의 조건을 다음의 예시를 들어 설명한다.
// Invalidate every query in the cache
queryClient.invalidateQueries()
// Invalidate every query with a key that starts with `todos`
queryClient.invalidateQueries({ queryKey: ['todos'] })
아래와 같이 투두 리스트에 새로운 할 일을 추가하는 상황을 생각해보자. ['todos'] , ['todos', 'isNotDone'] 쿼리키에 대해 모두 refetching이 수행된다.

따라서 쿼리키를 중앙에서 관리하는 것이 권장되며, 이는 효율적인 쿼리키 관리를 도와준다.
// constants.ts
export const QUERY_KEYS = {
todo: {
all: ["todo"],
list: ["todo", "list"],
detail: (id: string) => ["todo", "detail", id],
},
};
// 쿼리키 팩토리를 불러옴
import { QUERY_KEYS } from "@/lib/constants";
export function useTodosData() {
return useQuery({
queryKey: QUERY_KEYS.todo.all,
queryFn: fetchTodos,
});
}
다음과 같은 상황에서는 말이 달라진다. 상세 페이지로 이동했다가 다시 목록 페이지로 돌아오는 상황이다. ‘캐싱 메커니즘’에서 refetching이 수행되는 조건을 상기해보자. ['todos'] , ['todos', id] 의 쿼리키가 모두 ‘todos’로 시작되지만, ['todos', id] 의 값을 사용하는 컴포넌트(여기서는 상세 페이지)가 언마운트 되어 미사용 상태가 되었으므로 inactive 상태로 전환된다. 즉, stale 상태가 아니기 때문에 refetching이 일어나지 않은 것이다.
invalidateQueries 를 통해 refetching을 수행함으로써, 캐시를 업데이트할 수 있다고 했다. 유튜브의 무한 답글 기능이 지원되는 댓글창에 새로운 댓글을 추가하는 상황을 생각해보자. 새로운 댓글이 달릴 때마다 기존 댓글 중 마지막 댓글 이후에 추가된 등록한 댓글이 보여져야 하는 UI 요구사항이 주어진다. invalidateQueries 를 통해 모든 댓글 데이터를 다시 불러와야 할 것인가?
setQueryData 를 통해 캐시에 직접 접근하여 조작함으로써, 효율적인 데이터 업데이트를 수행할 수 있다. 이 방법을 사용하려면 반드시 서버는 데이터 업데이트 이후 수정된 데이터를 클라이언트에게 넘겨주어야 한다.
import { useQueryClient } from "@tanstack/react-query";
/**
* CREATE: 할 일
*/
export function useCreateTodoMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createTodo,
onSuccess: (newTodo) => {
// 캐시 직접 수정
queryClient.setQueryData<ITodo[]>(QUERY_KEYS.todo.list, (prevTodos) => {
if (!prevTodos) return [newTodo];
return [...prevTodos, newTodo];
});
},
});
}
참고자료
한 입 크기로 잘라먹는 실전 프로젝트 SNS 편 - 이정환
TanStack Query | React Query, Solid Query, Svelte Query, Vue Query
[10분 테코톡] 시모의 TanStack Query
TanStack Query 강좌 #1 소개/설치/useQuery