Tanstack Query에서 mutation 으로 달라지는 파라미터 variable?

eeennsu·2026년 6월 7일

개요

useMutation을 쓰다 보면 onSuccess, onError, onSettled 같은 콜백의 인자가 헷갈리는 순간이 온다. "이 variables는 어디서 온 거지?", "context는 또 뭐지?", "mutationFn은 인자를 안 받는데 onSuccess엔 왜 값이 들어오지?" 같은 의문들.

이 글은 mutation 라이프사이클 전체에서 어떤 값이 어디로 흘러가는지 정리한 글이다.



1. 큰 그림 — mutation의 라이프사이클

mutate(...)를 호출하는 순간부터 mutation이 끝날 때까지 호출되는 함수는 다섯 개다.

mutate(variables)
    │
    ▼
┌───────────────────┐
│  onMutate         │   요청 전에 실행. optimistic update에 쓰임.
│  (variables)      │   반환값이 context가 된다.
└───────────────────┘
    │
    ▼
┌───────────────────┐
│  mutationFn       │   실제 비동기 작업(API 호출 등).
│  (variables)      │   여기서 반환된 값이 data가 된다.
└───────────────────┘
    │
    ├── 성공 ─────────────┐
    │                     ▼
    │             ┌───────────────────────────────────┐
    │             │ onSuccess                         │
    │             │ (data, variables, context)        │
    │             └───────────────────────────────────┘
    │                     │
    └── 실패 ─────────┐   │
                      ▼   │
              ┌───────────────────────────────────┐
              │ onError                           │
              │ (error, variables, context)       │
              └───────────────────────────────────┘
                      │   │
                      ▼   ▼
              ┌─────────────────────────────────────────────────┐
              │ onSettled                                       │
              │ (data, error, variables, context)               │
              │ 성공/실패 무관하게 마지막에 실행                │
              └─────────────────────────────────────────────────┘

이 흐름에서 인자로 등장하는 네 가지 — variables, data, error, context — 각각의 정체를 하나씩 보자.



2. variables — mutate()에 넘긴 값 그대로

mutate(value) 호출 시 넘긴 첫 번째 인자가 그대로 variables다. 라이프사이클 전 구간(onMutate, mutationFn, onSuccess, onError, onSettled)에 동일한 값이 전달된다.

const { mutate } = useMutation({
  mutationFn: async (input: { id: number; name: string }) => {
    return await apiUpdateUser(input);
  },
  onSuccess: (data, variables) => {
    // variables === { id: 1, name: "철수" }
    console.log(variables.id);
  },
});

mutate({ id: 1, name: "철수" });

mutationFn이 인자를 안 받아도 variables는 전달된다

이게 헷갈리기 쉬운 포인트다. mutationFn의 시그니처와 무관하게 mutate()에 넘긴 값은 콜백들로 흘러간다.

const { mutate } = useMutation({
  // 인자 없는 시그니처
  mutationFn: async (): Promise<string> => {
    return await apiPostToken();
  },
  onSuccess: (data, variables) => {
    // variables엔 mutate()에서 넘긴 값이 그대로 들어 있음
    console.log(variables); // "/home"
  },
});

mutate("/home"); // ← 서버 요청에는 안 쓰이지만 onSuccess에서 쓸 수 있음

서버 요청에는 포함하지 않지만 콜백에서만 쓰고 싶은 값(예: 라우팅 경로, UI 상태 키)을 흘려보낼 때 쓰는 패턴이다. 다만 코드 읽는 사람이 헷갈리기 쉬우니 주석 한 줄 다는 걸 권장한다.

variables 안 넘기고 싶을 때

mutationFn이 정말로 인자를 안 받는다면 mutate() 호출 시에도 아무것도 안 넘기면 된다. 이때 variables는 undefined.

mutate(); // variables === undefined

타입스크립트에서 mutationFn: () => Promise<T>로 정의하면 mutate는 인자 없이 호출되도록 추론된다.



3. data — mutationFn이 resolve한 값

mutationFn이 반환한 값(Promise면 resolve된 값)이 onSuccess와 onSettled의 첫 번째 인자 data가 된다.

const { mutate } = useMutation({
  mutationFn: async (id: number) => {
    const response = await apiGetUser(id);
    return response.user; // ← 이 값이 data
  },
  onSuccess: (data) => {
    // data === response.user
  },
});

onError에는 data가 없다(에러가 났으니 당연). onSettled에는 data: T | undefined로 들어온다(실패 시 undefined).



4. error — mutationFn이 throw한 값

mutationFn이 throw하거나 reject하면 그 값이 onError와 onSettled의 error 인자로 들어온다.

const { mutate } = useMutation({
  mutationFn: async (id: number) => {
    throw new Error("Not found");
  },
  onError: (error, variables) => {
    // error === Error("Not found")
    // variables === id
  },
});

onSettled에서는 error: E | null이다(성공 시 null).


5. context — onMutate가 만든 임시 메모

여기가 가장 강력하면서도 가장 덜 알려진 인자다.

정체

onMutate의 반환값이 그대로 context가 되어 이후의 onSuccess, onError, onSettled로 전달된다. 주로 optimistic update의 롤백용 스냅샷을 저장하는 용도로 쓴다.

const { mutate } = useMutation({
  mutationFn: apiUpdateTodo,

  onMutate: async (newTodo) => {
    // 1. 진행 중인 쿼리 취소 (덮어쓰기 방지)
    await queryClient.cancelQueries({ queryKey: ["todos"] });

    // 2. 현재 캐시 값 스냅샷
    const previousTodos = queryClient.getQueryData(["todos"]);

    // 3. 낙관적으로 캐시 업데이트
    queryClient.setQueryData(["todos"], (old: Todo[]) => [...old, newTodo]);

    // 4. 스냅샷을 context로 반환 → onError에서 롤백에 사용
    return { previousTodos };
  },

  onError: (error, variables, context) => {
    // 실패 시 스냅샷으로 복원
    if (context?.previousTodos) {
      queryClient.setQueryData(["todos"], context.previousTodos);
    }
  },

  onSettled: () => {
    // 성공이든 실패든 서버 상태로 다시 sync
    queryClient.invalidateQueries({ queryKey: ["todos"] });
  },
});

왜 그냥 클로저로 안 쓰고 context로 받나

같은 컴포넌트 안에 변수 두고 쓰면 되지 않나 싶을 수 있는데, mutation이 동시에 여러 번 트리거될 수 있기 때문이다. 각 호출마다 독립된 context가 그 호출의 콜백 체인에만 전달되므로 서로 섞이지 않는다.

mutate(todoA); // context A 만들어짐 → onSuccess/onError에 context A
mutate(todoB); // context B 만들어짐 → onSuccess/onError에 context B

클로저로 변수를 잡으면 동시 실행 시 마지막 값으로 덮어쓰여 롤백이 깨진다. context는 이걸 깔끔하게 분리해준다.

context의 타입

onMutate가 반환한 값의 타입이 그대로 잡힌다. 반환 안 하면 undefined. onError/onSettled에서는 context: TContext | undefined로 들어온다(onMutate 자체가 throw할 수 있어서). onSuccess에서는 context: TContext로 non-nullable.


6. useMutation 콜백 vs mutate 콜백

콜백은 두 곳에 정의할 수 있다.

// 1) useMutation 레벨 - 훅 정의 시점
const mutation = useMutation({
  mutationFn,
  onSuccess: (data, variables, context) => { /* A */ },
});

// 2) mutate 레벨 - 호출 시점
mutation.mutate(value, {
  onSuccess: (data, variables, context) => { /* B */ },
});

실행 순서

둘 다 실행된다. 순서는 useMutation 레벨이 먼저, mutate 레벨이 나중. 같은 콜백을 두 군데 정의해도 덮어쓰기가 아니다.

mutate(value, { onSuccess: B })
   ↓
useMutation의 onSuccess(A) 실행
   ↓
mutate의 onSuccess(B) 실행

언마운트 시 차이

상황useMutation 콜백mutate 콜백
컴포넌트가 언마운트되어도 mutation 진행 중✅ 실행됨❌ 실행 안 됨

중요한 차이점이다. "이 mutation이 끝나면 무조건 캐시 invalidate" 같이 컴포넌트 생명주기와 무관해야 하는 로직은 useMutation 레벨에 둬야 한다. "성공하면 이 화면에서 토스트 띄우기" 같이 컴포넌트가 살아있을 때만 의미 있는 로직은 mutate 레벨에 둬도 된다.


7. 자주 헷갈리는 포인트 정리

1) mutationFn은 인자를 안 받는데 variables가 왜 있나?

variables는 mutate(...)에 넘긴 값을 라이프사이클 전체에 그대로 흘려보내는 별도 채널이다. mutationFn 시그니처와 무관.


2) onSuccess의 variables는 변형된 값인가, 원본인가?

원본 그대로. mutationFn 안에서 variables를 변형해도 onSuccess에 들어오는 variables는 처음 mutate()에 넘긴 값이다.

mutationFn: async (input) => {
  input.transformed = true; // 이렇게 바꿔도
  return ...;
},
onSuccess: (data, variables) => {
  // variables엔 transformed 없음 (원본 참조에 변형됐다면 보일 수도 있지만 의도된 흐름은 아님)
}

엄밀히는 같은 객체 참조이므로 mutationFn에서 객체 프로퍼티를 수정하면 onSuccess에서도 보일 수 있다. 하지만 명시적 데이터 전달 채널이 아니므로 의존하지 말 것. 값을 흘리고 싶으면 context를 쓰자.


3) data를 가공해서 다음 콜백으로 넘기고 싶다

mutationFn이 반환하는 값을 가공해서 반환하면 그게 data가 된다. 콜백 사이에서 추가 가공이 필요하면 onMutate에서 context로 흘려보내는 게 정석.


4) async 콜백 써도 되나?

onMutate, onSuccess, onError, onSettled 모두 async 가능. 단 onMutate의 async는 mutationFn 실행을 차단한다 — onMutate가 resolve된 후에 mutationFn이 실행된다(optimistic update가 먼저 끝나도록 보장하려는 의도된 동작). 나머지 콜백의 async는 mutation 자체의 settled 상태를 지연시키진 않지만 await mutateAsync()의 완료 시점에는 영향을 준다.


8. 정리

인자출처등장하는 콜백
variablesmutate(여기) 호출 시 첫 인자onMutate, mutationFn, onSuccess, onError, onSettled
datamutationFn이 resolve한 값onSuccess, onSettled
errormutationFn이 throw한 값onError, onSettled
contextonMutate의 반환값onSuccess, onError, onSettled

핵심: mutate()에 넘긴 값은 variables로, optimistic update용 스냅샷은 context로 흘린다. 두 채널은 독립적이고, 각 mutation 호출마다 별도로 격리된다.

profile
이력서 https://resume.eunsu.pro

0개의 댓글