TanStack Query 제대로 알아보기
React Query 시절부터 TanStack Query를 사용해 왔지만, 주로 useQuery와 useMutation의 기본적인 기능만 활용했습니다. 특히 staleTime과 gcTime은 설정해 본 적이 있어도 데이터가 언제 stale이 되고 언제 캐시에서 제거되는지 명확히 설명하기 어려웠습니다. 이 글에서는 두 개념을 다시 정리하고, 그동안 자주 쓰지 않았던 기능들이 어떤 상황에서 필요한지도 살펴보려고 합니다.
예제는 가상의 매장 관리 백오피스입니다. 운영자는 매장 목록에서 영업 중인 매장을 찾고, 상세 화면에서 담당 매니저를 배정하며, 메뉴 관리 페이지에서 재료가 떨어진 메뉴를 품절로 바꿉니다. 메뉴 관리 페이지로 이동했다 돌아오는 흐름은 캐시의 생명주기를 설명하는 데도 사용합니다. 이처럼 조회부터 변경까지 같은 백오피스에서 기능을 살펴보되, 필요할 때 다시 찾아볼 수 있도록 사용 상황별로 묶었습니다. API와 기본값은 @tanstack/react-query 5.103.2와 TanStack Query v5 문서를 기준으로 합니다.
useQuery가 저장하는 것은 요청 결과만이 아닙니다
컴포넌트마다 fetch를 직접 호출하면 로딩과 오류를 처리하는 코드뿐 아니라, 화면을 다시 열었을 때 기존 결과를 쓸지, 언제 새로 받을지도 직접 결정해야 합니다. TanStack Query는 queryKey로 요청 결과를 구분해 QueryCache에 보관하고, useQuery를 호출한 컴포넌트가 해당 결과의 변화를 구독하게 합니다. QueryClient는 이 캐시와 변경 작업을 담는 MutationCache를 관리합니다. QueryClient API
QueryClient
├─ QueryCache
│ ├─ ['stores', 'list', { status: 'all', page: 1 }]
│ └─ ['stores', 'detail', 'store-42']
└─ MutationCache
useQuery → QueryObserver → 해당 query의 결과 구독
구독자: 특정 query의 데이터와 요청 상태가 바뀌는 것을 받아 보는 대상입니다. 컴포넌트에서
useQuery를 호출하면QueryObserver가 이 역할을 맡습니다. 서버가 변경 내용을 실시간으로 보내 주는 구독과는 다릅니다. QueryObserver API
예를 들어 영업 중 매장 1페이지의 목록과 그 응답의 total을 표시하는 컴포넌트가 같은 key를 사용한다면 구독자가 둘입니다. 목록 컴포넌트만 사라져도 요약 건수가 그 query를 계속 읽고 있다면 gcTime은 시작되지 않습니다. 두 컴포넌트가 모두 사라져 구독자가 0명이 되면 query는 비활성 상태로 캐시에 남고, 그때부터 gcTime이 적용됩니다. 아래 데모에서는 메뉴 관리 페이지가 매장 목록을 구독하지 않으므로 매장 관리 페이지를 떠날 때 이 조건을 충족합니다. Important Defaults
이처럼 같은 queryKey를 사용하는 컴포넌트는 하나의 query 데이터를 봅니다. 해당 query에 이미 요청이 진행 중이라면 그 요청의 Promise도 공유할 수 있습니다. 다만 목록과 상세처럼 key가 다르면 같은 매장을 포함하더라도 별개의 캐시 항목입니다. 한쪽 데이터를 바꿨다고 다른 쪽이 자동으로 수정되지는 않습니다. Query API, Query Invalidation
요청 결과를 구분하는 queryKey
queryKey: 조회 결과에 붙이는 식별자입니다. 같은 key로 조회하면 같은 캐시 항목을 사용하고, key가 달라지면 결과를 따로 저장합니다.
매장 관리 페이지에서는 영업 상태와 페이지가 목록 응답을 바꿉니다. 따라서 두 값 모두 queryKey에 포함해야 합니다.
const storeKeys = {
all: ['stores'] as const,
lists: () => [...storeKeys.all, 'list'] as const,
list: (status: 'all' | 'open' | 'paused', page: number) =>
[...storeKeys.lists(), { status, page }] as const,
details: () => [...storeKeys.all, 'detail'] as const,
detail: (id: string) => [...storeKeys.details(), id] as const,
};
useQuery({
queryKey: storeKeys.list(status, page),
queryFn: ({ signal }) => fetchStores({ status, page, signal }),
});
전체 매장 1페이지와 영업 중인 매장 1페이지는 서로 다른 캐시에 저장됩니다. 상태를 key에서 빼면 필터를 바꿔도 이전 필터의 목록을 같은 query의 결과로 취급합니다. queryKey는 배열 최상위 구조를 사용하며, 배열 항목의 순서는 결과를 구분합니다. 객체 속성의 순서는 기본 해시에서 같은 값으로 취급하므로, 목록 응답을 바꾸는 조건을 모두 key에 넣는 것이 중요합니다. Query Keys
queryFn은 요청 방법을 결정합니다. TanStack Query가 HTTP 요청을 대신 만드는 것은 아니므로, fetch를 쓴다면 실패 응답을 직접 오류로 바꿔야 합니다. fetch는 4xx·5xx 응답만으로 Promise를 거부하지 않기 때문입니다.
type StoreSummary = {
id: string;
name: string;
status: 'open' | 'paused';
managerName: string | null;
};
type Store = StoreSummary & {
managerId: string | null;
address: string;
soldOutMenuCount: number;
};
type StorePage = {
items: StoreSummary[];
total: number;
hasMore: boolean;
};
class ResponseError extends Error {
constructor(public status: number) {
super(`HTTP ${status}`);
}
}
async function fetchStores({
status,
page,
signal,
}: {
status: 'all' | 'open' | 'paused';
page: number;
signal: AbortSignal;
}): Promise<StorePage> {
const params = new URLSearchParams({ status, page: String(page) });
const response = await fetch(`/api/stores?${params}`, { signal });
if (!response.ok) {
throw new ResponseError(response.status);
}
return response.json();
}
화면에서 필요 없어진 요청을 끝까지 기다릴까요?
운영자가 매장 목록의 필터를 전체 → 영업 중 → 휴점으로 빠르게 바꾼다고 가정해 보겠습니다. 각 필터는 서로 다른 queryKey를 사용하므로, 먼저 시작한 전체 요청의 응답이 휴점 query의 캐시를 덮지는 않습니다. 다만 화면에서 더 이상 보지 않는 목록을 계속 내려받는 비용은 남습니다. 앞의 fetchStores에서 signal을 fetch에 전달한 이유가 여기에 있습니다.
TanStack Query는 query 함수에 AbortSignal을 제공합니다. 요청이 낡아지거나 해당 query를 구독하는 컴포넌트가 없어져 신호가 취소되면, 이를 전달받은 fetch도 중단됩니다. 이때 취소된 query의 상태는 요청 전 상태로 돌아갑니다. 반대로 signal을 전달하지 않으면 화면을 떠나도 요청이 완료되어 결과가 캐시에 남을 수 있습니다. 곧바로 같은 화면으로 돌아올 가능성이 크다면 이 기본 동작이 유리할 수도 있으므로, 모든 조회를 무조건 중단해야 하는 것은 아닙니다. Query Cancellation
오래 걸리는 매장 목록 조회에 취소 버튼을 둘 때도 같은 연결이 필요합니다. cancelQueries는 선택한 query의 진행 상태를 취소하고 이전 상태로 되돌리며, fetch가 signal을 사용했다면 브라우저의 요청도 중단합니다.
const queryClient = useQueryClient();
<button
type="button"
onClick={() =>
queryClient.cancelQueries({
queryKey: storeKeys.list(status, page),
exact: true,
})
}
>
목록 요청 취소
</button>;
여기서 취소하는 것은 목록을 가져오는 요청입니다. 이미 서버에서 시작한 별도의 일괄 처리 작업까지 취소되는 것은 아니므로, 그런 작업을 중단하려면 서버의 취소 API가 따로 필요합니다. Query Cancellation
캐시에 남아 있는 데이터와 최신이라고 여기는 데이터
매장 관리 페이지를 떠났다가 돌아오면 TanStack Query는 이전 목록을 즉시 보여줄 수 있습니다. 이때 데이터의 신선도와 캐시의 보관 기간은 다른 설정이 결정합니다.
| 설정 | 기준 | 기본값(브라우저) |
|---|---|---|
staleTime | 데이터를 받은 뒤 얼마 동안 fresh로 볼지 | 0 |
gcTime | 구독자가 없는 query를 얼마 동안 캐시에 보관할지 | 5분 |
staleTime: 0이면 요청이 성공한 직후 데이터가 stale로 간주됩니다. stale은 삭제를 뜻하지 않습니다. 캐시의 데이터를 표시하면서 새 컴포넌트가 구독하거나, 창에 다시 포커스하거나, 네트워크가 복구되는 시점에 백그라운드 요청을 시작할 수 있다는 뜻입니다. Important Defaults
두 타이머가 시작되는 시점을 아래에서 확인할 수 있습니다. 매장 목록을 쓰는 매장 관리 페이지와 이를 쓰지 않는 메뉴 관리 페이지를 오가게 했습니다. 재생하면 관리자가 메뉴 관리로 한 번은 30초, 한 번은 60초 넘게 자리를 비웁니다. 두 번의 복귀에서 매장 관리가 무엇을 먼저 보여주는지 비교해 보세요.
재생을 누르면 관리자 한 명이 두 페이지를 오가는 2분 40초를 천천히 보여줍니다.
- 판교점
- 강남점
- 성수점
- 홍대점
- 아메리카노
- 카페라테
- 시즌 한정 에이드
['stores', 'list'] useQuery({
queryKey: storeKeys.list(status, page),
queryFn: ({ signal }) => fetchStores({ status, page, signal }),
staleTime: 30_000,
gcTime: 60_000,
});
데모와 같은 설정입니다. 매장 목록을 받은 뒤 30초 안에 매장 관리 페이지로 돌아오면 캐시를 사용하며 자동 재요청하지 않습니다. 데모의 첫 복귀는 메뉴 관리에 머문 시간이 30초여도 목록을 받은 지는 70초가 지났습니다. 캐시가 남아 있으므로 기존 목록을 먼저 표시하고 백그라운드에서 새 데이터를 가져옵니다. 두 번째 이동에서는 구독자가 없는 시간이 60초를 넘어 캐시가 제거됩니다. 이 시간은 동작을 보여주기 위한 값이며, 실제 값은 데이터가 바뀌는 빈도와 화면에서 허용할 수 있는 지연에 따라 정해야 합니다.
두 값을 정할 때도 질문이 다릅니다. 영업 상태가 다른 관리자에 의해 자주 바뀐다면 매장 목록의 staleTime을 짧게 두어 다시 들어왔을 때 서버 값을 확인할 수 있습니다. gcTime을 늘리면 메뉴 관리에서 오래 머물다 돌아와도 이전 목록을 먼저 볼 가능성이 커지지만, 사용하지 않는 필터와 페이지의 캐시도 더 오래 남습니다. gcTime을 늘린다고 그 목록이 최신이 되는 것은 아닙니다.
staleTime에는 숫자 외에 Infinity와 'static'도 줄 수 있습니다. 둘 다 시간 경과만으로 stale이 되지 않지만, Infinity는 invalidateQueries()로 무효화할 수 있고 'static'은 무효화해도 다시 조회하지 않습니다. 변경 후 수동으로 갱신할 가능성이 있는 기준 데이터라면 Infinity, 앱이 실행되는 동안 바뀌지 않는 데이터라면 'static'이 후보입니다. Important Defaults
stale 데이터가 다시 요청되는 순간
stale로 바뀌는 순간 타이머가 요청을 보내지는 않습니다. 기본적으로 새 구독자가 생기거나, 브라우저 창이 다시 보이거나, 네트워크가 복구될 때 stale query가 재조회됩니다. 필요한 경우 refetchOnMount, refetchOnWindowFocus, refetchOnReconnect로 각 조건을 조절할 수 있습니다. refetchInterval은 별도의 주기로 실행되며 staleTime과 독립적입니다. Important Defaults, Polling
useQuery({
queryKey: ['store-import-jobs', jobId],
queryFn: ({ signal }) => fetchStoreImportJob(jobId, signal),
refetchInterval: (query) =>
query.state.data?.status === 'running' ? 5_000 : false,
});
매장 데이터를 일괄로 가져오는 작업이 진행 중일 때만 5초마다 확인하고, 완료되면 폴링을 멈추는 예입니다. 이 화면에서는 관리자가 페이지를 보는 동안 작업이 끝났는지 알려줘야 하므로 창 포커스나 재방문만 기다릴 수 없습니다. 반대로 매장 목록처럼 사용자가 돌아왔을 때 최신화해도 되는 데이터라면 주기적인 요청을 설정할 필요가 없습니다. 브라우저가 백그라운드에 있을 때도 계속 조회해야 한다면 refetchIntervalInBackground: true를 추가합니다.
화면 상태와 요청 상태를 따로 봅니다
status와fetchStatus:status는 보여줄 데이터가 있는지, 조회가 실패했는지를 알려줍니다.fetchStatus는 지금 요청 중인지, 잠시 멈췄는지를 알려줍니다. 이미 데이터가 있어도 다시 요청 중일 수 있으므로 두 값을 따로 봅니다.
기존 목록을 표시하면서 재요청하는 경우 status는 success, fetchStatus는 fetching입니다. 오프라인에서 첫 요청이 대기 중이라면 status가 pending이면서 fetchStatus는 paused일 수도 있습니다. 둘을 같은 로딩 상태로 묶으면 이미 보여줄 수 있는 목록까지 스켈레톤으로 바꾸게 됩니다. Query State
const stores = useQuery({
queryKey: storeKeys.list(status, page),
queryFn: ({ signal }) => fetchStores({ status, page, signal }),
});
if (!stores.data) {
if (stores.isError) return <ErrorMessage error={stores.error} />;
return <StoreSkeleton />;
}
return (
<>
{stores.isFetching && <span>목록 갱신 중</span>}
<StoreList items={stores.data.items} />
</>
);
이 코드는 기존 데이터가 있으면 백그라운드 재조회가 실패해도 목록을 유지합니다. 관리자 입장에서는 목록을 읽거나 매장을 선택할 수 있는 상태가 유지되고, 작은 갱신 표시만 추가됩니다. 재조회 실패를 알릴 필요가 있다면 기존 목록을 지우지 않고 별도의 오류 표시를 붙일 수 있습니다.
isFetching은 첫 요청과 재요청을 모두 포함하고, isRefetching은 첫 요청을 제외한 재요청만 나타냅니다. v5에서 query의 isLoading은 isPending && isFetching입니다. 조건이 충족되지 않아 enabled: false인 query는 pending일 수 있지만 실제 요청은 하지 않으므로, 첫 요청 스피너를 표시할 때 두 상태의 차이가 중요합니다. v5 마이그레이션 가이드
조회를 언제, 몇 개 실행할지 정합니다
로그인한 운영자의 담당 지역을 알아야 그 지역의 매장을 조회할 수 있다면 첫 query의 결과를 두 번째 query에 연결해야 합니다.
enabled: Boolean(regionId)와 skipToken 모두 지역 ID가 생길 때까지 조회를 미루고, 이후에는 자동으로 실행할 수 있습니다. 아래에서는 queryFn을 정의하는 자리에서 지역 ID의 타입을 좁힐 수 있도록 skipToken을 사용했습니다.
const operator = useQuery({
queryKey: ['operator', email],
queryFn: ({ signal }) => findOperator(email, signal),
});
const regionId = operator.data?.regionId;
const regionStores = useQuery({
queryKey: ['stores', 'region', regionId],
queryFn: regionId
? ({ signal }) => fetchStoresByRegion(regionId, signal)
: skipToken,
});
regionId가 없을 때 두 번째 query는 요청하지 않습니다. 그렇지 않으면 /regions/undefined/stores 같은 잘못된 주소로 요청하거나, 지역을 모르는 상태의 결과를 정상 목록처럼 보여줄 수 있습니다. 첫 query가 성공하고 지역 ID가 생기면 두 번째 query가 시작되며, 지역이 바뀔 때는 새 ID가 key에 들어가 별도 캐시를 사용합니다.
같은 조회를 enabled로 작성해도 정상적으로 동작합니다. enabled가 false인 동안에는 자동 조회와 무효화에 따른 재조회를 건너뛰고, regionId가 생겨 true로 바뀌면 조회를 시작합니다. 다만 TypeScript는 enabled: Boolean(regionId)라는 설정만으로 별도의 queryFn 안에 있는 regionId를 string으로 좁히지 못합니다. 타입 단언 대신 함수 안에서 값을 확인할 수 있습니다.
const regionStores = useQuery({
queryKey: ['stores', 'region', regionId],
enabled: Boolean(regionId),
queryFn: ({ signal }) => {
if (!regionId) throw new Error('지역 ID가 없습니다');
return fetchStoresByRegion(regionId, signal);
},
});
skipToken 예제는 지역 ID가 있을 때만 함수 자체를 전달하므로 타입 단언이나 내부 검사가 필요하지 않습니다. 대신 skipToken인 상태에서 refetch()를 호출하면 실행할 queryFn이 없어 오류가 납니다. 아직 자동 실행 조건이 충족되지 않았을 때도 수동 refetch()를 제공해야 하는 화면이라면 enabled 방식이 필요하며, 지역 ID가 없는 상태에서는 버튼을 비활성화하거나 입력값을 확인해야 합니다. 두 요청은 순서대로 실행되므로, 필요하다면 서버가 이메일로 담당 지역의 매장을 바로 반환할 수 있는지도 살펴봐야 합니다. Disabling Queries, Dependent Queries
서로 독립적인 조회는 여러 useQuery를 나란히 호출하면 병렬로 시작됩니다. 운영자가 목록에서 비교할 매장을 여러 개 선택하고 선택 개수도 바뀐다면, 고정된 개수의 훅을 직접 쓰기 어렵습니다. useQueries는 선택한 ID마다 상세 query를 만들어 매장별 로딩과 오류를 따로 확인할 수 있게 합니다.
const selectedStores = useQueries({
queries: selectedIds.map((id) => ({
queryKey: storeKeys.detail(id),
queryFn: ({ signal }) => fetchStore(id, signal),
})),
});
반환값은 입력 순서와 같은 query 결과 배열입니다. 같은 매장을 두 번 선택하면 동일한 key의 query가 배열에 중복되므로, selectedIds를 먼저 고유 ID로 정리해야 합니다. 결과를 한 값으로 묶어야 하면 combine을 쓸 수 있지만, 비교 화면에서 매장별 로딩과 오류를 표시한다면 배열 그대로 두는 편이 읽기 쉽습니다. useQueries API
실패와 오프라인을 다루는 옵션
조회 실패는 브라우저에서 기본적으로 세 번 재시도하고, 서버 렌더링에서는 기본 재시도가 없습니다. 매장 상세 요청이 일시적인 서버 오류로 실패했다면 재시도가 도움이 되지만, 접근 권한이 없는 매장이나 존재하지 않는 매장을 반복해서 요청해도 결과는 달라지지 않습니다. 이때 retry를 오류 유형에 따라 조절합니다. 변경 요청인 mutation은 기본적으로 재시도하지 않는데, 담당 매니저 배정처럼 서버의 상태를 바꾸는 요청을 기계적으로 반복하면 중복 처리 여부를 따져야 하기 때문입니다. Query Retries, useMutation Options
useQuery({
queryKey: storeKeys.detail(id),
queryFn: ({ signal }) => fetchStore(id, signal),
retry: (failureCount, error) => {
if (error instanceof ResponseError && error.status < 500) return false;
return failureCount < 2;
},
});
앞의 fetchStores가 HTTP 상태 코드를 ResponseError에 담아 던졌듯, fetchStore도 같은 오류 타입을 사용한다고 가정합니다. 일반 Error만 던지면 재시도 콜백에서 404와 503을 구분할 수 없습니다. 위 코드는 4xx에서는 멈추고 그 밖의 오류는 최대 두 번 재시도합니다. 서버가 429를 반환한다면 일시적인 제한일 수 있으므로, 이 경우까지 재시도하지 않을지는 API 정책에 맞춰 별도로 정해야 합니다.
네트워크 연결 여부는 networkMode가 제어합니다. 매장 API를 직접 호출하는 기본 online 모드에서는 오프라인인 동안 요청을 일시 중지합니다. 반대로 백오피스에 로컬 저장소에서 읽는 비동기 설정값이 있다면 인터넷 연결과 무관하게 실행해야 하므로 always가 맞습니다. 서비스 워커가 매장 목록의 응답을 저장해 두었다면 offlineFirst로 일단 요청을 시도하고, 캐시에도 없어 실패했을 때 재시도를 일시 중지할 수 있습니다. 이 설정만으로 오프라인 데이터가 생기는 것은 아닙니다. Network Mode
목록을 넘기는 동안 이전 결과를 유지합니다
placeholderData: 새 결과를 기다리는 동안 화면에 먼저 보여줄 임시 데이터입니다. 이 값은 현재useQuery를 쓰는 곳에만 보이고, 새 query의 캐시에는 저장되지 않습니다.
매장 목록에서 1페이지를 읽다가 2페이지를 누르면, 페이지 번호가 key에 들어 있어 새 query를 구독합니다. 아직 방문하지 않은 페이지에는 캐시가 없으므로 목록이 스켈레톤으로 바뀔 수 있습니다. 운영자가 목록의 맥락을 잃지 않게 이전 페이지를 요청 중에 보여주고 싶다면 placeholderData를 사용할 수 있습니다.
import { keepPreviousData, useQuery } from '@tanstack/react-query';
const stores = useQuery({
queryKey: storeKeys.list(status, page),
queryFn: ({ signal }) => fetchStores({ status, page, signal }),
placeholderData: keepPreviousData,
});
return (
<>
<StoreList items={stores.data?.items ?? []} />
<button
disabled={stores.isPlaceholderData || !stores.data?.hasMore}
onClick={() => setPage((current) => current + 1)}
>
다음 페이지
</button>
</>
);
예제의 keepPreviousData는 2페이지를 요청하는 동안 1페이지 결과를 placeholderData로 사용합니다. isPlaceholderData로 임시 값인지 구분할 수 있으므로, 다음 페이지 버튼을 잠시 비활성화해 1페이지의 hasMore를 2페이지의 값으로 착각해 연속 이동하는 일을 막았습니다. 페이지 번호를 화면에 함께 보여준다면 현재는 이전 매장들이 표시되고 있다는 사실도 알려줘야 합니다. Paginated Queries
아래에서 두 방식으로 다음 페이지를 눌러 보면, 요청하는 1.5초 동안 목록 자리에 무엇이 남는지와 그동안 2페이지 캐시가 비어 있다는 점을 함께 확인할 수 있습니다.
방식을 고른 뒤 다음 페이지 누르기를 눌러 보세요.
data- 1페이지 매장
isPlaceholderData- false
isFetching- false
['stores', 'list', …] -
{ page: 1 } -
{ page: 2 } -
{ page: 3 }
| 먼저 보여줄 값 | 캐시에 저장 | 사용할 때 |
|---|---|---|
placeholderData | 저장하지 않음 | 이전 페이지나 불완전한 미리보기로 화면을 채울 때 |
initialData | 저장함 | 해당 key에 넣어도 되는 완전한 초기 데이터가 이미 있을 때 |
initialData는 query의 실제 초기 데이터로 캐시에 저장됩니다. 서버가 렌더링 전에 매장 상세 응답 전체를 이미 전달했다면 유용하지만, StoreSummary를 상세 query에 넣으면 목록에 없는 주소와 담당 매니저 ID까지 로드된 것처럼 취급하게 됩니다. 이 경우에는 목록의 요약 정보를 잠시 보여주는 placeholderData가 더 정확합니다. initialData가 원본 데이터를 받은 시각을 알 수 있다면 initialDataUpdatedAt으로 전달해 신선도 계산도 맞출 수 있습니다. Initial Query Data, Placeholder Query Data
useInfiniteQuery: 스크롤할 때 다음 목록을 이어 붙이는 것처럼, 여러 페이지를 한 번에 관리하는 훅입니다. 받은 페이지는data.pages에, 각 페이지를 가져올 때 쓴 값은data.pageParams에 순서대로 담깁니다.
한 페이지씩 교체하는 목록과 달리, 스크롤할수록 매장을 아래에 이어 붙이는 화면이라면 useInfiniteQuery가 맞습니다. 앞 페이지를 유지하면서 다음 묶음의 커서를 관리해야 하기 때문입니다. initialPageParam으로 첫 요청의 커서를 정하고, 서버 응답의 nextCursor를 getNextPageParam에서 다음 요청으로 넘깁니다. Infinite Queries
const stores = useInfiniteQuery({
queryKey: ['stores', 'infinite', { status }],
queryFn: ({ pageParam, signal }) =>
fetchStorePageByCursor({ status, cursor: pageParam, signal }),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
maxPages: 5,
});
const items = stores.data?.pages.flatMap((page) => page.items) ?? [];
nextCursor가 null 또는 undefined이면 hasNextPage가 false가 됩니다. 다음 묶음을 요청하는 중인지와 기존 페이지를 백그라운드에서 갱신하는 중인지는 각각 isFetchingNextPage와 isFetching으로 구분할 수 있습니다. 매장을 20페이지까지 내려간 뒤 창을 다시 열면 stale한 무한 query는 저장된 페이지를 첫 페이지부터 순서대로 재조회합니다. 앞쪽 매장의 추가·삭제로 커서가 달라졌을 수 있기 때문입니다. 예제의 maxPages: 5는 보관할 페이지 수를 제한해 재조회 비용과 메모리 사용량을 줄이지만, 밀려난 앞 페이지로 돌아갈 때는 다시 요청해야 합니다. Infinite Queries
변경이 끝난 뒤 어느 데이터를 갱신할지 정합니다
invalidateQueries: 지정한 key의 캐시 데이터를 오래된 것으로 표시합니다. 지금 화면에서 사용하는 query는 기본적으로 다시 요청하고, 사용하지 않는 query는 나중에 다시 사용할 때 새 데이터를 확인합니다.
매장에 담당 매니저를 배정하면 상세 화면의 담당자 이름과 목록의 담당자 열이 모두 달라질 수 있습니다. useMutation은 변경 요청의 상태를 관리하지만, 이 응답을 어떤 query들이 사용하고 있는지는 자동으로 추론하지 않습니다. 서버가 배정을 저장한 뒤 관련 목록과 상세를 다시 읽도록 명시합니다.
const queryClient = useQueryClient();
const assignManager = useMutation({
mutationFn: ({ id, managerId }: { id: string; managerId: string }) =>
assignStoreManagerOnServer(id, managerId),
onSuccess: async (_updatedStore, { id }) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: storeKeys.lists() }),
queryClient.invalidateQueries({ queryKey: storeKeys.detail(id) }),
]);
},
});
storeKeys.lists()처럼 접두 key를 쓰면 전체·영업 중·휴점 상태와 여러 페이지의 목록이 대상이 됩니다. 상세 query는 별도의 key이므로 함께 갱신할지 명시해야 합니다. Query Invalidation
예제에서 무효화 Promise를 기다리는 동안 mutation은 pending 상태를 유지합니다. 저장 버튼을 이 상태에 묶으면 서버의 배정 요청이 성공했어도 화면의 목록과 상세가 갱신되기 전까지 다시 누르지 못하게 할 수 있습니다. 화면 이동을 먼저 허용하고 새 데이터는 뒤에서 갱신해도 된다면 이 Promise를 기다리지 않는 선택도 가능합니다. Invalidations from Mutations
setQueryData: 특정 key에 저장된 캐시 데이터를 바로 바꿉니다. 서버가 수정된 매장 정보를 응답했다면, 같은 상세 정보를 다시 요청하지 않고 그 응답을 캐시에 넣을 수 있습니다.
서버가 수정된 매장 전체를 응답한다면 앞의 mutation에서 onSuccess만 아래처럼 바꿀 수 있습니다. 상세에는 서버 응답을 넣고, 담당자 열이 있는 목록만 다시 확인합니다.
onSuccess: async (updatedStore: Store) => {
queryClient.setQueryData(storeKeys.detail(updatedStore.id), updatedStore);
await queryClient.invalidateQueries({ queryKey: storeKeys.lists() });
},
서버 응답이 일부 필드만 담고 있다면 기존 캐시 데이터와 불변 방식으로 합쳐야 합니다. 목록의 모든 필터와 페이지에 있는 같은 매장을 직접 찾아 고치려면 누락되기 쉽습니다. 이런 경우에는 목록을 무효화하고 서버 응답으로 다시 확인하는 편이 명확합니다. Updates from Mutation Responses
지금까지의 선택지를 한 화면에서 비교해 보겠습니다. 화면에는 목록과 강남점 상세가 함께 열려 있고, 캐시에는 지금 보지 않는 목록 페이지와 다른 매장의 상세도 남아 있습니다. onSuccess에서 할 일을 고르고 홍길동을 배정하면, 어느 항목이 곧바로 다시 요청되고 어느 항목이 stale로 남았다가 다음에 사용할 때 확인되는지 볼 수 있습니다.
onSuccess에서 할 일을 고른 뒤 홍길동 배정하기를 눌러 보세요.
아무것도 호출하지 않음 invalidateQueries(lists()) invalidateQueries(detail(id)) invalidateQueries(lists()) setQueryData(detail(id), 응답) list { all, 1 }
미배정
fresh
detail 'store-42'
미배정
fresh
list { all, 2 } 없음
fresh
list { open, 1 } 미배정
fresh
detail 'store-7' 없음
fresh
화면은 캐시 다섯 항목 중 둘만 구독합니다
- 서버 담당자
- 미배정
- 다시 보낸 조회
- 0
mutate(variables)는 호출한 쪽에 Promise를 반환하지 않으며, 성공·실패 후 처리는 mutation 옵션의 onSuccess, onError, onSettled에서 다룹니다. 저장이 끝난 뒤 같은 함수에서 이동하거나 다른 비동기 작업을 이어야 한다면 await mutateAsync(variables)를 사용할 수 있습니다. Mutations
같은 매장에 대한 저장 순서를 보장하려면
mutation은 기본적으로 병렬로 실행됩니다. 같은 매장의 담당 매니저를 A로 바꾼 직후 B로 바꾸면, B 요청이 먼저 끝나고 뒤늦은 A 요청이 서버 값을 다시 A로 만들 수 있습니다.
scope는 어떤 mutation들을 한 줄로 대기시킬지 지정하는 옵션입니다. scope.id가 같은 요청은 앞선 요청이 끝날 때까지 다음 요청을 대기열에 두고 순서대로 실행합니다. 아래 코드에서는 매장 ID로 scope를 만듭니다. 같은 매장의 담당자 변경은 순서를 지키고, 서로 다른 매장의 변경은 병렬로 진행할 수 있습니다.
function useAssignManager(storeId: string) {
return useMutation({
mutationKey: ['stores', 'assign-manager'],
scope: { id: `store-${storeId}` },
mutationFn: (managerId: string) =>
assignStoreManagerOnServer(storeId, managerId),
});
}
아래는 담당자를 홍길동으로 고른 직후 임꺽정으로 바꾼 상황입니다. 홍길동 저장 요청이 더 느리게 끝나도록 했습니다. scope 설정을 바꿔 재생하면 서버에 마지막으로 남는 값과 두 저장이 모두 끝나는 시각이 어떻게 달라지는지 확인할 수 있습니다.
scope 설정을 고르고 재생해 보세요. 홍길동 저장이 느리게 끝나는 상황입니다.
mutationKey는 변경 작업을 식별하고 관찰하는 데 쓰이므로 같은 key만 지정해도 순차 실행되지는 않습니다. scope가 만드는 대기열은 이 클라이언트에서 시작한 요청에 적용되며, 다른 관리자의 브라우저에서 보낸 요청까지 순서대로 처리하는 서버 잠금은 아닙니다. 순서대로 두 번 저장할 필요가 없는 선택 UI라면 첫 요청 중에 선택을 잠시 막는 편이 더 단순할 수 있습니다. Mutation Scopes
변경 버튼과 떨어진 헤더에서 저장 상태를 보여줄 수도 있습니다. useIsMutating은 진행 중인 mutation 수, useMutationState는 필터에 맞는 mutation들의 상태나 입력값 배열을 반환합니다.
const savingCount = useIsMutating({
mutationKey: ['stores', 'assign-manager'],
});
const pendingMenuIds = useMutationState<string>({
filters: { mutationKey: ['menus', 'sold-out'], status: 'pending' },
select: (mutation) => (mutation.state.variables as { menuId: string }).menuId,
});
예를 들어 헤더는 savingCount > 0일 때 “매장 정보 저장 중”을 표시하고, 메뉴 목록의 각 행은 pendingMenuIds.includes(menu.id)로 품절 변경을 저장 중인 메뉴만 표시할 수 있습니다. 이 품절 변경은 바로 다음 절에서 다룹니다. 버튼과 헤더가 멀리 떨어져 있어도 별도의 전역 상태를 복사할 필요가 없습니다. 조회 중인 매장 query 수는 useIsFetching({ queryKey: storeKeys.all })로 읽을 수 있지만, 여기에는 최초 조회와 백그라운드 재조회가 모두 포함됩니다. 따라서 이 수를 화면 전체를 가리는 로딩 화면의 조건으로 바로 사용하지는 않습니다. useMutationState, useIsFetching
응답을 기다리기 전에 메뉴를 품절로 바꾼다면
낙관적 업데이트: 서버의 저장 응답을 기다리지 않고 화면을 먼저 바꿉니다. 저장에 실패하면 원래 값으로 되돌리고, 요청이 끝나면 서버 데이터를 다시 확인합니다.
점심 피크에 재료가 떨어지면 관리자는 메뉴 관리 화면에서 품절 스위치를 여러 개 연달아 누릅니다. 스위치마다 서버 응답을 기다리면 누른 스위치가 한참 뒤에야 움직여, 제대로 눌렸는지 몰라 한 번 더 누르게 됩니다. 품절 여부는 참·거짓 하나이고 실패하면 이전 값으로 되돌릴 수 있어 이 방식이 잘 맞습니다. 아래 코드는 한 매장의 메뉴 목록에서 품절 스위치 하나를 누르는 경우입니다.
type Menu = { id: string; name: string; soldOut: boolean };
const menuKeys = {
list: (storeId: string) => ['menus', storeId] as const,
};
const toggleSoldOut = useMutation({
mutationKey: ['menus', 'sold-out'],
mutationFn: ({
storeId,
menuId,
soldOut,
}: {
storeId: string;
menuId: string;
soldOut: boolean;
}) => updateMenuSoldOutOnServer(storeId, menuId, soldOut),
onMutate: async ({ storeId, menuId, soldOut }, context) => {
const key = menuKeys.list(storeId);
// 진행 중인 메뉴 조회가 낙관적 값을 덮지 않게 합니다.
await context.client.cancelQueries({ queryKey: key });
const previous = context.client.getQueryData<Menu[]>(key);
context.client.setQueryData<Menu[]>(key, (menus) =>
menus?.map((menu) => (menu.id === menuId ? { ...menu, soldOut } : menu)),
);
return { previous };
},
onError: (_error, { storeId }, onMutateResult, context) => {
context.client.setQueryData(
menuKeys.list(storeId),
onMutateResult?.previous,
);
},
onSettled: (_data, _error, { storeId }, _onMutateResult, context) =>
Promise.all([
context.client.invalidateQueries({ queryKey: menuKeys.list(storeId) }),
// 매장 상세의 품절 메뉴 수도 같은 사실을 보여주므로 함께 확인합니다.
context.client.invalidateQueries({ queryKey: storeKeys.detail(storeId) }),
]),
});
onMutate는 서버 응답 전에 실행됩니다. 먼저 진행 중인 메뉴 조회를 취소해 오래된 응답이 낙관적 값을 덮지 않게 하고, 기존 메뉴 목록을 보관한 다음 누른 메뉴만 품절로 바꾼 목록을 캐시에 넣습니다. 서버가 실패하면 onError에서 보관한 목록으로 되돌리고, 성공과 실패 어느 쪽이든 onSettled에서 서버 값을 다시 확인합니다. 이때 메뉴 목록뿐 아니라 매장 상세도 무효화하는 이유는, 매장 상세에 품절 메뉴 수가 따로 담겨 있기 때문입니다. 낙관적으로 바꾼 것은 메뉴 목록 하나이므로, 같은 사실을 보여주는 다른 query는 서버 응답 뒤에 다시 읽어야 화면 전체가 맞춰집니다.
아래에서 위 toggleSoldOut의 흐름을 서버 성공과 실패 두 경우로 따라가 볼 수 있습니다. 관리자가 강남점 메뉴 관리 화면에서 시즌 한정 에이드의 품절 스위치를 누른 상황입니다. 스위치가 서버 응답보다 먼저 움직이는 시점, 실패하면 스위치가 되돌아가는 과정, 마지막에 매장 상세의 품절 메뉴 수까지 서버 값으로 맞춰지는 순간을 확인해 보세요.
서버 응답을 고른 뒤 품절 스위치 누르기로 시작해, 다음을 눌러 한 단계씩 진행합니다.
- 1
- 2
- 3
- 4
menus 'store-42' 에이드 판매 중 detail 'store-42' 품절 0개 previous 없음 에이드는 판매 중입니다
품절 스위치는 연달아 누르는 경우가 많아 되돌리는 방식에 주의해야 합니다. 아메리카노를 품절로 바꾼 직후 카페라테도 바꿨는데 아메리카노 요청만 실패하면, onError가 되돌리는 previous는 아메리카노를 바꾸기 전의 목록 전체입니다. 그래서 카페라테의 낙관적 변경까지 함께 지워집니다. 요청 중인 스위치만 잠시 막거나, 실패했을 때 목록 전체가 아니라 실패한 메뉴 하나만 이전 값으로 고치는 방식을 고려해야 합니다. Optimistic Updates
QueryClient에서 자주 찾게 되는 캐시 API
mutation 콜백이나 라우터 로더에서는 훅 대신 QueryClient로 캐시를 읽고 제어할 수 있습니다. 이름이 비슷해도 데이터 수정, stale 표시, 제거는 서로 다른 동작입니다.
| 원하는 동작 | API | 확인할 점 |
|---|---|---|
| 현재 값을 한 번 읽기 | getQueryData, getQueriesData | 컴포넌트에서 호출해도 변경을 구독하지는 않음 |
| 데이터·갱신 시각 확인 | getQueryState | dataUpdatedAt, fetchStatus 등을 볼 수 있음 |
| 응답으로 캐시 수정 | setQueryData, setQueriesData | 불변 방식으로 수정. 복수 수정은 기존 query 대상 |
| 낡았다고 표시 | invalidateQueries | 활성 query는 기본적으로 다시 조회 |
| 직접 다시 요청 | refetchQueries | 필터에 맞는 query를 재조회 |
| 진행 중인 요청 중단 | cancelQueries | queryFn이 signal을 사용하면 네트워크도 중단 가능 |
| 초기 상태로 되돌리기 | resetQueries | 활성 query는 다시 요청될 수 있음 |
| 캐시 항목 제거 | removeQueries, clear | clear는 QueryCache와 MutationCache 전체를 비움 |
예를 들어 로그아웃 때 사용자별 데이터를 정리할 것인지, 변경 성공 뒤 관련 목록만 최신화할 것인지는 범위가 다릅니다. 후자에 clear()를 쓰면 다른 화면의 캐시까지 사라집니다. getQueryData()는 현재 값을 읽는 명령형 API이므로 렌더링에 계속 반영해야 하는 컴포넌트에는 useQuery가 필요합니다. QueryClient API, Query Filters
요청 시점을 화면의 동작에 맞춥니다
queryOptions: 조회에 필요한 key, 요청 함수,staleTime등을 함께 묶습니다. 설정을 만드는 함수일 뿐, 호출한다고 바로 요청하지는 않습니다. 같은 설정을 화면의useQuery와 사전 조회에 재사용할 수 있습니다.
매장 목록에서 상세 링크를 누른 뒤에야 상세 요청을 시작하면, 이동 후 로딩 화면을 기다려야 합니다. 링크에 마우스를 올렸을 때 상세 query를 미리 가져오면 클릭 전에 요청을 시작할 수 있습니다. 목록을 훑는 모든 매장을 미리 요청하는 대신 이동 가능성이 생긴 시점에만 비용을 쓰는 방식입니다. 아래 예제는 같은 storeDetailQuery(id)를 사전 조회와 상세 화면에서 사용하며, 화면 이동에는 React Router를 사용합니다. Query Options
import { queryOptions, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
const storeDetailQuery = (id: string) =>
queryOptions({
queryKey: storeKeys.detail(id),
queryFn: ({ signal }) => fetchStore(id, signal),
staleTime: 30_000,
});
function StoreLink({ store }: { store: StoreSummary }) {
const queryClient = useQueryClient();
return (
<Link
to={`/stores/${store.id}`}
onMouseEnter={() => {
void queryClient.query(storeDetailQuery(store.id)).catch(() => {});
}}
>
{store.name}
</Link>
);
}
// 상세 화면
const detail = useQuery(storeDetailQuery(id));
현재 문서의 queryClient.query()는 fresh 데이터가 캐시에 있으면 이를 반환하고, 그렇지 않으면 요청해 캐시에 저장합니다. 사전 조회가 끝난 뒤 30초 안에 상세 화면을 열면 같은 key의 fresh 데이터를 읽으므로 첫 로딩을 줄일 수 있습니다. 예제의 Link는 클라이언트 라우터로 이동해 같은 QueryClient를 유지합니다. 일반 링크로 문서를 새로 로드하면 메모리 캐시가 사라져 이 방식의 이점을 얻기 어렵습니다.
hover 시점과 클릭 사이가 길어 stale이 됐다면 화면에서 다시 조회할 수 있습니다. 예전 글에서 볼 수 있는 fetchQuery와 prefetchQuery는 현재 문서에서 사용 중단 예정 API로 표시됩니다. 사전 조회의 오류는 hover 순간에 화면 오류로 보여주지 않고, 실제 상세 화면의 useQuery가 처리하도록 위 코드에서 거부만 무시했습니다. QueryClient API
queryOptions로 묶은 key에는 결과 타입 정보도 연결됩니다. 따라서 queryClient.getQueryData(storeDetailQuery(id).queryKey)로 캐시를 읽을 때도 상세 데이터의 타입을 추론할 수 있습니다. Query Options
캐시를 읽는 컴포넌트도 필요한 값만 선택합니다
select: 조회한 데이터에서 이 컴포넌트에 필요한 값만 골라data로 받습니다. 예를 들어 매장 목록 응답에서total만 고를 수 있습니다. 캐시에 저장된 목록과 다른 컴포넌트가 받는 데이터는 그대로입니다.
헤더에 영업 중인 매장 수만 표시하는데 매장 이름이나 담당 매니저가 바뀔 때마다 헤더를 다시 그릴 필요는 없습니다. 아래의 selectTotal은 StorePage를 받아 total만 반환하므로, 헤더의 data에는 숫자가 들어갑니다.
const selectTotal = (page: StorePage) => page.total;
function OpenStoreCount() {
const { data: total } = useQuery({
queryKey: storeKeys.list('open', 1),
queryFn: ({ signal }) => fetchStores({ status: 'open', page: 1, signal }),
select: selectTotal,
});
return <span>영업 중 {total ?? 0}곳</span>;
}
목록 항목만 바뀌고 total이 같다면 헤더가 받는 값은 그대로입니다. Structural sharing은 새 응답에서 바뀌지 않은 부분에 기존 참조를 재사용하는 방식이며, TanStack Query는 JSON 호환 데이터에 기본으로 적용합니다. 다만 헤더의 건수가 목록 1페이지 응답과 다른 주기로 갱신되어야 한다면 별도의 건수 API와 query를 둬야 합니다. select는 요청 횟수나 캐시 항목을 분리하지 않습니다. Render Optimizations
TanStack Query는 useQuery 결과에서 실제로 읽은 속성도 추적합니다. data만 쓰는 컴포넌트는 isFetching의 변경만으로 다시 렌더링하지 않을 수 있습니다. 다음처럼 객체의 나머지 속성을 한꺼번에 펼치면 모든 속성을 읽게 되어 이 최적화가 무력화됩니다.
const { data } = useQuery(storeDetailQuery(id)); // 사용하는 속성만 읽음
const { data: store, ...rest } = useQuery(storeDetailQuery(id)); // 나머지도 모두 읽음
훅이 반환한 최상위 객체 자체는 렌더마다 새 참조일 수 있으나, data는 structural sharing으로 가능한 부분의 참조를 유지합니다. select 함수도 참조가 바뀌면 다시 실행될 수 있으므로 계산이 큰 선택 함수는 컴포넌트 밖에 선언하거나 useCallback으로 고정합니다. Render Optimizations
로딩과 오류를 컴포넌트 경계에 맡길 때
매장 상세 페이지 전체가 상세 데이터 없이는 의미가 없다면, 본문 컴포넌트마다 첫 로딩과 오류 분기를 둘 이유가 적습니다. useSuspenseQuery는 데이터가 준비될 때까지 React의 Suspense 경계에 로딩을 맡기고, 오류는 Error Boundary에서 처리하게 합니다. 이 훅이 정상적으로 렌더링한 시점에는 data가 정의되어 있어 상세 컴포넌트는 매장 정보 표시만 담당합니다. Suspense
function StoreDetail({ id }: { id: string }) {
const { data: store } = useSuspenseQuery(storeDetailQuery(id));
return <StoreContent store={store} />;
}
<ErrorBoundary fallback={<StoreError />}>
<Suspense fallback={<StoreSkeleton />}>
<StoreDetail id={id} />
</Suspense>
</ErrorBoundary>;
운영자의 지역 ID를 기다린 뒤 매장 목록을 조회하는 앞선 예제에는 useSuspenseQuery를 그대로 대입할 수 없습니다. enabled, placeholderData, skipToken을 사용할 수 없고, query 취소도 지원되지 않기 때문입니다. 같은 컴포넌트에 useSuspenseQuery를 여러 개 순서대로 쓰면 앞의 요청이 끝나야 다음 훅에 도달해 요청 폭포가 생길 수 있습니다. 독립적인 여러 조회라면 useSuspenseQueries나 상위 단계의 사전 조회를 고려해야 합니다. 일반 useQuery에서 특정 오류만 경계로 전달하고 싶다면 throwOnError에 함수를 줄 수도 있습니다. useSuspenseQuery API, useQuery Options
서버에서 먼저 조회하고 브라우저로 넘길 때
Hydration: 서버가 미리 가져온 데이터를 브라우저의 query 캐시에 옮기는 과정입니다. 서버에서
dehydrate()로 전달할 데이터를 꺼내고, 브라우저에서는HydrationBoundary아래의 컴포넌트가 그 데이터를 사용합니다.
서버가 매장 상세 HTML을 만들어 보냈는데 브라우저의 QueryClient는 비어 있다면, 화면을 연결한 직후 같은 데이터를 다시 요청할 수 있습니다. 아래처럼 서버가 상세 데이터를 조회하고 캐시 상태를 함께 넘기면, 브라우저는 그 결과를 처음부터 사용할 수 있습니다.
// 서버에서 실행되는 라우트 또는 로더의 개략적인 흐름
const queryClient = new QueryClient();
await queryClient.query(storeDetailQuery(id));
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<StoreDetail id={id} />
</HydrationBoundary>
);
서버의 queryFn은 서버에서도 호출할 수 있어야 하며, 요청마다 별도 QueryClient를 만들어 사용자 간 캐시가 섞이지 않게 해야 합니다. hydration 뒤 staleTime이 0이면 클라이언트에서 곧바로 백그라운드 재조회할 수 있습니다. 신선도는 서버가 데이터를 받은 시각을 기준으로 계산하므로, 첫 진입에서 재요청이 불필요하다면 적절한 staleTime을 지정합니다. Server Rendering & Hydration
브라우저 새로고침 뒤에도 매장 목록을 남겨 두려면 별도의 저장소가 필요합니다. 기본 캐시는 JavaScript 메모리에 있어 새로고침하면 사라지기 때문입니다. PersistQueryClientProvider와 persister를 사용하면 브라우저 저장소 등에 캐시를 저장하고 다시 읽을 수 있습니다. 복원된 목록이 지나치게 오래된 상태로 남지 않도록 저장 캐시의 허용 나이인 maxAge를 정하고, 복원 뒤 메모리에서 제거되는 gcTime도 함께 고려해야 합니다. 저장된 데이터를 읽을 수 있다는 사실만으로 오프라인 조회나 변경이 완성되는 것은 아니며, queryFn과 mutation이 오프라인에서 어떻게 동작하는지도 정해야 합니다. persistQueryClient
앱 전체 정책과 디버깅 도구
같은 백오피스에서도 조회 데이터의 변화 속도는 다릅니다. 영업 상태와 담당 매니저가 표시되는 목록은 다른 운영자의 변경을 빨리 확인해야 하지만, 매장 주소가 표시되는 상세 화면은 조금 늦게 다시 읽어도 될 수 있습니다. 모든 query에 같은 staleTime을 복사하지 않고, QueryClient의 defaultOptions로 기본 정책을 정한 뒤 setQueryDefaults()로 목록과 상세의 정책을 나눌 수 있습니다. key별 기본값은 넓은 key를 먼저, 더 구체적인 key를 나중에 등록해야 의도한 값이 우선합니다. QueryClient API, v5 마이그레이션 가이드
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, staleTime: 30_000 } },
});
queryClient.setQueryDefaults(storeKeys.lists(), { staleTime: 10_000 });
queryClient.setQueryDefaults(storeKeys.details(), { staleTime: 60_000 });
매장 관리로 돌아올 때 요청이 왜 발생했는지 헷갈리면 @tanstack/react-query-devtools에서 해당 key의 fresh·stale 상태, 구독자 수와 요청 상태를 확인할 수 있습니다. staleTime이 지난 것인지, 필터 변경으로 새 key가 생긴 것인지 먼저 구분하면 불필요하게 refetchOnMount를 끄지 않아도 됩니다. meta에는 오류 처리나 추적에 사용할 정보를 담아 공통 콜백에서 읽을 수 있습니다. 저수준 이벤트가 필요하면 queryClient.getQueryCache().subscribe()로 캐시 변경을 구독하지만, 일반 컴포넌트의 데이터 읽기는 useQuery가 담당하는 편이 맞습니다. Devtools, QueryCache API
필요할 때 다시 찾는 기준
| 하려는 일 | 고를 기능 | 기억할 차이 |
|---|---|---|
| 같은 응답을 재사용 | queryKey, staleTime | key가 캐시 항목을 구분하고 시간은 신선도를 결정함 |
| 사용하지 않는 데이터 보관 | gcTime | 구독자가 사라진 뒤부터 계산함 |
| stale 데이터 자동 최신화 | mount·focus·reconnect | stale이 되는 순간 바로 요청하지는 않음 |
| 일정 간격으로 확인 | refetchInterval | staleTime과 독립적으로 실행됨 |
| 조건이 생긴 뒤 조회 | enabled, skipToken | 둘 다 가능. skipToken은 타입을 좁히지만 수동 refetch()가 제한됨 |
| 이전 화면을 임시로 표시 | placeholderData | 현재 key의 캐시에 기록하지 않음 |
| 완전한 초기값을 저장 | initialData | 현재 key의 캐시에 기록함 |
| 페이지를 이어 붙임 | useInfiniteQuery | pages, pageParams, 다음 커서를 관리함 |
| 변경 뒤 서버 값으로 맞춤 | invalidateQueries | 일치하는 활성 query를 다시 조회함 |
| 서버 응답으로 바로 교체 | setQueryData | 해당 key를 불변 방식으로 수정함 |
| 요청 전 화면을 먼저 변경 | onMutate | 실패 시 되돌릴 방법이 필요함 |
| 같은 종류의 저장을 순서대로 실행 | mutation scope.id | mutationKey만으로는 순서가 정해지지 않음 |
| 서버에서 받은 결과를 첫 화면에 사용 | dehydrate, HydrationBoundary | 서버와 브라우저의 캐시를 이어 줌 |
| 새로고침 뒤에도 캐시 복원 | PersistQueryClientProvider | 저장소와 복원 정책이 추가로 필요함 |