최근 리팩터링 교재와 강의를 공부하면서 데이터와 로직을 함께 두는 class(in JS)를 사용하면 코드 응집도가 높아지고 유지 보수가 쉬워진다는 것을 배웠다. 필자는 대부분을 로직만 함수 추상화를 해두고 절차 지향적으로 코드를 짜왔기에 해당 부분을 사이드 프로젝트에 적용해야겠다고 생각하였다. 혼자 리액트 프로젝트에 클래스 적용을 도전하였을 때는 클래스 인스턴스를 어떻게 관리해야 하는지 감이 오지 않았는데, 운이 좋게도 원티드 프리온보딩 인턴십에서 이 부분을 다뤄주셔서 해당 강의를 참고하여 글을 작성하였다.
본 글에서는 아래와 같은 순서로 리팩토링이 이뤄진다.
1. Auth 관련 데이터와 로직을 Class로 추출
2. Class의 method를 Context API를 통해 리액트 컴포넌트에서 공유
3. 리액트 컴포넌트단의 복잡하고 반복되는 로직들을 커스텀 훅으로 추출
이 글은 개인 기록 용도로 작성되었으며 잘못된 부분이 존재할 수도 있습니다.
본 프로젝트는 React Native 라이브러리를 사용하며 일부 패키지를 제외하고 리팩토링 로직은 React와 동일하다. 구현해야할 AuthService는 다음과 같다.
먼저 Firebase 로그인에 필요한 데이터와 함수들을 모아 클래스로 추출한다. 클래스를 사용하면 함수를 필요할 때마다 import할 필요없이 데이터와 로직을 함께 가지고 다닐 수 있어 용이하다.
email
email
localLogin
signUp
login
logout
resetPassword
handleFetchUser
saveAsyncStorageUser
removeAsyncStorageUser
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();
}
}
추출한 클래스의 메소드를 리액트 컴포넌트에서 사용하려면 이를 전달할 통로가 필요하다. 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);
...
}
필자의 경우 Form을 검증하고 제출하는 로직이 생각보다 길어져 해당 부분을 custom hook으로 분리하였다. 예를 들어 회원가입 로직은 다음과 같다.
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>
</>
);
}
본 글에서 모두 다루지는 않았지만 authService 인스턴스를 통해 앱 내에서 method 뿐만 아니라 email
데이터도 공유할 수 있어 코드 응집도가 상당히 높아졌다. 글로 정리하고 나니 리액트에 객체지향적 관점을 도입하는 것이 많이 복잡하지 않게 느껴진다. 리팩토링을 공부하고 난 뒤 코드를 보는 관점이 많이 좋아졌다. 함수를 작성할 때 목적과 의도를 한 번 더 고민하고, TypeScript가 도입되지 않은 JavaScript 리팩토링 시 interface를 구축하고 불변성을 유지하기 위해 객체 대신 class를 사용하는 것을 생각해보게 되었다.
앞으로는 여러 인스턴스 공유가 필요할 때 Provider를 여러 개 사용하는 것 외의 대안이 있는지에 대해 공부해볼 예정이다. 또한 커스텀 훅으로 내부 구현을 추상화하였으나 내부 구현에 대해 여전히 반복되는 로직이 남아있어 리팩토링을 더 진행할 것이다.