업데이트
--
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 를 설정할 수 있도록 도와준다.
일단 다음과 같은 플로우를 생각해보도록 한다.
signIn...
함수를 이용하여 로그인을 한다.getIdToken
함수를 이용하여 ID 토큰을 생성한다./api/auth/login
API를 호출한다. 이 API 는 Next.js 의 API 로 /pages/api/auth/login.ts
에 구현된다./api/auth/login
함수는 Request body 로 받은 ID 토큰을 Firebase Admin SDK 의 createSessionCookie
함수를 이용하여 쿠키에 넣을 문자열을 생성한다. 그리고 이 문자열을 쿠키에 저장한다. 이때, iron-session
라이브러리를 사용하면 좋다.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 프로젝트와 에뮬레이터는 사용 중이라고 가정하겠다. 그렇다면, 프론트엔드 웹 쪽 코드에 적당히 다음과 같은 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
를 검사하도록한다.
이제 pages
의 index.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 는 서버 측 라이브러리이다. 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
에 접근할 수 있다.
세션을 통해 서버에게 인증 정보를 넘길 수 있게 되었으니, 브라우저에서도 인증 정보를 글로벌에 저장해두어 쓰도록 하자. 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 라이브러리는 약간의 설정 만으로 위 구현의 기능들은 물론, 로그인 시 리다이렉션 등도 모두 조절할 수 있으니, 필요하신 분들은 참고하길 바란다.