TanStack-Query는 비동기 데이터를 효율적으로 상태 관리 할 수 있도록 강력한 여러 기능을 제공하는 라이브러리다.
그 중 대표적인 훅 중 하나로 useQuery 가 있다. useQuery는 서버의 데이터를 조회하는 훅으로, GET 요청에 사용된다.
이번 글에서는 서버의 데이터를 추가, 삭제 등 조작하는 훅인 useMutation 에 대해서 알아본다.
mutation의 사전적 정의는 변경, 변이이다. 따라서, useMutation 은 흔히 POST, DELETE, PUT 등 서버의 데이터를 변경하는 요청에 사용된다. 또한, 해당 훅을 이용하면 낙관적 업데이트를 손쉽게 구현할 수 있다. 직접 서버를 생성하고 todo 앱을 만들어 하나하나 살펴보자.
1️⃣ 프로젝트 생성
전체 디렉토리 구조는 다음과 같다.
.
├─ client
│ └─ src
│ ├─ App.css
│ ├─ App.jsx
│ ├─ main.jsx
│ ├─ Buton.jsx
│ └─ util.js
└─ server
└─ app.js
✅ Server
express, cors 라이브러리를 설치한다.
npm i express cors
//app.js
const express = require('express');
const cors = require('cors');
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cors());
const PORT = 3000;
const todoList = [
{ id: 1, content: '밥 먹기' },
{
id: 2,
content: '손 씻기',
},
{
id: 3,
content: '산책 하기',
},
];
app.get('/', (req, res) => {
res.json(todoList);
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
✅ Client
Vite를 이용해 리액트 프로젝트를 생성하고 TanStack Query 라이브러리를 설치한다.
npm create vite@latest
npm i @tanstack/react-query
QueryClient 객체를 생성하고, QeuryClientProvider 를 통해 전역으로 주입한다.
//client/src/main.jsx
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
createRoot(document.getElementById('root')).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
useQuery 훅을 이용하여, 서버에 저장되어 있는 todoList를 받아온다.
또한 Button 컴포넌트에 newTodo 상태와, clearInput 함수를 props로 전달한다.
//client/src/App.jsx
import { useQuery } from '@tanstack/react-query';
import './App.css';
import { fetchTodo } from './util';
import Button from './Button';
import { useState } from 'react';
function App() {
const [newTodo, setNewTodo] = useState('');
const { data, isFetching } = useQuery({
queryKey: ['todo'],
queryFn: fetchTodo,
});
const handleInput = (e) => {
setNewTodo(e.target.value);
};
const clearInput = () => {
setNewTodo('');
};
if (isFetching) return <div>Fetching...</div>;
return (
<div>
<h1>TodoList</h1>
<ul>
{data?.map((todoList) => (
<li key={todoList.id}>{todoList.content}</li>
))}
</ul>
<input
type="text"
placeholder="추가할 할 일을 입력하세요"
onChange={handleInput}
value={newTodo}
/>
<Button text="추가" newTodo={newTodo} clearInput={clearInput} />
</div>
);
}
export default App;
/* client/src/App.css */
div {
display: flex;
flex-direction: column;
align-items: center;
}
input {
font-size: 16px;
}
Button 컴포넌트는 useMutation 로직이 포함될 컴포넌트이다.
//client/src/Button.jsx
const Button = ({ text,newTodo,clearInput }) => {
return <button>{text}</button>;
};
export default Button;
useQuery 훅의 queryFn에 할당한 함수는 따로 모듈로 분리했다.
//client/src/util.js
export const fetchTodo = async () => {
const response = await fetch('http://localhost:3000');
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
};
여기까지 진행하면 결과는 다음과 같다.

2️⃣ useMutation 사용법
먼저, useMutation 훅은 다음과 같이 사용된다.
const { mutate, isPending, isError, isSuccess } = useMutation({
mutationFn: () => {},
onSuccess: () => {},
onError: () => {},
onSettled: () => {},
onMutate: () => {},
});
useMutation에 할당하는 각각의 프로퍼티에 대해 살펴보자.
- mutationFn: 구조 분해 할당으로 받아온 mutate 함수를 통해 실행할 mutation 함수를 정의. 반드시 Promise를 반환하는 함수여야 함.
- onSuccess: mutate 가 성공했을 때 실행되는 함수
- onError: mutate가 실패했을 때 실행되는 함수
- onSettled: mutate가 성공하든 실패하든 완료되었을 때 실행되는 함수
- onMutate: mutate가 시작되기 전에 실행되는 함수. 낙관적 업데이트를 구현할 때 유용.
이제, 구조 분해 할당을 통해 반환되는 값들을 살펴보자.
- mutate: mutationFn에 정의한 함수. 즉, mutation을 실행하는 함수
- isPending: mutate가 진행 중인지 여부
- isError: mutate가 실패했는지 여부
- isSuccess: mutate가 성공했는지 여부
지금은 자주 사용되는 프로퍼티와 반환값만 살펴보았지만 이 외에도 다양한 프로퍼티와 반환값이 존재한다.
추가적인 정보는 아래 공식 사이트를 참고하면 된다.
useMutation | TanStack Query React Docs
tsx const { data, error, isError, isIdle, isPending, isPaused, isSuccess, failureCount, failureReason, mutate, mutateAsync, reset, status, submittedAt, variables, } = useMutation( { mutationFn, gcTime...
tanstack.com
항상 프로그래밍을 하면서 느끼는 것이, 이론만 보는 것은 크게 와닿지 않는다. 직접 코드를 구현해보고 결과가 어떻게 되는지 확인하는 것이 이해의 폭을 넓힐 수 있다.
먼저, 서버가 POST 요청에 응답할 수 있도록 핸들러를 추가한다.
//server/app.js
/*
생략
*/
const todoList = [
{ id: 1, content: '밥 먹기' },
{
id: 2,
content: '손 씻기',
},
{
id: 3,
content: '산책 하기',
},
];
app.post('/', (req, res) => {
setTimeout(() => {
todoList.push({
id: todoList.length + 1,
content: req.body.content,
});
res.status(201).json(req.body);
}, 3000);
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
일부러 setTimeout을 통해 3초 뒤에 응답하도록 하였다.
이제 POST 요청을 하는 함수를 클라이언트 util.js 에 구현해보자.
POST 요청을 하는 함수는, useMutation 훅의 mutationFn 프로퍼티에 대입될 예정이다.
//client/src/util.js
/*
생략
*/
export const addTodo = async (newTodo) => {
const response = await fetch('http://localhost:3000', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: newTodo }),
});
if (!response.ok) {
throw new Error('Failed to add todo');
}
return response.json();
};
이제 Button 컴포넌트에서 useMutation 훅을 사용해보자.
//client/src/Button.jsx
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { addTodo } from './util';
const Button = ({ text, newTodo, clearInput }) => {
const queryClient = useQueryClient();
const { mutate, isPending } = useMutation({
mutationFn: addTodo,
onSuccess: () => {
queryClient.invalidateQueries(['todo']);
clearInput();
},
onError: (error) => {
console.error(error.message);
},
});
return (
<>
<button onClick={() => mutate(newTodo)}>{text}</button>
{isPending && <div>할 일 목록을 추가 중입니다...</div>}
</>
);
};
export default Button;
util.js 에서 구현한 addTodo 함수를 mutationFn 프로퍼티의 값으로 사용하였다.
따라서 구조 분해 할당으로 받은 mutate 함수를 실행하면 addTodo 함수가 실행된다.
onSuccess 에 할당한 콜백 함수를 살펴보면 다음과 같다.
onSuccess: () => {
queryClient.invalidateQueries(['todo']);
clearInput();
},
queryClient.invalidateQueries 메서드는 매개변수로 할당한 queryKey의 모든 쿼리를 stale 상태로 바꾸고 해당 queryKey에 등록된 queryFn 함수를 통해 서버로 데이터를 재요청 하게 된다.
쉽게 말하면, POST 요청을 통해 서버의 데이터가 바뀌었으니, 변경된 데이터를 다시 요청하여 서버와 클라이언트의 상태를 동기화하는 것이다.
그리고, clearInput 함수를 통해 input에 입력한 값을 초기화 해준다.
여기까지 결과를 살펴보면 다음과 같다.

현재 서버에서 요청이 들어오면 3초 후에 응답을 하기 때문에, 3초 동안 isPending이 true가 되어 로딩 UI가 나오는 것이다.
3️⃣ 낙관적 업데이트
낙관적 업데이트(Optimistic Update)는 UI를 업데이트할 때 서버의 응답을 기다리지 않고, 요청이 성공할 것이라고 가정하여 클라이언트에서 미리 UI를 업데이트 하는 패턴이다. 즉, 데이터 변경 요청(POST, PUT, DELETE 등)이 성공할 것이라고 "낙관적으로" 생각하고, 성공 응답 전에 화면을 업데이트 하는 방법이다.
현재, POST 요청을 보내면 서버에서는 3초 간의 딜레이가 발생한 후, 응답을 한다.
낙관적 업데이트를 구현하면 이러한 딜레이를 무시한 채, 즉각적으로 화면에 반영하여 UX를 크게 향샹 시킬 수 있다.
useMutation 훅의 onMutate 프로퍼티를 사용하면 손쉽게 구현할 수 있다.
먼저, onMutate 프로퍼티가 사용되면 mutate 함수가 실행될 때 다음과 같은 순서로 작동된다.
- mutate 함수 실행
- onMutate에 할당한 콜백 함수 실행
- mutationFn에 할당한 콜백 함수 실행. 지금 예제에서는 addTodo 함수 실행
- addTodo 함수가 성공하면 onSuccess에 할당한 함수 실행
- addTodo 함수가 실패하면 onSuccess에 실패한 함수 실행
//client/src/Button.jsx
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { addTodo } from './util';
const Button = ({ text, newTodo, clearInput }) => {
const queryClient = useQueryClient();
const { mutate, isPending } = useMutation({
mutationFn: addTodo,
onMutate: async (newTodo) => {
// 혹시 모를 진행 중인 쿼리가 있을 경우를 대비
await queryClient.cancelQueries({ queryKey: ['todo'] });
const previousTodos = queryClient.getQueryData(['todo']);
queryClient.setQueryData(['todo'], (oldData) => {
const newTodoItem = {
id: oldData.length + 1,
content: newTodo,
};
return [...oldData, newTodoItem];
});
clearInput();
return { previousTodos };
},
onSuccess: () => {
queryClient.invalidateQueries(['todo']);
},
onError: (error, _, context) => {
console.error(error.message);
// 이전 상태로 롤백
queryClient.setQueryData(['todo'], context.previousTodos);
},
});
return (
<>
<button onClick={() => mutate(newTodo)}>{text}</button>
</>
);
};
export default Button;
onMutate 의 콜백 함수를 자세히 살펴보자.
onMutate 의 콜백 함수는 mutate 함수에 할당하는 변수를 그대로 받을 수 있다. 여기서는 newTodo가 된다.
onMutate: async (newTodo) => {
// 생략
},
먼저, cancelQuries 메서드를 통해 혹시라도 네트워크 요청 중인 'todo' 쿼리가 있다면 취소한다. 일종의 방어적 프로그래밍 기법이며 TanStack Query에서 낙관적 업데이트를 구현할 때 추천하는 패턴이다.
await queryClient.cancelQueries({ queryKey: ['todo'] });
그리고, 이전 'todo' queryKey에 캐싱되어 있는 데이터를 getQueryData 메서드를 통해 가져오고 previousTodos 변수에 할당한다. 해당 변수는 혹시라도 mutation 함수가 실패했을 때, 낙관적 업데이트를 취소하고 이전 상태로 되돌리기 위해 사용된다.
const previousTodos = queryClient.getQueryData(['todo']);
그리고, setQueryData 메서드를 통해 'todo' queryKey에 캐싱된 데이터에 newTodoItem을 추가하여 변경한다. setQueryData 메서드로 전달하는 콜백 함수 내의 oldData 는 'todo' queryKey에 캐싱된 데이터를 의미한다.
참고로, setQeuryData 로 캐싱된 데이터를 직접 변경하면 TanStack Query는 네트워크 요청 없이 변경된 데이터로 해당 queryKey를 사용하는 컴포넌트를 리렌더링 한다.
queryClient.setQueryData(['todo'], (oldData) => {
const newTodoItem = {
id: oldData.length + 1,
content: newTodo,
};
return [...oldData, newTodoItem];
});
마지막으로, clearInput() 함수를 통해 input 값을 비우고 {previousTodos} 를 반환하며 onMutate의 콜백 함수의 실행을 종료한다.
onMutate: async (newTodo) => {
// 생략
clearInput();
return { previousTodos };
},
onMutate를 통해 반환된 {previousTodos} 는 onSuccess, onError 콜백 함수의 세 번째 인자로 전달할 수 있다.
따라서, 현재 onError 의 콜백 함수를 보면 세 번째 인자인 context로 받은 것을 확인할 수 있다.

onError 콜백 함수는 setQueryData 메서드를 통해 이전에 캐싱된 데이터로 롤백하는 로직을 담고 있다.
여기까지 실행 결과는 다음과 같다.

자세히 보면 이상하게 동작되는 것을 볼 수 있다.
할 일을 계속 추가하는 도중 낙관적 업데이트가 된 UI들이 사라지고 이전에 입력했던 할 일들이 하나씩 화면에 렌더링 되는 현상이 발생했다.
상황을 분석하고 다음과 같은 이유를 알아냈다.
- 처음에 ㅇ 입력 -> 낙관적 업데이트 반영
- 두 번째에 ㄴ 입력 -> 낙관적 업데이트 반영
- 세 번째에 ㄹ 입력 -> 낙관적 업데이트 반영
- 네 번째에 ㅇ 입력 -> 낙관적 업데이트 반영
- 처음에 입력한 ㅇ에 대한 POST 요청이 3초가 지나서 서버가 todoList에 새로운 todo를 push하고 클라이언트에 응답
- Mutation 함수가 성공했기에 onSucess 콜백 함수가 실행되어 queryClient.invalidateQueries(['todo']) 실행
- 'todo' queryKey에 등록된 queryFn 즉, fetchTodo 함수가 실행되어 처음에 입력한 ㅇ까지 push된 todoList 전송
- 처음에 입력한 ㅇ까지 반영된 todoList가 화면에 리렌더링 되어 지금까지 반영된 낙관적 업데이트 모두 삭제
- 그 다음 입력한 ㄴ,ㄹ,ㅇ 도 입력한 순간을 기준으로 3초 뒤 위와 같은 이유로 화면에 리렌더링
결론은, 낙관적 업데이트를 위해 queryClient.invalidateQueries(['todo']) 를 실행하면 안되는 것이다.
따라서, onSucess의 콜백 함수를 삭제하였다. 애초에 낙관적 업데이트는 서버가 정상적으로 작동할 것이라는 가정으로 행해지는 업데이트기에 지금은 굳이 mutation 함수가 성공했을 때 추가적으로 할 작업이 없기 때문이다.
onSuccess 콜백 함수를 삭제한 후 결과는 다음과 같다.

마지막으로 mutation 함수가 실패했을 때의 상황을 만들어 onError 콜백 함수가 제대로 작용하는지 확인해보자.
서버의 포트 번호는 3000이지만 일부러 3001로 수정하여 잘못된 요청을 보내게 하였다.
export const addTodo = async (newTodo) => {
const response = await fetch('http://localhost:3001', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: newTodo }),
});
if (!response.ok) {
throw new Error('Failed to add todo');
}
return response.json();
};

초기에 낙관적 업데이트를 수행했음에도, 요청이 실패하여 다시 이전 상태로 되돌아간 것을 확인할 수 있다.
'React' 카테고리의 다른 글
| [React] TanStack-Query의 useInfiniteQuery 훅을 이용하여 무한스크롤 구현하기 (1) | 2025.01.10 |
|---|---|
| [React] 비동기 데이터 상태 관리와 TanStack-Query 라이브러리 (1) | 2024.12.19 |
| [React] 바닐라JS로 가상돔(VirtualDOM) 만들기 (0) | 2024.10.02 |