react native에서 비즈니스 로직 분리하기 - 2

고병찬·2024년 9월 24일

TIL

목록 보기
34/54

들어가며

어제 로그인 컴포넌트의 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가 잘 분리되었는지 과도하게 정보를 압축하지는 않았는지 다시 보려고 한다.

  1. signInWithGoogle
    onPress를 눌렀을 때 호출되는 signInWithGoogle은 내부에서 구글 로그인 웹뷰를 띄우고 구글 OAuth 토큰을 받아오고 해당 토큰을 api 서버에 보내서 로그인을 진행한다.
    내가 지금 생각했을 때 Login 컴포넌트에서 signInWithGoogle의 내부 로직을 알 필요가 전혀 없다.
    로직이 잘 분리되었다고 생각한다.

  2. initializeAuth
    initializeAuth는 구글로그인을 위해 androidClientId를 서버에서 받아오고 로컬 저장소에 엑세스토큰이나 리프레쉬토큰이 있는지 확인하는 로직을 가지고 있다.
    개선점
    1. 현재는 소셜 로그인이 구글밖에 없지만 나중엔 더 추가가 될 것. 우선 이름에서 구글 로그인의 init이라는 걸 명확히 해야할 것.
    2. 로컬 저장소에 토큰이 있는지 확인하는 로직은 나중에 스플래쉬 화면 상에서 처리해야 할 것 같다. 나중에 따로 분리할 것.
    3. androidClientId를 받아오는 로직을 굳이 index.jsx에서 보여줄 이유가 없다고 생각한다. useAuth를 호출할 때 useAuth 내부에서 androidClientId를 fetch하면 되는거 아닌가?

  3. 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 객체만 반환된다!

profile
안녕하세요, 반갑습니다.

0개의 댓글