TanStack Query Todo 실습: 서버 연결·낙관적 업데이트·실패 롤백

··설명 약 10분·작성: 해비·블로그 소개

TanStack Query로 Todo를 낙관적으로 갱신한 뒤 저장에 실패하면 어떻게 복구하나요?

낙관적 업데이트는 서버 성공을 기다리기 전에 Query 캐시를 바꿔 먼저 결과를 보여주는 방식입니다. 요청 전 값을 보관하고 실패하면 그 값으로 되돌리며 성공하면 확정 결과와 맞춥니다.

적용 조건·범위: 실행 환경은 React·TypeScript와 @tanstack/react-query 5.104.0입니다. React 환경이 없다면 React Vite 사용법 Vite 8 프로젝트 생성 실행 빌드를, 라이브러리 설치는 TanStack Query Provider와 첫 useQuery 상태 처리 실습 가이드의 설치 항목을 확인합니다. 아래는 별도 Vite 연습 앱 src/App.tsx 전체 내용입니다. src/main.tsx는 유지하며 이전 TaskFlow나 API 서버는 필요하지 않습니다. 요청 함수는 이 파일 안에 정의된 비동기 모형입니다.

본문 근거: tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates

이 기술만 따로 연습하기

이 개념 직접 실행

버튼을 조작하고 로딩·결과·캐시의 변화를 확인합니다.

예제 크게 보기 (새 탭)

기대 결과: 실패시키기를 켠 상태에서 전환하면 즉시 완료 → 약 0.7초 뒤 미완료로 복구됩니다. 실패시키기를 끈 뒤 전환하면 완료 상태가 유지됩니다.

직접 실행: 환경 준비와 전체 파일

실행 환경은 React·TypeScript와 @tanstack/react-query 5.104.0입니다. React 환경이 없다면 React Vite 사용법 Vite 8 프로젝트 생성 실행 빌드를, 라이브러리 설치는 TanStack Query Provider와 첫 useQuery 상태 처리 실습 가이드의 설치 항목을 확인합니다. 아래는 별도 Vite 연습 앱 src/App.tsx 전체 내용입니다. src/main.tsx는 유지하며 이전 TaskFlow나 API 서버는 필요하지 않습니다. 요청 함수는 이 파일 안에 정의된 비동기 모형입니다.

import { useState } from 'react';
import {
  QueryClient,
  QueryClientProvider,
  useQuery,
  useQueryClient,
  useMutation
} from '@tanstack/react-query';

const wait = (ms = 400) => new Promise<void>((resolve) => setTimeout(resolve, ms));
const client = new QueryClient({
  defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});

type Item = { id: number; done: boolean };
let original: Item = { id: 1, done: false };
function Demo() {
  const [fail, setFail] = useState(true);
  const queryClient = useQueryClient();
  const query = useQuery({
    queryKey: ['optimistic-item'],
    queryFn: async () => ({ ...original }),
  });
  const mutation = useMutation({
    mutationFn: async ({ done, fail }: { done: boolean; fail: boolean }) => {
      await wait(700);
      if (fail) throw new Error('저장 실패, 이전 값 복구');
      original = { ...original, done };
      return original;
    },
    onMutate: async ({ done }) => {
      await queryClient.cancelQueries({ queryKey: ['optimistic-item'] });
      const before = queryClient.getQueryData<Item>(['optimistic-item']);
      if (before) queryClient.setQueryData(['optimistic-item'], { ...before, done });
      return { before };
    },
    onError: (_error, _variables, context) => {
      if (context?.before) queryClient.setQueryData(['optimistic-item'], context.before);
    },
    onSettled: () => queryClient.invalidateQueries({ queryKey: ['optimistic-item'] }),
  });
  return (
    <main>
      <p>{query.data?.done ? '완료' : '미완료'}</p>
      <label><input type="checkbox" checked={fail} onChange={(event) => setFail(event.target.checked)} />실패시키기</label>
      <button disabled={!query.data || mutation.isPending}
        onClick={() => mutation.mutate({ done: !query.data!.done, fail })}>완료 전환</button>
      {mutation.isPending && <p>저장 중</p>}
      {mutation.isError && <p>{mutation.error.message}</p>}
    </main>
  );
}

export default function App() {
  return (
    <QueryClientProvider client={client}>
      <Demo />
    </QueryClientProvider>
  );
}
npm run dev
npm run build

자가 확인·변형: pending 동안 다른 토글을 막아 단일 mutation의 snapshot을 관찰합니다. 여러 요청을 동시에 허용하면 단순 이전 snapshot 복원만으로 충분할지 생각해 보세요.

검증 기록: 위 전체 코드는 타입검사·빌드와 jsdom 동작 검증을 통과했습니다. 실제 브라우저 화면과 누적 Next.js 프로젝트 통합 실행은 별도 확인 대상입니다.

기본 흐름

사용자 클릭
→ 진행 중인 refetch 취소
→ 이전 cache 저장
→ cache 먼저 변경
→ PATCH 요청
→ 성공: 서버와 재검증
→ 실패: 이전 cache로 rollback

mutationFn

export async function toggleTask(
  id: string,
): Promise<Task> {
  const response = await fetch(
    '/api/tasks/' + id,
    {
      method: 'PATCH',
    },
  );


  if (!response.ok) {
    throw new Error(
      '작업 상태 변경에 실패했습니다.',
    );
  }


  return response.json();
}

실제 Route Handler가 completed 값을 body로 받는 구조라면 mutation input을 객체로 변경하면 됩니다.

onMutate

mutation 요청 직전에 실행됩니다.

onMutate: async (id) => {
  await queryClient.cancelQueries({
    queryKey: taskKeys.lists(),
  });


  const previousTasks =
    queryClient.getQueryData<Task[]>(
      taskKeys.list('all'),
    );


  queryClient.setQueryData<Task[]>(
    taskKeys.list('all'),
    (old = []) =>
      old.map((task) =>
        task.id === id
          ? {
              ...task,
              completed:
                !task.completed,
            }
          : task,
      ),
  );


  return {
    previousTasks,
  };
},

cancelQueries를 먼저 하는 이유

optimistic cache를 적용한 직후 이전 refetch 응답이 도착하면 새 optimistic 상태를 다시 덮을 수 있습니다.

관련 query의 진행 중 요청을 취소한 뒤 cache를 변경합니다.

snapshot 저장

const previousTasks =
  queryClient.getQueryData<Task[]>(
    taskKeys.list('all'),
  );

실패할 경우 이 값을 rollback에 사용합니다.

onError rollback

onError: (
  _error,
  _id,
  context,
) => {
  if (context?.previousTasks) {
    queryClient.setQueryData(
      taskKeys.list('all'),
      context.previousTasks,
    );
  }
},

서버 요청이 실패하면 사용자가 봤던 optimistic 상태를 원래 cache로 되돌립니다.

onSettled 재검증

성공과 실패 여부와 관계없이 마지막에 서버 상태를 다시 확인할 수 있습니다.

onSettled: async () => {
  await queryClient.invalidateQueries({
    queryKey: taskKeys.lists(),
  });
},

최종 원본은 서버라는 원칙을 유지합니다.

전체 mutation

const queryClient =
  useQueryClient();


const mutation = useMutation({
  mutationFn: toggleTask,


  onMutate: async (id) => {
    await queryClient
      .cancelQueries({
        queryKey:
          taskKeys.lists(),
      });


    const previousTasks =
      queryClient
        .getQueryData<Task[]>(
          taskKeys.list('all'),
        );


    queryClient
      .setQueryData<Task[]>(
        taskKeys.list('all'),
        (old = []) =>
          old.map((task) =>
            task.id === id
              ? {
                  ...task,
                  completed:
                    !task.completed,
                }
              : task,
          ),
      );


    return {
      previousTasks,
    };
  },


  onError: (
    _error,
    _id,
    context,
  ) => {
    if (
      context?.previousTasks
    ) {
      queryClient
        .setQueryData(
          taskKeys.list('all'),
          context.previousTasks,
        );
    }
  },


  onSettled: async () => {
    await queryClient
      .invalidateQueries({
        queryKey:
          taskKeys.lists(),
      });
  },
});

optimistic update가 항상 좋은 것은 아닙니다

다음 상황에서는 서버 응답 후 갱신이 더 안전할 수 있습니다.

  • 서버 검증에 따라 결과가 크게 달라짐
  • 권한 오류가 자주 발생함
  • 재고·결제처럼 잘못된 성공 표시가 위험함
  • 여러 사용자가 동시에 같은 데이터를 편집함
  • rollback 로직이 지나치게 복잡함

빠른 UX보다 정확한 피드백이 더 중요한 도메인도 있습니다.

UI만 optimistic하게 표현하는 방법

TanStack Query는 cache를 직접 바꾸지 않고 mutation.variables를 이용해 pending UI만 추가하는 방식도 제공합니다.

화면 한곳에서만 optimistic 표시가 필요하다면 이 방식이 더 단순할 수 있습니다.

여러 컴포넌트가 같은 optimistic 상태를 봐야 할 때 cache 직접 변경을 검토합니다.

학습 완료 체크리스트

  • optimistic update의 전체 흐름을 설명할 수 있습니다.
  • onMutate에서 이전 cache를 저장할 수 있습니다.
  • cancelQueries가 필요한 이유를 이해했습니다.
  • onError에서 rollback할 수 있습니다.
  • onSettled에서 서버와 재검증할 수 있습니다.
  • optimistic update가 부적절한 상황을 판단할 수 있습니다.

선택 실습: 할 일 앱에 적용하기

TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 서버 데이터의 조회·변경과 캐시를 관리합니다.

TaskFlow 작업 완료 버튼을 누르는 즉시 목록 UI를 바꾸고, 서버 PATCH 실패 시 이전 상태로 되돌립니다.

선택: 연재 프로젝트와 전체 자료

연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.

TaskFlow 프로젝트 적용

중간부터 시작하는 전체 실습 파일

기존 Next.js Route Handler TaskFlow의 /tasks 화면과 /api/tasks를 이어서 확장합니다. 인증 없는 demo 메모리 저장소를 사용하는 로컬 실습이며 계정·외부 DB·API 키가 필요 없습니다. MSW를 활성화하지 않습니다. 서버를 다시 시작하면 데이터가 초기화됩니다.

이 글의 시작본은 직전 프로젝트 단계 완료본 전체입니다. 이미 직전 단계를 마쳤다면 그 폴더를 이어 쓰셔도 됩니다. Node.js 24.x와 npm을 사용하며 패키지 버전은 package-lock.json에 고정했습니다.

시작본에서 직접 바꾸기

시작본을 실행한 뒤 아래 변경 파일을 순서대로 수정합니다. 완료본의 같은 경로 파일에는 import와 입력 타입을 포함한 전체 코드가 있습니다.

  1. app/tasks/TaskItem.tsx — 이전 단계에 없던 파일을 만듭니다.
  2. app/tasks/TaskListClient.tsx — 기존 파일의 같은 위치를 갱신합니다.

변경 이유와 결과: TaskItem의 PATCH에 optimistic update·snapshot·rollback·settled invalidation을 연결합니다. 실습 실패 체크로 실패 전환이 원래 상태로 돌아오는지 확인합니다.

완료본의 docs/changes.patch에서 시작·완료 차이를, docs/current-step.md에서 파일별 적용 위치를 확인할 수 있습니다. 아래 전체 파일을 같은 경로에 반영한 다음 실행하세요.

app/tasks/TaskItem.tsx 전체 파일

'use client';
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { Task } from '../lib/types';
import { updateTask } from '../lib/tasks-api';
import { taskKeys } from '../lib/task-keys';
export default function TaskItem({ task, onSelect }: { task: Task; onSelect: (id: string) => void }) {
  const [fail, setFail] = useState(false);
  const client = useQueryClient();
  const mutation = useMutation({
    mutationFn: (completed: boolean) => updateTask(task.id, completed, fail),
    onMutate: async (completed) => {
      await client.cancelQueries({ queryKey: taskKeys.all });
      const lists = client.getQueriesData<Task[]>({ queryKey: taskKeys.lists() });
      const detail = client.getQueryData<Task>(taskKeys.detail(task.id));
      client.setQueriesData<Task[]>({ queryKey: taskKeys.lists() }, (rows) => rows?.map((row) => row.id === task.id ? { ...row, completed } : row));
      if (detail) client.setQueryData(taskKeys.detail(task.id), { ...detail, completed });
      return { lists, detail };
    },
    onError: (_error, _variables, context) => {
      for (const [key, rows] of context?.lists ?? []) client.setQueryData(key, rows);
      if (context?.detail) client.setQueryData(taskKeys.detail(task.id), context.detail);
    },
    onSettled: () => client.invalidateQueries({ queryKey: taskKeys.all }),
  });
  return (
    <li>
      <button onClick={() => onSelect(task.id)}>{task.title}</button>
      <button disabled={mutation.isPending} onClick={() => mutation.mutate(!task.completed)}>{task.completed ? '완료 취소' : '완료'}</button>
      <label><input type="checkbox" checked={fail} onChange={(event) => setFail(event.target.checked)} />실습 전환 실패</label>
      {mutation.isError && <span role="alert">{mutation.error.message}</span>}
    </li>
  );
}

app/tasks/TaskListClient.tsx 전체 파일

'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import type { Task } from '../lib/types';
import { getTasks, type TaskStatus } from '../lib/tasks-api';
import { taskKeys } from '../lib/task-keys';
import TaskItem from './TaskItem';
export default function TaskListClient({ status = 'all', onSelect = () => {} }: {
  status?: TaskStatus; onSelect?: (id: string) => void;
}) {
  const [fail, setFail] = useState(false);
  const query = useQuery({
    queryKey: taskKeys.list(status),
    queryFn: ({ signal }) => getTasks(signal, status, fail),
    staleTime: 30_000, gcTime: 5 * 60_000,
  });
  return (
    <section aria-label="서버 작업 목록">
      <label><input type="checkbox" checked={fail} onChange={(event) => setFail(event.target.checked)} />실습 조회 실패</label>
      <button onClick={() => void query.refetch()}>목록 다시 확인</button>
      {query.isFetching && <p role="status">요청 중입니다.</p>}
      {query.isPending ? <p>불러오는 중입니다.</p> : query.isError ? <p role="alert">{query.error.message}</p> : query.data.length === 0 ? <p>표시할 작업이 없습니다.</p> : (
        <ul>
          {query.data.map((task) => (
            <TaskItem key={task.id} task={task} onSelect={onSelect} />
          ))}
        </ul>
      )}
    </section>
  );
}

완료 확인

  1. 시작본은 직전 완료본과 동일한 ZIP입니다. 압축을 푼 뒤 npm ci로 잠금 파일의 의존성을 설치합니다.
  2. npm run dev 실행 후 http://localhost:3000/tasks를 엽니다.
  3. TaskItem의 PATCH에 optimistic update·snapshot·rollback·settled invalidation을 연결합니다. 실습 실패 체크로 실패 전환이 원래 상태로 돌아오는지 확인합니다.
  4. npm run typecheck와 npm run build가 성공하면 다음 프로젝트 단계로 갑니다.

이 체크포인트는 TypeScript 검사·프로덕션 빌드와 실제 소스를 사용한 JSDOM/상태 시나리오 검증을 통과했습니다. 별도 실제 브라우저 화면 검수는 이 파일 검증 범위에 포함하지 않았습니다.

아래 부분 예제는 핵심 변경을 설명합니다. 실행에 필요한 전체 파일과 보조 타입·API는 위 완료본에서 확인하세요.

누적 실습은 이 절의 프로젝트 단계 링크를 따라 진행합니다. 페이지 끝 학습 순서는 기술 주제 읽기 순서입니다.

이전 프로젝트 단계: TanStack Query 무한 스크롤 사용법: useInfiniteQuery로 목록 이어 불러오기. 위 시작본 ZIP 전체를 내려받으면 이전 글을 다시 조립하지 않고 이 단계부터 실행할 수 있습니다.

TaskItem의 완료 mutation에 snapshot·rollback·재검증을 추가합니다. 실제 서버 응답 지연과 실패를 확인하여 임시 성공 표시가 남지 않게 합니다.

이전 체크포인트

query-step-09-infinite

이번 목표

TaskFlow 작업 완료 버튼을 누르는 즉시 목록 UI를 바꾸고, 서버 PATCH 실패 시 이전 상태로 되돌립니다.

TaskItem 핵심 코드

const mutation = useToggleTask();


<button
  type="button"
  disabled={mutation.isPending}
  onClick={() =>
    mutation.mutate(task.id)
  }
>
  {task.completed
    ? '완료 취소'
    : '완료'}
</button>

optimistic cache가 먼저 바뀌므로 버튼 텍스트도 즉시 변경됩니다.

실패 테스트

학습용으로 Route Handler가 특정 제목의 작업에서 500을 반환하도록 임시 조건을 만들 수 있습니다.

클릭
→ UI 즉시 변경
→ PATCH 500
→ onError
→ 이전 cache 복원

테스트 후 강제 실패 코드는 제거합니다.

Network / Devtools 확인

  • mutation pending
  • cache 즉시 변경
  • 실패 시 rollback
  • settled 후 목록 invalidation

순서를 확인합니다.

npm run build

현재 확장 단계

✓ 1~9. 조회·mutation·목록 확장
→ 10. optimistic update / rollback
○ 11. 목록·상세 cache 동기화
○ 12. 서버 prefetch
○ 13. HydrationBoundary
○ 14~15. 오류·캐시 경계

완료 체크포인트

query-step-10-optimistic

다음 학습

다음 글에서는 같은 Task가 목록 query와 상세 query에 각각 존재할 때 캐시를 어떻게 맞출지, 수동 정규화와 invalidation의 비용을 비교합니다.

다음 프로젝트 단계: TanStack Query 수동 캐시 정규화: 목록·상세 동기화와 선택 기준

공식 문서

프로젝트 적용 후 확인: 실패한 낙관적 업데이트는 되돌아갈까요?

관찰 절차 작업 완료를 누른 즉시 화면과 Devtools 목록 캐시를 보고, 변경 요청을 지연 후 500으로 응답시킵니다. 실패 뒤 값이 원상 복구되는지, 이어서 성공 응답에서 재조회 결과가 서버 값과 일치하는지 봅니다.

예상 결과 요청 중에는 임시 값이 보이고 실패하면 이전 캐시가 복원됩니다. 성공 후에는 서버 응답을 기준으로 동기화됩니다.

오류 점검 롤백이 안 되면 onMutate가 반환한 이전 데이터와 onError의 복원 키를 확인합니다. 경합 시 cancelQueries와 마지막 invalidation을 점검합니다.

다음 학습 11회차에서 목록·상세 캐시를 함께 조정할지 결정합니다.

검증 범위 전체 체크포인트의 빌드·상태 동작은 로컬 검증을 통과했습니다. 실제 브라우저 시각 확인은 별도로 진행해야 합니다.

글 소개·학습 준비·전체 목차
TanStack Query Todo 실습: 서버 연결·낙관적 업데이트·실패 롤백

TanStack Query · 10 / 15 · 중급 학습 · optimistic update

서버 변경 요청이 끝날 때까지 기다린 뒤 화면을 갱신하면 네트워크가 느릴수록 버튼 반응이 늦게 느껴질 수 있습니다.

optimistic update는 서버 성공을 기다리기 전에 cache를 먼저 바꿔 사용자가 즉시 결과를 보게 하고, 요청이 실패하면 이전 cache로 되돌리는 방식입니다.

상단은 완결된 작은 토글 예제에서 성공과 실패 복구를 확인하고, 하단에서 TaskFlow의 실제 완료 API에 적용합니다.

이번 글에서 배우는 내용

  • optimistic update가 필요한 이유
  • onMutate
  • cancelQueries
  • 이전 cache snapshot
  • setQueryData
  • onError rollback
  • onSettled 재검증
  • optimistic update를 피해야 하는 경우

목차

이 글이 도움이 되었나요?

조회 중

읽음 기록

읽음 — / 전체 15개 · 필수 14개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기