티스토리 뷰
React에서 API를 다루다 보면 데이터를 조회하는 것만큼 데이터를 생성하거나 수정하고 삭제하는 작업도 자주 발생한다.
TanStack Query에서는 데이터를 조회할 때 useQuery를 사용하고 서버의 데이터를 변경할 때 useMutation을 사용한다.
처음 useMutation을 접했을 때는 단순히 POST나 PUT 요청을 보내는 함수 정도로 생각했는데
실제로 사용해 보니 요청 상태 관리부터 성공/실패 처리, 기존 Query 캐시 갱신까지 함께 처리할 수 있어 꽤 편리하다고 느꼈다.
이번 글에서는 useMutation의 기본적인 사용법과 실제 프로젝트에서 자주 사용하는 패턴을 정리해보려고 한다.
useMutation이란?
useQuery와 useMutation의 차이를 간단하게 정리하면 다음과 같다.
🟠 useQuery → 데이터를 조회할 때 사용
🟠 useMutation → 데이터를 생성, 수정, 삭제할 때 사용
예를 들어 게시글 목록을 조회하는 API가 있다면 useQuery를 사용할 수 있다.
반대로 아래와 같은 작업에는 useMutation을 사용할 수 있다.
- 회원가입/로그인 요청
- 게시글 등록/수정/삭제
- 댓글 등록
- 좋아요 처리
즉, 서버의 상태를 변경하는 요청이라고 생각하면 이해하기 쉽다.
기본적인 useMutation 사용법
먼저 게시글을 수정하는 API가 있다고 가정해보면
const updatePost = async ({
id,
title,
content,
}: {
id: number;
title: string;
content: string;
}) => {
const response = await fetch(`/posts/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title,
content,
}),
});
if (!response.ok) {
throw new Error("게시글 수정 실패");
}
return response.json();
};
이 API 함수를 useMutation과 연결하면 다음과 같이 사용할 수 있다.
import { useMutation } from "@tanstack/react-query";
const mutation = useMutation({
mutationFn: updatePost,
});
여기서 가장 중요한 부분은 mutationFn이다.
mutationFn: updatePost
mutationFn에는 실제 서버 요청을 수행하는 함수를 전달한다.
mutation을 실행하는 mutate
useMutation을 선언했다고 해서 API 요청이 바로 실행되는 것은 아니다.
useQuery는 조건에 따라 자동으로 데이터를 가져오는 경우가 많지만
mutation은 일반적으로 사용자의 행동에 따라 실행해야 한다.
이때 사용하는 것이 mutate다.
mutation.mutate({
id: 1,
title: "수정된 제목",
content: "수정된 내용",
});
예를 들어 버튼을 눌렀을 때 게시글을 수정하고 싶다면 다음과 같이 사용할 수 있다.
<button
onClick={() =>
mutation.mutate({
id: 1,
title: "수정된 제목",
content: "수정된 내용",
})
}
>
수정하기
</button>
mutate()에 전달한 객체는 그대로 mutationFn의 인자로 전달된다.
mutation.mutate({
id: 1,
title: "제목",
content: "내용",
});
즉 위 함수를 실행하면 내부적으로 다음 함수가 호출되는 구조다. 👇
updatePost({
id: 1,
title: "제목",
content: "내용",
});
성공과 실패 처리하기
API 요청을 보냈다면 성공했을 때와 실패했을 때 각각 처리해야 하는 경우가 많다.
이때 onSuccess, onError를 사용할 수 있다.
const mutation = useMutation({
mutationFn: updatePost,
onSuccess: (data) => {
console.log("수정 성공", data);
},
onError: (error) => {
console.error("수정 실패", error);
},
});
예를 들어 게시글 수정에 성공한 뒤 사용자에게 알림을 보여주거나 페이지를 이동할 수 있다.
const mutation = useMutation({
mutationFn: updatePost,
onSuccess: () => {
alert("게시글이 수정되었어요.");
navigate("/posts");
},
onError: () => {
alert("게시글 수정에 실패했어요.");
},
});
API 요청과 성공/실패에 따른 처리를 하나의 mutation 안에서 관리할 수 있다는 점이 편리하다.
mutate와 mutateAsync의 차이
mutation을 실행하는 방법은 크게 두 가지가 있다.
mutation.mutate();
또는
await mutation.mutateAsync();
mutate는 콜백 기반으로 처리할 때 사용하기 좋다.
mutation.mutate(
{
id: 1,
title: "제목",
content: "내용",
},
{
onSuccess: () => {
console.log("성공");
},
onError: () => {
console.log("실패");
},
}
);
반면 mutateAsync는 Promise를 반환하기 때문에 async/await을 사용할 수 있다.
try {
const result = await mutation.mutateAsync({
id: 1,
title: "제목",
content: "내용",
});
console.log(result);
navigate("/posts");
} catch (error) {
console.error(error);
}
요청 이후 여러 작업을 순차적으로 처리해야 한다면 mutateAsync가 읽기 편한 경우가 많다.
그렇다고 mutateAsync가 항상 더 좋은 것은 아니다.
단순히 성공/실패 콜백만 필요한 경우라면 mutate와 onSuccess, onError 조합으로도 충분하다.
mutation의 상태값 활용하기
useMutation은 API 요청뿐만 아니라 현재 요청의 상태도 관리해준다.
대표적으로 다음과 같은 값을 사용할 수 있다.
mutation.isPending;
mutation.isSuccess;
mutation.isError;
mutation.data;
mutation.error;
예를 들어 요청 중에는 버튼을 비활성화할 수 있다.
<button
onClick={handleUpdate}
disabled={mutation.isPending}
>
{mutation.isPending ? "수정 중..." : "수정하기"}
</button>
별도로 useState를 만들어서 로딩 상태를 관리하지 않고 mutation이 제공하는 상태를 활용할 수 있다는 장점이 있다.
❓ mutation 성공 후 화면의 데이터가 안 바뀌는 이유
useMutation을 처음 사용할 때 가장 헷갈릴 수 있는 부분인데,
게시글 수정 API 요청이 성공했는데 화면에는 수정 전 데이터가 그대로 표시되는 상황이 발생할 수 있다.
이유는 서버의 데이터와 TanStack Query가 가지고 있는 캐시는 별개이기 때문이다.
예를 들어 게시글을 다음 Query로 조회하고 있다고 가정한다.
useQuery({
queryKey: ["post", id],
queryFn: () => getPost(id),
});
그리고 mutation을 통해 게시글을 수정한다.
mutation.mutate({
id,
title,
content,
});
서버의 게시글은 정상적으로 변경됐지만 기존 Query 캐시에는 수정 전 데이터가 남아 있을 수 있다.
따라서 mutation 성공 후 해당 Query가 오래된 데이터라는 것을 TanStack Query에 알려줘야 한다.
invalidateQueries로 캐시 무효화하기
이때 사용하는 것이 invalidateQueries다.
먼저 useQueryClient를 가져오고,
import {
useMutation,
useQueryClient,
} from "@tanstack/react-query";
const queryClient = useQueryClient();
mutation 성공 후 Query를 무효화한다.
const mutation = useMutation({
mutationFn: updatePost,
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["post", variables.id],
});
},
});
게시글 목록도 함께 변경될 가능성이 있다면 목록 Query도 무효화할 수 있다.
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["post", variables.id],
});
queryClient.invalidateQueries({
queryKey: ["posts"],
});
},
이렇게 하면 TanStack Query가 해당 캐시를 오래된 데이터로 판단하고 필요한 시점에 다시 조회할 수 있다.
결국 흐름은 다음과 같다.
사용자가 수정 버튼을 클릭
↓
mutation.mutate()를 실행
↓
서버에 PUT 요청
↓
서버 데이터가 변경됨
↓
onSuccess가 실행됨
↓
invalidateQueries()를 실행
↓
관련 Query가 갱신
↓
화면에 최신 데이터가 표시됨
이 흐름을 이해하는 것이 useMutation을 사용할 때 가장 중요하다.
🔑 Query Key를 정확하게 맞춰야 한다
invalidateQueries를 사용할 때는 Query Key도 중요하다.
조회할 때 다음과 같이 작성했다면,
useQuery({
queryKey: ["post", id],
queryFn: () => getPost(id),
});
mutation에서도 동일한 구조의 Query Key를 사용해야 한다.
queryClient.invalidateQueries({
queryKey: ["post", id],
});
Query Key가 다르면 내가 원하는 Query가 제대로 갱신되지 않을 수 있다.
그래서 프로젝트가 커지면 Query Key를 별도로 관리하는 방식도 사용할 수 있다.
export const postKeys = {
all: ["posts"] as const,
detail: (id: number) =>
["post", id] as const,
};
사용할 때는 다음과 같이 작성할 수 있다.
queryClient.invalidateQueries({
queryKey: postKeys.detail(id),
});
Query Key를 여러 컴포넌트에서 직접 작성하는 것보다 오타나 구조 불일치를 줄일 수 있다.
⚠️ API 에러는 반드시 throw 해야 한다
fetch를 사용할 때 특히 주의해야 하는 부분이
fetch는 HTTP 응답이 400이나 500이라고 해서 자동으로 Promise를 reject하지 않는다.
const response = await fetch("/posts", {
method: "POST",
});
if (!response.ok) {
throw new Error("요청 실패");
}
return response.json();
따라서 에러를 throw해야 TanStack Query가 해당 요청을 실패한 mutation으로 인식하고 onError를 실행할 수 있다.
onError: (error) => {
console.error(error);
},
Axios처럼 HTTP 상태 코드에 따라 자동으로 reject되는 라이브러리를 사용하고 있었다면 이 차이 때문에 처음에는 헷갈릴 수 있다.
💭 마무리
처음에는 useMutation을 단순히 POST나 PUT 요청을 보내기 위한 Hook으로 생각했다.
하지만 실제로는 서버 데이터 변경 요청뿐만 아니라 요청 상태와 성공/실패 처리, 기존 Query 캐시와의 연결까지 담당하는 기능이다.
핵심은 서버의 데이터를 변경한 뒤 기존 Query 캐시를 어떻게 최신 상태와 동기화할 것인가이다.
기본적인 CRUD에서는 mutationFn, onSuccess, invalidateQueries 정도만 제대로 이해해도 충분히 활용할 수 있지만
이후 조금 더 복잡한 UI를 구현하게 된다면 setQueryData를 이용한 직접적인 캐시 업데이트나 Optimistic Update까지 확장해서 사용할 수 있을 것이다.

'FE > React' 카테고리의 다른 글
| 🔐 React 로그인 인증 흐름 정리 (ProtectedRoute / PublicRoute 설계) (0) | 2026.03.31 |
|---|---|
| 🐻 Zustand vs TanStack Query, 같이 쓰자! (0) | 2026.03.15 |
| 📚 기존 Vite + React 프로젝트에 TypeScript 추가하기 (마이그레이션 정리) (0) | 2026.03.12 |
| 📝 "리액트는 가상 DOM을 사용하여 성능을 최적화합니다." 가 무슨 말이야? (0) | 2023.09.13 |
| 클릭 이벤트로 알아보는 React vs 바닐라 JavaScript 핵심 차이 (1) | 2023.05.09 |