개인 프로젝트 | Linkeeper - 6
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_
접두사를 사용한다.
-
.env
파일 생성 -
VITE_
로 시작하는 변수명 사용// .env.local VITE_API_KEY= VITE_AUTH_DOMAIN= VITE_PROJECT_ID= VITE_STORAGE_BUCKET= VITE_MESSAGIN_ID= VITE_APP_ID=
-
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
이후에 환경 변수를 사용해서 다시 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 로그인 중 구글 로그인을 통한 인증을 사용한다.
// 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>
);
}
▶ 결과
4. AuthContext를 사용하여 사용자 인증 상태 관리
하지만 지금은 Header라는 특정 컴포넌트 내에 로그인과 관련된 상태를 가지고 있다. 로그인한 정보는 애플리케이션 전역에 필요한 상태이므로 Context를 사용하여 관리하도록 변경한다.
- 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
라이브러리를 이용하여 알림이 뜨도록 하여 사용자에게 즉각적인 피드백을 제공하도록 추가하였다.
댓글남기기