Firebase를 이용한 로그인, 로그아웃

Firebase란?

Firebase는 구글이 제공하는 서버리스 플랫폼이다. Firebase가 웹 서버를 개발하는 데 필요한 인프라를 제공하여 프론트엔드 개발자들은 서버를 따로 관리할 필요 없이 웹 애플리케이션을 만들 수 있다.

이처럼 특정 애플리케이션 개발에 필요한 백엔드 기능을 제공하는 클라우드 서비스를 BaaS(Backend as a Service)라고 한다. 개발자는 직접 서버 측 코드를 개발하지 않고 앱을 클라우드와 연동시켜 BaaS에서 제공하는 API를 호출하여 사용한다. 따라서 개발 시간을 단축하고 코드의 복잡성을 줄일 수 있다.

Firebase의 주요 서비스들

Firebase의 주요 서비스가 무엇이 있는지 알아보자.

Authentication

사용자 인증, 즉 회원 가입, 로그인을 제공하는 서비스이다. 이를 이용하면 이메일, 패스워드 로그인 뿐 아니라 SNS 로그인도 쉽게 구현할 수 있다.

FireStore

문서(Document) 중심의 NoSQL 데이터베이스이다. 정해진 스키마 없이 데이터를 저장할 수 있어, 다양한 형태의 데이터를 유연하게 처리할 수 있다.

Storage

파일을 업로드하고 다운로드하는 Google Drive, Dropbox 같은 서비스이다.

FireStore에 파일을 저장하지 않는 이유 - firestore에는 단일 문서 크기에 제한이 있어 대용량 파일을 저장하기에 적합하지 않다. Storage는 대용량 파일의 업로드 및 다운로드에 최적화 되어 있는 서비스이다.

FIrebase를 이용한 로그인, 로그아웃 구현

1. Firebase 환경 설정하기

firebase 설정 값과 같은 민감한 정보는 github에 올리지 않아야 한다. .env 파일 같은 환경 변수 파일에 저장하고, .gitignore 파일에 추가하여 git에서 추적하지 않도록 한다.

CRA로 만든 프로젝트와 다르게 Vite로 만든 프로젝트에서 환경변수 사용법이 달랐다. Vite에서는 REACT_APP_ 접두사가 아니라 VITE_ 접두사를 사용한다.

Vite의 환경 변수와 모드

  1. .env 파일 생성

  2. VITE_로 시작하는 변수명 사용

    // .env.local
    VITE_API_KEY=
    VITE_AUTH_DOMAIN=
    VITE_PROJECT_ID=
    VITE_STORAGE_BUCKET=
    VITE_MESSAGIN_ID=
    VITE_APP_ID=
    
  3. import.meta.env 객체를 이용해 환경 변수에 접근 ex) import.meta.env.VITE_API_KEY

    // firebase.ts
    const firebaseConfig = {
      apiKey: import.meta.env.VITE_API_KEY,
      authDomain: import.meta.env.VITE_AUTH_DOMAIN,
      projectId: import.meta.env.VITE_PROJECT_ID,
      storageBucket: import.meta.env.VITE_STORAGE_BUCKET,
      messagingSenderId: import.meta.env.VITE_MESSAGIN_ID,
      appId: import.meta.env.VITE_APP_ID,
    };
    

사실은..

github에 firebase 설정 값들을 그대로 올려버렸다.

바보

하지만 이 또한 예전에 겪은 실수였다. 이전에 작성해놓은 글을 보고 빠르게 수정했다. 급하게 수정하느라 완벽히 변경되지는 않았으나 나름.

메일이 하나 왔다. 제목은 [scseong/movie-app] Bearer Token exposed on GitHub

image-20240403145731591

이후에 환경 변수를 사용해서 다시 github에 업로드하였다.

import { initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';

const firebaseConfig = {
  apiKey: import.meta.env.VITE_API_KEY,
  authDomain: import.meta.env.VITE_AUTH_DOMAIN,
  projectId: import.meta.env.VITE_PROJECT_ID,
  storageBucket: import.meta.env.VITE_STORAGE_BUCKET,
  messagingSenderId: import.meta.env.VITE_MESSAGIN_ID,
  appId: import.meta.env.VITE_APP_ID,
};

const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);

2. 로그인, 로그아웃 구현

서비스를 빠르게 구축하기 위해서 Auth 부분은 최대한 간소화하고자 했다. 이메일/패스워드 로그인이 아닌 SNS 로그인 중 구글 로그인을 통한 인증을 사용한다.

자바스크립트로 Google을 사용하여 인증 | Firebase

// firebase.ts

import { initializeApp } from 'firebase/app';
import {
  getAuth,
  GoogleAuthProvider,
  onAuthStateChanged,
  signInWithPopup,
  signOut,
  User,
} from 'firebase/auth';

const firebaseConfig = {
  apiKey: import.meta.env.VITE_API_KEY,
  authDomain: import.meta.env.VITE_AUTH_DOMAIN,
  projectId: import.meta.env.VITE_PROJECT_ID,
  storageBucket: import.meta.env.VITE_STORAGE_BUCKET,
  messagingSenderId: import.meta.env.VITE_MESSAGIN_ID,
  appId: import.meta.env.VITE_APP_ID,
};

const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
const provider = new GoogleAuthProvider();

export async function login(): Promise<User | null> {
  return signInWithPopup(auth, provider)
    .then((result) => {
      const user = result.user;
      return user;
    })
    .catch((error) => {
      console.error(error);
      return null;
    });
}

export async function logout() {
  return signOut(auth)
    .then(() => null)
    .catch(console.error);
}

Firebase에서 제공하는 API를 통해 쉽게 로그인, 로그아웃을 구현할 수 있다.

각 컴포넌트에서 Auth와 관련된 코드를 작성하면 firebase에 필요한 패키지를 import 해야 하고 비즈니스 로직을 포함하게 된다. 따라서 firebase.ts 파일에서 로직을 작성하고, 컴포넌트에서는 내부 로직을 몰라도 함수 호출만으로 Auth와 관련된 기능을 사용할 수 있다.

사용자에 대한 상태를 가지고 있어야 하므로 useState 훅을 사용해 사용자 정보를 저장한다.

// Header.tsx
import { useState } from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { IoSearchOutline, IoBookmarkOutline } from 'react-icons/io5';
import { blindStyle } from '@styles/GlobalStyle';
import LogoIcon from '@assets/logo.svg';
import { login, logout } from '@apis/firebase';
import { User } from 'firebase/auth';

export default function Header() {
  const [user, setUser] = useState<User | null>(null);

  const handleLogin = async () => {
    const user = await login();
    if (user) {
      setUser(user);
    }
  };

  const handleLogout = async () => {
    const user = await logout();
    if (user === null) {
      setUser(null);
    }
  };

  return (
    <StHeader>
      <StWrapper>
        <StLogo>
          <Link to="/">
            <LogoIcon />
            <h1>linkeeper</h1>
          </Link>
        </StLogo>
        <StSubMenu>
          <div className="icons">
            <button aria-label="검색창 열기">
              <IoSearchOutline />
            </button>
            <Link to="/bookmark">
              <IoBookmarkOutline />
            </Link>
          </div>
          <div className="profile">
            {!user && <button onClick={handleLogin}>로그인</button>}
            {user && <button onClick={handleLogout}>로그아웃</button>}
          </div>
        </StSubMenu>
      </StWrapper>
    </StHeader>
  );
}

// style 생략

3. 로그인 정보 유지

로그인 했음에도 새로고침을 하면 다시 로그인을 해야 한다. 왜냐하면 로그인한 사용자의 상태를 컴포넌트 내에서 저장하고 있기 때문에 새로고침을 하면 상태가 있는 컴포넌트가 새롭게 만들어진다. 이때, 상태도 새롭게 만들어져 null로 초기화 되기 때문이다.

firebase는 로그인한 사용자의 정보를 indexedDB에 보관하고 있다. 새로고침 하더라도 로그인한 세션이 남아 있으면 로그인한 사용자의 정보를 사용할 수 있다.

onAuthStateChange 메서드는 사용자의 로그인 상태가 변경될 때마다 추적하여 알려준다. 로그인하거나 로그아웃, 애플리케이션이 시작할 때 로그인한 사용자의 세션이 남아 있다면 콜백함수를 실행한다.

// firebase.ts
export function onUserStateChange(callback: (user: User | null) => void) {
  onAuthStateChanged(auth, callback);
}

사용자의 로그인 상태가 변경될 때마다 setter 함수를 이용하여 사용자 정보를 저장한다.

// Header.tsx
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { IoSearchOutline, IoBookmarkOutline } from 'react-icons/io5';
import { blindStyle } from '@styles/GlobalStyle';
import LogoIcon from '@assets/logo.svg';
import { login, logout, onUserStateChange } from '@apis/firebase';
import { User } from 'firebase/auth';

export default function Header() {
  const [user, setUser] = useState<User | null>(null);

  const handleLogin = async () => {
    const user = await login();
    if (user) {
      console.log(user);
      setUser(user);
    }
  };

  const handleLogout = async () => {
    const user = await logout();
    if (user === null) {
      console.log(user);
      setUser(null);
    }
  };

  useEffect(() => {
    onUserStateChange((user) => {
      setUser(user);
    });
  }, []);

  return (
    <StHeader>
      <StWrapper>
        <StLogo>
          <Link to="/">
            <LogoIcon />
            <h1>linkeeper</h1>
          </Link>
        </StLogo>
        <StSubMenu>
          <div className="icons">
            <button aria-label="검색창 열기">
              <IoSearchOutline />
            </button>
            <Link to="/bookmark">
              <IoBookmarkOutline />
            </Link>
          </div>
          <div className="profile">
            {!user && <button onClick={handleLogin}>로그인</button>}
            {user && <button onClick={handleLogout}>로그아웃</button>}
          </div>
        </StSubMenu>
      </StWrapper>
    </StHeader>
  );
}

▶ 결과

result1

4. AuthContext를 사용하여 사용자 인증 상태 관리

하지만 지금은 Header라는 특정 컴포넌트 내에 로그인과 관련된 상태를 가지고 있다. 로그인한 정보는 애플리케이션 전역에 필요한 상태이므로 Context를 사용하여 관리하도록 변경한다.

React에서 데이터를 다루는 개념중 하나인 Context API를 사용하는 방법에 대해서 알아봅시다.

  • AuthContext 타입 정의
    • 컨텍스트가 다루게 될 데이터의 형태를 정의
  • AuthContext 생성
    • createContext 함수를 사용해 AuthContext를 생성
  • AuthContextProvider 컴포넌트 구현
    • useState를 사용해 현재 로그인한 사용자 상태 관리
    • useEffect 훅을 사용하여 컴포넌트가 마운트될 때 onUserStateChange 함수를 호출 - 사용자의 로그인 상태가 변경될 때마다 setUser를 호출하여 user 상태를 업데이트
    • AuthContext.Provider를 통해 user, login, logout을 컨텍스트의 값으로 제공
  • useAuthContext 커스텀 훅 정의
    • AuthContext의 현재 값에 쉽게 접근할 수 있으며, 이를 통해 사용자 인증 상태를 관리하는 로직을 컴포넌트 내부에서 직접적으로 사용
// AuthContext.tsx
import { User } from 'firebase/auth';
import { login, logout, onUserStateChange } from '@apis/firebase';
import {
  PropsWithChildren,
  createContext,
  useContext,
  useEffect,
  useState,
} from 'react';

type AuthContextType = {
  user: User | null;
  login: () => Promise<void>;
  logout: () => Promise<void>;
};

const AuthContext = createContext<AuthContextType | null>(null);

export function AuthContextProvider({ children }: PropsWithChildren) {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    onUserStateChange(setUser);
  }, []);

  return (
    <AuthContext.Provider value=>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuthContext() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('Cannot find AuthProvider');
  }
  return context;
}

AuthContextProvider를 사용하여 애플리케이션의 최상위 컴포넌트에 배치하여 하위 컴포넌트 어디서든지 현재 로그인한 사용자 정보에 접근하고, 로그인 및 로그아웃 함수를 사용할 수 있게 되었다.

// Root.tsx
import { useEffect, useState } from 'react';
import { Outlet } from 'react-router-dom';
import { Footer, Header, Main, Sidebar } from './layouts';
import { GlobalStyle } from '@styles/GlobalStyle';
import styled from 'styled-components';
import { AuthContextProvider } from '@context/AuthContext';

export default function Root() {
  return (
    <StLayoutContainer>
      <AuthContextProvider>
        <Header />
        <Sidebar />
        <Main>
          <Outlet />
        </Main>
        <Footer />
        <GlobalStyle />
      </AuthContextProvider>
    </StLayoutContainer>
  );
}

Header 컴포넌트에서는 이제 사용자 인증 상태를 저장하지 않고 useAuthContext 훅을 사용하여 AuthContext의 값을 가져온다.

// Header.tsx
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { IoSearchOutline, IoBookmarkOutline } from 'react-icons/io5';
import { blindStyle } from '@styles/GlobalStyle';
import LogoIcon from '@assets/logo.svg';
import { useAuthContext } from '@context/AuthContext';

export default function Header() {
  const { user, login, logout } = useAuthContext();

  const handleLogin = async () => {
    await login();
  };

  const handleLogout = async () => {
    await logout();
  };

  return (
    ( 생략 )
  );
}

5. 로그인 인증 상태 로딩

이제 애플리케이션이 실행되거나 새로고침 하더라도 로그인 정보를 유지할 수 있고 전역에서 로그인 상태에 접근할 수 있게 되었다. 하지만 3의 결과를 보면 로그인 된 상태에서 새로고침 하면 ‘로그인’ 텍스트가 보였다가 ‘로그아웃’ 텍스트가 보인다.

그 이유는 로그인 상태를 확인이 비동기적으로 이루어지기 때문이다. 애플리케이션이 처음 실행될 때, Firebase 인증 상태를 확인하는 시간이 소요된다. 사용자의 로그인 상태를 즉시 확인할 수 없기 때문에 로그인 상태를 확인하는 비동기 작업이 완료되기 전까지는 ‘로그인’ 상태로 보이다가, 로그인 상태가 확인되면 ‘로그아웃’으로 텍스트가 변경되는 것이다.

따라서 로그인 인증 상태가 확인될 때까지 UI를 보여주지 않고 인증 상태 확인이 진행되는 동안 로딩 인디케이터를 표시하고 로그인 상태를 반영하여 UI를 업데이트한다.

로딩 인디케이터는 사용자가 대기 시간을 더 잘 인지하고 이해할 수 있게 하여, 전반적인 사용자 경험을 개선한다.

authStateReady 메서드는 초기 인증 상태가 해결되는 즉시 해결되는 프로미스를 반환합니다. 프로미스가 해결되면 현재 사용자는 유효한 사용자이거나 사용자가 로그아웃한 경우 null일 수 있습니다. - Auth Interface | Firebase

// Root.tsx
import { useEffect, useState } from 'react';
import { Outlet } from 'react-router-dom';
import { Footer, Header, Main, Sidebar } from './layouts';
import { GlobalStyle } from '@styles/GlobalStyle';
import styled from 'styled-components';
import { AuthContextProvider } from '@context/AuthContext';
import { auth } from '@apis/firebase';
import LoadingIndicator from '@components/common/LoadingIndicator';

export default function Root() {
  const [isLoading, setLoading] = useState(true);

  const init = async () => {
    await auth.authStateReady(); 
    setLoading(false);
  };

  useEffect(() => {
    init();
  }, []);

  if (isLoading) {
    return <LoadingIndicator />;
  }

  return (
    <StLayoutContainer>
      <AuthContextProvider>
        <Header />
        <Sidebar />
        <Main>
          <Outlet />
        </Main>
        <Footer />
        <GlobalStyle />
      </AuthContextProvider>
    </StLayoutContainer>
  );
}

6. 결과

(+) 로그인, 로그아웃 시 react-toastify 라이브러리를 이용하여 알림이 뜨도록 하여 사용자에게 즉각적인 피드백을 제공하도록 추가하였다.

result2

카테고리:

업데이트:

댓글남기기