React Query를 사용하다 보면 mutation의 onSuccess
가 실행되지 않거나, mutate
와 mutateAsync
중 어떤 것을 써야 할지 헷갈리는 경우가 많습니다. 이 글에서는 @tanstack/react-query v5를 기준으로 mutation의 핵심 개념부터 실제 프로젝트에서 겪을 수 있는 함정들까지 상세히 다뤄보겠습니다.
onSuccess
실행 방식 이해mutate
vs mutateAsync
차이점과 선택 기준React Query에서 mutation 성공 후 처리는 크게 3가지 방식으로 할 수 있습니다.
const useCreatePackage = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createPackage,
onSuccess: (data) => {
// 🔥 이 훅을 사용하는 모든 mutate 호출에서 실행
queryClient.invalidateQueries(['packages']);
},
});
};
특징:
const { mutate } = useCreatePackage();
mutate(packageData, {
onSuccess: (data) => {
// 🔥 이 특정 mutate 호출에만 실행
navigate('/packages');
setModalOpen(false);
},
});
특징:
const { mutateAsync } = useCreatePackage();
try {
const result = await mutateAsync(packageData);
// 🔥 성공 시 여기서 처리
navigate('/packages');
showSuccessMessage();
} catch (error) {
// 🔥 실패 시 여기서 처리
showErrorMessage(error);
}
특징:
const { mutate } = useMutation({ mutationFn: updateUser });
// 🔥 비동기 작업을 시작하고 바로 다음 코드 실행
mutate(userData, {
onSuccess: (data) => console.log('성공!', data),
onError: (error) => console.log('실패!', error),
});
console.log('이 코드는 mutation 완료를 기다리지 않고 즉시 실행됨');
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 | 비동기 처리 최적화 |
이것은 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();
},
});
};
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);
});
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️⃣
// 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'),
});
},
});
};
};
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);
}
};
};
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('북마크 처리에 실패했습니다');
}
};
};
데이터 로직은 훅에서, 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');
},
});
};
};
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}`);
},
});
};
// 공통 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']);
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
에서mutate
호출 시 onSuccess
에서mutateAsync
로이 가이드가 React Query mutation을 더 안전하고 효율적으로 사용하는 데 도움이 되었기를 바랍니다. 실제 프로젝트에 적용해보시고, 궁금한 점이나 개선할 부분이 있다면 언제든 피드백 주세요! 🚀