effidevFlutter · Cloudflare 엣지 · 클라우드 비용 최적화
한국어

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

TanStack Query v5 낙관적 업데이트와 오프라인 캐싱 아키텍처 다이어그램

현대 웹 애플리케이션에서 사용자 경험(UX)은 애플리케이션의 성공을 좌우하는 핵심 요소입니다. 사용자가 버튼을 클릭했을 때 로딩 스피너를 보여주는 대신, 즉각적으로 UI가 반응한다면 얼마나 좋을까요?

이번 글에서는 TanStack Query v5 (이전 React Query)를 활용하여 지연 없는 경험을 제공하는 **낙관적 업데이트(Optimistic Updates)**를 구현하는 방법과, 네트워크 연결이 끊어졌을 때를 대비한 오프라인 캐싱 패턴을 심도 있게 다뤄봅니다.

1. 낙관적 업데이트(Optimistic Updates)란?

낙관적 업데이트는 서버의 응답을 기다리지 않고, 요청이 성공할 것이라고 낙관적으로 가정하여 UI를 먼저 업데이트하는 기법입니다.

TanStack Query v5에서는 useMutation 훅의 라이프사이클 콜백(onMutate, onError, onSettled)을 통해 이 롤백 메커니즘을 우아하게 처리할 수 있습니다.

[!NOTE] v5에서는 onMutate에서 반환된 컨텍스트 객체가 onErroronSettled로 전달되어 롤백에 사용됩니다.

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] });
    },
  });
}

핵심 포인트 분석

  1. cancelQueries: 낙관적 업데이트가 서버에서 넘어오는 예전 데이터로 덮어씌워지는 것을 방지합니다.
  2. previousPost 스냅샷: 캐시를 수정하기 전의 원본 상태를 저장합니다.
  3. 컨텍스트 반환: onMutate에서 반환한 값은 onError의 세 번째 인자로 전달되어 롤백 시 활용됩니다.

3. 오프라인 우선주의(Offline First)와 캐싱 패턴

오프라인 상태이거나 네트워크가 불안정할 때, 사용자가 빈 화면을 보지 않게 하려면 적절한 캐싱 전략이 필요합니다. TanStack Query v5는 기본적으로 강력한 오프라인 대응 메커니즘을 제공합니다.

3.1 Network Mode 설정

TanStack Query의 기본 networkModeonline입니다. 이는 네트워크가 연결되지 않은 상태에서는 쿼리나 뮤테이션을 실행하지 않고 대기(Pause) 상태로 둔다는 뜻입니다.

오프라인 우선 앱을 개발한다면 offlineFirst 설정을 활용해 캐싱된 데이터를 보여주면서 재연결 시 동기화를 시도하는 것이 좋습니다.

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      networkMode: 'offlineFirst',
      staleTime: 1000 * 60 * 5, // 5분
    },
  },
});

3.2 Persister를 이용한 영구 캐싱

브라우저를 새로고침하거나 닫았다가 열었을 때도 데이터를 유지하려면 Persister 플러그인이 필요합니다. v5에서는 @tanstack/query-sync-storage-persisteridb-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가 실행되면 이전 상태가 중간 상태로 오염될 수 있습니다.

해결책:

문제점 원인 해결 패턴
롤백 실패 onMutate 실행 전 캐시 스냅샷 누락 반드시 queryClient.getQueryData 반환값을 컨텍스트로 전달
데이터 덮어쓰기 진행 중인 GET 요청이 POST 후 도달 queryClient.cancelQueries를 onMutate 최상단에 배치
목록 추가 시 깜빡임 임시 ID와 서버 ID 불일치 서버 응답으로 온 객체로 임시 객체를 치환 (map 활용)

요약