어제 로그인 컴포넌트의 UI로직과 비즈니스 로직을 분리하다가 구글 로그인이 제대로 동작하지 않는 문제에 봉착했었다. 하지만 그건 나중에 해결하고 로직 분리부터 제대로 하려고 한다. 우선 어제 작성했던 코드에서 내가 잘 분리했는지 파악해보려고 한다.
비즈니스 로직을 분리하면서 커스텀 훅을 어떻게 작성하는게 좋을지 찾아보았다.
React 컴포넌트를 커스텀 훅으로 제공하기
React 에서 비즈니스 로직 분리하기 (Custom Hooks Pattern)
개인적으로 두번째 블로그가 큰 도움이 되었다. 느낀점으로는 커스텀 훅에 모든 로직을 우겨넣기보다는 다른 사람이 읽었을 때 해당 커스텀 훅이 어떻게 동작하는지 로직이 어느정도는 보여야 한다는 것이다.
첫번째 블로그는 커스텀 훅으로 비즈니스 로직을 분리함으로서 재활용성이 올라간다는 것을 느꼈다.
내가 이번에 이렇게 로직을 분리하는 가장 큰 이유는 재활용성보다는 관심사의 분리이다. 향후 다른 소셜 로그인을 추가할 때 Login 컴포넌트가 과도하게 커지는 것을 막기 위해, 나중에 유지보수할 때 UI로직과 비즈니스 로직이 섞여 어디를 어떻게 수정해야할지 이해하는 시간을 줄이기 위해
어제에 이어서 방금까지 좀더 코드를 작성하고 있었다. gpt의 도움을 받아가며.
작성중이던 코드는 다음과 같다.
//useAuth.js
import { useCallback, useState, useContext, useEffect } from 'react';
import * as Google from 'expo-auth-session/providers/google';
import * as Sentry from '@sentry/react-native';
import { useApi } from '../api/useApi';
import { useStorage } from './useStorage';
import { useDeviceToken } from './useDeviceToken';
import { LoginContext } from '@/contexts/LoginContext';
import { useRouter } from 'expo-router';
const useGoogleAuth = () => {
const [androidClientId, setAndroidClientId] = useState('');
const [request, response, promptAsync] = Google.useAuthRequest({
androidClientId,
});
const api = useApi();
const getAndroidClientId = useCallback(async () => {
try {
const androidClientIdResponse = await api.getAndroidClientId();
setAndroidClientId(androidClientIdResponse);
} catch (err) {
Sentry.captureException(err);
}
}, [api]);
return {
androidClientId,
request,
response,
promptAsync,
getAndroidClientId,
};
};
const useTokenManagement = () => {
const storage = useStorage();
const api = useApi();
const { deviceToken } = useDeviceToken();
const handleLocalToken = useCallback(async () => {
try {
const token = await storage.getItem('accessToken');
const userId = await storage.getItem('userId');
if (token && userId) {
await api.verifyToken(token);
return { token, userId };
}
} catch (err) {
Sentry.captureException(err);
}
return null;
}, [api, storage]);
const handleGoogleLogin = useCallback(
async token => {
try {
const loginResponse = await api.googleLogin({ token, deviceToken });
await storage.setItem('accessToken', loginResponse.access);
await storage.setItem('refreshToken', loginResponse.refresh);
const user = await api.getUserInfo(loginResponse.access);
await storage.setItem('userId', user.id.toString());
await storage.setItem('userName', user.username);
return { accessToken: loginResponse.access, userId: user.id };
} catch (err) {
Sentry.captureException(err);
return null;
}
},
[api, storage, deviceToken],
);
return { handleLocalToken, handleGoogleLogin };
};
export function useAuth() {
const { setIsLoggedIn, setUserId, setAccessToken } = useContext(LoginContext);
const router = useRouter();
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const { promptAsync, getAndroidClientId, response } = useGoogleAuth();
const { handleLocalToken, handleGoogleLogin } = useTokenManagement();
const handleLogin = async token => {
try {
const result = await handleGoogleLogin(token);
if (result) {
setAccessToken(result.accessToken);
setUserId(result.userId);
setIsLoggedIn(true);
router.replace('(tabs)');
} else {
setError('로그인에 실패했습니다.');
}
} catch (err) {
setError('로그인 중 오류가 발생했습니다.');
Sentry.captureException(err);
}
};
const checkLocalToken = useCallback(async () => {
const result = await handleLocalToken();
if (result) {
setAccessToken(result.token);
setUserId(result.userId);
router.replace('(tabs)');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const initializeAuth = useCallback(async () => {
setIsLoading(true);
await getAndroidClientId();
await checkLocalToken();
setIsLoading(false);
}, [getAndroidClientId, checkLocalToken]);
const signInWithGoogle = useCallback(async () => {
await promptAsync();
}, [promptAsync]);
useEffect(() => {
if (response?.type === 'success') {
const token = response.authentication?.idToken;
if (token) {
handleLogin(token);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [response]);
return {
isLoading,
error,
signInWithGoogle,
initializeAuth,
};
}
//index.jsx
import React, { useEffect } from 'react';
import { Image, StyleSheet, View } from 'react-native';
import { Button, Text } from '@ui-kitten/components';
import { StatusBar } from 'expo-status-bar';
import { GoogleIcon } from '@/components/GoogleIcon';
import { useAuth } from '@/hooks/auth/useAuth';
const imageSource = require('../assets/todo_logo.png');
const Login = () => {
const { isLoading, error, signInWithGoogle, initializeAuth } = useAuth();
useEffect(() => {
initializeAuth();
}, [initializeAuth]);
if (isLoading) {
return <LoadingSpinner />;
}
if (error) {
return <ErrorMessage message={error} />;
}
return (
<View style={styles.container}>
<StatusBar style="auto" />
<View style={styles.iconContainer}>
<Image source={imageSource} style={styles.icon} />
</View>
<View style={styles.buttonContainer}>
<Text category="h2">OneStep</Text>
<Button
accessoryLeft={GoogleIcon}
onPress={signInWithGoogle}
>
Sign in with Google
</Button>
</View>
</View>
);
};
// ... styles 코드는 그대로 유지 ...
export default Login;
우선 index.jsx를 보았을 때 로딩이나 에러 처리는 gpt가 마음대로 한 것이기에 무시할 것.
const {
isLoading,error, signInWithGoogle, initializeAuth } = useAuth()
여기서 signInWithGoogle, initializeAuth, useAuth가 잘 분리되었는지 과도하게 정보를 압축하지는 않았는지 다시 보려고 한다.
signInWithGoogle
onPress를 눌렀을 때 호출되는 signInWithGoogle은 내부에서 구글 로그인 웹뷰를 띄우고 구글 OAuth 토큰을 받아오고 해당 토큰을 api 서버에 보내서 로그인을 진행한다.
내가 지금 생각했을 때 Login 컴포넌트에서 signInWithGoogle의 내부 로직을 알 필요가 전혀 없다.
로직이 잘 분리되었다고 생각한다.
initializeAuth
initializeAuth는 구글로그인을 위해 androidClientId를 서버에서 받아오고 로컬 저장소에 엑세스토큰이나 리프레쉬토큰이 있는지 확인하는 로직을 가지고 있다.
개선점
1. 현재는 소셜 로그인이 구글밖에 없지만 나중엔 더 추가가 될 것. 우선 이름에서 구글 로그인의 init이라는 걸 명확히 해야할 것.
2. 로컬 저장소에 토큰이 있는지 확인하는 로직은 나중에 스플래쉬 화면 상에서 처리해야 할 것 같다. 나중에 따로 분리할 것.
3. androidClientId를 받아오는 로직을 굳이 index.jsx에서 보여줄 이유가 없다고 생각한다. useAuth를 호출할 때 useAuth 내부에서 androidClientId를 fetch하면 되는거 아닌가?
useAuth
이 커스텀 훅을 구글 로그인 커스텀 훅 전용으로 만들어야 한다고 생각한다. 왜냐하면 나중에 애플이나 페이스북 로그인 등 다른 소셜 로그인이 붙을텐데 해당 로그인들의 로직도 커스텀훅으로 분리해야 할 것이다. 그런데 이 모든 로직을 useAuth라는 한 훅 안에 구글 로그인 함수, 애플 로그인 함수 등을 다 넣으면 과도하게 커질 것 같다.
최종 결과
//useGoogleAuth.js
import { useCallback, useState, useContext, useEffect } from 'react';
import * as Google from 'expo-auth-session/providers/google';
import * as Sentry from '@sentry/react-native';
import { useApi } from '../api/useApi';
import { useStorage } from './useStorage';
import { useDeviceToken } from './useDeviceToken';
import { LoginContext } from '@/contexts/LoginContext';
import { useRouter } from 'expo-router';
const useGoogleAuth = () => {
const [androidClientId, setAndroidClientId] = useState('');
const [request, response, promptAsync] = Google.useAuthRequest({
androidClientId,
});
const api = useApi();
const router = useRouter();
const { handleLocalToken, handleGoogleLoginToken } = useToken();
const { setIsLoggedIn, setUserId, setAccessToken } = useContext(LoginContext);
const getAndroidClientId = useCallback(async () => {
try {
const androidClientIdResponse = await api.getAndroidClientId();
setAndroidClientId(androidClientIdResponse);
} catch (err) {
Sentry.captureException(err);
}
}, [api]);
const signInWithGoogle = useCallback(async () => {
if (androidClientId === '') {
} else {
await promptAsync();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleLogin = async token => {
try {
const result = await handleGoogleLoginToken(token);
if (result) {
setAccessToken(result.accessToken);
setUserId(result.userId);
setIsLoggedIn(true);
router.replace('(tabs)');
}
} catch (err) {
Sentry.captureException(err);
}
};
useEffect(() => {
getAndroidClientId();
handleLocalToken();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (response?.type === 'success') {
const token = response.authentication?.idToken;
if (token) {
handleLogin(token);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [response]);
return {
signInWithGoogle,
};
};
const useToken = () => {
const storage = useStorage();
const api = useApi();
const { deviceToken } = useDeviceToken();
const handleLocalToken = useCallback(async () => {
try {
const token = await storage.getItem('accessToken');
const userId = await storage.getItem('userId');
if (token && userId) {
await api.verifyToken(token);
return { token, userId };
} else {
return null;
}
} catch (err) {
Sentry.captureException(err);
}
}, [api, storage]);
const handleGoogleLoginToken = useCallback(
async token => {
try {
const loginResponse = await api.googleLogin({ token, deviceToken });
await storage.setItem('accessToken', loginResponse.access);
await storage.setItem('refreshToken', loginResponse.refresh);
const user = await api.getUserInfo(loginResponse.access);
await storage.setItem('userId', user.id.toString());
await storage.setItem('userName', user.username);
return { accessToken: loginResponse.access, userId: user.id };
} catch (err) {
Sentry.captureException(err);
return null;
}
},
[api, storage, deviceToken],
);
return { handleLocalToken, handleGoogleLoginToken };
};
export default useGoogleAuth;
//index.jsx
import React from 'react';
import { Image, StyleSheet, View } from 'react-native';
import { Button, Text } from '@ui-kitten/components';
import { StatusBar } from 'expo-status-bar';
import { GoogleIcon } from '@/components/GoogleIcon';
import useGoogleAuth from '@/hooks/auth/useGoogleAuth';
const imageSource = require('../assets/todo_logo.png');
const Login = () => {
const { signInWithGoogle } = useGoogleAuth();
return (
<View style={styles.container}>
<StatusBar style="auto" />
<View style={styles.iconContainer}>
<Image source={imageSource} style={styles.icon} />
</View>
<View style={styles.buttonContainer}>
<Text category="h2">OneStep</Text>
<Button accessoryLeft={GoogleIcon} onPress={() => signInWithGoogle()}>
Sign in with Google
</Button>
</View>
</View>
);
};
const styles = StyleSheet.create({
//생략
});
export default Login;
다 적고 보니 React 에서 비즈니스 로직 분리하기 (Custom Hooks Pattern)
에서 나오는 비즈니스 로직이 너무 지워진 Timer 예제와 비슷한 것 같지만 일단 여기까지 해보려고 한다.
onClick에 콜백 함수를 줄 때 () => {callbackFunc}: 화살표 함수를 사용해 새로운 함수를 생성해서 주는 방식과 callbackFunc: 함수 자체를 주는 방식의 차이가 궁금했다. 왜냐하면 내 코드에서 화살표함수로 넘겨줘야 signinWithGoogle 함수가 동작했기 때문.
그 차이는 바로바로 signinWithGoogle가 비동기이기 때문. 함수 자체로 호출하면 Promise 객체만 반환된다!