TanStack Query를 쓰는 이유: 서버 상태를 캐시하고 갱신하는 실전 기준
빠른 답
- TanStack Query는 서버 응답을 전역 상태처럼 보관하는 도구가 아니라, 서버 데이터의 조회, 캐싱, 갱신 흐름을 관리하는 도구입니다.
- 같은 데이터를 여러 컴포넌트가 읽을 때 중복 요청을 줄이고, 로딩과 에러, 재요청 상태를 일관된 방식으로 다룰 수 있습니다.
staleTime,gcTime,queryKey설계가 맞지 않으면 최신성 문제나 불필요한 네트워크 요청이 생길 수 있습니다.- 입력값, 모달 열림 여부, 작성 중인 폼 값처럼 브라우저가 원본인 상태는
useState, Zustand, Redux 같은 클라이언트 상태와 분리해서 다루는 편이 좋습니다.
목차
시간 흐름으로 이해하기
흐름으로 보기
이 흐름에서 컴포넌트는 서버 데이터를 직접 소유하지 않습니다. 서버 데이터의 원본은 서버에 있고, TanStack Query는 브라우저 안의 캐시와 갱신 정책을 관리합니다. 컴포넌트는 캐시를 구독하고, 필요한 시점에 다시 가져오거나 무효화하도록 요청합니다.
이 차이를 이해하면 TanStack Query를 “또 하나의 전역 상태 관리 도구”로 쓰는 실수를 줄일 수 있습니다. 전역 상태에 서버 응답을 복사해 두는 방식보다, 서버 상태는 쿼리 캐시에 맡기고 화면 상태는 클라이언트 상태로 분리하는 편이 데이터 흐름을 추적하기 쉽습니다.
서버 상태는 일반 상태와 다르다
React에서 상태라고 부르는 값이 모두 같은 성격을 갖지는 않습니다. 검색창에 입력 중인 값, 선택된 탭, 모달 열림 여부는 브라우저 안에서 사용자가 만든 클라이언트 상태입니다. 반면 사용자 정보, 게시글 목록, 주문 내역, 알림 개수는 서버가 원본을 가지고 있는 서버 상태입니다.
서버 상태에는 네트워크 요청, 실패 가능성, 최신성, 중복 조회, 재시도, 캐시 무효화 같은 문제가 따라옵니다. 같은 사용자 정보를 헤더, 설정 화면, 사이드바에서 동시에 필요로 할 수도 있고, 한 화면에서 수정한 결과가 다른 목록 화면에도 반영되어야 할 수도 있습니다.
처음에는 다음처럼 useEffect와 전역 상태로 직접 관리할 수 있습니다.
import { useEffect, useState } from 'react'
import { useUserStore } from './userStore'
export function useUserProfile(userId) {
const { user, setUser } = useUserStore()
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
let ignore = false
setLoading(true)
setError(null)
fetch(`/api/users/${userId}`)
.then((res) => {
if (!res.ok) {
throw new Error('사용자 조회 실패')
}
return res.json()
})
.then((data) => {
if (!ignore) {
setUser(data)
}
})
.catch((err) => {
if (!ignore) {
setError(err)
}
})
.finally(() => {
if (!ignore) {
setLoading(false)
}
})
return () => {
ignore = true
}
}, [userId, setUser])
return { user, loading, error }
}
이 코드는 작은 화면에서는 충분히 동작합니다. 다만 요청 취소, race condition, 캐싱, 재요청, 윈도우 포커스 복귀 시 갱신, 여러 컴포넌트의 중복 요청 제거까지 직접 챙기기 시작하면 로직이 빠르게 흩어집니다. React 공식 문서도 Effect 안에서 직접 데이터를 가져오는 방식이 서버 렌더링, 네트워크 waterfall, 캐시 부재 측면에서 비용을 만들 수 있다고 설명합니다.
TanStack Query는 이 반복되는 서버 상태 흐름을 컴포넌트 바깥의 쿼리 캐시로 옮깁니다. 컴포넌트는 “요청을 직접 소유”하기보다 “쿼리 결과를 구독”하는 쪽에 가까워집니다.
현재 기준 용어와 버전 차이
2026년 4월 기준 TanStack Query의 React 공식 문서는 v5를 기준으로 안내됩니다. 오래된 글에는 “React Query”라는 이름과 react-query 패키지명이 남아 있을 수 있습니다. 현재 새 코드에서는 @tanstack/react-query를 기준으로 보는 편이 맞습니다.
설치는 현재 패키지 이름을 사용합니다.
npm install @tanstack/react-query
npm install -D @tanstack/react-query-devtools
버전 차이에서 자주 헷갈리는 부분은 다음과 같습니다.
- 패키지 이름: v4부터
react-query가 아니라@tanstack/react-query를 사용합니다. - 호출 방식: v5에서는
useQuery({queryKey, queryFn, ...options})처럼 객체 하나를 넘기는 형식이 기준입니다. - 캐시 정리 옵션: v4의
cacheTime은 v5에서gcTime으로 이름이 바뀌었습니다. - 에러 경계 옵션: 예전
useErrorBoundary는 v5에서throwOnError이름을 사용합니다. - 상태 이름: v5에서는 초기 대기 상태를 설명할 때
isPending과status: 'pending'을 기준으로 이해해야 합니다.
cacheTime에서 gcTime으로 바뀐 이유는 이름이 주는 오해 때문입니다. gcTime은 데이터가 fresh로 유지되는 시간이 아니라, 더 이상 구독 중인 컴포넌트가 없는 inactive 쿼리를 메모리에 얼마나 남겨둘지 정하는 시간입니다. 데이터의 신선도는 staleTime이 담당합니다.
QueryClient에서 기본 정책 잡기
QueryClient는 앱 전체의 기본 쿼리 정책을 정하는 출발점입니다. 모든 API에 같은 시간을 적용하기보다, 대부분의 데이터에 적용할 기본값을 두고 개별 쿼리에서 덮어쓰는 방식이 관리하기 쉽습니다.
import React from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
gcTime: 10 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: true,
},
},
})
export function AppProviders({ children }) {
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools />
</QueryClientProvider>
)
}
staleTime은 데이터가 fresh로 간주되는 시간입니다. 이 시간이 지나면 데이터는 stale 상태가 되고, 새 쿼리 인스턴스가 마운트되거나 창 포커스가 돌아오거나 네트워크가 재연결되는 시점에 백그라운드 refetch가 일어날 수 있습니다.
gcTime은 캐시 정리 시간입니다. 해당 쿼리를 구독하는 컴포넌트가 없어 inactive 상태가 된 뒤 얼마 동안 메모리에 남겨둘지를 정합니다. 공식 문서의 기본값은 inactive 쿼리를 5분 뒤 garbage collection 하는 것입니다.
주문 상태, 재고, 채팅 알림처럼 최신성이 중요한 데이터는 staleTime을 짧게 두거나 명시적인 무효화 흐름을 설계하는 편이 낫습니다. 반대로 자주 바뀌지 않는 설정값이나 참조 데이터는 staleTime을 길게 잡아 불필요한 요청을 줄일 수 있습니다.
queryKey로 데이터 소유권 나누기
queryKey는 캐시의 주소에 가깝습니다. 쿼리 함수가 page, status, userId 같은 값에 의존한다면 그 값도 키에 포함되어야 합니다. 그래야 필터가 바뀌었을 때 이전 데이터와 새 데이터를 TanStack Query가 구분할 수 있습니다.
import { useQuery } from '@tanstack/react-query'
async function fetchProjects({ queryKey }) {
const [, params] = queryKey
const search = new URLSearchParams({
page: String(params.page),
status: params.status,
})
const res = await fetch(`/api/projects?${search.toString()}`)
if (!res.ok) {
throw new Error('프로젝트 목록 조회 실패')
}
return res.json()
}
export function useProjects({ page, status }) {
return useQuery({
queryKey: ['projects', { page, status }],
queryFn: fetchProjects,
staleTime: 30 * 1000,
select: (data) => ({
items: data.items,
totalCount: data.totalCount,
}),
})
}
page가 바뀌면 쿼리 키가 달라지고, TanStack Query는 다른 캐시 항목으로 인식합니다. 반대로 status를 키에서 빼면 “완료 프로젝트 목록을 요청했는데 진행 중 프로젝트 목록 캐시가 보이는” 식의 문제가 생길 수 있습니다.
select는 서버 응답 전체에서 화면이 필요한 형태만 읽게 해줍니다. 원본 응답을 전역 상태에 다시 복사하거나, 파생 데이터를 별도 useState로 저장하는 대신 쿼리 결과를 필요한 모양으로 선택할 수 있습니다. 이 방식은 리렌더링 범위를 이해하는 데도 도움이 됩니다.
mutation 후 캐시 갱신하기
조회는 useQuery, 생성과 수정과 삭제는 보통 useMutation으로 표현합니다. mutation이 성공하면 서버의 원본이 바뀐 것이므로, 관련 쿼리를 무효화해 다음 조회에서 새 데이터를 가져오게 할 수 있습니다.
import { useMutation, useQueryClient } from '@tanstack/react-query'
async function updateProjectStatus({ projectId, status }) {
const res = await fetch(`/api/projects/${projectId}/status`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ status }),
})
if (!res.ok) {
throw new Error('프로젝트 상태 변경 실패')
}
return res.json()
}
export function useUpdateProjectStatus() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateProjectStatus,
onSuccess: async (_data, variables) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['projects'] }),
queryClient.invalidateQueries({
queryKey: ['project', variables.projectId],
}),
])
},
})
}
무효화는 단순히 “캐시를 삭제한다”와 같지 않습니다. 해당 쿼리를 stale로 표시하고, 활성 상태라면 refetch로 이어질 수 있습니다. 목록 화면이 열려 있다면 변경된 서버 상태를 다시 가져오고, 닫혀 있다면 다음에 열릴 때 갱신될 수 있습니다.
응답이 작고 변경 범위가 분명한 경우에는 setQueryData로 캐시를 직접 갱신할 수도 있습니다. 다만 여러 페이지, 필터, 목록에 영향을 주는 수정이라면 invalidate가 더 단순한 구조를 만들 때가 많습니다.
리렌더링 관점에서 보는 장점
서버 응답을 큰 전역 상태 객체에 넣어두면 관련 없는 필드 변화에도 여러 컴포넌트가 영향을 받을 수 있습니다. 또한 같은 서버 데이터를 컴포넌트 상태, 전역 상태, 파생 상태로 복사하기 시작하면 어떤 값이 최신인지 판단하기 어려워집니다.
TanStack Query는 queryKey 단위로 서버 상태를 나누고, 컴포넌트가 필요한 쿼리 결과를 구독하게 합니다. 같은 키를 사용하는 여러 컴포넌트는 요청과 결과 상태를 공유할 수 있고, select를 사용하면 화면에 필요한 형태만 읽을 수 있습니다.
물론 TanStack Query를 쓴다고 리렌더링 문제가 자동으로 모두 사라지는 것은 아닙니다. 큰 응답을 여러 화면에서 그대로 넘기거나, select에서 매번 불필요한 새 객체를 많이 만들거나, 쿼리 키가 자주 바뀌는 구조라면 여전히 비용이 생길 수 있습니다. 먼저 데이터 소유권과 키 설계를 분명히 하고, 이후 실제 병목을 React DevTools나 TanStack Query Devtools로 확인하는 순서가 다루기 쉽습니다.
흔한 안티패턴
첫 번째 안티패턴은 useQuery로 받은 서버 데이터를 다시 Zustand나 Redux에 저장하는 방식입니다. 이렇게 되면 쿼리 캐시와 전역 상태가 같은 서버 응답을 각각 들고 있게 됩니다. refetch로 쿼리 캐시가 갱신되어도 전역 상태가 그대로 남아 있으면 화면마다 다른 값을 볼 수 있습니다.
두 번째는 서버 응답에서 만든 파생 값을 다시 useState에 넣는 방식입니다. 예를 들어 프로젝트 목록에서 완료된 항목만 보여주기 위해 completedProjects를 별도 상태로 저장하면 원본 목록이 갱신될 때 파생 상태도 함께 갱신해야 합니다. 이런 값은 렌더링 시 계산하거나 select로 읽는 편이 흐름을 따라가기 쉽습니다.
세 번째는 queryKey에 의존 값을 빠뜨리는 것입니다. 쿼리 함수가 userId, page, status, sort를 사용한다면 그 값은 키에도 들어가야 합니다. 그렇지 않으면 서로 다른 요청이 같은 캐시를 공유할 수 있습니다.
네 번째는 enabled: false와 수동 refetch를 남용하는 방식입니다. 특정 조건이 충족된 뒤에만 요청해야 하는 상황에서는 유용하지만, 대부분의 데이터 흐름을 명령형 호출로 바꾸면 TanStack Query의 선언적인 캐싱 모델을 제대로 활용하기 어렵습니다.
마지막으로 클라이언트 상태까지 TanStack Query에 넣으려는 경우도 있습니다. 검색어 입력 중인 값, 모달 열림 여부, 작성 중인 폼 값은 서버가 원본이 아닙니다. 이런 값은 로컬 상태나 클라이언트 상태 관리 도구로 두고, 확정된 검색 조건이나 저장 요청처럼 서버와 연결되는 지점에서 쿼리 키나 mutation으로 넘기는 편이 구조가 분명합니다.
언제 쓰고 언제 분리할까
TanStack Query가 잘 맞는 값은 서버에서 가져오고, 시간이 지나면 낡아질 수 있으며, 여러 컴포넌트가 함께 읽는 데이터입니다. 사용자 프로필, 게시글 목록, 상품 상세, 검색 결과, 알림 목록 같은 값이 여기에 속합니다.
반대로 사용자가 입력 중인 검색어, 열려 있는 드롭다운, 작성 중인 폼의 임시 값, 현재 선택된 탭은 브라우저 안에서 생긴 상태입니다. 이 값들은 TanStack Query 캐시에 넣기보다 useState, useReducer, Zustand, Redux 같은 클라이언트 상태로 다루는 편이 이해하기 쉽습니다.
구분 기준은 “이 값의 원본이 어디에 있는가”입니다. 서버가 원본이면 TanStack Query의 캐시와 갱신 흐름에 맡기고, 브라우저의 상호작용이 원본이면 클라이언트 상태로 둡니다. 이 경계가 분명하면 어떤 쿼리를 무효화해야 하는지, 어떤 컴포넌트가 다시 렌더링되는지 추적하기 쉬워집니다.
더 읽을 공식 문서
- TanStack Query Important Defaults: stale 처리, 백그라운드 refetch,
gcTime, retry 기본값을 확인할 수 있습니다. - TanStack Query Query Keys: 쿼리 키에 의존 값을 포함해야 하는 이유를 설명합니다.
- TanStack Query v5 Migration Guide: 객체 형식 API,
cacheTime에서gcTime으로의 변경,throwOnError변경 등을 확인할 수 있습니다. - TanStack Query v4 Migration Guide:
react-query에서@tanstack/react-query로 바뀐 패키지 이름과 import 변경을 확인할 수 있습니다. - React 공식 문서: Synchronizing with Effects: Effect에서 직접 데이터 fetching을 다룰 때의 한계와 대안을 함께 볼 수 있습니다.
원문 참고
https://www.maeil-mail.kr/question/86
댓글
댓글 쓰기