한 줄 정의
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