Next.js 에 Firebase Auth 추가하기

byron1st·2022년 2월 28일
5

Next.js 배우기

목록 보기
3/4

Firebase Auth과 Next.js

업데이트

  • 2022-11-17: Firebase 의 Session Cookies를 이용하여 구현하는 것으로 변경하였다.

--

Firebase 는 사용자 인증 서비스인 Auth, 데이터베이스 서비스들인 Cloud Firestore, Realtime Database 등을 제공해주는 일종의 Backend as a Service 이다. Firebase 는 프론트엔드 개발자들이 마치 앱 개발의 일부처럼 백엔드 기능을 사용할 수 있도록 해주는 서비스라서, 웹앱의 경우 SPA(Single Page Application), PWA(Progressive Web App) 개발에 좀 더 최적화되어 있다. 그래서 Client SDK 를 통해 사용자 생성, 상태 관리, 이메일 인증 등을 모두 프론트엔드 앱에서 처리할 수 있도록 되어 있다.

Next.js는 SSR(Server Side Rendering) 웹앱 개발을 위한 서버 프레임워크이기 때문에, Firebase 와는 딱 맞는다고 할 수 없다. 태생이 서버 측에서 많은 부분을 처리해주어야 하는데, Firebase 의 Client SDK 자체는 사용자의 모든 생명주기(생성, 로그인, 로그아웃, 삭제 등)가 프론트엔드 앱에서 이루어지는 것을 가정하고 있다. 그래서 Next.js 의 GetServerSideProps 같은 서버측 함수에서 사용자 인증 정보를 접근하기가 자유롭지 않다.

특히, 인증된 사용자만 접근이 가능한 페이지들에 대한 라우팅을 구현할 때, SPA 앱에서 Firebase 를 사용하는 것에 비해 매끄럽지 못하다.

SPA의 경우 라우팅이 브라우저 내에서 이루어진다. Firebase의 Client SDK는 브라우저 내 스토리지에 현재 사용자의 인증 정보를 저장하고 관리하기 때문에, 이를 바탕으로 앱 내 라우팅을 인증 상태에 따라 손쉽게 제한할 수 있다.

하지만 Next.js의 경우, 라우팅은 곧 서버로 GET 호출을 보내 원하는 웹 페이지를 서버에서 렌더링 한 후 돌려받는걸 의미한다. (Next.js 의 /pages 폴더 내에는 서버측에서 렌더링 되어 브라우저로 전송되는 웹페이지들이 위치하고 있다.) 이 때, 서버 측으로 인증 정보를 보내는 방법은 Cookie 뿐이다. (혹시 이 때 커스텀 헤더를 다이내믹하게 붙일 수 있는 방법을 아는 분이 있다면 알려주시길 바란다. 일단 난 모른다.)

Firebase Auth의 경우, 커스텀 백엔드 서버와의 소통을 ID 토큰을 이용하여 진행한다. ID 토큰은 다소 길이가 긴 일반 문자열로 되어 있는데, 백엔드 서버는 Firebase Admin SDK 를 이용하여 이를 검증 및 파싱하여 사용한다. 즉, 우리는 Next.js 서버로 이 ID 토큰을 보낼 수 있는 법을 찾아야 한다.

다만, ID 토큰의 경우 생명주기가 매우 짧기 때문에, Cookie 에 의존하여 사용자 세션을 관리하는 전통적인 웹사이트의 경우에 사용이 불편하다. 그래서 Firebase Admin SDK 를 이용하여, 지속 시간이 좀 더 긴(최대 2주) Cookie 를 설정할 수 있도록 도와준다.

구현 전략

일단 다음과 같은 플로우를 생각해보도록 한다.

  1. Firebase Client SDK 에서 signIn... 함수를 이용하여 로그인을 한다.
  2. 로그인이 성공적으로 이루어지면, getIdToken 함수를 이용하여 ID 토큰을 생성한다.
  3. 이 JWT를 JSON body에 넣어 /api/auth/login API를 호출한다. 이 API 는 Next.js 의 API 로 /pages/api/auth/login.ts에 구현된다.
  4. /api/auth/login 함수는 Request body 로 받은 ID 토큰을 Firebase Admin SDK 의 createSessionCookie 함수를 이용하여 쿠키에 넣을 문자열을 생성한다. 그리고 이 문자열을 쿠키에 저장한다. 이때, iron-session 라이브러리를 사용하면 좋다.
  5. 사용자가 복잡한 권한 구조를 갖는게 아니라, 단순히 '로그인 여부'만 판단해도 괜찮다면, Next.js의 Middleware 를 사용해서, 쿠키의 존재 여부만 판단한 후 그대로 진행할지, 아니면 login 페이지로 리다이렉션 시킬 지 결정할 수 있다. 아쉽게도 Middleware 함수에서는 Firebase Admin SDK 를 이용할 수 없기 때문에, 쿠키의 문자열을 파싱하는 건 불가능하다.
  6. 이제 웹브라우저에는 Next.js 서버로 호출을 보낼 때(페이지 라우팅 포함) 사용할 세션 쿠키도 함께 저장된다. 서버의 GetServerSideProps 함수에서는 iron-session 라이브러리로 쿠키를 파싱하여 Firebase Session Cookie 문자열을 가져온 후, 이를 Firebase Admin SDK의 verifySessionCookie 함수로 검증 및 파싱하여 사용한다.

Next.js 서버의 커스텀 인증을 위한 세션 쿠키를 별도로 관리한다고 생각하면 되겠다.

개인적으로 사용자 인증 상태 정보를 중복해서 저장하는 것이 마음에 들지 않아서, 다른 방법을 여러차례 검색해보고, Firebase Auth 를 아애 배제한체 별도로 사용자 테이블 및 기능도 구현해보았으나, 역시 그래도 Firebase Auth가 가장 편하고, 기능도 좋다.

그래서 다른 사람들의 구현도 살펴보고 특히 Next.js 문서에서 소개해주는 next-firebase-auth 라이브러리 또한 유사하게 구현이 되어 있었다. 덕분에 찝찝함을 좀 걷어내고, 나도 같은 방식으로 구현을 해보았다.
(next-firebase-auth 를 쓰면 되지 않나? 싶은데, 아무래도 잘 적응이 되지 않아 간단하게 필요한 내용만 구현을 해보았다.)

Firebase (Client) SDK

일단 프론트엔드 앱 쪽을 먼저 구현해보자. 우선 Firebase 프로젝트와 에뮬레이터는 사용 중이라고 가정하겠다. 그렇다면, 프론트엔드 웹 쪽 코드에 적당히 다음과 같은 Firebase Client SDK 설정이 있을 것이다.

import { initializeApp } from 'firebase/app'
import { getAuth, connectAuthEmulator } from 'firebase/auth'

const firebaseConfig = {
  apiKey: ...
}

const firebaseClientApp = initializeApp(firebaseConfig)
export const firebaseClientAuth = getAuth(firebaseClientApp)
connectAuthEmulator(firebaseClientAuth, 'http://localhost:9099')

connectAuthEmulator 함수를 통해 에뮬레이터에 접근한다. 만약 Next.js에서 window 객체가 없다는 에러가 발생할 경우, getAuth 함수 이전에 typeof window !== undefined 를 검사하도록한다.

이제 pagesindex.tsx 코드에 로그인 코드를 입력해주자.

import { useState } from 'react'
import type { GetServerSideProps, NextPage } from 'next'
import { signInWithEmailAndPassword } from 'firebase/auth'
import { useRouter } from 'next/router'
import { firebaseClientAuth } from 'client/firebase'

export const getServerSideProps: GetServerSideProps = ...

const Home: NextPage = () => {
  const router = useRouter()

  const [email, setEmail] = useState<string>('')
  const [password, setPassword] = useState<string>('')

  const login = async () => {
    // 1. Firebase 로그인
    const credential = await signInWithEmailAndPassword(
      firebaseClientAuth,
      email,
      password,
    )
    
    // 2. JWT 생성
    const idToken = await credential.user.getIdToken()
    
    // 3. Next.js 의 로그인 함수 호출
    await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ idToken }),
    })

    // 완료되면, 인증 받은 사용자만 접근 가능한 페이지로 라우팅
    await router.push('/user')
  }

  return (
    <div>
      <input
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        type="email"
      />
      <input
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        type="password"
      />
      <button onClick={login}>로그인</button>
    </div>
  )
}

export default Home

login 함수를 보면 앞서 구현 전략에서 말한 1~3번 스탭이 담겨있음을 알 수 있다.

Firebase Admin SDK

Firebase Admin SDK 는 서버 측 라이브러리이다. Admin SDK는 인증 정보(위 코드의 firebaseConfig 값)가 공개되도 되는 Client SDK 와는 다르게 비공개로 보호되어야 한다. 이는 사용자 생성, 제거 같은 관리자 기능을 포함하기 때문에, 관리자임을 증명할 수 있는 프라이빗 키(private key)가 포함되어 있기 때문이다.

프로덕션 단계에서의 Admin SDK 인증 정보 배포는 링크를 참고하고, 일단 나는 .env.local 에 에뮬레이터 접속을 위한 환경변수를 설정해주었다.

GOOGLE_CLOUD_PROJECT="..."
FIREBASE_AUTH_EMULATOR_HOST="localhost:9099"

그리고 적당한 파일을 생성하고, 다음 코드를 통해 Firebase Admin SDK를 초기화 해주자.

import { initializeApp } from 'firebase-admin/app'
import { getAuth } from 'firebase-admin/auth'

const firebaseApp = global.firebaseApp ?? initializeApp()

global.firebaseApp = firebaseApp

export const firebaseAuth = getAuth(firebaseApp)

firebaseApp 객체의 재사용을 위해 global 객체에 저장해둔다. global 객체에 저장하면, TypeScript 의 타입 검사에 걸리는데, 난 그냥 // @ts-ignore 처리해둔다. global 객체에 타입을 정하는 방법도 있으니, 참고하길.

이제 서버측 API를 만들어보자. /pages/api/auth 폴더에 login.ts 를 만든다.

export default withIronSessionApiRoute(
  async (req: NextApiRequest, res: NextApiResponse): Promise<void> => {
    const { idToken } = req.body as LoginUserBody
    if (!idToken) {
      res.status(401)
      throw new Error('no valid idToken')
    }

     const sessionCookie = await auth.createSessionCookie(idToken, {
      expiresIn: 60 * 60 * 24 * 1000, // 밀리초 단위로, 만료 기간을 정해준다. iron session의 만료 시간과 맞춰주는 것이 좋다.
    })

    req.session.value = sessionCookie
    await req.session.save()
  },
  sessionOptions,
)

(try-catch 를 적절히 써서 에러를 처리하거나, 이전 글에서 구현한 withMiddlewares 코드는 이 글의 핵심이 아니므로 제외하였다)

위 코드는 /api/auth/login 으로 호출할 수 있는 API 로, idToken 값을 받아, 이를 세션에 저장하는 코드이다. 이를 위해, iron-session 라이브러리를 사용한다. 이 API는 위의 Home 리액트 컴포넌트의 login 함수에서 "3. Next.js 의 로그인 함수 호출" 부분에서 호출된다.

/api/auth/login API를 통해 세션을 브라우저에 저장하면, 이제 GetServerSideProps 같은 서버 측 함수에서도 세션 쿠키 파싱을 통해 세션에 저장된 uid 에 접근할 수 있다.

UserContext 만들기

세션을 통해 서버에게 인증 정보를 넘길 수 있게 되었으니, 브라우저에서도 인증 정보를 글로벌에 저장해두어 쓰도록 하자. Firebase Client SDK 는 브라우저 내의 로컬 저장소를 이용하여 인증과 관련된 정보들을 저장하고 있다. 그래서 브라우저를 리프래쉬하거나, 재시작해도 인증 정보가 남아있다.

이러한 브라우저 내의 인증 정보를 글로벌로 상태 관리 하기 위해서는 React Context 를 사용하면 된다. Redux 같은 별도의 상태관리 라이브러리를 쓰고 있다면 그것도 좋다. 하지만 난 개인적으로 Context 면 충분하고, 그 이상의 필요를 느껴본 적은 없다.

import { firebaseClientAuth } from 'client/firebase'
import { User } from 'firebase/auth'
import { createContext, ReactNode, useEffect, useState } from 'react'

interface UserContextType {
  user: User | null
}

export const UserContext = createContext<UserContextType>({ user: null })

interface UserProviderProps {
  children: ReactNode
}

export function UserProvider({ children }: UserProviderProps): JSX.Element {
  const [authedUser, setAuthedUser] = useState<User | null>(null)

  useEffect(() => {
    firebaseClientAuth.onAuthStateChanged((user) => {
      setAuthedUser(user)
    })
  }, [])

  // 저장된 사용자를 Context 로 children 에게 배포한다.
  return (
    <UserContext.Provider value={{ user: authedUser }}>
      {children}
    </UserContext.Provider>
  )
}

위 코드에서 핵심은 useEffect 이다. Firebase Client SDK 는 onAuthStateChanged 라는 이벤트 방식으로 구현된 함수를 갖고 있는데, 사용자 인증 정보가 변경될 때 마다, 파라미터로 넘긴 콜백 함수를 실행해준다.

콜백 함수 안에는 보통 setState 함수를 호출하는 코드 정도가 담기지만, 우리는 추가로 서버의 /api/auth/login API를 호출하여 세션 정보를 최신화해준다. 그리고 만약 Firebase Client SDK의 인증 정보가 모종의 이유로(expired 되었다던가) 없어졌다면(즉, user === null 이라면), /api/auth/logout 을 호출하여 세션 정보도 같이 없애준다. 이를 통해, 브라우저의 쿠키에 저장된 인증 정보를 Firebase Client SDK의 인증 정보와 동기화 시켜준다.

이 부분이 나를 찝찝하게 하는 부분이다. 동일한 내용의 상태(사용자 인증 정보)를 Firebase Client SDK와 브라우저 세션이 별도로 중복해서 관리하는 상황이기 때문이다. 일단 다른 방법이 떠오르지 않기 때문에, 이렇게 구현하기는 했지만, 다른 더 좋은 방법이 있으면 좋겠다.

쿠키도 브라우저 세션과 관계없이 정해진 기간동안 유지되기 때문에, useEffect 에서도 불러줄 필요는 없다. (아니면, 로그인/로그아웃 함수에 있는 API 호출을 여기 useEffect로 가져와서 처리할 수도 있겠다)

성능상 손해

참고로, 위의 방식을 쓸 경우, 브라우저를 리프래쉬 하거나 재시작했을 때, 구글의 인증 서버에 접속하여 로그인을 확인하고 idToken 을 가져오는 시간에 추가로 /api/auth/login 을 호출하는 시간이 소요된다. 그래서 초기 시작 시 성능상 손해가 있다는 점을 기억할 필요가 있다.

하지만 불필요한 /api/auth/login을 막기 위해 쿠키의 보안 수준을 낮춰서 쿠키 존재 여부를 확인하는 것은 피하는 것이 좋다. 이 쿠키에는 uid가 들어있기 때문에, 브라우저의 JavaScript가 접근이 불가능하도록 httpOnly 옵션을 걸어두는게 좋다.

next-firebase-auth

참고로 위의 구현 내용은 앞서 말한 바와 같이 next-firebase-auth 라이브러리와 유사하다. next-firebase-auth 라이브러리는 약간의 설정 만으로 위 구현의 기능들은 물론, 로그인 시 리다이렉션 등도 모두 조절할 수 있으니, 필요하신 분들은 참고하길 바란다.

profile
Hyperledger Fabric, React/React Native, Software Architecture

0개의 댓글