
이 글에서 완성하는 것
제목 입력부터 useMutation 저장, 실패 시 입력 보존과 재제출, invalidateQueries 완료 대기까지 하나의 폼으로 완성합니다. 저장 성공 뒤 목록 읽기 실패도 별도 상태로 처리합니다.

선수 학습은 Provider와 첫 useQuery, queryKey 설계, queryFn 계약입니다. 이 글에서는 같은 Provider 안에서 기존 캐시를 관찰한다는 전제 위에 사용자 동작을 추가합니다.
1. 프로젝트를 실행하고 관찰할 상태 정하기
실습 ZIP을 풀고 프로젝트 폴더에서 아래 명령을 실행합니다. Node.js 22.12 이상 또는 24 LTS와 npm이 필요합니다. 별도 API 키나 서버는 필요하지 않습니다. 이 글의 코드는 React 19.3.0, TanStack Query 5.102.8, Vite 8.3.0을 사용합니다.
npm install
npm run dev
npm test
npm run build
index.html은 문서와 진입점, src/styles.css는 흑백 기본 스타일, src/main.jsx는 안정적인 QueryClient와 Provider, src/api.js는 비동기 fixture, src/App.jsx는 UI를 담당합니다. JSX 파일은 JavaScript 기반이며 TypeScript 설정 없이 실행합니다. 테스트 파일과 Vitest 설정도 ZIP에 포함됩니다. 직접 의존성은 package.json에서 정확한 버전으로 고정했으며, 최초 설치 후 생성되는 lockfile을 보관하면 전이 의존성 재현에도 도움이 됩니다.
fixture는 지연 뒤 Promise를 완료하는 메모리 함수입니다. 실제 HTTP 요청이나 데이터베이스를 호출하지 않으며, 새로고침하면 메모리 상태가 초기화됩니다. 버튼과 캐시의 상태 전이를 학습하기 위한 실행 환경이고 실제 서버의 영속성이나 네트워크 장애 복구를 증명하는 환경은 아닙니다.
src/main.jsx
실습 파일
파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.
전체 코드
index.html
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>TanStack Query 실습</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
package.json
{
"name": "tanstack-query-mutation-save-practice",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "vitest run"
},
"dependencies": {
"@tanstack/react-query": "5.102.8",
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"vite": "8.3.0",
"vitest": "4.1.11",
"jsdom": "30.0.1",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.7",
"@testing-library/jest-dom": "7.0.1"
}
}
src/App.jsx
import React, { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { fixture, lessonKey } from './api.js';
export function App({ api = fixture }) {
const [title, setTitle] = useState('');
const client = useQueryClient();
const lessons = useQuery({ queryKey: lessonKey, queryFn: () => api.list() });
const save = useMutation({
mutationFn: (value) => api.save(value),
retry: false,
onSuccess: () => {
setTitle('');
return client.invalidateQueries({ queryKey: lessonKey, exact: true });
},
});
function submit(event) {
event.preventDefault();
if (!title.trim() || save.isPending) return;
save.mutate(title.trim());
}
return (
<main>
<h1>수업 저장 실습</h1>
<form onSubmit={submit}>
<label htmlFor="title">수업 제목</label>
<input
id="title"
value={title}
disabled={save.isPending}
onChange={(event) => {
setTitle(event.target.value);
save.reset();
}}
/>
<button disabled={!title.trim() || save.isPending}>
{save.isPending ? '저장 및 갱신 중' : '저장'}
</button>
</form>
{save.isError && (
<p role="alert">{save.error.message} 저장 버튼으로 다시 시도하세요.</p>
)}
{save.isSuccess && (
<p role="status">
{lessons.isError
? '저장은 완료됐지만 목록 확인이 필요합니다.'
: '저장 및 목록 갱신 완료'}
</p>
)}
<section aria-label="수업 목록">
{lessons.isPending && <p role="status">목록을 불러오는 중</p>}
{lessons.isError && (
<>
<p role="alert">{lessons.error.message}</p>
<button disabled={lessons.isFetching} onClick={() => lessons.refetch()}>
목록 다시 읽기
</button>
</>
)}
{lessons.data && (
<ul>
{lessons.data.map((lesson) => (
<li key={lesson.id}>{lesson.title}</li>
))}
</ul>
)}
{lessons.isFetching && !lessons.isPending && <p>목록 확인 중</p>}
</section>
<section aria-label="실패 재현">
<button disabled={save.isPending} onClick={() => api.failNextSave()}>
다음 저장 실패시키기
</button>
<button disabled={save.isPending} onClick={() => api.failNextRead()}>
다음 목록 읽기 실패시키기
</button>
</section>
</main>
);
}
src/App.test.jsx
import React from 'react';
import { test, expect, vi } from 'vitest';
import { render, screen, waitFor, act, cleanup } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { App } from './App.jsx';
import { lessonKey } from './api.js';
function deferred() {
let resolve;
const promise = new Promise((done) => {
resolve = done;
});
return { promise, resolve };
}
function show(api) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
render(
<QueryClientProvider client={client}>
<App api={api} />
</QueryClientProvider>,
);
return client;
}
test('입력 → 저장 응답 → 후속 목록 완료까지 pending을 유지하고 캐시를 바꾼다', async () => {
const write = deferred();
const read = deferred();
const api = {
list: vi
.fn()
.mockResolvedValueOnce([{ id: 1, title: '첫 수업' }])
.mockReturnValueOnce(read.promise),
save: vi.fn(() => write.promise),
};
const client = show(api);
const user = userEvent.setup();
await screen.findByText('첫 수업');
await user.type(screen.getByLabelText('수업 제목'), '새 수업');
await user.click(screen.getByRole('button', { name: '저장', exact: true }));
expect(screen.getByRole('button', { name: '저장 및 갱신 중' })).toBeDisabled();
await act(async () => write.resolve({ id: 2, title: '새 수업' }));
await waitFor(() => expect(api.list).toHaveBeenCalledTimes(2));
expect(screen.getByRole('button', { name: '저장 및 갱신 중' })).toBeDisabled();
expect(screen.queryByText('저장 및 목록 갱신 완료')).not.toBeInTheDocument();
await act(async () =>
read.resolve([
{ id: 1, title: '첫 수업' },
{ id: 2, title: '새 수업' },
]),
);
await screen.findByText('저장 및 목록 갱신 완료');
expect(screen.getByText('새 수업')).toBeInTheDocument();
expect(client.getQueryData(lessonKey)).toHaveLength(2);
cleanup();
client.clear();
});
test('저장 실패는 입력을 보존하고 자동 재시도 없이 사용자가 재제출한다', async () => {
const api = {
list: vi.fn().mockResolvedValue([{ id: 1, title: '첫 수업' }]),
save: vi
.fn()
.mockRejectedValueOnce(new Error('저장 실패'))
.mockResolvedValueOnce({ id: 2, title: '재시도' }),
};
const client = show(api);
const user = userEvent.setup();
await screen.findByText('첫 수업');
await user.type(screen.getByLabelText('수업 제목'), '재시도');
await user.click(screen.getByRole('button', { name: '저장', exact: true }));
await screen.findByRole('alert');
expect(screen.getByLabelText('수업 제목')).toHaveValue('재시도');
expect(api.save).toHaveBeenCalledTimes(1);
expect(api.list).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole('button', { name: '저장', exact: true }));
await screen.findByText('저장 및 목록 갱신 완료');
expect(api.save).toHaveBeenNthCalledWith(2, '재시도');
cleanup();
client.clear();
});
test('저장은 성공했지만 목록 갱신 실패 시 저장을 반복하지 않고 읽기만 재시도한다', async () => {
const api = {
list: vi
.fn()
.mockResolvedValueOnce([{ id: 1, title: '첫 수업' }])
.mockRejectedValueOnce(new Error('읽기 실패'))
.mockResolvedValueOnce([{ id: 2, title: '새 수업' }]),
save: vi.fn().mockResolvedValue({ id: 2, title: '새 수업' }),
};
const client = show(api);
const user = userEvent.setup();
await screen.findByText('첫 수업');
await user.type(screen.getByLabelText('수업 제목'), '새 수업');
await user.click(screen.getByRole('button', { name: '저장', exact: true }));
await screen.findByText('저장은 완료됐지만 목록 확인이 필요합니다.');
await user.click(screen.getByRole('button', { name: '목록 다시 읽기' }));
await screen.findByText('새 수업');
expect(api.save).toHaveBeenCalledTimes(1);
cleanup();
client.clear();
});
src/api.js
export const lessonKey = ['lessons', 'list'];
export function createFixture() {
let lessons = [{ id: 1, title: '첫 수업' }];
let failSave = false;
let failRead = false;
const wait = () => new Promise((resolve) => setTimeout(resolve, 350));
return {
failNextSave() {
failSave = true;
},
failNextRead() {
failRead = true;
},
async list() {
await wait();
if (failRead) {
failRead = false;
throw new Error('목록 갱신 실패');
}
return lessons.map((lesson) => ({ ...lesson }));
},
async save(title) {
await wait();
if (failSave) {
failSave = false;
throw new Error('저장 실패: 입력은 보존됩니다.');
}
const lesson = { id: lessons.length + 1, title };
lessons = [...lessons, lesson];
return lesson;
},
};
}
export const fixture = createFixture();
src/main.jsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { App } from './App.jsx';
import './styles.css';
const client = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});
createRoot(document.getElementById('root')).render(
<QueryClientProvider client={client}>
<App />
</QueryClientProvider>,
);
src/setup.js
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(cleanup);
src/styles.css
* {
box-sizing: border-box;
}
body {
margin: 0;
color: #222;
background: #fff;
font:
17px/1.7 system-ui,
sans-serif;
}
main {
max-width: 760px;
margin: 48px auto;
padding: 24px;
}
h1 {
font-size: 28px;
}
section,
form {
border-top: 1px solid #bbb;
padding: 20px 0;
}
label {
display: block;
}
input,
button {
font: inherit;
padding: 8px 12px;
border: 1px solid #777;
color: inherit;
background: #fff;
}
button {
cursor: pointer;
margin: 8px 8px 8px 0;
}
button:disabled {
color: #777;
background: #eee;
cursor: default;
}
:focus-visible {
outline: 3px solid #333;
outline-offset: 3px;
}
[role='alert'] {
border-left: 4px solid #333;
padding-left: 12px;
}
li {
padding: 5px 0;
}
vite.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: { environment: 'jsdom', setupFiles: './src/setup.js' },
});
2. 저장 함수의 성공과 실패를 먼저 만들기
이번 화면은 수업 제목을 입력해 목록에 추가합니다. 읽기는 useQuery가 담당하고, 사용자가 제출할 때 실행하는 쓰기는 useMutation이 담당합니다. 아직 제출하지 않은 제목은 서버 데이터가 아니므로 useState에 둡니다. 목록 캐시를 입력값으로 덮어쓰거나 타이핑할 때마다 mutation을 실행하지 않습니다.
fixture의 list는 복사한 배열을 반환하고 save는 새 항목을 생성합니다. 실패 플래그는 한 번 사용하면 해제됩니다. 따라서 “다음 저장 실패시키기”를 누른 뒤 제출하면 저장은 실패하고, 같은 입력으로 다음 제출을 하면 성공합니다. 읽기 실패 플래그도 별도이므로 저장 성공 뒤 목록 갱신 실패를 독립적으로 재현할 수 있습니다.
src/api.js
save가 오류를 throw하는 것은 useMutation이 isError와 error를 제공하기 위한 계약입니다. 실패를 빈 객체나 성공처럼 보이는 메시지로 반환하면 훅은 정상 완료로 판단할 수 있습니다. 실제 API로 옮길 때도 응답 상태를 확인하고 실패는 예외 또는 rejected Promise로 전달해야 합니다.
3. 폼 제출을 useMutation에 연결하기
App의 submit은 기본 폼 이동을 막고, 공백 제목과 진행 중 재제출을 차단한 뒤 mutate에 정리된 문자열을 넘깁니다. mutationFn은 그 문자열을 저장 함수에 전달합니다. 훅을 이벤트 핸들러 안에서 생성하는 것이 아니라 컴포넌트 상단에서 선언하고, 사용자의 제출 순간에 mutate만 호출합니다.
진행 중에는 입력과 저장 버튼을 함께 잠급니다. 입력이 바뀌면 이전 mutation 표시를 reset해 지난 성공·오류가 새 초안의 결과처럼 보이지 않게 합니다. reset은 저장을 되돌리는 작업도, query 캐시 삭제도 아닙니다. 여기서는 새로운 입력을 시작할 때 mutation 상태 메시지를 정리하는 용도입니다.
src/App.jsx
4. 저장과 목록 갱신을 하나의 진행 상태로 보여주기
onSuccess에서 제목을 비운 뒤 invalidateQueries가 반환한 Promise를 그대로 반환합니다. 따라서 write 함수가 먼저 끝나도 active 목록의 후속 읽기가 끝나기 전에는 저장 버튼이 “저장 및 갱신 중”으로 남습니다. 이 예제는 목록 한 캐시만 갱신하므로 같은 lessonKey와 exact: true를 사용합니다.
중괄호 본문에서 invalidateQueries를 호출만 하고 return을 빼면 onSuccess가 기다릴 Promise를 전달하지 않습니다. 비슷하게 보이는 두 코드라도 사용자에게 완료를 알리는 시점이 달라집니다. 여기서는 후속 읽기의 완료를 기다리는 것이 폼 제출 흐름의 일부이므로 return을 코드의 계약으로 유지합니다.
갱신 Promise가 끝났다고 항상 새 목록을 받았다는 뜻은 아닙니다. invalidateQueries의 기본 동작은 refetch 실패를 호출자에게 다시 throw하지 않으므로, 목록의 isError도 확인합니다. “다음 목록 읽기 실패시키기” 후 저장하면 저장 자체는 완료되고 입력도 비워지지만 화면은 “저장은 완료됐지만 목록 확인이 필요합니다”라고 안내합니다. 사용자는 저장을 다시 보내지 않고 “목록 다시 읽기”를 누릅니다.
이 구분은 생성 요청의 중복을 막는 데 중요합니다. 서버가 이미 새 항목을 만들었다면 읽기 실패 때문에 같은 생성을 다시 보내면 두 항목이 생길 수 있습니다. 실제 API의 응답 유실로 저장 여부가 불명확한 상황은 서버의 멱등성 키나 저장 결과 조회 정책으로 해결해야 합니다. 단순 버튼 비활성화만으로 여러 탭이나 다른 사용자의 요청까지 제어할 수는 없습니다.
5. 오류를 만든 뒤 회복 경로까지 실행하기
먼저 제목을 입력하고 실패 재현 버튼 없이 저장합니다. 목록에 새 제목이 추가되고 입력이 비워지는지 확인합니다. 다음에는 실패 재현 버튼을 누르고 제출합니다. 오류 문구가 나타나고 입력은 보존되어야 하며 저장 버튼을 다시 누르면 같은 내용을 재제출할 수 있어야 합니다. retry: false는 자동 재시도를 끈 명시적인 실습 정책입니다.
자동 재시도 횟수를 늘리는 것은 이 예제의 사용자 재제출과 다른 정책입니다. 읽기는 보통 다시 요청해도 상태를 바꾸지 않지만, 생성 요청은 중복 실행의 영향을 고려해야 합니다. 실패 종류를 구분하지 않고 mutation에 query와 같은 재시도 정책을 붙이지 않습니다. 이 실습에서는 실패 뒤 사용자가 입력을 검토하고 제출하는 흐름을 먼저 완성합니다.
이 프로젝트는 서버 확정 뒤 목록을 다시 읽는 방식입니다. 낙관적 UI가 필요하면 저장 전에 cancelQueries로 충돌할 읽기를 멈추고, 이전 캐시를 보관하고, 불변 방식으로 임시 항목을 넣고, 실패 시 복원하고, 종료 뒤 재검증하는 별도 흐름이 필요합니다. 전체 목록 스냅샷 복원은 다른 mutation의 성공 결과까지 지울 수 있습니다. 단일 화면의 직렬 저장처럼 동시 변경 범위를 제한하거나, 항목별 mutation 식별과 병합 정책을 먼저 정한 뒤 확장해야 합니다. 낙관적 갱신은 이 실습의 확장 과제입니다.
갱신이 계속 보이지 않는 원인을 찾는 경우에는 mutation 뒤 화면 갱신 진단 글에서 key, client, observer를 점검하세요. 이 글의 목표는 진단 항목을 반복하는 것이 아니라 하나의 입력을 저장·실패·재시도·목록 확인까지 끝내는 것입니다.
API 의미는 공식 Mutations와 Invalidations from Mutations에서 확인할 수 있습니다. 문법은 v5 객체 옵션 형식을 사용합니다.
6. 사용자 행동 테스트와 완료 기준
완료 기준은 세 가지입니다. 저장 응답만 끝난 시점에는 아직 pending이고 목록이 끝나야 완료 표시가 나와야 합니다. 저장 실패는 입력을 남기고 자동 재시도 없이 사용자가 재제출할 수 있어야 합니다. 저장 성공 뒤 목록 읽기 실패는 쓰기를 반복하지 않고 읽기만 재시도하여 회복해야 합니다. 첫 테스트는 최신 항목이 UI와 같은 queryKey 캐시에 들어오는 것까지 확인합니다.
테스트에서는 매 사례마다 새 QueryClient를 만들고 UI를 unmount한 뒤 client.clear()로 정리합니다. api를 props로 주입하여 Promise의 성공·실패 시점을 직접 제어합니다. 고정 시간만 기다리는 테스트와 달리 “아직 요청이 끝나지 않은 순간”을 명시적으로 검사할 수 있습니다. getByRole과 레이블로 실제 화면을 조작하고, 내부 함수 호출 여부에 더해 사용자가 보는 문구와 버튼 상태를 확인합니다.
src/App.test.jsx
검증 결과: Vitest/jsdom 사용자 동작 테스트 3개 통과, Vite 프로덕션 빌드 통과입니다. 실제 브라우저 실행, HTTP 서버 연결, 배포 검증은 수행하지 않았습니다. 네트워크 환경에서의 취소, 인증, 타임아웃은 실제 API로 연결한 뒤 별도로 확인해야 합니다.
실습 파일과 다음 학습
연결 학습: 무효화·응답 반영·낙관적 변경 선택
목표·선행 지식: 추가·수정·삭제 요청의 대기 시간과 실패 경험을 비교합니다.
invalidateQueries는 서버 기준 목록을 다시 확인하는 단순한 전략입니다. setQueryData는 응답을 이용해 불변 방식으로 캐시를 직접 바꿉니다. 낙관적 변경은 성공 전에 바꾸므로 이전 값과 실패 복구가 필요합니다. DELETE가 항상 삭제된 객체를 응답한다고 가정하지 말고 빈 응답이면 요청에 사용한 ID를 활용합니다.
직접 확인할 과제
onSuccess와 onMutate에 같은 UI 변경을 각각 넣어 반영 시점의 차이를 비교하세요. 실패 시 입력을 보존하고 요청 중 중복 실행을 막습니다. 무효화 Promise를 반환하면 후속 확인을 기다리는 흐름을 만들 수 있습니다.
공통 실습 ZIP · 다음 학습 · 라이브러리 선택 가이드
공통 ZIP은 버전을 고정한 학습 예제입니다. UI 파일은 Radix·Sonner·Embla 기반 축약 구현이며 shadcn CLI 생성물과 동일하지 않습니다. 적용 범위와 실행 방법은 ZIP의 README를 확인하세요.
이 글이 도움이 되었나요?
TanStack Query 학습 순서
필수 11개 · 전체 12개
읽음 기록 관리
전체 과정 목차 (12개)
- 필수 길잡이 · TanStack Query vs Zustand: 서버 상태와 클라이언트 상태 차이
- 필수 학습 · TanStack Query Provider와 첫 useQuery 상태 처리 실습 가이드
- 필수 학습 · TanStack Query queryKey 설계 기준: 배열 키로 캐시 구분하기
- 필수 학습 · TanStack Query queryFn 사용법: 데이터 요청 로직 분리하기
- 필수 학습 · TanStack Query staleTime gcTime 차이: 캐시 시간 기준
- 필수 학습 · TanStack Query useMutation 저장 실습: 실패·재시도와 목록 갱신까지 현재 글
- 선택 참고 · TanStack Query 화면 갱신 문제 해결: 데이터 변경 후 UI가 바뀌지 않을 때
- 필수 학습 · TanStack Query 페이지네이션 실습: placeholderData로 이전 목록 유지하기
- 필수 학습 · TanStack Query 무한 스크롤 사용법: useInfiniteQuery로 목록 이어 불러오기
- 필수 선수 · TanStack Query Hydration 오류 해결: QueryClient 설정 기준
- 필수 학습 · TanStack Query Todo 실습: 서버 연결·낙관적 업데이트·실패 롤백
- 필수 학습 · TanStack Query 수동 캐시 정규화: 목록·상세 동기화와 선택 기준
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.