React Query Mutation 완벽 가이드: onSuccess, mutate, mutateAsync 제대로 사용하기

장성우·2025년 8월 5일
0

React Query를 사용하다 보면 mutation의 onSuccess가 실행되지 않거나, mutate와 mutateAsync 중 어떤 것을 써야 할지 헷갈리는 경우가 많습니다. 이 글에서는 @tanstack/react-query v5를 기준으로 mutation의 핵심 개념부터 실제 프로젝트에서 겪을 수 있는 함정들까지 상세히 다뤄보겠습니다.

🎯 알게 될 점

  • React Query mutation의 3가지 onSuccess 실행 방식 이해
  • mutate vs mutateAsync 차이점과 선택 기준
  • 컴포넌트 언마운트 시 발생하는 문제와 해결법
  • 실제 프로젝트에서 사용할 수 있는 베스트 프랙티스

📖 React Query Mutation의 3가지 패턴

React Query에서 mutation 성공 후 처리는 크게 3가지 방식으로 할 수 있습니다.

1. 훅 레벨에서 onSuccess 정의

const useCreatePackage = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: createPackage,
    onSuccess: (data) => {
      // 🔥 이 훅을 사용하는 모든 mutate 호출에서 실행
      queryClient.invalidateQueries(['packages']);
    },
  });
};

특징:

  • 해당 훅으로 생성한 모든 mutation이 성공할 때마다 실행
  • 캐시 무효화, 전역 상태 업데이트 등 공통 로직에 적합
  • 컴포넌트 마운트 상태와 무관하게 실행

2. mutate 호출 시 onSuccess 전달

const { mutate } = useCreatePackage();

mutate(packageData, {
  onSuccess: (data) => {
    // 🔥 이 특정 mutate 호출에만 실행
    navigate('/packages');
    setModalOpen(false);
  },
});

특징:

  • 특정 mutate 호출에만 실행
  • 컴포넌트별 특수한 처리(네비게이션, 모달 닫기 등)에 적합
  • ⚠️ 컴포넌트가 언마운트되면 실행되지 않음

3. mutateAsync로 Promise 처리

const { mutateAsync } = useCreatePackage();

try {
  const result = await mutateAsync(packageData);
  // 🔥 성공 시 여기서 처리
  navigate('/packages');
  showSuccessMessage();
} catch (error) {
  // 🔥 실패 시 여기서 처리
  showErrorMessage(error);
}

특징:

  • Promise를 반환하여 await으로 결과 대기
  • 복잡한 흐름 제어, 순차적 처리에 적합
  • try/catch로 명시적인 에러 처리 가능

⚡ mutate vs mutateAsync: 언제 뭘 써야 할까?

mutate: Fire and Forget 방식

const { mutate } = useMutation({ mutationFn: updateUser });

// 🔥 비동기 작업을 시작하고 바로 다음 코드 실행
mutate(userData, {
  onSuccess: (data) => console.log('성공!', data),
  onError: (error) => console.log('실패!', error),
});

console.log('이 코드는 mutation 완료를 기다리지 않고 즉시 실행됨');

mutateAsync: Promise 기반 처리

const { mutateAsync } = useMutation({ mutationFn: updateUser });

try {
  console.log('mutation 시작');
  const result = await mutateAsync(userData);
  console.log('성공!', result);
  // 성공 후에만 실행되는 코드
  navigate('/success');
} catch (error) {
  console.log('실패!', error);
  // 실패 후에만 실행되는 코드
  setError(error.message);
}

console.log('mutation 완료 후 실행됨');

선택 기준표

상황추천 방법이유
단순한 성공/실패 처리mutate + onSuccess/onError간단하고 직관적
복잡한 흐름 제어mutateAsync + try/catch순차적 처리 가능
여러 mutation 연속 실행mutateAsync에러 처리와 흐름 제어 용이
성공 후 추가 API 호출 필요mutateAsync순서 보장
즉시 다음 작업 진행mutate비동기 처리 최적화

🚨 자주 빠지는 함정들과 해결법

함정 1: 컴포넌트 언마운트 시 onSuccess 미실행

이것은 React Query를 사용할 때 가장 흔히 겪는 문제 중 하나입니다.

// ❌ 위험한 패턴
const CreateItemPage = () => {
  const { mutate } = useMutation({ mutationFn: api.create });

  const handleSubmit = () => {
    mutate(data, {
      onSuccess: () => {
        // 컴포넌트가 언마운트되면 실행되지 않음!
        navigate('/other-page');
        showSuccessToast();
      },
    });

    // mutate 호출 직후 다른 페이지로 이동하면?
    navigate('/loading-page'); // onSuccess가 실행되지 않을 수 있음
  };
};

해결법: 훅 레벨에서 처리

// ✅ 안전한 패턴
const useCreateItem = () => {
  const navigate = useNavigate();

  return useMutation({
    mutationFn: api.create,
    onSuccess: () => {
      // 훅 레벨에서 처리하여 컴포넌트 언마운트와 무관
      navigate('/success-page');
      showSuccessToast();
    },
  });
};

함정 2: 여러 번 연속 mutate 호출 시 예상과 다른 동작

const { mutate } = useMutation({ mutationFn: api.create });

// ❌ 문제가 될 수 있는 패턴
['item1', 'item2', 'item3'].forEach(item => {
  mutate(item, {
    onSuccess: (<) => {
      console.log('완료!'); // 마지막 호출에 대해서만 실행될 수 있음
    },
  });
});

해결법: 훅 레벨에서 공통 처리

// ✅ 안전한 패턴
const { mutate } = useMutation({
  mutationFn: api.create,
  onSuccess: (data, variables) => {
    console.log(`${variables} 완료!`); // 모든 호출에 대해 실행
  },
});

['item1', 'item2', 'item3'].forEach(item => {
  mutate(item);
});

함정 3: onSuccess 실행 순서 오해

React Query는 정해진 순서로 onSuccess를 실행합니다.

const mutation = useMutation({
  mutationFn: createItem,
  onSuccess: (data) => {
    console.log('1️⃣ 훅 레벨 onSuccess 실행');
  },
});

mutation.mutate(data, {
  onSuccess: (data) => {
    console.log('2️⃣ mutate 레벨 onSuccess 실행');
  },
});

// 실행 순서: 1️⃣ → 2️⃣


💡 실전 사용 예시

예시 1: 패키지 생성 (역할 분리 패턴)

// API 훅 - 데이터 관련 공통 로직
export const usePackageCreate = () => {
  const queryClient = useQueryClient();

  return useMutation<({
    mutationFn: createPackage,
    onSuccess: () => {
      // 항상 실행되어야 하는 데이터 동기화
      queryClient.invalidateQueries(['packages']);
    },
  });
};

// 컴포넌트 - UI 관련 특수 처리
const PackageCreatePage = () => {
  const { mutate } = usePackageCreate();
  const { openModal } = useModalStore();
  const navigate = useNavigate();

  const handleSubmit = (data) => {
    mutate(data, {
      onSuccess: () => {
        // 이 페이지에서만 필요한 UI 처리
        openModal({
          title: '패키지가 생성되었습니다',
          onConfirm: () => navigate('/packages'),
        });
      },
    });
  };
};

예시 2: 복잡한 흐름 제어 (mutateAsync 활용)

const BusinessSearchModal = () => {
  const { mutateAsync: searchBusiness } = useBusinessSearch();
  const { mutateAsync: saveRecentSearch } = useSaveRecentSearch();

  const handleSearch = async (searchData) => {
    try {
      setLoading(true);

      // 1단계: 사업자 정보 검색
      const { data, status } = await searchBusiness(searchData);

      if (status === 'success') {
        // 2단계: 성공 시 최근 검색어 저장
        await saveRecentSearch(searchData);

        // 3단계: 결과 표시
        onSearchComplete(data);
      }
    } catch (error) {
      setError(error.message);
    } finally {
      setLoading(false);
    }
  };
};

예시 3: 낙관적 업데이트 패턴

const BookmarkButton = ({ item, isBookmarked }) => {
  const { mutateAsync } = useBookmarkToggle();
  const [optimisticBookmark, setOptimisticBookmark] = useState(isBookmarked);

  const handleToggle = async () => {
    const newBookmarkState = !optimisticBookmark;

    // 즉시 UI 업데이트 (낙관적 업데이트)
    setOptimisticBookmark(newBookmarkState);

    try {
      await mutateAsync({
        bookmark: newBookmarkState,
        itemId: item.id,
      });

      // 성공 시 추가 처리
      showToast(`북마크가 ${newBookmarkState ? '추가' : '제거'}되었습니다`);

    } catch (error) {
      // 실패 시 원래 상태 복원
      setOptimisticBookmark(isBookmarked);
      showErrorToast('북마크 처리에 실패했습니다');
    }
  };
};


🏆 베스트 프랙티스

1. 역할 분리 원칙

데이터 로직은 훅에서, UI 로직은 컴포넌트에서

// ✅ 권장: 명확한 역할 분리
const useItemCreate = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: createItem,
    onSuccess: () => {
      // 🎯 데이터 관련 로직만
      queryClient.invalidateQueries(['items']);
    },
  });
};

const ItemCreatePage = () => {
  const { mutate } = useItemCreate();

  const handleSubmit = (data) => {
    mutate(data, {
      onSuccess: () => {
        // 🎯 UI 관련 로직만
        showSuccessToast('아이템이 생성되었습니다');
        navigate('/items');
      },
    });
  };
};

2. 타입 안전성 확보

interface CreateItemRequest {
  name: string;
  description: string;
}

interface CreateItemResponse {
  id: number;
  name: string;
  createdAt: string;
}

const useItemCreate = () => {
  return useMutation<
    CreateItemResponse,
    Error,
    CreateItemRequest
  >({
    mutationFn: createItem,
    onSuccess: (data, variables) => {
      // data: CreateItemResponse (타입 추론됨)
      // variables: CreateItemRequest (타입 추론됨)
      console.log(`생성된 아이템 ID: ${data.id}`);
    },
  });
};

3. 재사용 가능한 패턴 만들기

// 공통 mutation 훅 패턴
const useOptimisticMutation = <TData, TVariables>(
  mutationFn: MutationFunction<TData, TVariables>,
  queryKey: string[],
  options?: UseMutationOptions<TData, Error, TVariables>
) => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn,
    onSuccess: () => {
      queryClient.invalidateQueries(queryKey);
    },
    onError: (error) => {
      console.error('Mutation failed:', error);
    },
    ...options,
  });
};

// 사용
const usePackageCreate = () =>
  useOptimisticMutation(createPackage, ['packages']);

const useItemCreate = () =>
  useOptimisticMutation(createItem, ['items']);

4. 에러 처리 표준화

const useCreateItem = () => {
  return useMutation({
    mutationFn: createItem,
    onSuccess: (data) => {
      queryClient.invalidateQueries(['items']);
    },
    onError: (error) => {
      // 공통 에러 처리
      console.error('Item creation failed:', error);

      // 사용자에게 친화적인 에러 메시지 표시
      const userMessage = error.code === 'VALIDATION_ERROR'
        ? '입력값을 확인해주세요'
        : '서버 오류가 발생했습니다';

      showErrorToast(userMessage);
    },
  });
};


🎉 마무리

React Query의 mutation은 올바르게 사용하면 매우 강력한 도구가 됩니다. 핵심은 역할을 명확히 분리하고, 컴포넌트 생명주기를 고려하여 적절한 패턴을 선택하는 것입니다.

기억해야 할 핵심 포인트:

  • 공통 로직은 훅 레벨 onSuccess에서
  • UI 특화 로직은 mutate 호출 시 onSuccess에서
  • 복잡한 흐름은 mutateAsync
  • 컴포넌트 언마운트 가능성을 항상 고려하기

이 가이드가 React Query mutation을 더 안전하고 효율적으로 사용하는 데 도움이 되었기를 바랍니다. 실제 프로젝트에 적용해보시고, 궁금한 점이나 개선할 부분이 있다면 언제든 피드백 주세요! 🚀

0개의 댓글