무엇
QueryClient는 TanStack Query의 중심 객체다. 모든 쿼리와 뮤테이션의 캐시를 쥐고 있고, 기본 옵션을 정하며, 캐시를 직접 조작하는 메서드를 제공한다.
앱에서 하나 만들어 QueryClientProvider로 내려주고, 그 아래 컴포넌트들이 같은 인스턴스를 공유한다.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient()
function App() {
return (
<QueryClientProvider client={queryClient}>
<Routes />
</QueryClientProvider>
)
}
생성자는 세 가지를 받는다. defaultOptions(모든 쿼리·뮤테이션에 적용될 기본값), queryCache, mutationCache. 대부분은 defaultOptions만 쓴다.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1분간 fresh로 취급
gcTime: 5 * 60 * 1000, // v5: cacheTime 아님
},
},
})
무슨 일이 일어나나
QueryClient는 내부에 QueryCache와 MutationCache를 하나씩 들고 있다. 컴포넌트에서 useQuery를 부르면 그 쿼리는 이 캐시에 쿼리 키로 저장되고, 같은 키를 쓰는 곳끼리 데이터를 공유한다. QueryClient의 메서드는 대부분 이 캐시를 상대로 동작한다 — 특정 키를 찾아 무효화하거나, 데이터를 꺼내거나, 새로 채워 넣는다.
키를 찾을 때는 **부분 일치(partial match)**가 기본이다. ['todos']로 무효화하면 ['todos']뿐 아니라 ['todos', 1], ['todos', { page: 2 }]처럼 앞부분이 같은 키가 전부 걸린다. 정확히 그 키만 대상으로 삼으려면 exact: true를 준다.
메서드는 성격이 갈린다. getQueryData·setQueryData는 동기로 캐시를 읽고 쓴다(이미 있는 데이터를 다룬다는 전제). fetchQuery·prefetchQuery·invalidateQueries는 비동기로 실제 네트워크 요청을 다루고 Promise를 돌려준다.
사용법
컴포넌트 안에서는 useQueryClient로 인스턴스를 얻는다. 직접 import한 인스턴스를 쓰지 말고, 훅으로 Provider가 내려준 것을 받는 게 안전하다.
import { useMutation, useQueryClient } from '@tanstack/react-query'
function AddTodo() {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: (title: string) => postTodo(title),
onSuccess: () => {
// todos로 시작하는 쿼리를 전부 무효화 → 다시 가져옴
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
return <button onClick={() => mutation.mutate('새 할 일')}>추가</button>
}
invalidateQueries — 매칭된 쿼리를 stale로 표시하고, 화면에 떠 있는(active) 쿼리는 곧바로 다시 가져온다. refetchType으로 다시 가져올 대상을 고른다: 'active'(기본), 'inactive', 'all', 'none'. 데이터를 바꾼 뒤 서버 상태와 다시 맞추는 가장 흔한 방법이다.
setQueryData — 캐시를 동기로 직접 갱신한다. 두 번째 인자로 새 값을 주거나, (old) => new 형태의 함수를 준다. 함수형일 때는 반드시 새 객체·배열을 만들어 돌려줘야 한다(기존 값을 직접 고치지 말 것). undefined를 돌려주면 갱신을 건너뛴다.
// 낙관적 업데이트: 서버 응답 전에 캐시를 먼저 바꿔둔다
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old ? [...old, newTodo] : [newTodo],
)
// 그냥 읽기만
const todos = queryClient.getQueryData<Todo[]>(['todos'])
fetchQuery vs prefetchQuery — 둘 다 쿼리를 가져와 캐시에 넣는다. fetchQuery는 데이터를 Promise로 돌려주고 실패하면 throw한다. prefetchQuery는 데이터를 돌려주지도, throw하지도 않는다(Promise<void>). 미리 데이터만 채워두고 결과는 신경 쓰지 않을 때 prefetchQuery를 쓴다.
// 마우스를 올렸을 때 상세 데이터를 미리 채워둔다
function onHover(id: number) {
queryClient.prefetchQuery({
queryKey: ['todo', id],
queryFn: () => fetchTodo(id),
staleTime: 10 * 1000, // 이 시간 안이면 이미 있는 데이터로 스킵
})
}
실무 예시
가장 흔한 흐름은 "뮤테이션 성공 → 관련 쿼리 무효화"다. 목록에 항목을 추가하면 목록 쿼리를 무효화해 서버 기준으로 다시 그린다. 응답을 기다리는 동안 화면을 먼저 바꾸고 싶으면 setQueryData로 낙관적 업데이트를 하고, 실패 시 되돌린다.
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (next: Todo) => {
// 진행 중인 refetch가 낙관적 값을 덮지 않게 먼저 취소
await queryClient.cancelQueries({ queryKey: ['todos'] })
const prev = queryClient.getQueryData<Todo[]>(['todos'])
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old?.map((t) => (t.id === next.id ? next : t)),
)
return { prev } // onError로 넘길 롤백 스냅샷
},
onError: (_err, _next, ctx) => {
queryClient.setQueryData(['todos'], ctx?.prev) // 되돌림
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] }) // 최종 동기화
},
})
왜 중요한가
QueryClient는 TanStack Query에서 캐시를 직접 다루는 유일한 창구다. useQuery가 자동으로 처리하지 못하는 일 — 뮤테이션 뒤 무효화, 낙관적 업데이트, 라우트 이동 전 프리페치 — 은 전부 이 객체의 메서드로 한다. 훅 사용법을 익힌 다음 반드시 손에 붙여야 할 대상이고, invalidateQueries와 setQueryData 둘만 제대로 써도 실무의 캐시 관리 대부분이 해결된다. v5에서 cacheTime이 gcTime으로 바뀐 점만 기억해 두면 된다.
Reference