TanStack Query는 "서버에서 온 데이터"를 다루는 전용 도구다. useState/useEffect로 직접 짜던 로딩·에러·캐시·재요청 로직을 캐싱·무효화·낙관적 업데이트라는 세 축으로 대체하면, 컴포넌트에서 상태 관리 코드가 눈에 띄게 줄어든다.
핵심은 서버 상태와 클라이언트 상태를 분리하는 사고방식이다. staleTime과 gcTime의 차이, queryKey를 어떻게 설계하느냐, 언제 invalidate하고 언제 setQueryData로 직접 갱신하느냐 — 이 감각만 잡으면 나머지는 API 문서 수준이다. 아래는 내가 실무에서 데여가며 정리한 패턴들이다.
처음 TanStack Query(예전 이름 React Query)를 접했을 때 나는 이걸 그냥 "fetch 래퍼" 정도로 봤다. 그러다 한 프로젝트에서 useEffect 안에 요청을 넣고, 로딩 플래그를 useState로 관리하고, 화면 이동 후 돌아올 때마다 데이터가 깜빡이는 걸 손으로 막다가 결국 항복했다. 서버 데이터는 근본적으로 클라이언트 상태와 다르게 취급해야 한다는 걸 그때 배웠다.
서버 상태와 클라이언트 상태는 왜 나눠야 하나
둘의 소유권이 다르기 때문이다. 클라이언트 상태는 내 브라우저가 유일한 진실의 출처다 — 모달 열림 여부, 입력 폼의 임시 값, 다크모드 토글 같은 것. 반면 서버 상태는 진짜 주인이 서버에 있고, 내 화면에 있는 건 그 데이터의 "복사본이자 스냅샷"일 뿐이다. 다른 사용자가 값을 바꾸면 내 복사본은 조용히 낡는다.
이 차이를 인정하면 필요한 기능이 저절로 정해진다. 서버 상태에는 캐싱, 백그라운드 갱신, 중복 요청 제거(dedup), 요청 실패 시 재시도, 여러 컴포넌트 간 공유가 필요하다. 이걸 Redux나 useState로 손수 구현하면 결국 TanStack Query를 반쯤 다시 만들게 된다. 개인적으로는 전역 상태 라이브러리에 서버 응답을 통째로 넣던 습관을 버린 게 가장 큰 변화였다.
기본값부터 알고 시작하자
TanStack Query의 동작 대부분은 기본값이 결정한다. 그런데 이 기본값이 초보자를 가장 많이 헷갈리게 한다. 특히 staleTime의 기본이 0이라는 점 — 즉 데이터를 받자마자 곧바로 "낡음(stale)"으로 표시된다. 아래는 v5 기준 자주 만나는 기본값이다.
| 옵션 | 기본값 | 의미 |
|---|---|---|
| staleTime | 0ms | 받자마자 stale 처리 → 트리거 시 재요청 |
| gcTime | 5분(300000ms) | 비활성 캐시가 메모리에서 제거되기까지 |
| retry | 3회 | 쿼리 실패 시 재시도 횟수(지수 백오프) |
| refetchOnWindowFocus | true | 탭 복귀 시 stale이면 자동 재요청 |
| refetchOnReconnect | true | 네트워크 재연결 시 재요청 |
"왜 창을 클릭할 때마다 네트워크 탭에 요청이 뜨지?"의 범인이 바로 staleTime=0 + refetchOnWindowFocus=true 조합이다. 이건 버그가 아니라 설계다. 데이터를 항상 신선하게 유지하려는 기본 정책. 다만 실무에선 이게 과할 때가 많아서 staleTime을 올려 조정한다.
staleTime과 gcTime, 헷갈리면 여기만 보자
한 줄 요약: staleTime은 "언제 다시 가져올까", gcTime은 "언제 캐시를 버릴까"다. 완전히 다른 축인데 이름이 비슷해서 자꾸 섞인다. staleTime이 지나면 데이터는 stale이 되지만 화면엔 여전히 보인다 — 다음 트리거 때 백그라운드로 새로 받아 조용히 교체할 뿐이다. 반면 gcTime은 그 쿼리를 구독하는 컴포넌트가 하나도 없을 때부터 카운트가 시작되고, 시간이 다 되면 메모리에서 아예 삭제된다.
| 구분 | staleTime | gcTime |
|---|---|---|
| 질문 | 언제 재요청? | 언제 메모리에서 삭제? |
| 카운트 시작 | 데이터 수신 시점 | 구독자(observer)가 0이 된 시점 |
| 지나면 | stale 표시(화면엔 유지) | 캐시 제거(다음엔 새 로딩) |
| 기본값 | 0 | 5분 |
보통 staleTime은 gcTime보다 작거나 같게 잡는 게 자연스럽다. gcTime이 staleTime보다 짧으면 캐시가 사라진 뒤엔 stale이고 뭐고 남는 게 없으니까. 데이터 성격에 맞춰 값을 정하는 게 실무 감각인데, 아래 예시가 출발점으로 쓸 만하다.
// QueryClient 전역 기본값 — 앱 성격에 맞게 한 번만 정해두면 편하다
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1분간은 fresh로 간주 → 불필요한 재요청 억제
gcTime: 5 * 60 * 1000, // 5분 뒤 비활성 캐시 정리
retry: 1, // 기본 3회는 실패 UX가 느려서 1회로 줄이는 편
refetchOnWindowFocus: false, // 관리자 대시보드처럼 실시간성이 덜하면 끈다
},
},
});
// 데이터 성격별로 개별 쿼리에서 덮어쓰기
// - 거의 안 변하는 값(국가 목록, 카테고리): staleTime: Infinity
// - 실시간성 높은 값(재고, 알림): staleTime: 0 + refetchInterval 사용
queryKey 설계가 절반이다
queryKey는 캐시의 "주소"이자 무효화의 "필터"다. 이걸 대충 잡으면 나중에 invalidation이 지옥이 된다. 규칙은 단순하다 — 배열로 쓰고, 큰 범주에서 작은 범주로 계층을 만들고, 쿼리 함수가 의존하는 모든 변수를 키에 포함시킨다. 변수를 키에 안 넣으면 파라미터가 바뀌어도 캐시가 안 갈려서 옛날 데이터가 화면에 남는다. 이거, 문서에 잘 안 나오는데 초보 때 진짜 많이 데인다.
// 나쁜 예: 문자열 키 + 의존 변수 누락
useQuery({ queryKey: ['todos'], queryFn: () => fetchTodos(status) });
// status가 바뀌어도 같은 캐시를 봐서 화면이 안 바뀐다
// 좋은 예: 배열 계층 + 의존 변수 포함
useQuery({
queryKey: ['todos', 'list', { status, page }],
queryFn: () => fetchTodos({ status, page }),
});
useQuery({
queryKey: ['todos', 'detail', todoId],
queryFn: () => fetchTodo(todoId),
});
// Query Key Factory 패턴 — 키를 한 곳에서 관리하면 오타·불일치가 사라진다
export const todoKeys = {
all: ['todos'] as const,
lists: () => [...todoKeys.all, 'list'] as const,
list: (filters: TodoFilters) => [...todoKeys.lists(), filters] as const,
details: () => [...todoKeys.all, 'detail'] as const,
detail: (id: number) => [...todoKeys.details(), id] as const,
};
이 팩토리 패턴을 쓰면 무효화가 아름다워진다. todoKeys.all로 무효화하면 todos로 시작하는 모든 캐시가 한 방에 날아가고, todoKeys.list(filters)로 하면 딱 그 목록만 갱신된다. 접두사 매칭이라 가능한 일이다.
무효화(invalidation): 뮤테이션 후 데이터 갱신하기
데이터를 바꾸는 요청(생성·수정·삭제) 뒤에 목록을 최신으로 맞추는 가장 정석적인 방법이 invalidateQueries다. 동작은 두 단계다 — 해당 키의 캐시를 stale로 표시하고, 지금 화면에서 쓰이는(active) 쿼리는 즉시 재요청한다. "서버가 진실"이라는 원칙에 가장 충실한 방식이라, 나는 확신이 없으면 일단 invalidate부터 쓴다.
function useAddTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (newTodo: NewTodo) => api.post('/todos', newTodo),
onSuccess: () => {
// todos로 시작하는 목록 계열을 stale로 만들고 active면 재요청
queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
},
});
}
// 특정 조건만 정밀 무효화하고 싶을 때 predicate 사용
queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === 'todos' &&
(query.queryKey[2] as any)?.status === 'done',
});
['todos']로 무효화하면 ['todos', 'detail', 5]까지 전부 stale이 된다. 이게 편할 때도 있지만, 상세는 안 건드리고 목록만 갱신하고 싶다면 키를 lists()처럼 구체적으로 잡아야 한다. 반대로 정확히 일치만 원하면 exact: true를 준다.낙관적 업데이트: 응답을 기다리지 않는 UI
낙관적 업데이트(optimistic update)는 서버 응답이 오기 전에 "성공했다고 가정하고" UI를 먼저 바꾸는 기법이다. 좋아요 버튼, 체크박스 토글처럼 실패 확률이 낮고 즉각성이 중요한 상호작용에 잘 맞는다. 핵심은 실패했을 때 원래대로 되돌리는 롤백을 반드시 짝으로 준비하는 것. 이걸 빼먹으면 서버는 실패했는데 화면만 성공한 채로 남는 최악의 버그가 생긴다.
TanStack Query에선 useMutation의 onMutate → onError → onSettled 3단 콜백으로 구현한다. onMutate에서 진행 중인 재요청을 취소하고 이전 값을 스냅샷으로 저장한 뒤 캐시를 미리 갱신하고, onError에서 스냅샷으로 되돌리고, onSettled에서 서버와 최종 동기화를 위해 무효화한다.
function useToggleTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (todo: Todo) =>
api.patch(`/todos/${todo.id}`, { done: !todo.done }),
onMutate: async (todo) => {
// 1) 이 키에 대한 진행 중 재요청 취소 — 낙관값이 덮어써지는 걸 막는다
await queryClient.cancelQueries({ queryKey: todoKeys.lists() });
// 2) 롤백용 이전 스냅샷 저장
const previous = queryClient.getQueryData(todoKeys.lists());
// 3) 캐시를 낙관적으로 즉시 갱신
queryClient.setQueryData(todoKeys.lists(), (old: Todo[] = []) =>
old.map((t) => (t.id === todo.id ? { ...t, done: !t.done } : t))
);
// onError로 넘길 context 반환
return { previous };
},
onError: (_err, _todo, context) => {
// 4) 실패 시 스냅샷으로 롤백
if (context?.previous) {
queryClient.setQueryData(todoKeys.lists(), context.previous);
}
},
onSettled: () => {
// 5) 성공/실패 무관하게 서버 상태와 최종 재동기화
queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
},
});
}
순서를 지키는 게 생명이다. cancelQueries를 빼면, 낙관값을 넣은 직후 도착한 이전 재요청 응답이 낙관값을 덮어써서 화면이 순간 되돌아갔다가 다시 바뀌는 깜빡임이 생긴다. 실무에서 이 깜빡임 원인 못 찾아 한참 헤맨 적 있다. 참고로 v5부터는 mutation의 variables와 isPending을 렌더링에 직접 써서 캐시를 안 건드리는 더 가벼운 낙관 UI도 가능한데, 목록 정합성이 중요하면 위 캐시 방식이 여전히 안전하다.
프리페치로 체감 속도 끌어올리기
프리페치(prefetch)는 사용자가 데이터를 요청하기 전에 미리 받아 캐시에 심어두는 기법이다. 목록에서 항목에 마우스를 올리는 순간 상세를 미리 받아두면, 클릭했을 때 로딩 스피너 없이 즉시 화면이 뜬다. 체감 속도가 확 달라진다. 리스트→상세 이동이 잦은 화면에서 투자 대비 효과가 가장 좋은 최적화다.
function TodoRow({ todo }: { todo: Todo }) {
const queryClient = useQueryClient();
const prefetch = () => {
queryClient.prefetchQuery({
queryKey: todoKeys.detail(todo.id),
queryFn: () => fetchTodo(todo.id),
staleTime: 30 * 1000, // 이미 fresh 캐시가 있으면 프리페치 건너뜀
});
};
return (
<li onMouseEnter={prefetch} onFocus={prefetch}>
<Link to={`/todos/${todo.id}`}>{todo.title}</Link>
</li>
);
}
Next.js App Router 같은 서버 컴포넌트 환경에선 서버에서 prefetchQuery로 받아 dehydrate → 클라이언트에서 HydrationBoundary로 넘기는 SSR 패턴을 쓴다. 서버에서 이미 받아둔 데이터라 첫 화면에 로딩이 아예 없고, 클라이언트는 그 캐시를 그대로 이어받아 이후 갱신만 담당한다.
흔한 실수 6가지
코드 리뷰에서 반복적으로 잡게 되는 것들만 추렸다. 대부분은 "기본값을 몰라서" 혹은 "서버 상태를 클라이언트 상태처럼 다뤄서" 생긴다.
- queryKey에 의존 변수 누락 — 파라미터가 바뀌어도 옛 캐시를 봐서 화면이 안 갱신된다. queryFn이 쓰는 값은 전부 키에 넣어라.
- useEffect로 데이터를 다시 useState에 복사 — 캐시를 두고 또 상태를 만드는 안티패턴. useQuery가 반환하는 data를 그냥 쓰면 된다.
- 낙관적 업데이트에서 롤백 누락 — onError 없이 onMutate만 있으면 실패가 화면에 반영되지 않는다.
- 서버 응답 전체를 Redux/Zustand에 복사 — 두 소스가 어긋나기 시작한다. 서버 데이터는 Query 캐시가 단일 출처.
- enabled 없이 조건부 쿼리 — id가 undefined인데 요청이 나가 400/404가 뜬다.
enabled: !!id로 막아라. - 에러/로딩 무시 — isPending, isError를 안 다루면 undefined 접근으로 런타임 에러. select로 파생 값을 만들 때 특히.
cacheTime → gcTime, isLoading은 최초 로딩 전용으로 좁아지고 일반적 대기는 isPending, 재요청 중 표시는 isFetching이다. v4 기준 예제를 복붙하다 isLoading이 기대와 다르게 동작하면 이 변경 때문일 가능성이 높다.언제 쓰지 말아야 하나
솔직히 말하면 모든 데이터에 TanStack Query가 답은 아니다. 폼 입력값처럼 순수하게 로컬한 임시 상태는 그냥 useState가 낫다. 서버와 왕복이 없는 값을 Query 캐시에 억지로 넣으면 오히려 개념이 꼬인다. 또 WebSocket으로 밀려오는 실시간 스트림은 Query의 요청-응답 모델과 결이 달라서, 소켓으로 받은 데이터를 setQueryData로 캐시에 반영하는 하이브리드가 현실적이다. 도구의 경계를 아는 게 도구를 아는 것보다 중요할 때가 많다.
자주 묻는 질문
staleTime과 gcTime 중 뭘 먼저 조정해야 하나요?
staleTime부터 조정하세요. 불필요한 재요청과 창 포커스 시 깜빡임은 대부분 기본값 0에서 비롯됩니다. 데이터가 얼마나 자주 바뀌는지에 맞춰 1분~무한대 사이로 잡으면 체감 개선이 큽니다. gcTime은 메모리 관리라 웬만하면 기본 5분으로 둬도 됩니다.
invalidateQueries와 setQueryData는 언제 각각 쓰나요?
서버를 신뢰하고 최신을 다시 받고 싶으면 invalidateQueries, 서버 응답에 새 데이터가 이미 들어있어 재요청이 아깝거나 낙관적 업데이트로 즉시 반영해야 하면 setQueryData입니다. 확신이 없으면 invalidate가 안전합니다. 정합성이 서버 기준으로 맞춰지니까요.
낙관적 업데이트에서 onSettled의 invalidate가 꼭 필요한가요?
권장됩니다. 낙관값과 실제 서버 결과가 미세하게 다를 수 있기 때문입니다(예: 서버가 부여한 id, 정렬 순서, 파생 필드). onSettled에서 무효화해 서버 상태로 최종 재동기화하면 클라이언트 추정과 실제의 불일치를 없앨 수 있습니다.
React Query와 TanStack Query는 다른 건가요?
같은 라이브러리입니다. v4 이후 React 외 프레임워크(Vue, Svelte, Solid)를 지원하며 TanStack Query로 이름을 바꿨습니다. React용 패키지 이름은 @tanstack/react-query이고, 예전 react-query는 더 이상 유지되지 않습니다.
전역 상태 라이브러리(Redux/Zustand)를 없애도 되나요?
서버 상태만 있었다면 상당 부분 없앨 수 있습니다. 하지만 모달·테마·다단계 폼 같은 순수 클라이언트 상태가 남으면 가벼운 Zustand나 Context를 함께 쓰는 게 자연스럽습니다. Query가 서버, 별도 스토어가 클라이언트를 맡는 분담이 유지보수에 유리합니다.
v4 코드를 v5로 올릴 때 주의할 점은?
cacheTime이 gcTime으로, 상태 플래그 의미가 바뀌었습니다(isPending/isLoading/isFetching). 또 useQuery/useMutation이 단일 옵션 객체 형태만 받도록 시그니처가 통일됐습니다. 공식 codemod가 대부분 자동 변환해주니 먼저 돌려보길 권합니다.

댓글 0