[Refactoring] 프론트엔드의 AuthService에 Class, Context API, Custom hook을 첨가하여 객체 지향적으로 만들기

Quartz 쿼츠·2023년 1월 9일
1

Better Code & Design

목록 보기
4/4
post-thumbnail

Introduction

최근 리팩터링 교재와 강의를 공부하면서 데이터와 로직을 함께 두는 class(in JS)를 사용하면 코드 응집도가 높아지고 유지 보수가 쉬워진다는 것을 배웠다. 필자는 대부분을 로직만 함수 추상화를 해두고 절차 지향적으로 코드를 짜왔기에 해당 부분을 사이드 프로젝트에 적용해야겠다고 생각하였다. 혼자 리액트 프로젝트에 클래스 적용을 도전하였을 때는 클래스 인스턴스를 어떻게 관리해야 하는지 감이 오지 않았는데, 운이 좋게도 원티드 프리온보딩 인턴십에서 이 부분을 다뤄주셔서 해당 강의를 참고하여 글을 작성하였다.

본 글에서는 아래와 같은 순서로 리팩토링이 이뤄진다.

1. Auth 관련 데이터와 로직을 Class로 추출
2. Class의 method를 Context API를 통해 리액트 컴포넌트에서 공유
3. 리액트 컴포넌트단의 복잡하고 반복되는 로직들을 커스텀 훅으로 추출

이 글은 개인 기록 용도로 작성되었으며 잘못된 부분이 존재할 수도 있습니다.

Auth Service 리팩토링하기

본 프로젝트는 React Native 라이브러리를 사용하며 일부 패키지를 제외하고 리팩토링 로직은 React와 동일하다. 구현해야할 AuthService는 다음과 같다.

  • 사용자의 이메일/비밀번호 입력: firebase signUp/login 로직에 사용 ➡️ AsyncStorage(웹의 LocalStorage)에 이메일 저장
  • AsyncStorage에 저장되어있는 이메일: 앱 시작시 자동 로컬 로그인 & firebase logout 로직에 사용

1. Class로 데이터와 로직 함께 두기

먼저 Firebase 로그인에 필요한 데이터와 함수들을 모아 클래스로 추출한다. 클래스를 사용하면 함수를 필요할 때마다 import할 필요없이 데이터와 로직을 함께 가지고 다닐 수 있어 용이하다.

  • private field: email
    • 클래스 외부에서는 필드값 변경이 불가능하게 캡슐화하였다.
  • public method: email localLogin signUp login logout resetPassword
    • 로그인/회원가입/비밀번호 찾기 등의 firebase auth 함수와 관련된 로직
  • private method: handleFetchUser saveAsyncStorageUser removeAsyncStorageUser
    • response 데이터를 바탕으로 asyncStorage와 관련된 로직을 내부에서만 처리하게 하였다.
    • firebase와 asyncStorage 관련 함수를 따로 관리하고 싶어 추출했으나 지나친 면이 있어 saveAsyncStorageUser removeAsyncStorageUser는 다시 인라인하는 것이 좋아보인다.
export default class AuthService {
  #email;

  constructor() {
    this.#email = "";
  }

  email() {
    return this.#email;
  }

  localLogin(email) {
    this.#email = email;
  }

  async signUp(input) {
    const signUpData = await createUserWithEmailAndPassword(
      authService,
      input.email,
      input.password
    );
    this.#handleFetchUser(signUpData);
  }

  async login(input) {
    const loginData = await signInWithEmailAndPassword(
      authService,
      input.email,
      input.password
    );
    await this.#handleFetchUser(loginData);
  }

  async logout() {
    authService.signOut();
    this.#email = "";
    this.#removeAsyncStorageUser();
  }

  async resetPassword(email) {
    await sendPasswordResetEmail(authService, email);
  }

  async #handleFetchUser(fetchUser) {
    const { email, localId } = fetchUser._tokenResponse;
    const asyncUser = new AsyncFirebaseUser(email, localId);
    this.#email = email;
    this.#saveAsyncStorageUser(asyncUser.emailAndIdObj);
  }

  async #saveAsyncStorageUser(userData) {
    saveStorageFirebaseUser(userData);
  }

  async #removeAsyncStorageUser() {
    removeStorageFirebaseUser();
  }
}

2. Context API로 Class instance / method를 전달할 통로 만들기

추출한 클래스의 메소드를 리액트 컴포넌트에서 사용하려면 이를 전달할 통로가 필요하다. Context API를 사용하여 Provider는 authService 인스턴스를 props로 주입받고, 해당 인스턴스의 method를 context로 보내준다. 이 때 context로 보내주지 않은 메소드는 접근이 불가하다. 사용 편의성을 위해 useAuth hook을 별도로 생성하였다.

import { createContext, useContext } from "react";

const AuthContext = createContext(null);

export const useAuth = () => useContext(AuthContext);

export function AuthProvider({ authService, children }) {
  const email = authService.email.bind(authService);
  const localLogin = authService.localLogin.bind(authService);
  const login = authService.login.bind(authService);
  const signUp = authService.signUp.bind(authService);
  const logout = authService.logout.bind(authService);
  const resetPassword = authService.resetPassword.bind(authService);
  
  return (
    <AuthContext.Provider
      value={{
        email,
        localLogin,
        login,
        signUp,
        logout,
        resetPassword,
      }}
    >
      {children}
    </AuthContext.Provider>
  );
}

생성된 Provider 컴포넌트로 AuthService가 필요한 컴포넌트들을 감싼다. 해당 프로젝트에서는 Redux로 추가 전역 상태관리를 진행 중인데 Context API가 아닌 Redux로 해당 코드를 변환할 수 있을지 추후 알아볼 예정이다.


import { AuthProvider } from "./context/AuthProvider";
import AuthService from "./class/AuthService-firebase";

const authService = new AuthService();

export default function App() {
  return (
    <AuthProvider authService={authService}>
      <Provider store={reduxStore}>
        <NavigationContainer>
          <StackNavigation />
        </NavigationContainer>
      </Provider>
    </AuthProvider>
  );
}

이제 Provider로 감싸진 컴포넌트들에서는 useAuth를 사용하여 간편하게 클래스 메소드를 활용할 수 있다.

import { useAuth } from "../context/AuthProvider";

export default function Login() {
  
  const auth = useAuth();
  auth.login(input);
  
  ...
}

3. Custom hook으로 분리하기

필자의 경우 Form을 검증하고 제출하는 로직이 생각보다 길어져 해당 부분을 custom hook으로 분리하였다. 예를 들어 회원가입 로직은 다음과 같다.

  1. 사용자로부터 받은 이메일/비밀번호를 검증한다.
    • 실패 시 오류 메시지를 사용자에게 출력한다.
  2. Firebase 회원가입을 시도한다.
    • 성공/실패에 대한 메시지를 사용자에게 출력한다.
  3. 로그인 화면에서 설정 화면으로 이동한다.
const useAuthForm = (navigation) => {
  const auth = useAuth();
  const [formMsg, setFormMsg] = useState("");

  const validateEmail = (email) => {
    if (!email || !email.includes("@")) {
      setFormMsg(LOGIN_MSG.EMAIL_ERR); 
      return false;
    }
    return true;
  };

  const useSignUpForm = async (input) => {
    if (!validateEmail(input.email) || !validatePassword(input.password)) { //1.
      return;
    }

    try {
      await auth.signUp(input); //2.
      setFormMsg(LOGIN_MSG.SUCCESS);
      navigation.navigate(SCREEN_NAME.SETTING); //3.
    } catch (error) {
      setFormMsg(`${LOGIN_MSG.FAIL}\n에러 코드:${JSON.stringify(error.code)}`);
    }
  };

 ...

  const resetFormMsg = () => {
    setFormMsg("");
  };

  return {
    formMsg,
    resetFormMsg,
    useSignUpForm,
    useLoginForm,
    useResetPassword,
  };
};

export default useAuthForm;

해당 커스텀 훅을 사용하면 리액트 컴포넌트 내부를 아래와 같이 간결하게 유지할 수 있다. handleSubmit 함수를 객체로 만들어 formState에 따라 다른 커스텀 훅의 함수를 호출하게 하였다.


export default function AuthSubmit({ formState, navigation, input }) {
  const { formMsg, resetFormMsg, useSignUpForm, useLoginForm, useResetPassword } = useAuthForm(navigation);

  useEffect(() => {
    resetFormMsg();
  }, [formState]);

  const handleSubmit = {
    SIGN_UP() {
      useSignUpForm(input);
    },
    LOGIN() {
      useLoginForm(input);
    },
    REST_PASSWORD() {
      useResetPassword(input.email);
    },
  };

  return (
    <>
      <TouchableOpacity
        onPress={handleSubmit[formState]}
        style={styles.submitBtn}
      >
        <Text>{BTN_TEXT[formState]}</Text>
      </TouchableOpacity>
      <Text style={styles.msg}>{formMsg}</Text>
    </>
  );
}

후기 & Work to do

본 글에서 모두 다루지는 않았지만 authService 인스턴스를 통해 앱 내에서 method 뿐만 아니라 email 데이터도 공유할 수 있어 코드 응집도가 상당히 높아졌다. 글로 정리하고 나니 리액트에 객체지향적 관점을 도입하는 것이 많이 복잡하지 않게 느껴진다. 리팩토링을 공부하고 난 뒤 코드를 보는 관점이 많이 좋아졌다. 함수를 작성할 때 목적과 의도를 한 번 더 고민하고, TypeScript가 도입되지 않은 JavaScript 리팩토링 시 interface를 구축하고 불변성을 유지하기 위해 객체 대신 class를 사용하는 것을 생각해보게 되었다.

앞으로는 여러 인스턴스 공유가 필요할 때 Provider를 여러 개 사용하는 것 외의 대안이 있는지에 대해 공부해볼 예정이다. 또한 커스텀 훅으로 내부 구현을 추상화하였으나 내부 구현에 대해 여전히 반복되는 로직이 남아있어 리팩토링을 더 진행할 것이다.

profile
Code what we love. 좋아하는 것들을 구현하고 있는 프론트엔드 개발자입니다. 사용자도 함께 만족하는 서비스를 만들고 싶습니다.

0개의 댓글