TanStack Query + Zustand: 서버 상태와 클라이언트 상태를 분리하는 패턴과 안티패턴
Redux를 쓰던 시절을 돌아보면, API 응답값과 모달 open/close 상태가 한 스토어 안에 뒤섞여 있었던 기억이 납니다. "이거 어디서 바뀐 거지?"라며 디버거를 붙잡고 씨름했던 날들이 꽤 많았죠. 그러다 TanStack Query(구 React Query)를 도입하면서 뭔가 달라졌다고 느꼈는데, 처음엔 그냥 "캐싱이 편해졌네" 정도로만 이해했습니다. 왜 그게 근본적으로 달랐는지, 그건 한참 뒤에야 알았습니다.
이 글에서는 두 라이브러리가 각각 어떤 상태를 담당해야 하는지, 경계를 어떻게 설계하면 되는지, 실무에서 흔하게 빠지는 함정은 어떻게 피할 수 있는지를 코드와 함께 살펴봅니다. React 기반 프로젝트를 다루는 프론트엔드 개발자라면 바로 적용해볼 수 있는 내용입니다.
서버 상태와 클라이언트 상태는 처음부터 다른 라이브러리가 맡아야 하며, TanStack Query + Zustand 조합이 그 역할을 가장 깔끔하게 분담하는 이유가 이 글의 핵심입니다.
핵심 개념
서버 상태와 클라이언트 상태, 왜 구분해야 할까
저도 처음엔 헷갈렸는데, 두 상태의 근본적인 차이를 이해하고 나면 구분이 꽤 자연스러워집니다.
| 구분 | 서버 상태 | 클라이언트 상태 |
|---|---|---|
| 위치 | 원격 데이터베이스, API | 브라우저 메모리 |
| 특성 | 비동기, 언제든 외부에서 변경 가능 | 동기, 내 앱만 제어 |
| 예시 | 상품 목록, 유저 프로필, 주문 내역 | 모달 open/close, 검색 필터값, 폼 wizard 단계 |
| 관리 도구 | TanStack Query | Zustand |
서버 상태는 내가 아무것도 안 하고 있어도 다른 사용자나 백엔드 배치 작업에 의해 바뀔 수 있습니다. 그래서 항상 "지금 이 데이터가 최신인가?"를 고민해야 하고, 캐시 만료·백그라운드 리페치·중복 요청 제거 같은 작업이 필요합니다. 반면 클라이언트 상태는 오직 내 앱 코드만 바꿀 수 있고, 페이지를 새로고침하면 사라져도 괜찮은 일시적인 데이터입니다.
핵심 원칙: 서버에서 온 데이터를 Zustand에 복사하는 순간, 두 개의 진실 원본(source of truth)이 생깁니다. 이 두 복사본이 언젠가 어긋나면서 버그가 생기는 건 시간문제입니다.
TanStack Query가 서버 상태를 처리하는 방식
TanStack Query는 queryKey를 기준으로 캐시를 관리합니다. 같은 key로 여러 컴포넌트가 동시에 데이터를 요청해도 실제 네트워크 요청은 한 번만 나가고, stale time이 지나면 자동으로 백그라운드에서 리페치합니다. 이 전략이 바로 stale-while-revalidate입니다. 캐시된 데이터가 오래됐더라도(stale) 일단 그 데이터를 먼저 보여주고, 동시에 백그라운드에서 새 데이터를 가져오는(revalidate) 방식인데, 사용자 입장에서 빈 화면 대신 이전 데이터라도 즉시 볼 수 있어 UX가 자연스러워집니다. 이 모든 걸 직접 구현하려면 꽤 복잡한 로직이 필요한데, TanStack Query가 그걸 대신해줍니다.
Zustand가 클라이언트 상태를 처리하는 방식
Zustand는 gzip 기준 약 1KB의 초경량 라이브러리입니다. Provider 계층이 필요 없고, 싱글턴 스토어를 만들기 때문에 컴포넌트 트리 어디서든 바로 접근할 수 있습니다. Redux처럼 action, reducer를 별도로 정의할 필요 없이 create 함수 하나로 스토어를 뚝딱 만들 수 있어요.
import { create } from 'zustand'
interface UIState {
isModalOpen: boolean
openModal: () => void
closeModal: () => void
}
const useUIStore = create<UIState>((set) => ({
isModalOpen: false,
openModal: () => set({ isModalOpen: true }),
closeModal: () => set({ isModalOpen: false }),
}))이 스토어에 API 응답 데이터가 들어오는 순간, 이 라이브러리가 잘하는 영역을 벗어나는 겁니다.
실전 적용
예시 1: 기본 역할 분리
가장 기본적인 패턴부터 봅니다. 상품 목록을 불러오는 서버 상태와, 장바구니에 담긴 아이템을 관리하는 클라이언트 상태를 완전히 분리한 구조입니다.
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { create } from 'zustand'
// ✅ 서버 상태 — TanStack Query 담당
// 캐싱, 리페칭, 로딩/에러 상태가 자동으로 처리됩니다
function useProducts() {
return useQuery({
queryKey: ['products'],
queryFn: async () => {
const res = await fetch('/api/products')
if (!res.ok) throw new Error('상품 목록을 불러오지 못했습니다')
return res.json()
},
staleTime: 1000 * 60 * 5, // 5분간 fresh 유지
})
}
// ✅ 클라이언트 상태 — Zustand 담당
// 페이지를 새로고침하면 초기화되는 순수 UI 상태입니다
interface CartState {
selectedItems: string[]
addItem: (id: string) => void
removeItem: (id: string) => void
}
const useCartStore = create<CartState>((set) => ({
selectedItems: [],
addItem: (id) => set((state) => ({
selectedItems: [...state.selectedItems, id],
})),
removeItem: (id) => set((state) => ({
selectedItems: state.selectedItems.filter((item) => item !== id),
})),
}))| 역할 | 담당 라이브러리 | 이유 |
|---|---|---|
| 상품 목록 fetching | TanStack Query | 서버에 존재, 캐시 관리 필요 |
| 선택된 아이템 목록 | Zustand | 브라우저에만 존재, 서버 동기화 불필요 |
예시 2: 검색 필터 — 두 상태가 만나는 지점
앞서 본 useProducts를 조금 더 발전시킨 버전입니다. 사용자가 입력하는 필터값(클라이언트 상태)이 서버 요청의 파라미터로 연결되는 시나리오인데요, 실무에서 자주 맞닥뜨리는 상황입니다. 여기서 흔한 실수는 필터를 바꿀 때마다 수동으로 데이터를 다시 불러오는 코드를 작성하는 것입니다. queryKey를 잘 설계하면 그럴 필요가 없습니다.
// Zustand: 사용자가 조작하는 필터값 (클라이언트 상태)
interface FilterState {
keyword: string
category: string
setKeyword: (keyword: string) => void
setCategory: (category: string) => void
}
const useFilterStore = create<FilterState>((set) => ({
keyword: '',
category: 'all',
setKeyword: (keyword) => set({ keyword }),
setCategory: (category) => set({ category }),
}))
// TanStack Query: 필터값을 queryKey에 포함시켜 서버 조회
function useFilteredProducts() {
// 선택자(selector)로 필요한 상태만 구독합니다
// 통째로 구독하면 keyword, category와 무관한 변경에도 리렌더링이 발생할 수 있습니다
const keyword = useFilterStore((s) => s.keyword)
const category = useFilterStore((s) => s.category)
return useQuery({
queryKey: ['products', { keyword, category }],
queryFn: () => fetchProducts({ keyword, category }),
// v4의 keepPreviousData를 대체하는 v5 방식 — 필터 전환 시 이전 데이터를 유지해 깜빡임을 방지합니다
placeholderData: (previousData) => previousData,
})
}이 패턴의 핵심은 queryKey: ['products', { keyword, category }] 부분입니다. Zustand의 상태가 바뀌면 queryKey가 달라지고, TanStack Query는 이를 새로운 쿼리로 인식해 알아서 데이터를 가져옵니다. 서버 응답을 Zustand에 다시 저장할 이유가 전혀 없어집니다.
예시 3: 뮤테이션과 UI 피드백
뮤테이션 이후 상태 처리도 역할을 깔끔하게 나눌 수 있습니다. 이 패턴에서 제가 초반에 헷갈렸던 부분이 바로 "토스트 메시지도 Zustand에 넣어야 하나?"였는데, 당연히 Zustand의 몫입니다. 서버 데이터가 아닌 순수 UI 상태니까요. 서버 상태 갱신은 TanStack Query의 invalidateQueries가, UI 피드백은 Zustand가 맡으면 됩니다.
// Zustand: 토스트 같은 순수 UI 피드백 상태
const useUIStore = create<{ toastMessage: string; showToast: (msg: string) => void }>((set) => ({
toastMessage: '',
showToast: (msg) => {
set({ toastMessage: msg })
setTimeout(() => set({ toastMessage: '' }), 3000)
},
}))
// TanStack Query: 뮤테이션 처리 및 캐시 무효화
function useUpdateProfile(userId: string) {
const queryClient = useQueryClient()
const { showToast } = useUIStore()
return useMutation({
mutationFn: updateUserProfile,
onSuccess: () => {
// 서버 상태 갱신 — Query 캐시를 무효화해 자동 리페치
queryClient.invalidateQueries({ queryKey: ['user', userId] })
showToast('프로필이 업데이트되었습니다')
},
onError: () => {
showToast('업데이트에 실패했습니다. 다시 시도해 주세요')
},
})
}예시 4: 멀티스텝 폼 — 클라이언트 상태가 가장 명쾌하게 보이는 케이스
제가 실무에서 "아, 이건 명확히 분리됐다"고 가장 명쾌하게 느낀 케이스가 바로 폼 위저드입니다. 여러 단계로 이루어진 폼은 서버와 최종 제출 시에만 통신하면 됩니다. 그 전까지의 모든 상태는 완전히 클라이언트 영역입니다.
// Zustand: 폼의 현재 단계와 입력 데이터 (클라이언트 상태)
interface FormStore {
step: number
// 실제 프로젝트에서는 각 단계별 타입을 union으로 구체화하는 것을 권장합니다
// 예: type StepData = Step1Data | Step2Data | Step3Data
formData: Record<string, unknown>
nextStep: () => void
prevStep: () => void
setField: (key: string, value: unknown) => void
reset: () => void
}
const useFormStore = create<FormStore>((set) => ({
step: 1,
formData: {},
nextStep: () => set((s) => ({ step: s.step + 1 })),
prevStep: () => set((s) => ({ step: Math.max(1, s.step - 1) })),
setField: (key, value) =>
set((s) => ({ formData: { ...s.formData, [key]: value } })),
reset: () => set({ step: 1, formData: {} }),
}))
// TanStack Query: 최종 제출만 담당 (서버와 통신)
function useSubmitOrder() {
const { formData, reset } = useFormStore()
return useMutation({
mutationFn: () => submitOrder(formData),
onSuccess: () => {
reset() // 제출 후 폼 초기화
},
})
}장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 관심사 분리 | 각 라이브러리가 자기 전문 영역만 담당해 코드 추론이 쉬워집니다 |
| Zustand 스토어 단순화 | TanStack Query가 서버 데이터를 흡수하면, Zustand에는 순수 UI 상태만 남아 스토어가 극도로 가벼워집니다 |
| 캐시 자동 관리 | 리페치, stale time, 중복 요청 제거를 직접 구현할 필요가 없습니다 |
| 독립적인 DevTools | TanStack Query DevTools와 Zustand DevTools(Redux DevTools 연동)를 각각 독립적으로 디버깅할 수 있습니다 |
| 번들 크기 | TanStack Query ~13KB + Zustand ~1KB(gzip)로 Redux 대비 훨씬 가볍습니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 경계 판단 모호성 | "이 상태가 서버인가, 클라이언트인가"를 팀이 명확히 합의하지 않으면 혼용이 재발합니다. 실제로 PR 리뷰 때 이 기준을 두고 팀원과 자주 충돌했던 경험이 있습니다. | 팀 내 ADR(Architecture Decision Record)로 기준을 문서화해두면 좋습니다 |
| queryKey 설계 부담 | Zustand 상태를 queryKey에 올바르게 반영하지 않으면 stale 데이터를 보여줄 수 있습니다 | 필터·페이지네이션 관련 모든 변수를 queryKey에 포함시키는 것을 권장합니다 |
| 낙관적 업데이트 복잡성 | 롤백이 필요한 경우 onMutate/onError 패턴을 정확히 이해해야 합니다 |
TanStack Query 공식 문서의 Optimistic Updates 가이드를 참고하면 좋습니다 |
| MFE 환경의 싱글턴 관리 | TanStack Query v5에서 contextSharing prop이 제거됐습니다. Context API 기반 공유가 Module Federation 환경에서 의도치 않게 QueryClient 인스턴스를 분리시키는 문제 때문이었습니다. (마이크로 프론트엔드 환경이 아니라면 해당 없습니다) |
호스트 앱에서 QueryClient 인스턴스를 expose하고 리모트 앱이 import해 사용하는 Federated State 패턴을 활용하면 됩니다 |
실무에서 가장 흔한 실수
onSuccess에서 Zustand에 응답 데이터를 저장하는 안티패턴:queryClient.getQueryData()나useQuery의 반환값을 그냥 쓰면 되는데, 굳이 Zustand에 복사하면 두 곳이 어긋나는 순간 디버깅 지옥이 시작됩니다.- queryKey에 의존하는 Zustand 상태를 빠뜨리는 실수: 필터값을 queryKey에 넣지 않으면, 필터를 바꿔도 캐시된 이전 결과가 그대로 표시됩니다. 컴포넌트에서 필터 변경이 반영되지 않는다면 가장 먼저 queryKey를 확인해보시면 좋습니다.
- 서버 상태인지 클라이언트 상태인지 판단을 미루는 습관: 초반에 "일단 Zustand에 넣고 나중에 분리하자"라고 생각하면, 나중에 의존성이 얽혀서 분리가 어려워집니다. 처음부터 "이 데이터가 서버에 존재하는가?"를 기준으로 결정하는 습관이 중요합니다.
마치며
TanStack Query와 Zustand의 조합이 강력한 이유는 각자가 잘하는 영역에만 집중하기 때문이고, 이 분리를 처음부터 의식적으로 설계하느냐가 코드베이스의 복잡도를 좌우합니다.
지금 바로 해볼 수 있는 3단계:
- 현재 프로젝트의 상태 목록을 한 번 적어보시면 좋습니다. 각 상태 옆에 "서버에 존재하는가? (Y/N)"를 표시해보면, 어떤 상태가 잘못된 라이브러리에 들어가 있는지 바로 보입니다.
- TanStack Query를 설치하고 기존에 Zustand로 관리하던 API 응답 데이터를
useQuery로 옮겨보시면 좋습니다.pnpm add @tanstack/react-query @tanstack/react-query-devtools한 줄이면 시작할 수 있습니다. - DevTools를 양쪽 모두 설정해두면 TanStack Query DevTools에서 캐시 상태를 실시간으로 관찰할 수 있습니다. Zustand 상태는 Redux DevTools에서 확인할 수 있는데, 이를 위해서는
zustand/middleware의devtools미들웨어를 스토어에 감싸줘야 합니다(create(devtools(...))형태). 두 DevTools를 나란히 띄워두면 경계가 얼마나 명확한지 바로 느낄 수 있습니다.
참고 자료
- Does TanStack Query replace client state managers? | TanStack Query 공식 문서
- Overview | TanStack Query 공식 문서
- Federated State Done Right: Zustand, TanStack Query, and the Patterns That Actually Work | DEV Community
- Separating Concerns with Zustand and TanStack Query | volodymyrrudyi.com
- Redux vs TanStack Query & Zustand in 2025 | bugragulculer.com
- 우아콘 2023 — 프론트엔드 상태관리 실전 편 with React Query & Zustand | velog
- React Query + Zustand로 Server State와 Client State를 분리하기 | velog
- React Query 도입 시, 왜 상태 관리와 아키텍처도 함께 바꿔야 할까? | Medium