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

2026.09.15·약 22분·작성: 해비·블로그 소개
TanStack Query Todo 실습: 서버 연결·낙관적 업데이트·실패 롤백 학습 표지

이 글은 프론트엔드 라이브러리 연결 과정의 일부입니다. React 컴포넌트·props·state·이벤트와 TypeScript 객체·배열 타입을 먼저 익혀 주세요. 원본 강의의 문장을 옮기는 대신 독립적인 예제와 확인 과제를 구성했습니다.

학습 목표: 실제 로컬 API를 연결하고 추가·수정·삭제별 캐시 전략과 실패 복구를 확인합니다.

예상 학습 시간: 70분. 개인별 차이가 있으며 설치 시간은 제외합니다.

서버를 원본으로 바꿉니다

로컬 Todo의 todos 배열을 Query 캐시에 복사해 두는 방식으로 확장하지 않습니다. 서버 응답은 TanStack Query가 관리하고 입력 중인 값만 React에 둡니다. 학습용 json-server는 server/db.json을 읽는 별도 프로세스입니다. JSON 파일을 React에서 직접 수정하는 것과 구분하세요.

npm run server

별도 터미널에서 서버를 실행합니다. 이 ZIP은 json-server 1.0.0-beta.3을 고정하고 문자열 ID를 사용합니다. Vite /api 프록시를 통해 요청하며 운영 인증·권한·배포는 범위에 없습니다. DB 파일은 클라이언트 src 밖에 두었습니다.

API 함수는 성공과 실패를 명확히 전달합니다

fetch는 HTTP 404나 500을 받았다고 자동으로 예외를 던지지 않으므로 response.ok를 검사합니다. POST·PATCH의 JSON 본문에는 Content-Type을 지정합니다. 조회는 AbortSignal을 전달하여 취소가 실제 fetch에도 연결되게 합니다. DELETE는 응답 본문이 비어 있을 수 있어 삭제한 ID를 직접 반환합니다.

QueryClient·queryKey·상태 표시

QueryClient는 공통 진입점에서 한 번 생성합니다. list와 detail의 키를 분리하고 all 접두사로 관련 캐시를 묶습니다. 서버 응답을 바꾸는 검색 조건이 생기면 그 값도 키에 포함해야 합니다. isPending은 처음 데이터가 없는 상태, isFetching은 조회가 진행 중인 상태를 구분하는 데 사용합니다. 갱신 실패가 발생해도 기존 데이터가 있으면 목록과 오류를 함께 보여 줍니다.

작업 실습의 전략 확인할 점
추가 성공 후 목록 무효화 서버 생성 ID와 정렬을 다시 받음
완료 변경 응답 전 캐시 변경·실패 롤백 취소·스냅샷·원복·최종 재조회
삭제 성공 후 filter, 이후 재확인 실패한 항목을 미리 없애지 않음

낙관적 업데이트 네 단계

먼저 cancelQueries를 기다려 이전 조회가 낙관적 값을 덮지 않게 합니다. 다음으로 기존 목록을 스냅샷으로 저장합니다. 그 뒤 해당 ID만 새 객체로 바꿔 화면을 즉시 갱신합니다. 요청이 실패하면 스냅샷을 돌려주고 성공·실패 모두 onSettled에서 서버를 재확인합니다. onMutate 반환값은 이후 오류 처리에서 사용하는 롤백 자료입니다.

최종 재조회도 실패할 수 있으므로 이 순서가 서버와 항상 일치함을 보장하지는 않습니다. 예제는 오류를 남기고 수동 다시 조회 버튼을 제공합니다. onSettled가 invalidation Promise를 반환하여 재조회 처리가 끝날 때까지 pending을 유지합니다. 성공한 저장과 성공한 재조회는 별개 결과입니다.

동시 변경은 별도 문제입니다

목록 전체 스냅샷으로 롤백하면 다른 수정 결과까지 과거로 되돌릴 수 있습니다. 따라서 이 학습 화면에서는 하나의 mutationKey로 쓰기 진행 상태를 공유하고 추가·완료·삭제를 동시에 실행하지 못하게 합니다. 이것은 현재 화면의 단순화이며 다중 탭·사용자 경쟁을 해결하지는 않습니다. 실무에서는 항목별 작업 기록, 서버 버전·충돌 정책 등을 별도로 설계합니다.

직접 해보기와 확인 질문

서버가 켜진 상태에서 추가·완료·삭제를 확인한 뒤 서버를 중단하고 완료 체크를 바꾸세요. 다시 서버를 켜고 목록을 재조회하세요.

풀이 기준 보기

체크가 먼저 바뀌어도 요청 실패 뒤 원래 값으로 돌아와야 합니다. 오류가 화면에 남고 입력값은 실패만으로 사라지지 않아야 합니다. 서버 재시작 뒤 실제 값을 다시 읽습니다. Devtools에서 list 키와 갱신 시점을 함께 관찰하세요.

공통 실습 실행

아래 공통 ZIP을 풀고 Node.js 24 환경에서 실행합니다. package-lock.json에 실습 버전을 고정했습니다. 본문의 경로와 ZIP의 경로는 같습니다.

npm ci
npm run dev

터미널에 표시된 주소를 열고 이 글에 해당하는 메뉴를 선택하세요. 서버 Todo만 별도 터미널의 npm run server가 필요합니다. 본문 코드에서 생략한 공통 설정과 UI 파일까지 ZIP에 포함되어 있습니다.

프론트엔드 라이브러리 공통 실습 ZIP

src/api/todos.ts

공통 실습 ZIP 안의 전체 파일입니다. 이 파일 하나만으로 독립 실행되지는 않으며 import하는 파일은 ZIP에 함께 들어 있습니다.

import type { Todo } from "../types";

async function request<T>(path: string, init?: RequestInit): Promise<T> {
  const response = await fetch(`/api${path}`, init);
  if (!response.ok) throw new Error(`요청 실패 (${response.status})`);
  return response.json() as Promise<T>;
}
export const fetchTodos = (signal?: AbortSignal) =>
  request<Todo[]>("/todos", { signal });
export const createTodo = (content: string) =>
  request<Todo>("/todos", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ content: content.trim(), isDone: false }),
  });
export const updateTodo = ({ id, isDone }: Pick<Todo, "id" | "isDone">) =>
  request<Todo>(`/todos/${encodeURIComponent(id)}`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ isDone }),
  });
export async function deleteTodo(id: string): Promise<string> {
  const response = await fetch(`/api/todos/${encodeURIComponent(id)}`, {
    method: "DELETE",
  });
  if (!response.ok) throw new Error(`삭제 실패 (${response.status})`);
  return id;
}

src/queries/todos.ts

공통 실습 ZIP 안의 전체 파일입니다. 이 파일 하나만으로 독립 실행되지는 않으며 import하는 파일은 ZIP에 함께 들어 있습니다.

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createTodo, deleteTodo, fetchTodos, updateTodo } from "../api/todos";
import type { Todo } from "../types";

export const todoKeys = {
  all: ["course-todo"] as const,
  list: ["course-todo", "list"] as const,
  detail: (id: string) => ["course-todo", "detail", id] as const,
};
export const todoMutationKey = ["course-todo", "write"] as const;
export function useRemoteTodos() {
  return useQuery({
    queryKey: todoKeys.list,
    queryFn: ({ signal }) => fetchTodos(signal),
    staleTime: 30_000,
  });
}
export function useCreateRemoteTodo() {
  const client = useQueryClient();
  return useMutation({
    mutationKey: todoMutationKey,
    mutationFn: createTodo,
    onSuccess: () => client.invalidateQueries({ queryKey: todoKeys.list }),
  });
}
export function useUpdateRemoteTodo() {
  const client = useQueryClient();
  return useMutation({
    mutationKey: todoMutationKey,
    mutationFn: updateTodo,
    onMutate: async (updated) => {
      await client.cancelQueries({ queryKey: todoKeys.list });
      const previous = client.getQueryData<Todo[]>(todoKeys.list);
      client.setQueryData<Todo[]>(todoKeys.list, (current) =>
        current?.map((todo) =>
          todo.id === updated.id ? { ...todo, ...updated } : todo,
        ),
      );
      return { previous };
    },
    onError: (_error, _variables, snapshot) => {
      if (snapshot?.previous)
        client.setQueryData(todoKeys.list, snapshot.previous);
    },
    onSettled: () => client.invalidateQueries({ queryKey: todoKeys.all }),
  });
}
export function useDeleteRemoteTodo() {
  const client = useQueryClient();
  return useMutation({
    mutationKey: todoMutationKey,
    mutationFn: deleteTodo,
    onSuccess: (id) => {
      client.setQueryData<Todo[]>(todoKeys.list, (current) =>
        current?.filter((todo) => todo.id !== id),
      );
      client.removeQueries({ queryKey: todoKeys.detail(id), exact: true });
    },
    onSettled: () => client.invalidateQueries({ queryKey: todoKeys.list }),
  });
}

src/pages/remote-page.tsx

공통 실습 ZIP 안의 전체 파일입니다. 이 파일 하나만으로 독립 실행되지는 않으며 import하는 파일은 ZIP에 함께 들어 있습니다.

import { useState, type FormEvent } from "react";
import { useIsMutating, useQueryClient } from "@tanstack/react-query";
import {
  todoMutationKey,
  useCreateRemoteTodo,
  useDeleteRemoteTodo,
  useRemoteTodos,
  useUpdateRemoteTodo,
} from "../queries/todos";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

export default function RemotePage() {
  const client = useQueryClient();
  const query = useRemoteTodos();
  const create = useCreateRemoteTodo();
  const update = useUpdateRemoteTodo();
  const remove = useDeleteRemoteTodo();
  const busy = useIsMutating({ mutationKey: todoMutationKey }) > 0;
  const [content, setContent] = useState("");
  function submit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    if (!content.trim() || client.isMutating({ mutationKey: todoMutationKey }))
      return;
    create.mutate(content, { onSuccess: () => setContent("") });
  }
  const error = create.error || update.error || remove.error;
  if (query.isPending) return <p role="status">목록을 불러오는 중입니다.</p>;
  if (!query.data)
    return (
      <section>
        <p role="alert">{query.error?.message}</p>
        <Button onClick={() => query.refetch()}>다시 조회</Button>
      </section>
    );
  return (
    <section>
      <h2>서버 Todo</h2>
      <p>
        추가는 무효화, 완료 변경은 낙관적 업데이트, 삭제는 성공 응답 후
        반영합니다.
      </p>
      {query.isError && (
        <p role="alert">새로 조회하지 못했습니다. 기존 목록을 표시합니다.</p>
      )}
      <form onSubmit={submit}>
        <label htmlFor="remote-content">할 일</label>
        <Input
          id="remote-content"
          value={content}
          onChange={(event) => setContent(event.target.value)}
          disabled={busy}
        />
        <Button type="submit" disabled={busy}>
          추가
        </Button>
      </form>
      {error && <p role="alert">{error.message} — 다시 시도할 수 있습니다.</p>}
      <p role="status">
        {busy
          ? "저장 및 목록 확인 중"
          : query.isFetching
            ? "목록 갱신 중"
            : "요청 대기"}
      </p>
      <ul>
        {query.data.map((todo) => (
          <li
            key={todo.id}
            className="flex items-center justify-between gap-4 border p-4"
          >
            <label className="flex items-center gap-3">
              <input
                type="checkbox"
                checked={todo.isDone}
                disabled={busy}
                onChange={(event) => {
                  if (!client.isMutating({ mutationKey: todoMutationKey }))
                    update.mutate({
                      id: todo.id,
                      isDone: event.target.checked,
                    });
                }}
              />
              {todo.content}
            </label>
            <Button
              variant="destructive"
              disabled={busy}
              onClick={() => {
                if (!client.isMutating({ mutationKey: todoMutationKey }))
                  remove.mutate(todo.id);
              }}
            >
              삭제
            </Button>
          </li>
        ))}
      </ul>
      {query.data.length === 0 && <p>할 일이 없습니다.</p>}
      <Button
        variant="outline"
        disabled={busy || query.isFetching}
        onClick={() => query.refetch()}
      >
        서버 목록 다시 확인
      </Button>
    </section>
  );
}

server/db.json

공통 실습 ZIP 안의 전체 파일입니다. 이 파일 하나만으로 독립 실행되지는 않으며 import하는 파일은 ZIP에 함께 들어 있습니다.

{"todos":[{"id":"1","content":"라우팅 복습","isDone":false},{"id":"2","content":"상태 구분 연습","isDone":true}]}

UI 파일 범위: ZIP의 src/components/ui는 shadcn CLI 생성물이 아닙니다. 레지스트리 접속 실패로 Radix·Sonner·Embla를 사용하는 축약 학습용 구현을 작성했습니다. 공식 설치 절차는 별도 프로젝트에서 비교하세요. 공식 컴포넌트 전체 기능과 동일하다고 보장하지 않습니다.

검증 범위

Node.js 24.19.0, React 19.3.0, React Router 7.18.3, Zustand 5.0.15, TanStack Query 5.102.8, Tailwind CSS 4.3.3에서 타입 검사와 Vite 빌드를 확인했습니다. 자동 테스트는 jsdom 기반입니다. 실제 브라우저의 포커스·레이아웃·화면낭독기와 모든 네트워크 경쟁 상황은 별도 확인 대상입니다.

npm test
npm run build

공식 문서

2026-09-14 확인. 공식 최신 문서의 버전과 실습 고정 버전이 다를 수 있으므로 설치 버전은 ZIP을 기준으로 비교합니다.

이 글이 도움이 되었나요?

조회 중

TanStack Query 학습 순서

필수 11개 · 전체 12개

읽음 기록 관리

전체 과정 목차 (12개)
  1. 필수 길잡이 · TanStack Query vs Zustand: 서버 상태와 클라이언트 상태 차이
  2. 필수 학습 · TanStack Query Provider와 첫 useQuery 상태 처리 실습 가이드
  3. 필수 학습 · TanStack Query queryKey 설계 기준: 배열 키로 캐시 구분하기
  4. 필수 학습 · TanStack Query queryFn 사용법: 데이터 요청 로직 분리하기
  5. 필수 학습 · TanStack Query staleTime gcTime 차이: 캐시 시간 기준
  6. 필수 학습 · TanStack Query useMutation 저장 실습: 실패·재시도와 목록 갱신까지
  7. 선택 참고 · TanStack Query 화면 갱신 문제 해결: 데이터 변경 후 UI가 바뀌지 않을 때
  8. 필수 학습 · TanStack Query 페이지네이션 실습: placeholderData로 이전 목록 유지하기
  9. 필수 학습 · TanStack Query 무한 스크롤 사용법: useInfiniteQuery로 목록 이어 불러오기
  10. 필수 선수 · TanStack Query Hydration 오류 해결: QueryClient 설정 기준
  11. 필수 학습 · TanStack Query Todo 실습: 서버 연결·낙관적 업데이트·실패 롤백 현재 글
  12. 필수 학습 · TanStack Query 수동 캐시 정규화: 목록·상세 동기화와 선택 기준

새 글 받아보기

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

RSS 피드 구독하기

댓글 남기기