4. mutate vs mutateAsync
2025년 9월 12일
import { useMutation } from '@tanstack/react-query'; import { AxiosError } from 'axios'; import { api } from './axios'; import { processApiError } from './queryClient'; import type { HttpMethod, MutationApiOptions } from './types'; export const useMutationApi = <TData, TVariables = void>( method: HttpMethod, url: string, options?: MutationApiOptions<TData, TVariables>, ) => { return useMutation<TData, AxiosError, TVariables>({ mutationFn: async (variables: TVariables) => { try { const response = await api[method]<TData>(url, variables); return response.data; } catch (error) { processApiError(error); throw error; } }, ...options, }); }; const createMethodHook = (method: HttpMethod) => { return <TData, TVariables = void>(url: string, options?: MutationApiOptions<TData, TVariables>) => useMutationApi<TData, TVariables>(method, url, options); }; export const usePostApi = createMethodHook('post'); export const usePutApi = createMethodHook('put'); export const usePatchApi = createMethodHook('patch'); export const useDeleteApi = createMethodHook('delete');
현재 코드는 여러 문제들이 있는 것 같습니다.
그 상황에서 그대로 kakao로그인 기능을 진행하고 있어서,
// useKakaoAuth.ts const loginWithCode = (authorizationCode: string) => { kakaoLogin({ code: authorizationCode }); // ← 결과를 기다리지 않음 }; // KakaoCallbackPage.tsx useEffect(() => { const code = urlParams.get('code'); if (code) { loginWithCode(code); // ← 호출 후 즉시 다음 코드 실행 } }, []);
카카오로그인, 즉 post 할때 대기하지 않고 바로 다음 코드가 시작됩니다.
mutate vs mutateAsync
서칭 결과
가장 큰 차이점은, mutate는 아무것도 반환하지 않지만, mutateAync는 뮤테이션 결과를 포함한 promise를 반환합니다.
mutate는 Promise를 반환하지 않는 ‘트리거 함수’입니다.(스스로 async 가 아님).
호출 즉시 실행되고, 성공,실패는 onSuccess, onError, onSettled로만 처리해야 합니다.
UI 업데이트를 빠르게 트리거하거나 fire and forget방식으로 쓸 때 적합합니다.
mutateAsync는 반환값이 Promise이고 await 가능합니다.
이에, try catch 문으로 에러 핸들링이 가능합니다. 다른 비동기 작업과 순서를 보장해야할 때 적합합니다.
그래서, mutate는 단순히 요청을 시작시키고, 그 결과는 등록된 콜백으로만 받을 수 있습니다. 반면 mutateAync는 await 가능하기 때문에, 함수 실행 흐름을 멈추고 결과를 기다릴 수 있습니다.
용도에 따른 사용이 필요합니다.
- 빠른 UI 반영, 콜백 기반 처리 → mutate
- 비동기 로직 제어, await 필요 → mutateAsync
그래서 지금 코드의 문제점은,
- mutate만 노출
- useKaKaoAuth가 mutate를 감싼 loginWithCode만 제공해서, 호출 쪽 에서는 결과를 기다릴 방법이 없습니다. 이에, 성공하면 Success UI, 실패하면 error UI → n초 뒤 이동 같은 순차 제어가 불가능합니다.
- 리다이렉트를 훅 내부 onSuccess/onError에서 수행
- useKaKaoLogin의 옵션 콜백에서 window.location.href를 바꾸고 있어서 콜백 페이지가 UI 상태를 업데이트할 타이밍 자체가 사라집니다. (바로 언마운트)
- 조금 더 구체적으로 이야기하면, window.location.href는 브라우저에 지금 페이지를 떠나라고 명령하는 것이고, 그 순간 현재 리액트 트리가 파괴됩니다.
- 그러면 콜백 페이지 컴포넌트를 setState(”sucess”) 처럼 처리를 해도 의도한 렌더링이 구현이 안될 것입니다. 예를들어 스피너, 성공 아이콘, 3초 후 이동 같은..
- useKaKaoLogin의 옵션 콜백에서 window.location.href를 바꾸고 있어서 콜백 페이지가 UI 상태를 업데이트할 타이밍 자체가 사라집니다. (바로 언마운트)
- 에러 / 성공 정보를 상위로 올리지 않음
- 에러, 응답이 전부 훅 내부에서 소비됩니다. 그럼 콜백페이지는 무저건 alert → 이동 밖에 못합니다.
- 사실, 재사용이 필요한 훅은 아니라서 그렇게 크게 중요하진 않을 수 있지만 UI 제어권이 없고 에러 처리 일관성이 어렵습니다.
그래서 해야할 Todo
페이지에서만 쓰는 api커스텀 훅이라고 해도, 훅은 네트워크만 담당하고 비즈니스 사이드 이펙트(리다이렉트,알림,ui)는 페이지에서 담당하는 방향으로 가야합니다
또 카카오 로그인 페이지에서는 mutateAsync를 사용해야 합니다.
또 부가적으로는, 지금 임시로 로컬스트로지에 토큰을 저장하는것으로 해놓은 것을 쿠키로 변경해야 할 것입니다.
해결
그럼 순서대로 하나씩 문제를 해결해 나가야 합니다.
1. useKaKaoLogin 사이드 이펙트 제거
즉 onSuccess, onError 제거
export const useKakaoLogin = () => { return usePostApi<KakaoLoginResponse, KakaoLoginRequest>('/login'); };
이런식으로 간단하게 변경합니다.
2. useKaKaoAuth에서 mutateAsync로 변경 및 data, error 그대로 넘겨줌
import { usePostApi } from '../useMutationApi'; import { getKakaoLoginUrl } from './utils'; import type { KakaoLoginRequest, KakaoLoginResponse } from './types'; /** * 카카오 로그인 API 호출 훅 */ export const useKakaoLogin = () => { return usePostApi<KakaoLoginResponse, KakaoLoginRequest>('/login'); }; /** * 카카오 로그인 전체 프로세스 훅 */ export const useKakaoAuth = () => { const { mutateAsync: kakaoLogin, isPending, data, error } = useKakaoLogin(); /** * 카카오 로그인 시작 */ const startKakaoLogin = () => { try { // 카카오 로그인 페이지로 리다이렉트 const loginUrl = getKakaoLoginUrl(); window.location.href = loginUrl; } catch (error) { console.error('카카오 로그인 URL 생성 실패:', error); alert('카카오 로그인 설정에 문제가 있습니다.'); } }; /** * authorization code로 백엔드에 로그인 요청 */ const loginWithCode = (authorizationCode: string) => { kakaoLogin({ code: authorizationCode }); }; return { startKakaoLogin, loginWithCode, isPending, data, error, }; };
3. 에러 전역핸들러에서 throw문제
기존 코드는 processApiError에서 에러를 그대로 throw하여,
mutationFn에서 throw를 안해도 되는 , 코드를 간결하게 하는 장점이 있었습니다. 다만 타입스크립에서 throw를 안적으면, 오류(unreachable)경고가 떠서, 실제로 실행이 안되고 전역으로 throw됨에도 throw를 한번 더 작성해줘야 하는 문제가 있습니다.
} catch (error) { throw processApiError(error); // ← 전역에서 가공 후, 가공된 에러로 '1회만' 던짐 }
이에, processApiError에서 에러를 단순히 리턴만 하게 만들었습니다. 그리고 useMutationApi의 try catch 문에서 throw processApiError(e) 를 해주게 변경하였습니다.
사실 차이가 없어보이는 느낌도 있습니다.
어디서 던지는 결국 Promise가 reject되니, 호출자는 catch 하는것 똑같은거 아닌가?
processApi에서 throw를 하면, 전역 처리 로직이 실행된 직후에 reject됩니다. 즉 전역 에러 토스트, 리다이렉트가 무조건 먼저 실행되고 호출자에서 또 처리하면 중복 UX위험이 있습니다.
콜백페이지에서 실패 ui, 2초뒤 이동을 하고 싶어도 전역이 이미 이동시켜버릴 수 있죠.
mutationFn에서 throw, processApiError는 가공만 하면 전역은 로깅, 메세지 정규화까지만 하고 리다이렉트 토스트는 호출자에서 일관 제어하여 같은 에러를 두군데서 나눠 처리하는 일이 줄어듭니다.
그리고 리액트 쿼리는 mutationFn이 던져야 onerror, retry 로직을 정확히 탑니다. 앞서 말했듯이 onError, retry가 불리기 전에 컴포넌트가 언마운트 될 수도 있습니다.
둘 다 “결국 reject”인 건 맞지만,
- 전역에서 즉시 throw하면: 전역 부작용이 항상 먼저 실행되어 페이지 제어권/연출이 제한, 중복 처리 위험 ↑
- mutationFn에서만 throw하도록 하면: 전역은 가공·로그만, UI/리다이렉트는 호출자가 전부 컨트롤 → UX 일관성·유연성 ↑
4. 콜백페이지에서 직접 성공, 실패 처리 직접 진행.
1. mutateAyncs로 프로미스를 받아서 try,catch로 처리
import React, { useEffect, useRef, useState } from 'react'; import { getKakaoAuthorizationCode, getKakaoErrorMessage, getKakaoLoginStatus, useKakaoAuth, } from '@/Apis/kakao'; import { useNavigate } from 'react-router-dom'; export const KakaoCallbackPage: React.FC = () => { const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); const [message, setMessage] = useState<string>(''); const { loginWithCode, isPending } = useKakaoAuth(); const navigate = useNavigate(); const timeout = useRef<NodeJS.Timeout | null>(null); useEffect(() => { const loginStatus = getKakaoLoginStatus(); if (loginStatus === 'success') { // 성공 시 authorization code 추출 const authorizationCode = getKakaoAuthorizationCode(); console.log('🎉 카카오 로그인 성공!'); console.log('📝 Authorization Code:', authorizationCode); console.log('�� 전체 URL:', window.location.href); // 백엔드로 POST 요청 보내기 if (authorizationCode) { console.log('📤 백엔드로 POST 요청 전송 중...'); const handleLogin = async () => { try { setStatus('loading'); setMessage('로그인 처리 중...'); const result = await loginWithCode(authorizationCode); window.history.replaceState({}, '', '/kakao/callback'); console.log('✅ 백엔드 로그인 성공:', result); setStatus('success'); setMessage('로그인이 완료되었습니다!'); // 토큰 저장 if (result.access_token) { localStorage.setItem('access_token', result.access_token); } // 3초 후 메인 페이지로 이동 timeout.current = setTimeout(() => { navigate('/home'); }, 3000); } catch (error) { console.error('❌ 백엔드 로그인 실패:', error); setStatus('error'); setMessage('로그인에 실패했습니다. 다시 시도해주세요.'); // 3초 후 로그인 페이지로 이동 timeout.current = setTimeout(() => { navigate('/login'); }, 3000); } }; handleLogin(); } } else if (loginStatus === 'error') { // 에러 시 에러 메시지 추출 const errorMessage = getKakaoErrorMessage(); console.log('❌ 카카오 로그인 실패!'); console.log('🚨 Error Message:', errorMessage); console.log('�� 전체 URL:', window.location.href); setStatus('error'); setMessage(`로그인 실패: ${errorMessage || '알 수 없는 오류'}`); // 3초 후 로그인 페이지로 이동 timeout.current = setTimeout(() => { navigate('/login'); }, 3000); } else { console.log('⏳ 카카오 로그인 대기 중...'); setMessage('카카오 로그인을 처리하고 있습니다...'); } return () => { if (timeout.current) clearTimeout(timeout.current); }; }, []); // isPending 상태도 UI에 반영 const currentStatus = isPending ? 'loading' : status; const currentMessage = isPending ? '서버와 통신 중...' : message; return ( <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', backgroundColor: '#f5f5f5', padding: '20px', }} > <div style={{ backgroundColor: 'white', padding: '40px', borderRadius: '12px', boxShadow: '0 4px 12px rgba(0,0,0,0.1)', maxWidth: '400px', width: '100%', textAlign: 'center', }} > {currentStatus === 'loading' && ( <> <div style={{ width: '50px', height: '50px', border: '4px solid #f3f3f3', borderTop: '4px solid #fee500', borderRadius: '50%', animation: 'spin 1s linear infinite', margin: '0 auto 20px', }} /> <h2 style={{ color: '#333', marginBottom: '10px' }}>로그인 처리 중...</h2> <p style={{ color: '#666' }}>{currentMessage}</p> </> )} {currentStatus === 'success' && ( <> <div style={{ fontSize: '60px', marginBottom: '20px' }}>🎉</div> <h2 style={{ color: '#00a86b', marginBottom: '10px' }}>로그인 성공!</h2> <p style={{ color: '#666', marginBottom: '20px' }}>{currentMessage}</p> <p style={{ color: '#999', fontSize: '14px' }}>3초 후 메인 페이지로 이동합니다...</p> </> )} {currentStatus === 'error' && ( <> <div style={{ fontSize: '60px', marginBottom: '20px' }}>❌</div> <h2 style={{ color: '#e74c3c', marginBottom: '10px' }}>로그인 실패</h2> <p style={{ color: '#666', marginBottom: '20px' }}>{currentMessage}</p> <p style={{ color: '#999', fontSize: '14px' }}>3초 후 로그인 페이지로 이동합니다...</p> </> )} </div> <style>{` @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } `}</style> </div> ); };
loginWithCode가 Promise를 리턴하여 콜백 페이지에서 await으로 정확히 결과를 기다린 뒤 성공, 실패 ui, 리다이렉트 순서를 의도한대로 통제할 수 있게 되었습니다.
ux는 성공, 실패 메세지를 보여주고 3초뒤 이동하게 함으로써 사용자의 인지를 향상시켰습니다.
그리고 window.location.href는 기존 렌더트리를 파괴함으로써 spa의 장점을 파괴하기에, useNavigate으로 변경하였습니다
결과
이렇게 코드를 수정함으로써 여러 장점이 있는 것 같습니다.
1. 흐름제어를 mutateAync를 await해서 결과를 확실이 기다리고, 성공,실패 ui, 지연, 이동 을 페이지에서 원하는 ui로 확실하게 제어할 수 있습니다.
2. 책임분리, 이제는 useKaKaoAuth는 네트워크만 담당합니다.
3. 에러처리 아키텍쳐, 이제는 중복 에러처리는 진행하지 않습니다. processApiError는 정규화, 로깅만 진행하고 던지는건 mutationFn에서 한번만 진행합니다. 그리고 호출자는 try,catch로 화면별 맞춤 처리가 가능합니다. 이는 리액트 쿼리의 onError,재시도와도 흐름이 일치됩니다.
4. 네비게이션 방식 → 기존 window.location.href는 즉시이동, 풀 리로드, 상태,캐시 모두 초기화, 전환중 UI미표시, spa장점 사망 등의 문제가 있었고 useNavigate를 통해 spa 전환, ui 먼저 보여주고 이동, 빠르고 부드러운 ux, 앱상태와 캐시보존 등의 장점이 있습니다.
5. ux적으로도 기존에는 성공 실패 메세지를 못보고 바로 페이지 이동이 있던 것을 loading success, error 상태를 명확히 노출하고 async await 사용으로 setTimeout과 함께 상태를 인지하고 페이지 이동이 가능합니다
6. 또한 isPending을 ui에 반영하여 네트워크 진행상황이 사용자와 공유됩니다.
7. 뒤로가기에서도 좋은점이 있는 것 같습니다. 기존에는 code/state쿼리가 히스토리에 남아 뒤로가기 시 노출이 되었으나, 현재는 history.replaceState로 code/state쿼리 히스토리에 제거
8. 또한 setTimeout 핸들러를 정리해주면서 컴포넌트 언마운트 시 메모리 누수, 의도치 않은 네비게이션을 방지하엿습니다
9. 또한 책임 분리로 추후 테스트, 유지보수성이 좋아진 것 같습니다