TanStack Query v5 (React Query) 실전: 낙관적 업데이트(Optimistic Updates)와 오프라인 캐싱 패턴

현대 웹 애플리케이션에서 사용자 경험(UX)은 애플리케이션의 성공을 좌우하는 핵심 요소입니다. 사용자가 버튼을 클릭했을 때 로딩 스피너를 보여주는 대신, 즉각적으로 UI가 반응한다면 얼마나 좋을까요?
이번 글에서는 TanStack Query v5 (이전 React Query)를 활용하여 지연 없는 경험을 제공하는 **낙관적 업데이트(Optimistic Updates)**를 구현하는 방법과, 네트워크 연결이 끊어졌을 때를 대비한 오프라인 캐싱 패턴을 심도 있게 다뤄봅니다.
1. 낙관적 업데이트(Optimistic Updates)란?
낙관적 업데이트는 서버의 응답을 기다리지 않고, 요청이 성공할 것이라고 낙관적으로 가정하여 UI를 먼저 업데이트하는 기법입니다.
- 장점: 사용자는 네트워크 지연(Latency)을 느끼지 못하며 앱이 매우 빠르다고 느낍니다.
- 단점: 서버 요청이 실패했을 때, UI를 이전 상태(롤백)로 복구해야 하는 복잡성이 있습니다.
TanStack Query v5에서는 useMutation 훅의 라이프사이클 콜백(onMutate, onError, onSettled)을 통해 이 롤백 메커니즘을 우아하게 처리할 수 있습니다.
[!NOTE] v5에서는
onMutate에서 반환된 컨텍스트 객체가onError와onSettled로 전달되어 롤백에 사용됩니다.
2. TanStack Query v5로 낙관적 업데이트 구현하기
간단한 ‘좋아요(Like)’ 토글 기능을 예시로 살펴보겠습니다.
import { useMutation, useQueryClient } from '@tanstack/react-query';
// 포스트 타입 정의
interface Post {
id: string;
title: string;
likes: number;
}
export function useToggleLike() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (postId: string) => fetch(`/api/posts/${postId}/like`, { method: 'POST' }),
// onMutate: 뮤테이션 함수가 실행되기 전에 호출됨
onMutate: async (newPostId) => {
// 1. 진행 중인 리패치를 취소하여 낙관적 업데이트를 덮어쓰지 않도록 함
await queryClient.cancelQueries({ queryKey: ['posts', newPostId] });
// 2. 에러 발생 시 복구를 위해 이전 상태 스냅샷 저장
const previousPost = queryClient.getQueryData<Post>(['posts', newPostId]);
// 3. 낙관적으로 캐시 업데이트
if (previousPost) {
queryClient.setQueryData<Post>(['posts', newPostId], {
...previousPost,
likes: previousPost.likes + 1,
});
}
// 4. 에러 발생 시 사용할 컨텍스트 반환
return { previousPost };
},
// onError: 뮤테이션 실패 시 onMutate에서 반환한 컨텍스트로 롤백
onError: (err, newPostId, context) => {
if (context?.previousPost) {
queryClient.setQueryData(['posts', newPostId], context.previousPost);
}
},
// onSettled: 성공이든 실패든 뮤테이션이 완료되면 캐시 무효화로 최신화
onSettled: (newPostId) => {
queryClient.invalidateQueries({ queryKey: ['posts', newPostId] });
},
});
}
핵심 포인트 분석
cancelQueries: 낙관적 업데이트가 서버에서 넘어오는 예전 데이터로 덮어씌워지는 것을 방지합니다.previousPost스냅샷: 캐시를 수정하기 전의 원본 상태를 저장합니다.- 컨텍스트 반환:
onMutate에서 반환한 값은onError의 세 번째 인자로 전달되어 롤백 시 활용됩니다.
3. 오프라인 우선주의(Offline First)와 캐싱 패턴
오프라인 상태이거나 네트워크가 불안정할 때, 사용자가 빈 화면을 보지 않게 하려면 적절한 캐싱 전략이 필요합니다. TanStack Query v5는 기본적으로 강력한 오프라인 대응 메커니즘을 제공합니다.
3.1 Network Mode 설정
TanStack Query의 기본 networkMode는 online입니다. 이는 네트워크가 연결되지 않은 상태에서는 쿼리나 뮤테이션을 실행하지 않고 대기(Pause) 상태로 둔다는 뜻입니다.
online(기본값): 네트워크가 연결될 때까지 요청 일시 정지always: 네트워크 상태와 무관하게 무조건 캐시 갱신 및 페칭 시도offlineFirst: 기본적으로 오프라인 캐시를 우선 사용하되, 백그라운드 동기화 수행
오프라인 우선 앱을 개발한다면 offlineFirst 설정을 활용해 캐싱된 데이터를 보여주면서 재연결 시 동기화를 시도하는 것이 좋습니다.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
networkMode: 'offlineFirst',
staleTime: 1000 * 60 * 5, // 5분
},
},
});
3.2 Persister를 이용한 영구 캐싱
브라우저를 새로고침하거나 닫았다가 열었을 때도 데이터를 유지하려면 Persister 플러그인이 필요합니다. v5에서는 @tanstack/query-sync-storage-persister나 idb-keyval (IndexedDB) 기반의 비동기 Persister를 주로 사용합니다.
IndexedDB를 활용한 캐시 영속화 예시:
import { QueryClient } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
import { get, set, del } from 'idb-keyval';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
cacheTime: 1000 * 60 * 60 * 24, // 24시간 동안 캐시 유지
},
},
});
const indexedDBPersister = createAsyncStoragePersister({
storage: {
getItem: async (key) => await get(key),
setItem: async (key, value) => await set(key, value),
removeItem: async (key) => await del(key),
},
});
// App 최상단에서 Provider로 감싸기
function App() {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister: indexedDBPersister }}
>
<YourApp />
</PersistQueryClientProvider>
);
}
4. 실전 엣지 케이스 및 아키텍처 결정
낙관적 업데이트와 오프라인 캐싱을 실무에 적용할 때는 몇 가지 주의할 엣지 케이스가 있습니다.
4.1 연속적인 낙관적 업데이트 충돌
사용자가 ’좋아요’를 빠르게 여러 번 누르는 경우를 생각해 보세요. 매번 onMutate가 실행되면 이전 상태가 중간 상태로 오염될 수 있습니다.
해결책:
- UI 측에서 디바운싱(Debouncing) 적용
- 캐시 업데이트 전 로컬에 업데이트 큐를 만들어 관리
- 배열형 데이터(예: Todo 추가)의 경우 임시 ID(
uuid)를 발급하고 서버 응답 후 실제 ID로 교체
| 문제점 | 원인 | 해결 패턴 |
|---|---|---|
| 롤백 실패 | onMutate 실행 전 캐시 스냅샷 누락 | 반드시 queryClient.getQueryData 반환값을 컨텍스트로 전달 |
| 데이터 덮어쓰기 | 진행 중인 GET 요청이 POST 후 도달 | queryClient.cancelQueries를 onMutate 최상단에 배치 |
| 목록 추가 시 깜빡임 | 임시 ID와 서버 ID 불일치 | 서버 응답으로 온 객체로 임시 객체를 치환 (map 활용) |
요약
- 낙관적 업데이트는 사용자가 기다림 없이 즉각적인 반응을 느끼게 해 UX를 획기적으로 향상시킵니다.
onMutate에서 스냅샷을 저장하고onError에서 롤백하는 패턴을 일관되게 유지하세요.- 오프라인 우선주의를 위해
offlineFirst네트워크 모드와 IndexedDB 기반의 Persister를 결합하여 견고한 오프라인 웹 앱을 구축할 수 있습니다. - 연속적인 이벤트나 ID 관리 등 실무 엣지 케이스를 사전에 설계하여 데이터 정합성을 유지해야 합니다.