19. 카카오콜백페이지 리팩터링을 해야했던 이유
2025년 10월 13일
카카오콜백페이지는 현재 굉장히 많을 일을 하고 있었습니다.
이로인한 여러 문제점이 있었습니다
- 비즈니스 로직과 ui가 한 파일에 가득함
- 이로인해 테스트코드작성 굉장히 어려움
- 재사용 불가능-딱히 재사용할 일은 없지만..
- 가독성 엉망
최우선으로 일단
상태관리, 비즈니스 로직(handleError,handleLogin 등), useEffect 초기화 로직 을 아예 다른 커스텀 훅 파일로 이동하기로 마음먹었습니다.
이렇게 되면 컴포넌트는 UI만 담당하게 됩니다.
useKaKaoCallBack.ts 라는 커스텀 훅 파일을 만들었습니다.
그 내부는 다음과 같습니다.
에러처리
const handleError = (errorMessage: string, shouldLog = false, error?: unknown) => { if (shouldLog && error) { console.error('로그인 실패:', error); } setStatus('error'); setMessage(errorMessage); timeout.current = setTimeout(() => { navigate('/login'); }, 3000); };
에러 상태 설정 및 3초후 로그인 페이지로 리다이렉션
회원가입 처리 함수
const handleRegistration = async (code: string, nickname: string) => { const result = await registerWithCode({ code, nickname }); if (result.accessToken) { setAccessToken(result.accessToken, 7); sessionStorage.removeItem('temp_nickname'); setStatus('success'); setMessage('회원가입이 완료되었습니다!'); timeout.current = setTimeout(() => navigate('/home'), 2000); } };
회원가입 api 호출, 토큰 저장, 홈으로 이동
일반 로그인
const handleLoginFlow = async (code: string) => { const result = await loginWithCode(code); setStatus('success'); setMessage('로그인이 완료되었습니다!'); if (result.accessToken) { setAccessToken(result.accessToken, 7); } timeout.current = setTimeout(() => navigate('/home'), 3000); };
로그인api호출, 토큰 저장, 홈으로 이동
메인 함수
const handleLogin = async (authorizationCode: string) => { if (isProcessing.current) return; // 중복 실행 방지 try { isProcessing.current = true; setStatus('loading'); setMessage('로그인 처리 중...'); const savedNickname = sessionStorage.getItem('temp_nickname'); if (savedNickname) { await handleRegistration(authorizationCode, savedNickname); } else { await handleLoginFlow(authorizationCode); } isProcessing.current = false; processedCode.current = null; } catch (error) { isProcessing.current = false; processedCode.current = null; const axiosError = error as AxiosError; if (axiosError?.response?.status === 401) { // 신규 사용자 → 캐릭터 생성 페이지 setStatus('success'); setMessage('새로운 계정이 생성되었습니다!'); timeout.current = setTimeout(() => navigate('/character-create'), 2000); } else { handleError('로그인에 실패했습니다. 다시 시도해주세요.'); } } };
시행착오
useEffect에 의존성 배열을 비우면 lint경고가 뜨기에, useCallback으로 앞선 함수들을 감싸고, 의존성 배열을 채우며 lint 경고를 해결했으나, 로그인 성공 200이 떴음에도 페이지 이동이 안되고 무한굴레에 갇히는 현상이 발생했습니다.
useEffect 에서 setTimeout을 clear 해버려 navigate가 실행되지 않는, 화면이 넘어가지 않는 상황이 발생해 버린 것입니다.
이에 다시 useCallback을 없애고 의존성 배열을 비워두었습니다.
장점
- 테스트
만약 기존 파일에서 테스트를 한다면 컴포넌트 전체를 마운트해야 테스트가 가능하고, 돔 조작도 해야하고설정이 굉장히 어려워 집니다. 테스트 자체도 느려지고, 격리된 단위 테스트도 불가능 할 것 같습니다.
- 관심사분리
ui는 ui만 띄우게 하면서 책임 분리가 이루어졌습니다.
- 재사용성
- 가독성
- 유지보수성
- 디버깅 쉬움
- 타입 안정성
// 명확한 인터페이스 type CallbackStatus = 'loading' | 'success' | 'error'; interface UseKakaoCallbackReturn { status: CallbackStatus; message: string; isPending: boolean; } export const useKakaoCallback = (): UseKakaoCallbackReturn => { // ... };
- 리렌더링 성능
기존에는 컴포넌트 내부 상태 변경 시 전체가 리렌더링 되고, 로직도 매번 재 정의가 되었을 것입니다.
분리 이후로는 훅 내부 로직은 한번만 정의되고, 컴포넌트는 상태 변경시에만 리레네더링 되며 함수 재생성이 없어 성능적으로 좋아집니다.
그럼 왜 useCallback을 사용했을 때 문제가 발생했을까?
사실 이론적으로는, useCallback을 사용해서 함수를 한번만 만들면, useEffect가 다시 실행되어 Timeout이 리셋되는 경우가 발생하지 않을 것이라 생각했습니다. 하지만 왜 이런일이 생겼을 지 생각해 보았습니다.
일단 흐름은, 로그인이 성공하고, 200까지 받아왔는데 useEffect가 재실행되어 타임아웃이 초기화되고 navigate이 실행되지 못하는 상황이었습니다.
당시 코드는 다음과 같았습니다.
const handleError = useCallback( (errorMessage: string) => { setStatus('error'); setMessage(errorMessage); timeout.current = setTimeout(() => { navigate('/login'); }, 3000); }, [navigate], ); const handleRegistration = useCallback( async (code: string, nickname: string) => { const result = await registerWithCode({ code, nickname }); if (result.accessToken) { setAccessToken(result.accessToken, 7); sessionStorage.removeItem('temp_nickname'); setStatus('success'); setMessage('회원가입이 완료되었습니다!'); timeout.current = setTimeout(() => { navigate('/home'); }, 2000); } }, [registerWithCode, setAccessToken, navigate], ); const handleLoginFlow = useCallback( async (code: string) => { const result = await loginWithCode(code); setStatus('success'); setMessage('로그인이 완료되었습니다!'); if (result.accessToken) { setAccessToken(result.accessToken, 7); } timeout.current = setTimeout(() => { navigate('/home'); }, 3000); }, [loginWithCode, setAccessToken, navigate], ); const handleLogin = useCallback( async (authorizationCode: string) => { if (isProcessing.current) return; try { isProcessing.current = true; setStatus('loading'); setMessage('로그인 처리 중...'); const savedNickname = sessionStorage.getItem('temp_nickname'); if (savedNickname) { await handleRegistration(authorizationCode, savedNickname); } else { await handleLoginFlow(authorizationCode); } isProcessing.current = false; processedCode.current = null; } catch (error) { } }, [handleRegistration, handleLoginFlow, handleError, navigate], ); useEffect(() => { const loginStatus = getKakaoLoginStatus(); if (loginStatus === 'success') { const authorizationCode = getKakaoAuthorizationCode(); if ( authorizationCode && !isProcessing.current && processedCode.current !== authorizationCode ) { processedCode.current = authorizationCode; window.history.replaceState({}, '', '/auth/kakao/callback'); handleLogin(authorizationCode); } } else if (loginStatus === 'error') { const errorMessage = getKakaoErrorMessage(); handleError(`로그인 실패: ${errorMessage || '알 수 없는 오류'}`); } else { setMessage('카카오 로그인을 처리하고 있습니다...'); } return () => { if (timeout.current) { clearTimeout(timeout.current); } }; }, [handleLogin, handleError]); const isPending = isLoginPending || isRegisterPending; const currentStatus = isPending ? 'loading' : status; const currentMessage = isPending ? '서버와 통신 중...' : message; return { status: currentStatus, message: currentMessage, isPending, }; };
한눈에 봐도 의존성 체인이 심각합니다. 문제가 생겨도 어디서 리렌더링이 유발되었는지 찾기도 힘든 상황입니다. useCallback을 생각없이 최적화 한답시고 막 사용하면 이런 사고가 발생하는 것 같습니다..
상황을 분석해보자면,
handleLoginFlow 안에서, 일단 loginWithCode로 api를 호출합니다.
이로인해, setPending(true)로 인해 리렌더링이 됩니다.
이렇게 되면, loginWithCode 도 새 참조를 하게 되고,
handleLoginFlow 도 재생성,
handleLogin도 재생성,
useEffect도 재실행,
cleanup 합니다. 하지만 이땐 setTimeout이 설정되지 않아 상관없을 것 같습니다
하지만 이후, api 호출하고 성공하고, setStatus(’success’)가 되면
setTimeout(3초후 navigate)이 설정되고,
또 리렌더링이 됩니다.(useState)
이렇게 되면, loginWithCode가 또 새 참조를 받고
handleLoginFlow 재생성
handleLogin재생성
useEffect 재실행
clearTimeout → setTimeout 취소
→ 이동안됨
이런 문제가 생긴 것 같습니다.
loginWithCode가 일반함수였던게 문제라고 생각하면,
아까 코드를 유지하면서
export const useKakaoAuth = () => { const { mutateAsync: kakaoLogin, isPending, data, error } = useKakaoLogin(); const loginWithCode = useCallback((authorizationCode: string) => { return kakaoLogin({ code: authorizationCode }); }, [kakaoLogin]); return useMemo(() => ({ loginWithCode, isPending, data, error, }), [loginWithCode, isPending, data, error]); };
이런식으로 loginWithCode도 useCallback으로 감싸면 될 수도 있었을 것 같습니다.
하지만 그냥 useEffect 의존성 배열을 비우는게 훨씬 더 합리적인 방법인 것 같습니다..
일단 문제 상황은, useKaKaoAuth가 loginWithCode를 useCallback없이 선언해서, 상태변화로 유발된 리렌더링에서, 함수가 새로 생겨, 이것이 useCallback의존성 체인을 유발하고 그런 문제가 생겼던 것으로 판단 됩니다.