TanStack Query 낙관적 업데이트: onMutate로 즉시 반영하고 onError로 자동 롤백하는 법
이 글은 TanStack Query의
useQuery와useMutation기본 사용법을 알고 있는 개발자를 대상으로 합니다.
데이터 그리드에서 셀 하나를 수정했는데, 화면이 아무 반응 없이 멈춰 있다가 0.5초 후에 갱신되는 경험을 해보신 적 있으신가요? 저도 처음 관리자 대시보드를 만들 때 이게 꽤 걸렸습니다. 기술적으로는 틀린 게 없는데, 실제로 써보면 뭔가 느리고 답답한 느낌이 드는 거죠. 사용자 입장에서는 "내가 클릭한 게 반영된 건지 아닌지" 순간적으로 헷갈립니다.
이 문제를 해결하는 패턴이 바로 **낙관적 업데이트(Optimistic Update)**입니다. 서버 응답을 기다리지 않고 UI를 먼저 바꿔버린 다음, 실패하면 되돌리는 방식인데요. TanStack Query의 onMutate + onError 조합이 이 패턴을 구현하는 가장 깔끔한 방법입니다. 이 패턴 하나로 코드 몇 줄만 추가해서 사용자가 체감하는 응답속도를 즉시 끌어올릴 수 있습니다.
핵심 개념
낙관적 업데이트란 무엇인가
이름 그대로 "잘 될 거라고 낙관하고 먼저 업데이트"하는 패턴입니다. 대부분의 사용자 액션은 실제로 성공합니다. 네트워크가 멀쩡하고 서버도 살아있다면 좋아요 버튼 하나 누르는 게 실패할 확률은 극히 낮죠. 그렇다면 성공을 가정하고 UI를 먼저 바꾸고, 만약 실패하면 그때 되돌리면 됩니다.
이 흐름을 TanStack Query의 세 가지 생명주기 콜백이 자연스럽게 담당합니다.
| 콜백 | 실행 시점 | 역할 |
|---|---|---|
onMutate |
뮤테이션 함수 실행 전 | 진행 중인 refetch 취소 → 현재 상태 스냅샷 → 캐시 즉시 업데이트 → context 반환 |
onError |
뮤테이션 실패 시 | context로 캐시를 이전 상태로 복원(롤백) |
onSettled |
성공/실패 모두 | 쿼리 무효화(invalidate)로 서버 상태와 동기화 |
context가 롤백의 핵심
솔직히 처음 이 패턴을 접했을 때 "왜 onMutate가 뭔가를 return하지?"라고 한참 고민했습니다. 그 반환값이 바로 context입니다.
context:
onMutate가 반환한 값이 TanStack Query 내부적으로 보존되었다가,onError와onSettled의 마지막 인자로 자동 전달되는 객체. 전역 상태 없이도 롤백에 필요한 이전 데이터를 안전하게 전달할 수 있습니다.
한 가지 더 알아두면 좋은 게 있는데요. onMutate가 반환하는 context의 타입은 Context | undefined입니다. 즉, onError에서 받는 세 번째 인자가 undefined일 수 있어서, 이후 예시 코드에서 context?.snapshot처럼 옵셔널 체이닝을 쓰는 건 이 때문입니다. onMutate가 실행되기 전에 에러가 났거나 context를 반환하지 않은 경우를 방어하는 거죠.
const mutation = useMutation({
mutationFn: updateRowData,
onMutate: async (newData) => {
// 진행 중인 refetch가 낙관적 업데이트를 덮어쓰지 않도록 취소
await queryClient.cancelQueries({ queryKey: ['grid-data'] });
// 롤백을 위해 현재 상태 스냅샷
const previousData = queryClient.getQueryData(['grid-data']);
// 캐시를 즉시 업데이트 → 그리드가 바로 반영됨
queryClient.setQueryData(['grid-data'], (old: Row[] = []) =>
old.map((row) =>
row.id === newData.id ? { ...row, ...newData } : row
)
);
// context로 이전 상태 전달
return { previousData };
},
onError: (err, newData, context) => {
// context?.previousData로 캐시를 롤백 (context가 undefined일 수 있으므로 옵셔널 체이닝)
queryClient.setQueryData(['grid-data'], context?.previousData);
},
onSettled: () => {
// 성공이든 실패든 서버 상태와 최종 동기화
queryClient.invalidateQueries({ queryKey: ['grid-data'] });
},
});cancelQueries가 왜 필요한가
onMutate 첫 줄에 있는 cancelQueries가 빠지면 어떤 일이 벌어질까요? 백그라운드에서 refetch가 진행 중이었다면, 그 요청이 낙관적으로 업데이트된 캐시를 서버의 이전 데이터로 덮어쓸 수 있습니다. 즉시 반영한 UI가 0.3초 후에 다시 원래대로 돌아가버리는 것이죠. cancelQueries는 이 시나리오를 막아줍니다.
주의:
cancelQueries는 비동기 함수입니다.await를 빠뜨리면 취소 처리가 완료되기 전에 캐시 업데이트가 먼저 실행될 수 있습니다.
TanStack Query v5: 두 가지 패턴의 공존
v5부터는 캐시 기반 방식(onMutate) 외에 UI 변수 기반 방식도 공식 지원합니다. 코드량이 훨씬 줄어드는 대신, 적용 범위가 제한적입니다.
// v5 변수 기반 낙관적 업데이트
const { mutate, isPending, variables } = useMutation({ mutationFn: updateItem });
const displayData = isPending
? data.map((item) =>
item.id === variables.id ? { ...item, ...variables } : item
)
: data;| 패턴 | 적합한 상황 | 롤백 필요 여부 |
|---|---|---|
onMutate 캐시 기반 |
다수 컴포넌트가 같은 데이터를 공유하는 그리드, 대시보드 | 필요 (onError로 처리) |
변수 기반 (variables) |
단일 컴포넌트 내 간단한 토글, 좋아요 버튼 | 불필요 (뮤테이션 완료 시 자동 해소) |
실전 적용
이제 이 개념들을 실제 코드에 적용해보겠습니다.
예시 1: 데이터 그리드 인라인 편집
실무에서 제일 많이 마주치는 상황인데요. 관리자 화면의 테이블에서 셀을 클릭해 수정하면 즉시 반영되고, 저장 실패 시 해당 행만 원래 값으로 되돌아옵니다.
import { useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
// Row 타입: { id: string; [key: string]: unknown }
function useGridRowUpdate(tableId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (updatedRow: Row) => api.updateRow(tableId, updatedRow),
onMutate: async (updatedRow) => {
await queryClient.cancelQueries({ queryKey: ['rows', tableId] });
const snapshot = queryClient.getQueryData<Row[]>(['rows', tableId]);
queryClient.setQueryData<Row[]>(['rows', tableId], (rows = []) =>
rows.map((r) => (r.id === updatedRow.id ? updatedRow : r))
);
return { snapshot };
},
onError: (_err, _updatedRow, context) => {
// context가 undefined일 수 있으므로 옵셔널 체이닝 사용
queryClient.setQueryData(['rows', tableId], context?.snapshot);
toast.error('저장에 실패했습니다. 변경사항이 되돌아갑니다.');
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['rows', tableId] });
},
});
}| 코드 부분 | 역할 |
|---|---|
cancelQueries |
진행 중인 데이터 페칭이 낙관적 상태를 덮어쓰는 것 방지 |
getQueryData + snapshot |
롤백에 사용할 이전 데이터 보존 |
setQueryData |
그리드가 즉시 반영되도록 캐시 직접 업데이트 |
toast.error |
롤백 발생 시 사용자에게 명확한 피드백 제공 |
invalidateQueries |
서버 최신 상태와 최종 동기화 |
예시 2: 소셜 좋아요 버튼
이건 좀 더 섬세하게 다뤄야 하는 케이스입니다. 카운트와 토글 상태를 동시에 업데이트해야 하거든요. 단일 컴포넌트에서만 표시된다면 v5의 변수 기반 방식이 더 간단하지만, 같은 게시물의 좋아요 수가 여러 곳에 노출된다면 캐시 기반이 적합합니다.
// Post 타입: { id: string; liked: boolean; likeCount: number; [key: string]: unknown }
function useLikePost() {
const queryClient = useQueryClient();
return useMutation({
// isCurrentlyLiked: 현재 좋아요가 눌려있는 상태인지 여부 (누를 예정인 상태가 아님)
mutationFn: ({ postId, isCurrentlyLiked }: { postId: string; isCurrentlyLiked: boolean }) =>
isCurrentlyLiked ? api.unlikePost(postId) : api.likePost(postId),
onMutate: async ({ postId, isCurrentlyLiked }) => {
await queryClient.cancelQueries({ queryKey: ['post', postId] });
const previousPost = queryClient.getQueryData<Post>(['post', postId]);
queryClient.setQueryData<Post>(['post', postId], (old) => {
if (!old) return old;
return {
...old,
liked: !isCurrentlyLiked,
likeCount: isCurrentlyLiked ? old.likeCount - 1 : old.likeCount + 1,
};
});
return { previousPost };
},
onError: (_err, { postId }, context) => {
queryClient.setQueryData(['post', postId], context?.previousPost);
},
onSettled: (_data, _err, { postId }) => {
queryClient.invalidateQueries({ queryKey: ['post', postId] });
},
});
}예시 3: Drag & Drop 순서 변경 (신중한 설계가 필요한 케이스)
드래그로 항목 순서를 바꿀 때도 낙관적 업데이트를 적용할 수 있습니다. 다만 서버가 순서에 제약을 두거나(예: 우선순위 규칙), 다른 사용자가 동시에 순서를 변경하는 환경이라면 롤백 후 혼란을 줄 수 있어 신중하게 설계해야 합니다.
// onMutate만 발췌 — onError와 onSettled는 앞선 예시와 동일한 구조
onMutate: async ({ draggedId, targetIndex }: { draggedId: string; targetIndex: number }) => {
await queryClient.cancelQueries({ queryKey: ['items'] });
const previousItems = queryClient.getQueryData<Item[]>(['items']);
queryClient.setQueryData<Item[]>(['items'], (old = []) => {
const reordered = [...old];
const draggedIndex = reordered.findIndex((i) => i.id === draggedId);
const [removed] = reordered.splice(draggedIndex, 1);
reordered.splice(targetIndex, 0, removed);
return reordered;
});
return { previousItems };
},장단점 분석
세 가지 예시를 보고 나니 이 패턴이 꽤 강력하다는 게 느껴지실 텐데요. 그렇다면 언제 쓰지 말아야 할까요?
장점
| 항목 | 내용 |
|---|---|
| 체감 속도 향상 | 즉각적인 UI 피드백으로 체감 응답속도 최대 40% 개선 효과 |
| UX 연속성 | 네트워크 지연이 있어도 인터랙션이 끊기지 않음 |
| 캐시 일관성 | setQueryData로 여러 컴포넌트에 동시 반영 가능 |
| 자동화된 롤백 | context 패턴으로 별도 전역 상태 없이 롤백 구현 |
| 코드 집중도 | 낙관적 업데이트 로직이 뮤테이션 한 곳에 집약됨 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 클라이언트-서버 불일치 | 일시적으로 실제 서버 상태와 캐시가 달라짐 | onSettled에서 invalidateQueries로 항상 동기화 |
| Race Condition | 빠른 연속 조작 시 여러 뮤테이션이 충돌할 수 있음 | 뮤테이션 직렬화 또는 디바운싱 적용 |
| 서버 로직 중복 | 서버의 데이터 변환 결과를 클라이언트가 예측해야 함 | 서버와 클라이언트의 로직 차이를 최소화하도록 API 설계 |
| 롤백 UX 설계 | 알림 없는 롤백은 사용자에게 불쾌한 경험 | onError에 반드시 Toast/Alert 피드백 추가 |
Race Condition이란: 여러 비동기 작업이 순서 보장 없이 진행될 때 예기치 않은 결과가 발생하는 현상입니다.
cancelQueries만으로는 완전히 해결되지 않는 케이스가 있어 TanStack Query GitHub Discussion #7932에서도 활발히 논의 중입니다. 해당 토론의 현재 결론은 "복수 뮤테이션 직렬화 또는 디바운싱이 가장 현실적인 해결책"이며, 라이브러리 차원의 공식 해결책은 아직 논의 중입니다.
뮤테이션 직렬화: 동시에 여러 뮤테이션이 실행되지 않도록 순서를 보장하는 기법. 버튼 비활성화나
isPending상태를 활용해 다음 요청을 이전 요청이 완료된 후에만 허용하는 방식으로 적용할 수 있습니다.
실무에서 가장 흔한 실수
저도 처음엔 이걸 놓쳐서 한참 헤맸는데, 실무에서 실제로 자주 보이는 실수들입니다.
cancelQueries를await없이 호출하는 것 —await를 빠뜨리면 취소가 완료되기 전에 캐시 업데이트가 실행되어, 이후 완료된 refetch가 낙관적 상태를 덮어쓸 수 있습니다. 직접 겪기 전까지는 증상이 워낙 간헐적으로 나타나서 원인을 찾기가 꽤 어렵습니다.- 롤백 후 사용자 알림을 생략하는 것 — 화면이 갑자기 이전 상태로 돌아가는데 아무 메시지가 없으면, 사용자는 버그라고 느끼거나 자신의 액션이 무시됐다고 혼란을 겪습니다.
onError에는 항상 명확한 피드백이 함께 있어야 합니다. - 삭제 작업이나 결제 트랜잭션에 낙관적 업데이트를 적용하는 것 — 항목이 목록에서 즉시 사라졌다가 실패 후 다시 나타나면 굉장히 어색합니다. 결제·금전 트랜잭션, 실패율이 높은 작업도 마찬가지로 낙관적 업데이트가 오히려 독이 될 수 있습니다. 이런 경우엔 서버 응답을 기다리는 게 훨씬 낫습니다.
마치며
이 패턴을 실무에 적용하고 나서 가장 크게 달라진 건, 사용자 불만보다 오히려 제 코드에 대한 자신감이었습니다. 롤백이 자동화되니 "실패하면 어쩌지"라는 불안 없이 UI를 과감하게 먼저 움직일 수 있게 됐거든요. onMutate에서 스냅샷을 찍고 context로 전달하면, onError가 전역 상태 없이도 언제든 이전 상태로 안전하게 복원해줍니다.
지금 바로 시작해볼 수 있는 3단계를 공유합니다:
- 현재 프로젝트에서 사용자가 자주 클릭하는 뮤테이션 하나를 골라
onMutate콜백을 추가해보시면 좋습니다.await queryClient.cancelQueries(...)→getQueryData로 스냅샷 →setQueryData로 즉시 반영 순서입니다. onError에queryClient.setQueryData(key, context?.snapshot)한 줄로 롤백을 연결한 뒤, 네트워크 탭에서 요청을 강제 실패시켜 롤백이 동작하는지 확인해볼 수 있습니다.- 단일 컴포넌트에서만 표시되는 간단한 토글이라면, v5의
variables기반 방식으로 더 적은 코드로 같은 효과를 낼 수 있습니다. 반면 같은 데이터를 여러 컴포넌트가 동시에 보여주거나, 롤백 후 사용자에게 명확한 알림이 필요한 경우라면onMutate캐시 기반이 더 적합합니다. 두 패턴의 차이를 직접 비교해보시면 판단 기준이 자연스럽게 잡힙니다.
참고 자료
- Optimistic Updates | TanStack Query v5 공식 문서
- Optimistic Updates Cache 예제 | TanStack Query v5
- Optimistic Updates UI 예제 | TanStack Query v5
- Mutations | TanStack DB 공식 문서
- TanStack DB Beta 출시 소식 | InfoQ
- Concurrent Optimistic Updates in React Query | Tkdodo 블로그
- Optimistic Updates in TanStack Query v5 | Medium
- Building a Data Table with Optimistic Updates | DEV Community
- Automatic Rollback with Normalized Data | DEV Community
- Why I Never Use Optimistic Updates | DEV Community
- How Optimistic Updates Make Apps Feel Faster | OpenReplay Blog
- How to Use useOptimistic Hook in React | FreeCodeCamp
- Race Condition with cancelQueries | GitHub Discussion