TanStack Query + Zustand 낙관적 업데이트 — useMutation · onMutate · queryOptions 실전 가이드
솔직히 말하면, 저도 처음엔 "그냥 Zustand 하나로 다 관리하면 안 되나?" 싶었습니다. API로 받아온 데이터도 Zustand에 때려박고, 모달 열림 상태도 같은 스토어에 넣고... 그렇게 몇 달을 버티다가 동기화 버그가 슬금슬금 기어나오기 시작했습니다. 서버에서 업데이트가 왔는데 UI는 여전히 예전 데이터를 보여주고, 새로고침하면 갑자기 맞아지는 그 불쾌한 경험 — 혹시 비슷하게 겪어보셨나요?
이 글은 React 훅에 익숙하고, Zustand나 Redux 같은 상태 관리 라이브러리를 한 번이라도 써본 프론트엔드 개발자를 대상으로 합니다. TanStack Query의 useMutation · onMutate · queryOptions와 Zustand가 낙관적 업데이트에서 어떻게 역할을 나눠 가지는지를 실전 코드와 함께 풀어드립니다. 공식 문서에서 잘 다루지 않는 동시 뮤테이션 경쟁 조건 처리까지 담았으니, 끝까지 읽어보시면 실무에서 바로 꺼내 쓸 수 있는 패턴을 하나씩 챙겨가실 수 있을 겁니다.
핵심 개념
서버 상태 vs UI 상태 — 왜 나눠야 할까
"상태(state)"라고 부르지만 성격이 전혀 다른 두 종류의 데이터가 있습니다.
| 구분 | 담당 도구 | 특성 |
|---|---|---|
| 서버 상태 (Server State) | TanStack Query | 비동기, 캐시, 재검증, 서버와 동기화 필요 |
| UI 상태 (Client State) | Zustand | 동기, 클라이언트 전용, 세션 내에서만 의미 있음 |
서버에서 받아온 할 일 목록은 서버 상태입니다. 언제든 다른 탭이나 다른 사용자가 수정할 수 있고, 새로고침하면 달라질 수 있습니다. 반면 "사이드바가 열려 있는가", "어떤 항목이 선택되어 있는가", "필터가 무엇으로 설정되어 있는가"는 UI 상태입니다. 서버와 무관하게 클라이언트 안에서만 살고 죽습니다.
이 둘을 같은 Zustand 스토어에 넣으면 API 응답이 올 때마다 수동으로 스토어를 업데이트해야 하고, 다른 탭에서 데이터가 바뀌어도 알 수 없습니다. 캐시 만료도 직접 구현해야 하죠. 결국 TanStack Query가 이미 해결해놓은 문제들을 다시 손으로 짜는 셈이 됩니다.
핵심 규칙: 서버 데이터는 절대 Zustand에 복사하지 마세요. 두 곳에 같은 데이터를 두면 동기화 버그는 필연적으로 발생합니다.
낙관적 업데이트 전체 흐름
낙관적 업데이트(Optimistic Update)는 서버 응답을 기다리지 않고 UI를 즉시 업데이트한 뒤, 서버 결과에 따라 확정하거나 롤백하는 패턴입니다. 좋아요 버튼을 눌렀을 때 숫자가 즉시 올라가는 것, 장바구니에 상품을 담았을 때 바로 추가되어 보이는 것이 모두 이 패턴입니다.
사용자 액션 발생
→ [즉시] UI에 낙관적 반영 (TQ 캐시 직접 조작 또는 Zustand)
→ [비동기] 서버로 실제 요청 전송 (useMutation)
→ 성공: onSuccess로 임시 ID 교체 → onSettled에서 invalidateQueries
→ 실패: 이전 상태로 롤백 (스냅샷 복원 또는 Zustand revert 함수 호출)TanStack Query는 useMutation의 onMutate → onSuccess/onError → onSettled 콜백 체인으로 이 흐름을 구조적으로 처리할 수 있게 해줍니다. 각 콜백이 정확히 어떤 역할을 하는지는 아래 예시에서 바로 확인할 수 있습니다.
queryOptions 팩토리 패턴 (v5 공식 마이그레이션 가이드 권장)
TanStack Query v5에서 도입된 queryOptions() 헬퍼는 공식 마이그레이션 가이드에서 권장하는 패턴입니다. 쿼리 키와 설정을 한 곳에서 정의해서 useQuery, prefetchQuery, invalidateQueries, setQueryData 모두에서 타입 안전하게 재사용할 수 있습니다.
// queries/todos.ts
import { queryOptions } from '@tanstack/react-query'
import { fetchTodo, fetchTodos } from '../api/todos' // fetch wrapper 또는 axios instance
export const todoQueries = {
all: () =>
queryOptions({
queryKey: ['todos'],
queryFn: fetchTodos,
}),
detail: (id: string) =>
queryOptions({
queryKey: ['todos', id],
queryFn: () => fetchTodo(id),
}),
}이렇게 정의해두면 invalidateQueries(todoQueries.all())처럼 쿼리 키를 문자열로 직접 쓰지 않아도 되고, 타입스크립트가 키 불일치를 컴파일 타임에 잡아줍니다. 쿼리 키 오타 때문에 캐시 무효화가 안 되는 버그를 한 번이라도 겪어보셨다면 이 패턴의 가치를 바로 체감하실 수 있습니다.
실전 적용
예시 1: TanStack Query 캐시 직접 조작으로 낙관적 업데이트
TanStack Query 공식 권장 방식입니다. 별도의 Zustand 스토어 없이 queryClient 캐시를 직접 조작해서 낙관적 UI를 구현합니다. 단일 컴포넌트 트리에서 데이터를 소비하는 경우에 가장 깔끔한 방법입니다.
// hooks/useTodoMutation.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { todoQueries } from '../queries/todos'
import type { NewTodo, Todo } from '../types/todo' // 도메인 타입 선언 위치
function useAddTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationKey: ['todos'],
mutationFn: (newTodo: NewTodo) => api.addTodo(newTodo), // api: fetch wrapper 또는 axios instance
onMutate: async (newTodo) => {
// 1. 진행 중인 리페치 취소 — 이걸 빠뜨리면 낙관적 업데이트가 백그라운드 리페치에
// 즉시 덮어씌워집니다. 실무에서 이 줄 누락으로 30분 디버깅한 적 있습니다...
await queryClient.cancelQueries(todoQueries.all())
// 2. 실패 시 롤백을 위한 이전 상태 스냅샷 저장
const previousTodos = queryClient.getQueryData(todoQueries.all().queryKey)
// 3. 낙관적 캐시 업데이트 — 서버 응답 전에 UI에 즉시 반영
// tempId를 context에 함께 전달해 onSuccess에서 서버 ID로 교체할 때 사용합니다
const tempId = `temp-${Date.now()}`
queryClient.setQueryData(
todoQueries.all().queryKey,
(old: Todo[] | undefined) => [
...(old ?? []),
{ ...newTodo, id: tempId, status: 'pending' as const },
]
)
return { previousTodos, tempId }
},
onSuccess: (serverTodo, _newTodo, context) => {
// 4. 서버에서 받은 실제 ID로 임시 항목 교체
// 이 단계를 빠뜨리면 temp-xxx ID가 남아 이후 수정·삭제 뮤테이션이 실패합니다
queryClient.setQueryData(
todoQueries.all().queryKey,
(old: Todo[] | undefined) =>
(old ?? []).map((todo) =>
todo.id === context?.tempId ? serverTodo : todo
)
)
},
onError: (_err, _newTodo, context) => {
// 5. 실패 시 이전 상태로 정확히 롤백
queryClient.setQueryData(
todoQueries.all().queryKey,
context?.previousTodos
)
},
onSettled: () => {
// 6. 성공/실패 무관하게 서버 데이터로 최종 동기화
queryClient.invalidateQueries(todoQueries.all())
},
})
}각 콜백의 역할을 정리하면 다음과 같습니다.
| 콜백 | 시점 | 역할 |
|---|---|---|
onMutate |
뮤테이션 시작 직전 | 리페치 취소 → 스냅샷 저장 → 낙관적 캐시 업데이트 |
onSuccess |
서버 요청 성공 시 | 임시 ID를 서버 응답 ID로 교체 |
onError |
서버 요청 실패 시 | context의 스냅샷으로 캐시 원복 |
onSettled |
성공/실패 관계없이 | invalidateQueries로 서버 데이터 재동기화 |
setQueryData에서 old의 타입이 Todo[] | undefined인 점도 주목해두세요. 캐시에 데이터가 아직 없을 수 있기 때문에 ...(old ?? [])로 방어적으로 처리하는 것이 런타임 안전성 면에서 맞습니다.
이번엔 여러 컴포넌트가 낙관적 UI를 동시에 공유해야 하는 상황으로 넘어가 봅니다.
예시 2: Zustand + TanStack Query 역할 분담 패턴
헤더의 장바구니 아이콘 뱃지와 사이드 패널의 장바구니 목록이 동시에 업데이트되어야 하는 상황을 생각해보세요. TQ 캐시만으로도 할 수 있지만, 여러 컴포넌트 트리에 즉각적인 UI 반응을 퍼뜨려야 할 때는 Zustand가 더 어울립니다.
한 가지 먼저 짚고 넘어갈 점이 있습니다. 아래 코드의 optimisticItems는 서버 데이터의 복사본이 아닙니다. 뮤테이션이 진행되는 동안에만 존재하는 임시 낙관적 상태입니다. 서버에서 응답이 오면 onSettled에서 이 상태를 초기화하고, 이후로는 TQ 캐시의 서버 데이터가 진실의 원천이 됩니다.
// store/cartStore.ts
import { create } from 'zustand'
import type { CartItem } from '../types/cart'
interface CartStore {
optimisticItems: CartItem[] // 서버 확인 전 임시 항목 (서버 데이터 복사본이 아님)
isOptimisticUpdating: boolean
addItemOptimistic: (item: CartItem) => void
revertAddItem: (itemId: string) => void
settleOptimistic: () => void // 뮤테이션 완료 후 낙관적 상태 초기화
}
const useCartStore = create<CartStore>((set) => ({
optimisticItems: [],
isOptimisticUpdating: false,
addItemOptimistic: (item) =>
set((state) => ({
optimisticItems: [...state.optimisticItems, item],
isOptimisticUpdating: true,
})),
revertAddItem: (itemId) =>
set((state) => ({
optimisticItems: state.optimisticItems.filter((i) => i.id !== itemId),
isOptimisticUpdating: false,
})),
settleOptimistic: () =>
set({ optimisticItems: [], isOptimisticUpdating: false }),
}))// hooks/useCartMutation.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useCartStore } from '../store/cartStore'
import type { CartItem } from '../types/cart'
function useAddToCart() {
const { addItemOptimistic, revertAddItem, settleOptimistic } = useCartStore()
const queryClient = useQueryClient()
return useMutation({
mutationFn: (item: CartItem) => api.addToCart(item),
onMutate: (item) => {
// Zustand로 즉각 UI 반영 — 모든 구독 컴포넌트에 즉시 전달됩니다
addItemOptimistic(item)
return { itemId: item.id }
},
onError: (_err, _item, context) => {
// 실패 시 낙관적으로 추가된 항목 제거
if (context) revertAddItem(context.itemId)
},
onSettled: () => {
// 낙관적 상태 초기화 — 이 시점부터 TQ 서버 데이터가 진실의 원천
settleOptimistic()
queryClient.invalidateQueries({ queryKey: ['cart'] })
},
})
}컴포넌트에서는 isOptimisticUpdating 상태에 따라 어떤 데이터를 보여줄지 결정할 수 있습니다.
// CartDisplay.tsx
import { useQuery } from '@tanstack/react-query'
import { useCartStore } from '../store/cartStore'
function CartDisplay() {
const { data: serverCart } = useQuery({ queryKey: ['cart'], queryFn: fetchCart })
const { optimisticItems, isOptimisticUpdating } = useCartStore()
// 낙관적 업데이트 중: 서버 데이터 + 낙관적 항목을 합쳐 보여줌
// 완료 후: TQ 서버 데이터만 사용
const displayItems = isOptimisticUpdating
? [...(serverCart?.items ?? []), ...optimisticItems]
: serverCart?.items ?? []
return <CartList items={displayItems} />
}이 패턴은 의도적으로 Zustand와 TQ 두 곳에 관련 데이터가 잠깐 공존하는 구조입니다. 뮤테이션이 완료되는 순간 Zustand 상태가 초기화되기 때문에 장기적인 이중 진실 문제는 생기지 않습니다.
여기까지 기본 낙관적 업데이트 패턴을 살펴봤습니다. 이제 좀 더 까다로운 케이스로 넘어가 보겠습니다.
심화: 동시 뮤테이션 경쟁 조건 처리
사용자가 빠르게 여러 번 할 일을 추가하는 상황을 생각해봅니다. 첫 번째 뮤테이션이 onSettled에서 invalidateQueries를 실행해 리페치를 트리거하는 사이, 두 번째 뮤테이션이 낙관적으로 업데이트해놓은 캐시를 그 리페치 결과가 덮어쓰는 경우가 생길 수 있습니다.
"그럼 refetchOnWindowFocus: !queryClient.isMutating(...)를 쓰면 되지 않나?" 싶을 수 있는데, 이 방법은 의도대로 동작하지 않습니다. refetchOnWindowFocus는 useQuery가 실행되는 시점에 한 번만 평가되는 정적 옵션이기 때문에, 뮤테이션 시작·종료에 따라 자동으로 재평가되지 않습니다. isMutating()은 그 렌더 시점의 스냅샷을 반환할 뿐입니다.
TkDodo가 실제로 권장하는 접근은 queryClient.setMutationDefaults로 뮤테이션 전역 기본값을 설정하고, onSettled에서 아직 진행 중인 다른 뮤테이션이 있는지 확인한 뒤 마지막 뮤테이션만 재검증을 실행하는 방식입니다.
// QueryClient 설정 시 todos 뮤테이션 전역 기본값 설정
// (보통 앱 초기화 파일 또는 QueryClient 생성 직후에 작성합니다)
queryClient.setMutationDefaults(['todos'], {
onMutate: async () => {
// 어떤 todos 뮤테이션이든 시작 전에 진행 중인 리페치를 취소
await queryClient.cancelQueries(todoQueries.all())
},
onSettled: async () => {
// 다른 todos 뮤테이션이 아직 진행 중이라면 재검증을 미룹니다
// 마지막 뮤테이션이 끝날 때 한 번만 서버 동기화 — 불필요한 리페치를 줄입니다
if (!queryClient.isMutating({ mutationKey: ['todos'] })) {
await queryClient.invalidateQueries(todoQueries.all())
}
},
})setMutationDefaults로 공통 로직을 중앙화하면 개별 useMutation마다 같은 onMutate/onSettled 코드를 반복하지 않아도 됩니다. 뮤테이션 종류가 많아질수록 이 패턴의 가치가 커집니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 명확한 책임 분리 | 서버 상태와 UI 상태가 섞이지 않아 예측 가능성이 높고 디버깅이 쉬워집니다 |
| 번들 크기 절감 | Redux 생태계 대비 코드량과 의존성이 크게 줄어듭니다 |
| 자동 캐시 무효화 | invalidateQueries 한 줄로 관련 쿼리 전체가 최신화됩니다 |
| 타입 안전성 | queryOptions 팩토리로 쿼리 키와 데이터 타입을 한 곳에서 관리할 수 있습니다 |
| 즉각적 UX | 낙관적 업데이트로 사용자가 네트워크 지연을 체감하지 못합니다 |
| 개발자 도구 | React Query DevTools로 캐시 상태를 실시간으로 시각화할 수 있습니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 동시 뮤테이션 경쟁 조건 | 두 뮤테이션이 동시에 실행될 때 리페치가 낙관적 상태를 덮어쓸 수 있음 | setMutationDefaults + isMutating 체크로 마지막 뮤테이션만 재검증 |
| 롤백 복잡성 | 중첩된 데이터 구조에서 정확한 롤백 구현이 어려움 | Immer 미들웨어 활용, 스냅샷 전략을 미리 설계 |
| 이중 진실 위험 | Zustand에 서버 데이터를 복사하는 실수 발생 가능 | 팀 내 코딩 규칙 + 코드 리뷰로 방지 |
| 임시 ID 관리 | temp-xxx 방식의 임시 ID를 서버 ID로 교체하는 로직 누락 |
onSuccess에서 서버 응답 ID로 캐시 교체하는 로직 필수 |
| 학습 곡선 | 두 라이브러리의 개념과 역할 경계를 이해하는 데 시간이 필요함 | 공식 문서와 TkDodo 블로그를 함께 읽어보시면 도움이 됩니다 |
경쟁 조건(Race Condition)이란? 두 개 이상의 비동기 작업이 동시에 실행될 때 실행 순서에 따라 결과가 달라지는 상황입니다. 낙관적 업데이트에서는 첫 번째 뮤테이션의 리페치가 두 번째 뮤테이션의 낙관적 캐시를 덮어쓰는 형태로 주로 나타납니다.
실무에서 가장 흔한 실수
-
onMutate에서cancelQueries를 빠뜨리는 경우: 백그라운드 리페치가 낙관적 업데이트를 즉시 덮어써서 UI가 잠깐 깜빡이는 현상이 생깁니다.onMutate시작 시await queryClient.cancelQueries()를 반드시 호출하세요. -
Zustand에 API 응답 데이터를 복사해 저장하는 경우: 서버 데이터는 TanStack Query가 이미 캐시하고 있습니다. Zustand에 복사본을 두면 두 곳의 데이터가 언제 어디서 달라질지 예측하기 어려워집니다. Zustand는 모달, 선택 항목, 필터 같은 순수 UI 상태만 담아두는 것이 좋습니다.
-
낙관적으로 생성한 항목의 임시 ID를 서버 ID로 교체하지 않는 경우:
temp-1234같은 임시 ID가 서버 응답 후에도 남아 있으면 이후 수정·삭제 뮤테이션이 실패합니다. 예시 1에서 보여드린 것처럼onSuccess에서 서버 응답 ID로 캐시를 교체하는 로직이 필수입니다.
마치며
TanStack Query는 서버 상태, Zustand는 UI 상태 — 이 단 하나의 원칙만 지켜도 상태 관리 버그의 절반은 사라집니다.
Apollo Client에서 이 아키텍처로 전환한 팀들이 번들 크기와 초기 로딩 속도에서 의미 있는 성과를 보고하는 이유는 단순합니다. 무거운 캐시 레이어를 걷어내고 두 라이브러리의 책임을 명확히 나눴을 때, "이 데이터가 지금 어디서 와야 하지?"라는 질문에 즉시 답할 수 있게 됩니다. 그 명확함이 결국 개발 속도와 코드베이스 안정성을 동시에 끌어올립니다.
지금 바로 시작해볼 수 있는 3단계:
-
현재 Zustand 스토어를 열어서 각 상태 조각에 "서버에서 왔나, 아니면 UI 전용인가?"를 태깅해보세요. 서버에서 온 것들이 TanStack Query로 이전할 후보입니다.
-
queries/디렉토리를 만들고 도메인별로queryOptions팩토리를 정의해보세요.pnpm add @tanstack/react-query로 설치한 뒤 시작하면 됩니다. -
가장 자주 쓰는 뮤테이션 하나에
onMutate→onSuccess→onError→onSettled패턴을 적용해보세요. 할 일 추가, 좋아요, 장바구니 담기 중 하나를 골라 이 글의 예시 1 패턴을 그대로 옮겨보시면, 나머지 뮤테이션은 같은 패턴의 반복이라는 걸 금방 체감하실 수 있을 겁니다.
더 깊이 알아보고 싶다면: TkDodo의 Mastering Mutations in React Query와 Concurrent Optimistic Updates in React Query가 이 글에서 다룬 내용을 훨씬 세밀하게 파고듭니다. 공식 문서보다 실무 중심으로 쓰여 있어 읽어보시길 추천합니다.
다음 글: TanStack Query
suspense모드와 Error Boundary를 함께 활용해 비동기 UI의 로딩·에러·성공 상태를 선언적으로 분리하는 방법
참고 자료
- Optimistic Updates | TanStack Query v5 공식 문서
- useMutation Reference | TanStack Query v5 공식 문서
- Concurrent Optimistic Updates in React Query | TkDodo 블로그
- Mastering Mutations in React Query | TkDodo 블로그
- Federated State Done Right: Zustand, TanStack Query, and the Patterns That Actually Work | DEV Community
- Building Lightning-Fast UIs: Implementing Optimistic Updates with React Query and Zustand | Medium
- How We Cut 70% Bundle Size: The TanStack Query + Zustand Architecture at GLINCKER | Medium
- Why queryOptions Will Change How You Use TanStack Query | Medium
- Optimistic Updates in Tanstack Query v5 | Medium
- Redux vs TanStack Query & Zustand in 2025 | Bugra Gulculer
- Mutations | TanStack DB 공식 문서
- Zustand 공식 저장소 | GitHub