TanStack React Query와 서버 상태
React 앱에서 상태를 관리할 때, 보통 Redux나 Zustand 같은 라이브러리를 쓴다. 그런데 이 상태 관리 도구에 API 응답 데이터까지 넣기 시작하면 상태가 급격히 복잡해진다. 로딩 중인지, 에러가 났는지, 데이터가 오래됐는지, 다시 요청해야 하는지... 이걸 전부 직접 관리해야 한다.
여기서 핵심적인 질문이 하나 있다: "서버에서 온 데이터"와 "UI에서 만든 데이터"는 같은 종류의 상태인가?
클라이언트 상태 vs 서버 상태
클라이언트 상태는 앱 안에서만 존재한다. 사이드바가 열려있는지, 다크모드인지, 폼에 뭘 입력했는지. 이 데이터의 소유자는 클라이언트 자체다. 동기적이고, 예측 가능하고, 내가 직접 제어할 수 있다.
서버 상태는 근본적으로 다르다. 데이터가 서버에 있고, 비동기 API로만 접근할 수 있다. 다른 사용자가 같은 데이터를 수정할 수 있어서 내가 가진 복사본이 언제든 "낡은 데이터(stale)"가 될 수 있다. 네트워크를 거치니까 실패할 수도 있다.
이 두 가지를 같은 store에 넣으면 문제가 생긴다. 상품 목록을 Redux에 넣었다고 치자. 사용자가 다른 탭에서 상품을 수정했다면? 내 Redux에 있는 데이터는 이미 틀린 데이터다. 그럼 폴링? 리페치 타이밍? 캐시 무효화? 이걸 전부 액션과 리듀서로 직접 짜야 한다. 이게 바로 React Query가 해결하려는 문제다.
React Query의 핵심 개념
Query - 데이터 읽기
const { data, isLoading, error } = useQuery({
queryKey: ['products', productId],
queryFn: () => fetchProduct(productId),
});
queryKey는 이 데이터의 고유 식별자다. React Query는 이 키를 기준으로 캐시를 관리한다. 같은 키로 여러 컴포넌트에서 useQuery를 호출해도 실제 네트워크 요청은 한 번만 나간다. 나머지는 캐시된 결과를 공유한다.
queryFn은 실제 데이터를 가져오는 함수다. axios든 fetch든 Promise를 반환하기만 하면 된다. React Query는 이 함수의 내부 구현에는 관심이 없다. "언제, 얼마나 자주 호출할지"를 관리하는 게 자기 역할이다.
Mutation - 데이터 쓰기
const mutation = useMutation({
mutationFn: (newProduct) => createProduct(newProduct),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
서버 데이터를 변경할 때는 useMutation을 쓴다. 핵심은 onSuccess에서 invalidateQueries를 호출하는 부분이다. "상품을 새로 만들었으니, 상품 목록 캐시는 이제 낡았다"고 알려주는 것이다. React Query는 해당 키의 데이터를 자동으로 다시 가져온다.
이게 왜 중요한가? 직접 구현하면 "상품 생성 → 성공 → 목록 API 재호출 → 상태 업데이트 → 로딩 처리"를 일일이 연결해야 한다. invalidateQueries 한 줄로 이 전체 흐름이 자동화된다.
Stale-While-Revalidate 전략
React Query의 캐싱 전략은 HTTP의 stale-while-revalidate 개념에서 가져왔다. 핵심 아이디어는 간단하다: 낡은 데이터라도 일단 보여주고, 백그라운드에서 새 데이터를 가져온다.
useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
staleTime: 5 * 60 * 1000, // 5분간 fresh
});
staleTime은 데이터가 "신선한" 상태로 유지되는 시간이다. 이 시간 안에는 같은 쿼리를 실행해도 네트워크 요청이 나가지 않는다. 시간이 지나면 데이터는 "stale" 상태가 되고, 다음번 이 쿼리가 필요할 때 백그라운드에서 리페치가 일어난다.
사용자 입장에서는 항상 즉각적인 응답을 받는다. 캐시된 데이터를 먼저 보여주니까. 그리고 새 데이터가 도착하면 자연스럽게 업데이트된다. 빈 화면이나 로딩 스피너를 최소화하는 UX 전략이기도 하다.
자동 리페치
React Query는 데이터가 stale 상태일 때 특정 이벤트가 발생하면 자동으로 리페치한다:
- 윈도우 포커스: 사용자가 다른 탭에 갔다가 돌아올 때
- 네트워크 재연결: 오프라인에서 온라인으로 복구될 때
- 컴포넌트 마운트: stale 데이터를 사용하는 컴포넌트가 새로 렌더링될 때
이 동작이 왜 합리적인가? 사용자가 탭을 전환하고 돌아왔다면, 그 사이에 데이터가 바뀌었을 수 있다. 네트워크가 끊겼다가 복구됐다면, 오프라인 동안의 변경사항을 반영해야 한다. 이런 상황을 직접 addEventListener로 감지하고 처리하는 건 꽤 번거로운 작업인데, React Query가 기본 동작으로 제공한다.
상태 분리의 실질적 효과
React Query를 도입하면 전역 상태 store가 극적으로 작아진다. 상품 목록, 사용자 정보, 주문 내역 같은 서버 데이터가 전부 빠지기 때문이다. Zustand나 Redux에는 순수한 UI 상태만 남는다: 모달 열림 여부, 선택된 탭, 사이드바 상태 같은 것들.
// Before: 모든 상태가 하나의 store에
GlobalStore = 서버데이터 + UI상태 + 로딩플래그 + 에러상태
// After: 관심사 분리
React Query = 서버 데이터 (캐싱, 리페치, 동기화)
Zustand = UI 상태 (테마, 레이아웃, 임시 값)
이 분리가 가져오는 가장 큰 변화는 "캐싱과 동기화를 직접 구현하지 않아도 된다"는 점이다. 서버 상태 관리의 복잡한 부분을 라이브러리에 위임하고, 개발자는 "어떤 데이터를 보여줄 것인가"에만 집중할 수 있다.
정리
- 서버 상태와 클라이언트 상태는 성격이 다르므로, 같은 store에 넣으면 캐싱·리페치·동기화를 전부 직접 구현해야 하는 부담이 생긴다.
- queryKey 기반 캐시 관리와 stale-while-revalidate 전략으로, 사용자에게는 즉각적인 응답을 보여주면서 백그라운드에서 데이터를 갱신한다.
- invalidateQueries 한 줄로 mutation 후의 캐시 무효화가 자동화되므로, 데이터 변경 → 목록 갱신 흐름을 선언적으로 처리할 수 있다.