TanStack Query useMutation 저장 실습: 실패·재시도와 목록 갱신까지

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

TanStack Query useMutation 저장 성공 후 목록을 어떻게 갱신하나요?

useMutation은 저장 요청의 대기·실패·성공 상태를 관리합니다. 저장에 성공해도 목록 캐시에 새 항목이 자동으로 들어가지는 않으므로 정확한 목록 queryKey를 무효화해 다시 조회합니다.

적용 조건·범위: 실행 환경은 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/mutations · tanstack.com/query/latest/docs/framework/react/guides/query-invalidation

이 기술만 따로 연습하기

기대 결과: 저장하면 pending 동안 버튼이 비활성화되고 재조회 후 새 작업이 목록에 나타납니다. 실패 입력 → 저장에서는 오류가 표시되고 새 행은 생기지 않습니다.

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

실행 환경은 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 } },
});

let saved: string[] = ['기존 작업'];
async function readRows() { await wait(); return [...saved]; }
async function saveTitle(title: string) {
  await wait();
  if (title === '실패') throw new Error('연습용 저장 실패');
  if (!title.trim()) throw new Error('제목을 입력하세요');
  saved = [...saved, title.trim()];
  return title.trim();
}
function Demo() {
  const [title, setTitle] = useState('새 작업');
  const queryClient = useQueryClient();
  const query = useQuery({ queryKey: ['saved'], queryFn: readRows });
  const mutation = useMutation({
    mutationFn: saveTitle,
    retry: false,
    onSuccess: async () => {
      await queryClient.invalidateQueries({ queryKey: ['saved'] });
      setTitle('');
    },
  });
  return (
    <main>
      <label>제목 <input value={title} onChange={(event) => setTitle(event.target.value)} /></label>
      <button disabled={mutation.isPending} onClick={() => mutation.mutate(title)}>저장</button>
      <button onClick={() => setTitle('실패')}>실패 입력</button>
      {mutation.isPending && <p>저장 중</p>}
      {mutation.isError && <p>{mutation.error.message}</p>}
      <ul>{query.data?.map((row, index) => <li key={index}>{row}</li>)}</ul>
    </main>
  );
}

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

자가 확인·변형: 실패 후 제목을 수정해 다시 저장할 수 있습니다. 이 저장소는 메모리 모형이라 새로고침하면 사라집니다. 실제 서버에 영구 저장했다고 해석하지 않습니다.

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

mutationFn 만들기

app/lib/tasks-api.ts

type CreateTaskInput = {
  title: string;
};


export async function createTask(
  input: CreateTaskInput,
): Promise<Task> {
  const response = await fetch('/api/tasks', {
    method: 'POST',
    headers: {
      'Content-Type':
        'application/json',
    },
    body: JSON.stringify(input),
  });


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


  return response.json();
}

queryFn과 마찬가지로 HTTP 오류를 직접 던집니다.

첫 useMutation

const mutation = useMutation({
  mutationFn: createTask,
});

실행할 때 mutate를 호출합니다.

mutation.mutate({
  title: 'TanStack Query 공부',
});

mutation 상태

isPending
→ 서버 변경 요청 진행 중


isError
→ mutationFn 실패


isSuccess
→ mutation 성공

버튼 중복 클릭을 막을 때 isPending을 활용할 수 있습니다.

<button
  type="submit"
  disabled={mutation.isPending}
>
  {mutation.isPending
    ? '저장 중...'
    : '추가'}
</button>

실패 재시도

mutation은 기본적으로 실패를 자동 재시도하지 않습니다. 아래 retry: 1은 재시도를 명시한 비교 예제이며 상단 독립 앱의 retry: false와 구분합니다. 동일한 저장을 반복해도 되는 요청인지와 실패 원인을 먼저 판단한 뒤 정책을 정합니다.

useMutation({
  mutationFn: createTask,
  retry: 1,
});

모든 오류를 무조건 재시도하는 것이 좋은 것은 아닙니다.

예를 들어 잘못된 입력으로 발생한 400 오류는 같은 요청을 다시 보내도 성공하지 않을 가능성이 큽니다.

실제 서비스에서는 오류 종류에 따라 retry 함수를 설계할 수 있습니다.

성공 후 목록 갱신

서버에는 새 작업이 저장됐지만 기존 [‘tasks’] 캐시는 자동으로 새 항목을 포함하지 않습니다.

QueryClient를 가져옵니다.

const queryClient = useQueryClient();

성공 후 목록 query를 invalidation합니다.

const mutation = useMutation({
  mutationFn: createTask,
  onSuccess: async () => {
    await queryClient.invalidateQueries({
      queryKey: taskKeys.lists(),
    });
  },
});

무효화된 active query는 다시 서버 데이터를 확인할 수 있습니다.

TaskForm 입력은 React state

입력 중인 title까지 Query cache에 넣을 이유는 없습니다.

const [title, setTitle] =
  useState('');

역할은 다음과 같습니다.

입력 중 문자열
→ React state


POST /api/tasks 상태
→ useMutation


작업 목록 원본
→ useQuery

TaskForm 예제

'use client';


import {
  useMutation,
  useQueryClient,
} from '@tanstack/react-query';
import { useState } from 'react';
import {
  createTask,
} from '../lib/tasks-api';
import {
  taskKeys,
} from '../lib/task-keys';


export default function TaskForm() {
  const [title, setTitle] =
    useState('');


  const queryClient =
    useQueryClient();


  const mutation = useMutation({
    mutationFn: createTask,
    retry: 1,
    onSuccess: async () => {
      setTitle('');


      await queryClient
        .invalidateQueries({
          queryKey:
            taskKeys.lists(),
        });
    },
  });


  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();


        const value = title.trim();
        if (!value) return;


        mutation.mutate({
          title: value,
        });
      }}
    >
      <label htmlFor="task-title">
        새 작업
      </label>


      <input
        id="task-title"
        value={title}
        onChange={(event) =>
          setTitle(
            event.target.value,
          )
        }
      />


      <button
        type="submit"
        disabled={
          mutation.isPending
        }
      >
        {mutation.isPending
          ? '저장 중...'
          : '추가'}
      </button>


      {mutation.isError ? (
        <p>
          {mutation.error.message}
        </p>
      ) : null}
    </form>
  );
}

학습 완료 체크리스트

  • useMutation과 useQuery 역할을 구분할 수 있습니다.
  • mutationFn으로 POST 요청을 분리할 수 있습니다.
  • mutation pending/error 상태를 UI에 연결할 수 있습니다.
  • retry를 상황에 맞게 설정할 수 있습니다.
  • mutation 성공 후 관련 query를 invalidation할 수 있습니다.

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

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

POST /api/tasks를 useMutation으로 연결하고 저장 후 목록을 다시 확인합니다.

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

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

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/TaskDashboard.tsx — 기존 파일의 같은 위치를 갱신합니다.
  2. app/tasks/TaskForm.tsx — 이전 단계에 없던 파일을 만듭니다.

변경 이유와 결과: POST /api/tasks와 TaskForm의 useMutation을 연결합니다. 성공 시 목록 무효화, 실패 시 입력 보존과 오류 표시를 확인합니다.

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

app/tasks/TaskDashboard.tsx 전체 파일

'use client';
import { useState } from 'react';
import TaskListClient from './TaskListClient';
import type { TaskStatus } from '../lib/tasks-api';
import TaskDetailClient from './TaskDetailClient';
import TaskForm from './TaskForm';
export default function TaskDashboard() {
  const [status, setStatus] = useState<TaskStatus>('all');
  const [selected, setSelected] = useState<string | null>(null);
  return (
    <main>
      <h1>TaskFlow Query</h1>
      <label>필터 <select value={status} onChange={(event) => setStatus(event.target.value as TaskStatus)}>
        <option value="all">전체</option><option value="active">진행 중</option><option value="completed">완료</option>
      </select></label>
      <TaskForm />
      <TaskListClient status={status} onSelect={setSelected} />
      {selected && <TaskDetailClient taskId={selected} />}
    </main>
  );
}

app/tasks/TaskForm.tsx 전체 파일

 'use client';
import { useState, type FormEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createTask } from '../lib/tasks-api';
import { taskKeys } from '../lib/task-keys';
export default function TaskForm() {
  const [title, setTitle] = useState('');
  const [fail, setFail] = useState(false);
  const client = useQueryClient();
  const mutation = useMutation({
    mutationFn: () => createTask(title, fail),
    onSuccess: async () => { setTitle(''); await client.invalidateQueries({ queryKey: taskKeys.all }); },
  });
  function submit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    if (title.trim().length >= 2) mutation.mutate();
  }
  return (
    <form onSubmit={submit}>
      <label htmlFor="task-title">새 작업</label>
      <input id="task-title" value={title} onChange={(event) => setTitle(event.target.value)} minLength={2} maxLength={100} required />
      <label><input type="checkbox" checked={fail} onChange={(event) => setFail(event.target.checked)} />실습 저장 실패</label>
      <button disabled={mutation.isPending}>추가</button>
      {mutation.isPending && <p role="status">저장 중입니다.</p>}
      {mutation.isError && <p role="alert">{mutation.error.message}</p>}
    </form>
  );
}

완료 확인

  1. 시작본은 직전 완료본과 동일한 ZIP입니다. 압축을 푼 뒤 npm ci로 잠금 파일의 의존성을 설치합니다.
  2. npm run dev 실행 후 http://localhost:3000/tasks를 엽니다.
  3. POST /api/tasks와 TaskForm의 useMutation을 연결합니다. 성공 시 목록 무효화, 실패 시 입력 보존과 오류 표시를 확인합니다.
  4. npm run typecheck와 npm run build가 성공하면 다음 프로젝트 단계로 갑니다.

기존 체크포인트 검증 기록: TypeScript 검사·프로덕션 빌드와 실제 소스를 사용한 JSDOM/상태 시나리오 검증을 통과한 기록입니다. 별도 실제 브라우저 화면 검수는 그 파일 검증 범위에 포함하지 않았습니다. 이번 문장 보완에서 재실행한 결과는 아닙니다.

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

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

이전 프로젝트 단계: TanStack Query staleTime gcTime 차이: 캐시 시간 기준. 위 시작본 ZIP 전체를 내려받으면 이전 글을 다시 조립하지 않고 이 단계부터 실행할 수 있습니다.

tasks-api.ts에 저장 함수를 두고 TaskForm에서 useMutation을 연결합니다. 성공 뒤 목록 키를 invalidation하고 입력값은 로컬 state로 유지합니다.

이전 체크포인트

query-step-05-cache-time

이번 목표

POST /api/tasks를 useMutation으로 연결하고 저장 후 목록을 다시 확인합니다.

변경 파일

app/lib/tasks-api.ts
app/tasks/TaskForm.tsx

Before

POST 요청 상태와 목록 갱신을 직접 관리

After

useMutation
→ pending / error / success


onSuccess
→ task 목록 invalidation

실행 확인

  1. 작업 제목을 입력합니다.
  2. 저장 중 버튼이 비활성화되는지 확인합니다.
  3. Network에서 POST /api/tasks를 확인합니다.
  4. 성공 후 GET /api/tasks가 다시 호출되는지 확인합니다.
  5. 새 항목이 목록에 표시되는지 확인합니다.
npm run build

현재 확장 단계

✓ 1~5. 조회·캐시 기반
→ 6. useMutation
○ 7. 화면 갱신 문제 진단
○ 8~11. 페이지·낙관적 업데이트·캐시 동기화
○ 12~15. SSR·Hydration

완료 체크포인트

query-step-06-mutation

다음 학습

다음 글에서는 mutation이 성공했는데 화면이 바뀌지 않는 대표적인 상황을 queryKey 불일치, invalidation 범위, setQueryData 기준으로 진단합니다.

다음 프로젝트 단계: TanStack Query 화면 갱신 문제 해결: 데이터 변경 후 UI가 바뀌지 않을 때

공식 문서

프로젝트 적용 후 확인: 저장이 끝나면 목록은 언제 갱신될까요?

관찰 절차 /tasks에서 작업을 하나 저장하고 Network의 변경 요청 및 뒤따르는 GET /api/tasks를 순서대로 봅니다. 실패 응답을 한 번 만들고 오류 메시지와 목록이 그대로인지 확인한 뒤 재시도합니다.

예상 결과 성공 시 변경 요청 뒤 목록 무효화와 재조회로 새 값이 표시됩니다. 실패 시 성공 메시지와 목록 갱신이 발생하지 않아야 합니다.

오류 점검 저장 성공인데 목록이 그대로면 mutation 성공 콜백, invalidateQueries의 queryKey와 활성 목록 여부를 확인합니다.

다음 학습 7회차에서 UI가 갱신되지 않을 때 원인을 진단합니다.

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

글 소개·학습 준비·전체 목차
TanStack Query useMutation 저장 실습: 실패·재시도와 목록 갱신까지

TanStack Query · 6 / 15 · 필수 학습 · useMutation

useMutation은 저장 요청의 대기·실패·성공을 구분합니다. 저장이 성공해도 기존 목록 캐시는 자동으로 새 항목을 포함하지 않으므로 onSuccess에서 정확한 목록 키를 무효화하고 다시 조회합니다. 아래 모형에서 실패 입력과 수정 후 재저장을 각각 확인합니다.

상단은 메모리 기반 비동기 함수로 저장 중·실패·성공 후 갱신을 독립 실습합니다. 하단에서는 TaskFlow의 POST /api/tasks를 mutationFn에 연결합니다.

이번 글에서 배우는 내용

  • useMutation 기본 구조
  • mutationFn
  • mutate와 mutateAsync
  • isPending, isError, isSuccess
  • retry 옵션
  • 성공 후 invalidateQueries
  • TaskForm 로컬 state와 서버 mutation 역할 분리

목차

이 글이 도움이 되었나요?

조회 중

읽음 기록

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

이 글을 쓴 해비

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

새 글 받아보기

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

RSS 피드 구독하기

댓글 남기기