[글마디 프로젝트] Firebase 인증 기능 적용 회고 (feat. MVC 모델)

Minsu Han·2023년 4월 16일
0

Side Projects

목록 보기
2/10
post-thumbnail

시작하며

ES6 자바스크립트를 활용하여 진행중인 개인 토이프로젝트에 Google Firebase Authentication을 적용하여 이메일/비밀번호 기반 계정생성, 로그인, 로그아웃 기능을 구현한 회고입니다.

Firebase 인증을 선택한 이유

  1. Firebase는 인증에 필요한 백엔드 서비스를 제공해줍니다. 저처럼 백엔드 구현에 대한 지식이 없는 사람들이 클라이언트 단에서 인증 API를 제대로 사용하기만 하면 백엔드 구축 없이 인증 기능을 프로젝트에 적용할 수 있습니다.
  2. 인증 요청 시 발생하는 여러 가지 실패 사유들(ex. 이미 존재하는 이메일, 잘못된 이메일 형식, 지나치게 짧은 비밀번호, 네트워크 연결 실패 등)을 인증 결과에 Error code로 제공하기 때문에, 해당 Error code에 맞게 적절하게 예외처리하는 코드만 작성하면 되므로 인증 과정에서의 예외 처리도 간편합니다.
  3. OAuth2를 활용할 수 있어서 소셜 플랫폼 계정으로 간편하게 인증하는 기능을 적용할 수 있습니다.

MVC 모델을 기반으로 한 인증 과정 요약

Firebase 인증을 구현하는 데 필요한 코드를 아래와 같이 MVC 모델로 분리하였습니다.

  • Model: auth.js
  • View: loginView.js, registerView.js
  • Controller: controller.js

위 모델을 활용하여 인증을 처리하는 과정은 아래와 같습니다.

  1. Controller는 loginView / registerView가 인증 버튼 클릭을 감지한 경우 인증 요청 및 결과 수신을 위해 실행할 핸들러를 등록해 놓습니다.
  2. loginView / registerView에서 인증 버튼 클릭을 감지하면, 유저가 인증을 위해 form에 입력한 이메일, 비밀번호 정보를 Controller가 등록한 핸들러에 인자로 전달합니다.
  3. Controller는 전달받은 정보를 Model(auth.js)에게 전달하면서 인증을 요청하고, 인증 결과 수신을 대기합니다.
  4. 인증 결과를 Controller가 수신하면, 인증 성공 여부에 따라 loginView / registerView에서 적절한 메시지를 표시하게 합니다.

후에 기술하겠지만, Controller에는 auth.js 측에서 로그인, 로그아웃 등에 의한 유저 정보 변경을 감지한 경우 처리할 내용을 구현한 핸들러도 등록합니다.

구현을 위한 틀을 구성했으니 본격적인 구현 과정을 설명하겠습니다.

1. Firebase 프로젝트 생성, 앱 등록, SDK 설치 및 Firebase 초기화

Firebase 인증 API를 적용하려면 우선 Firebase 프로젝트를 생성하고, 해당 프로젝트에 서비스할 본인의 앱(저는 웹프로젝트니까 웹 앱입니다)을 등록한 다음, SDK 설치 및 Firebase 초기화를 진행해야 합니다. 이 과정은 아래 공식 문서에서 상세하게 설명되어 있으니 순서에 따라 진행하면 됩니다.

자바스크립트 프로젝트에 Firebase 추가
https://firebase.google.com/docs/web/setup?hl=ko#add-sdk-and-initialize

NPM으로 firebase SDK를 설치한 다음, 앱 등록 과정에서 받은 Firebase 초기화 코드를 auth.js파일에 추가합니다.

/* auth.js */

// Import the functions you need from the SDKs you need
import { initializeApp } from "firebase/app";
// TODO: Add SDKs for Firebase products that you want to use
// https://firebase.google.com/docs/web/setup#available-libraries
import {
  createUserWithEmailAndPassword,
  getAuth,
  signInWithEmailAndPassword,
  onAuthStateChanged,
  signOut,
} from "firebase/auth";

class Auth {
  // Your web app's Firebase configuration
  // 자신의 앱 등록 과정에서 받은 config 정보를 추가합니다.
  #firebaseConfig = {
    apiKey: "",
    authDomain: "xxx.firebaseapp.com",
    databaseURL: "https://xxx-default-rtdb.firebaseio.com",
    projectId: "xxx",
    storageBucket: "xxx.appspot.com",
    messagingSenderId: "",
    appId: "",
  };

  // Initialize Firebase
  #firebase_app = initializeApp(this.#firebaseConfig);

2. auth.js: Firebase 인증 API 사용하여 회원가입/로그인/로그아웃 메서드 구현하기

Firebase를 초기화했으니 이제 앱에서 Firebase가 제공하는 여러 가지 서비스를 사용할 수 있습니다. 그 중에서 저는 인증 서비스가 필요하므로 인증 API를 import하여 사용하면 됩니다. 이 역시 공식 문서에 자세하게 설명되어 있습니다.

자바스크립트를 사용하여 비밀번호 기반 계정으로 Firebase에 인증하기
https://firebase.google.com/docs/auth/web/password-auth?hl=ko

인증 API를 사용하기 위해 auth.js 파일에 다음 코드를 추가합니다.

import {
  createUserWithEmailAndPassword,
  getAuth,
  signInWithEmailAndPassword,
  onAuthStateChanged,
  signOut,
} from "firebase/auth";

// 아까 초기화한 Firebase를 전달하여 auth 객체를 생성합니다.
#auth = getAuth(this.#firebase_app);

공식 문서를 참고하면 이메일/비밀번호 기반의 계정생성, 로그인, 로그아웃을 위해 인증 API가 제공하는 메서드는 각각 createUserWithEmailAndPasswordsignInWithEmailAndPassword, 그리고 signOut입니다.

이를 사용하여 auth.js가 controller에게 인증을 위해 제공할 public 메서드를 작성합니다.

/* auth.js */

  /**
   * @description 이메일/패스워드 기반 Firebase 계정 생성
   * @param { string } email
   * @param { string } password
   * @returns { Object } userCredential
   */
  async signUpEmail(email, password) {
    try {
      // ret data: (userCredential);
      const data = await createUserWithEmailAndPassword(
        this.#auth,
        email,
        password
      );
      console.log(data);
      return data;
    } catch (err) {
      throw err;
    }
  }

  /**
   * @description 이메일/패스워드 기반 Firebase 로그인
   * @param { string } email
   * @param { string } password
   * @returns { Object } userCredential
   */
  async signInEmail(email, password) {
    try {
      // ret data: (userCredential);
      const data = await signInWithEmailAndPassword(
        this.#auth,
        email,
        password
      );
      console.log(data);
      return data;
    } catch (err) {
      throw err;
    }
  }

  /**
   * @description 현재 Firebase 인증 User 로그아웃
   */
  async signOutUser() {
    try {
      await signOut(this.#auth);
    } catch (err) {
      throw err;
    }
  }

인증 과정에서 발생하는 오류는 메서드를 호출하는 Controller에서 에러코드에 따라 처리하도록 throw하였습니다.

3. controller.js: 인증 요청 및 결과를 처리하는 메서드 구현하기

이제 Controller에서 auth가 제공하는 메서드를 호출하여 인증을 요청하고 수신한 결과를 처리하는 코드를 작성합니다.

auth가 제공하는 메서드를 호출하여 인증 요청을 하고, 수신한 인증 결과에 따라 View에서 유저에게 적절한 화면을 표시하도록 합니다.

공식 문서를 참고하면 이전에 auth가 인증 과정에서 throw한 오류 객체에는 code와 message 속성이 존재합니다.

/* 공식 문서의 샘플코드 */
import { getAuth, createUserWithEmailAndPassword } from "firebase/auth";

const auth = getAuth();
createUserWithEmailAndPassword(auth, email, password)
  .then((userCredential) => {
    // Signed in
    const user = userCredential.user;
    // ...
  })
  .catch((error) => {
    const errorCode = error.code;
    const errorMessage = error.message;
    // ..
  });

Firebase에서 제공하는 해당 error code에 따라 분기하여 Controller가 적절한 메시지를 화면에 표시하도록 할 수 있습니다.

/* controller.js */
"use strict";

import loginView from "./views/loginView.js";
import registerView from "./views/registerView.js";
import auth from "./auth.js";

/**
 *
 * @param { Array } formData registerView가 제공하는 form data
 * @description 회원가입 폼으로부터 입력받은 데이터를 auth.js에 정의된 Firebase 기반 회원가입 함수에 전달하여 회원가입을 진행함
 *
 */
const controlCreateAccount = async function (formData) {
  try {
    // 로딩 스피너 표시
    registerView.toggleButtonSpinner();
    // Firebase 회원가입 인증 대기 및 수신
    // formData[0]: Email, formData[1]: Password
    const data = await auth.signUpEmail(formData[0], formData[1]);
    // 인증 완료 메시지 표시
    registerView.renderSuccessMessage("회원가입이 완료되었습니다.");
    // 로딩 스피너 제거
    registerView.toggleButtonSpinner();
    // 회원가입 창 닫기
    registerView.closeModal();
  } catch (err) {
    // 로딩 스피너 제거
    registerView.toggleButtonSpinner();
    // 오류 코드에 따른 메시지 표시
    switch (err.code) {
      case "auth/email-already-in-use":
        registerView.renderError("이미 사용 중인 이메일입니다.");
        return;
      case "auth/weak-password":
        registerView.renderError("비밀번호는 6글자 이상이어야 합니다.");
        return;
      case "auth/network-request-failed":
        registerView.renderError("네트워크 연결에 실패하였습니다.");
        return;
      case "auth/invalid-email":
        registerView.renderError("잘못된 이메일 형식입니다.");
        return;
      case "auth/internal-error":
        registerView.renderError("잘못된 요청입니다.");
        return;
      default:
        registerView.renderError("회원가입에 실패 하였습니다.");
    }
  }
};

/**
 *
 * @param { Array } formData loginView가 제공하는 form data
 * @description 로그인 폼으로부터 입력받은 데이터를 auth.js에 정의된 Firebase 기반 로그인 함수에 전달하여 로그인을 진행함
 *
 */
const controlSignIn = async function (formData) {
  try {
    // 로딩 스피너 표시
    loginView.toggleButtonSpinner();
    // Firebase 로그인 인증 대기
    const data = await auth.signInEmail(formData[0], formData[1]);
    // 인증 완료 메시지 표시
    loginView.renderSuccessMessage("로그인 성공");
    // 로딩 스피너 제거
    loginView.toggleButtonSpinner();
    // 로그인 창 닫기
    loginView.closeModal();
  } catch (err) {
    // 로딩 스피너 제거
    loginView.toggleButtonSpinner();
    // 오류코드에 따른 메시지 표시
    switch (err.code) {
      case "auth/user-not-found" || "auth/wrong-password":
        loginView.renderError("이메일 혹은 비밀번호가 일치하지 않습니다.");
        return;
      case "auth/weak-password":
        loginView.renderError("비밀번호는 6글자 이상이어야 합니다.");
        return;
      case "auth/network-request-failed":
        loginView.renderError("네트워크 연결에 실패하였습니다.");
        return;
      case "auth/invalid-email":
        loginView.renderError("잘못된 이메일 형식입니다.");
        return;
      case "auth/internal-error":
        loginView.renderError("잘못된 요청입니다.");
        return;
      default:
        loginView.renderError("로그인에 실패하였습니다.");
    }
  }
};

/* 로그아웃 버튼 클릭 이벤트 핸들러 */
const controlSignOut = async function () {
  try {
    await auth.signOutUser();
    loginView.renderSuccessMessage("로그아웃되었습니다");
  } catch (err) {
    console.log(err);
    loginView.renderError("로그아웃 실패");
  }
};

4. View: 이벤트를 감지할 때마다 controller가 인증 요청을 하도록 하기

Controller는 그렇다면 언제 자신이 갖고 있는 인증 요청 메서드를 실행해야 할까요? 유저가 로그인/회원가입을 위한 form을 입력하고 제출 버튼을 클릭하여 이벤트가 발생했을 때입니다. 그 이벤트는 View 측에서 감지하게 하고, handler로 위에서 구현한 Controller의 인증 요청 메서드를 등록하고자 합니다.

하지만 View에서 Controller의 메서드를 명시적으로 등록하는 것은 MVC 모델의 지향점에 맞지 않습니다. 물론 MVC 모델로 분리하여 구현하는 것 자체로도 결합도를 많이 낮추고 있는 것이겠지만, Controller는 제어권을 가진 객체이니 View, Model의 함수를 직접 호출하더라도 View, Model에서 Controller의 함수를 직접 호출하는 것은 지양하는 게 좋겠네요.

따라서 View에서 Controller에게 이벤트 발생 시 실행할 핸들러를 등록할 수 있는 public 메서드를 제공하도록 구현하였습니다.

/* loginView.js */

  /* 로그인 폼 submit 클릭시 로직 */

  /**
   *
   * @param { function } handler Controller의 로그인 진행 함수
   * @description 로그인 폼 제출버튼을 눌렀을 때 로그인을 진행하는 핸들러 함수가 실행될 수 있도록 핸들러를 등록함
   */
  addHandlerSignIn(handler) {
    this.#form.addEventListener('submit', function (e) {
      // 이벤트 리스너의 this는 이벤트를 감지한 해당 요소임
      // FormData 객체는 form의 input 요소들을 ['name', 'value'] 형태로 매핑해 준다.
      const formData = [...new FormData(this)].map(data => data[1]);

      e.preventDefault();
      handler(formData);
    });
  }
  
  /* 로그아웃 버튼 클릭시 실행할 핸들러를 등록하게 함 */
  addHandlerSignOut(handler) {
    this.#btnLogout.addEventListener('click', handler);
  }
/* registerView.js */

  /**
   * @param { function } handler Controller의 회원가입 진행 함수
   * @description 회원가입 폼 제출버튼을 눌렀을 때 회원가입을 진행하는 핸들러 함수가 실행될 수 있도록 핸들러를 등록함
   */
  addHandlerCreateAccount(handler) {
    this.#form.addEventListener('submit', function (e) {
      // 이벤트 리스너의 this는 이벤트를 감지한 해당 요소임
      // FormData 객체는 form의 input 요소들을 ['name', 'value'] 형태로 매핑해 준다.
      const formData = [...new FormData(this)].map(data => data[1]);

      e.preventDefault();
      handler(formData);
    });
  }

위와 같이 View가 제공하는 메서드를 사용하여 Controller는 이벤트 발생 시 실행할 함수들(controlCreateAccount, controlSignIn, controlSignOut)을 등록합니다.

/* controller.js */
/**
 * @description View / Auth 측에서 감지한 각 이벤트들에 대해 핸들러를 구독발행하는 메서드
 */
const init = function () {
  registerView.addHandlerCreateAccount(controlCreateAccount);
  loginView.addHandlerSignIn(controlSignIn);
  loginView.addHandlerSignOut(controlSignOut);
};

init();

이로써 Controller는 View에서 감지한 form 제출 이벤트에 따라 적절한 메서드를 실행하도록 미리 등록해 놓습니다.

유저가 form의 제출 버튼을 누르면 View는 이벤트를 감지하고, 미리 Controller가 등록한 handler를 실행함으로써 인증 요청을 하는 것입니다.

5. onAuthStateChanged 를 사용하여 인증 상태 변경 감지하기

로그인에 성공하거나 로그인한 계정이 변경된 경우 화면에 현재 유저의 프로필과 로그아웃 버튼을 표시하고, 만약 로그아웃한 경우에는 화면에 로그인 버튼만 표시하게 하고 싶습니다.

이때, 인증 상태 변경을 감지하는 onAuthStateChanged 함수를 사용할 수 있습니다.

해당 함수는 인증 상태 변경을 감지한 다음 현재 user 정보를 제공합니다. 로그아웃 상태이면 user 정보는 null 입니다.

Firebase에서 사용자 관리하기
https://firebase.google.com/docs/auth/web/manage-users?hl=ko#get_the_currently_signed-in_user

인증 상태 변경을 Auth가 감지했을 때 실행할 handler를 Controller가 등록할 수 있는 public 메서드를 auth.js에 추가합니다.

/* auth.js */

  /**
   * @description 인증 정보 변경(로그인, 로그아웃 등)을 감지한 경우 Controller에서 실행할 함수 등록
   * @param { Function } handler
   */
  onUserStateChange(handler) {
    onAuthStateChanged(this.#auth, (user) => {
      handler(user);
    });
  }

controller.js에 인증 상태 변경 시 실행할 handler를 등록하는 코드를 추가합니다.

/* controller.js */
/**
 *
 * @param { Object } user Firebase Auth 유저 객체
 * @description 로그인 상태 변경 감지 시 실행할 핸들러
 */
const controlUserStateChange = function (user) {
  /* 로그인 상태에 따라 로그아웃/로그인 버튼 중 하나를 렌더링 */
  loginView.clearHeaderButtons();
  if (user) {
    loginView.showNavLogOutButton();
    loginView.showNavAccountButton(auth.getCurrentUserData().email);
  } else {
    loginView.showNavLoginButton();
  }
};

/**
 * @description View / Auth 측에서 감지한 각 이벤트들에 대해 핸들러를 구독발행하는 메서드
 */
const init = function () {
  auth.onUserStateChange(controlUserStateChange);
  
  registerView.addHandlerCreateAccount(controlCreateAccount);
  loginView.addHandlerSignIn(controlSignIn);
  loginView.addHandlerSignOut(controlSignOut);
};

init();

Auth 측에서 인증 상태 변경을 감지하였을 때 처리할 내용(controlUserStateChange)을 Controller가 등록하였습니다.

구현 결과

Firebase 인증을 사용하여 계정생성, 로그인, 로그아웃을 진행하고, 오류 발생시 적절한 메시지를 표시합니다.

또한, 유저 상태가 변경될 때마다 유저 정보를 콘솔에 출력하며, 프로필 정보에 로그인한 계정의 이메일을 표시하는 모습입니다.

추가) Google로 로그인하기

Firebase는 소셜 계정을 사용한 로그인도 지원합니다. 그 중에서 Google로 로그인하는 기능을 추가적으로 구현하였습니다. 방식은 위에서 했던 것과 매우 비슷합니다. SDK에서 제공하는 GoogleAuthProvider를 사용하여 signInWithPopup 메서드를 호출하기만 하면 됩니다. 호출 시 전달하는 인자는 Auth 객체와 GoogleAuthProvider 객체입니다.

자바스크립트로 Google을 사용하여 인증
https://firebase.google.com/docs/auth/web/google-signin?hl=ko

/* auth.js */
import {
  createUserWithEmailAndPassword,
  getAuth,
  signInWithEmailAndPassword,
  onAuthStateChanged,
  signOut,
  GoogleAuthProvider,
  signInWithPopup,
} from "firebase/auth";

class Auth {

  #provider = new GoogleAuthProvider();

  /**
   * @description Google로 로그인
   * @returns { Object } userCredential
   */
  async signInGoogle() {
    try {
      const data = await signInWithPopup(this.#auth, this.#provider);
      const credential = GoogleAuthProvider.credentialFromResult(data);
      const token = credential.accessToken;

      return credential;
    } catch (err) {
      console.log(err);
      throw err;
    }
  }
}

그리고 이메일/비밀번호 기반 로그인을 구현했던 것처럼 View와 Controller, Model(Auth) 간의 상호작용을 구현하기만 하면 Google 로그인 역시 쉽게 적용할 수 있었습니다.

profile
기록하기

0개의 댓글