이 포스팅의 GitHub Repository
Next.js, Firebase를 이용해서 기본적인 어플리케이션을 만들어보기로 했다.
만들기 전 생각했던 조건과 기본 기능들이다.
Next.js를 공부할 겸 간단하게 만들어보려고 했지만 생각보다 막히는 부분이 꽤 있어서 우여곡절을 겪었다.
특히 getServerSideProps 함수 내에서 Firebase Auth를 연결하려고 하는 부분에서 삽질을 많이 하고 시간이 오래 걸렸다.
Firebase는 사용자 인증 서비스(Auth), 데이터 베이스 서비스(Cloud Firestore, Realtime database)등을 제공해주는 일종의 백엔드 서비스이다. Firebase의 Client SDK는 사용자의 모든 생명주기(계정 생성, 로그인, 로그아웃, 삭제 등)이 모두 프론트엔드 앱에서 이루어진다고 가정하고 있다. 실제로 Firebase Client SDK는 브라우저 내의 스토리지에 현재 사용자의 인증정보를 저장하고 관리한다. 만약 Next.js를 사용하지 않고 Plain React의 SPA환경이라면 라우팅이 브라우저 내에서 이루어지므로, 여기서 Firebase Client SDK를 사용할 경우 앱 내 라우팅을 인증 상태에 따라 쉽게 제한할 수 있다.
반면 Next.js에서의 라우팅은 서버로 GET호출을 보내 원하는 웹 페이지를 서버에서 렌더링 한 후 브라우저로 돌려받는 것을 의미한다. 예를 들어 로그인 후에만 접근할 수 있는 페이지를 만든다고 할 때, 서버 측에서 인증 정보를 얻은 후에 해당 페이지로 라우팅을 할 수 있다. 이렇게 보면 SSR 웹앱 개발을 위한 서버 프레임워크인 Next.JS는 Firebase와 딱 맞는다고 할 수 없다. Next JS의 GetServerSideProps와 같은 서버 측 함수에서는 Fireabse의 사용자 인증 정보에 접근하기가 자유롭지 못하다.
나는 Next.js를 학습하기위해 CRUD에 관련된 데이터를 모두 서버 사이드를 활용하면서, Firebase의 Auth(로그인, 회원가입)을 사용해 사용자 uid별로 Fireabse Store db를 생성하고 데이터를 다루고 싶었다. 때문에 로그인 후 사용자 데이터(사용자 이메일, 글 생성, 수정, 읽어오기 ..)를 가져오려면 GetServerSideProps 함수에서 firebase Auth의 uid를 가져와야했다.
공식 Next.js with firebase authentication 예제 프로젝트가 있긴 하나 getServerSideProps내에서 사용자 인증없이 API에 인증된 POST요청을 만드는 방법만 보여주고있어 크게 도움이 되지는 않았다.
Firebase Auth는 커스텀 백엔드(여기서는 Next.js서버)와의 소통을 ID토큰을 이용해 진행한다. 이때 서버 측으로 인증정보를 보내는 방법은 cookie를 사용해야한다. 즉 쿠키를 이용해 Next.js서버에서 ID토큰을 가져오고 uid를 얻을 수 있는 코드를 작성하면 문제가 해결된다. (쉽게 말하면 getServerSideProps 함수 안에서 ID토큰의 uid를 꺼낼 수 있으면 해결인 것)
ID토큰 이란? Firebase Client App이 커스텀 백엔드와 통신할 경우 커스텀 백엔드 서버에서 현재 로그인한 사용자를 식별할 수 있어야 한다. 이때 ID토큰이 필요하다. 먼저 로그인에 성공한 후 HTTPS를 사용해 사용자의 ID토큰을 서버로 보낸다. 그 다음 서버에서 ID토큰의 무결성과 신뢰성을 확인하고 uid를 검색한다.(Firebas Admin SDK를 이용해 이를 검증 및 파싱한다.) 전송된 uid를 사용해 현재 서버에서 로그인한 사용자를 식별할 수 있다.
여러 해결 방법을 찾아보던 중 아래 포스팅의 방법으로 문제를 해결할 수 있었다.
📄 Authenticated server-side rendering with Next.js and Firebase
verifyIdToken(cookie.token)함수의 리턴값으로 uid 가져옴이 포스팅에서는 다루고있지 않지만 해당 프로젝트에서는 글을 생성, 수정 하여 Firestore db에 올릴 때에 로그인한 사용자의 uid를 collection명으로 사용하고 있다. 그러므로 로그인 한 사용자의 글 목록을 가져오려면 uid를 사용해 Firestore DB의 collection에 접근해야 한다.
아래 부터는 포스팅 내용 번역 + 해결 방법 적용한 내 코드
들어가기 전 확인
1. nextjs 어플리케이션 생성
2. Firebase 프로젝트 생성 ( Firebase Console )
3..env파일에 Firebase Client, Admin에서 필요한 환경변수 세팅
/src 안에 frebase 폴더를 생성한다.
// src/firebase/firebaseClient.ts
import { getApp, getApps, initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore/lite';
export const firebaseConfig = {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
databaseURL: 'https://test-e2e5c-default-rtdb.asia-southeast1.firebasedatabase.app/',
};
const app = !getApps().length ? initializeApp(firebaseConfig) : getApp();
const db = getFirestore(app);
const auth = getAuth(app);
export { app, auth, db };
!getApps().length 확인은 Next.js가 어플리케이션을 리로드 할 때 실수로 SDK를 다시 초기화 하는 것을 방지할 수 있다.
// src/firebase/firebaseAdmin.ts
import * as admin from 'firebase-admin';
const firebaseAdminConfig = {
privateKey: (process.env.NEXT_PUBLIC_FIREBASE_PRIVATE_KEY as string).replace(/\\n/g, '\n'),
clientEmail: process.env.NEXT_PUBLIC_FIREBASE_CLIENT_EMAIL,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
};
if (!admin.apps.length) {
admin.initializeApp({
credential: admin.credential.cert(firebaseAdminConfig),
databaseURL: 'https://test-e2e5c-default-rtdb.asia-southeast1.firebasedatabase.app',
});
}
export { admin };
privateKey에 (process.env.NEXT_PUBLIC_FIREBASE_PRIVATE_KEY as string).replace(/\\n/g, '\n')이 부분은 추후 vercel로 배포할 때 private key에 앞뒤 붙어있는 문자열을 읽지 못해서 작성한 코드이다. 로컬에서반 실행시킨다면 process.env.NEXT_PUBLIC_FIREBASE_PRIVATE_KEY만 작성해도 된다.
현재 사용자에 대한 정보를 전역에서 사용하기 위해 React Context API를 사용했다.
AuthProvider 컴포넌트에서 user 상태값(userState)을 초기화하고 전역 데이터로 관리한다.
// src/context/authProvoider.ts
import React, { useState, useEffect, useContext, createContext, useMemo } from 'react';
import { getAuth, User } from 'firebase/auth';
import nookies from 'nookies';
const AuthContext = createContext<{ user: User | null }>({
user: null,
});
export const AuthProvider = ({ children }: any) => {
const [userState, setUserState] = useState<User | null>(null);
useEffect(() => {
return getAuth().onIdTokenChanged(async (user) => {
if (!user) {
// ID토큰 없음
setUserState(null);
nookies.set(null, 'token', '', { path: '/' });
return;
}
// 토큰 쿠키를 설정한다.
setUserState(user);
const token = await user.getIdToken();
nookies.destroy(null, 'token');
nookies.set(null, 'token', token, { path: '/' });
});
}, []);
useEffect(() => {
const refreshToken = setInterval(async () => {
const { currentUser } = getAuth();
if (currentUser) await currentUser.getIdToken(true);
}, 10 * 60 * 1000);
return () => clearInterval(refreshToken);
}, []);
const user = useMemo(
() => ({
user: userState,
}),
[userState]
);
return <AuthContext.Provider value={user}>{children}</AuthContext.Provider>;
};
export const useAuth = () => {
return useContext(AuthContext);
};
이제 useEffect hook안에서 firebase auth의 onIdTokenChagned함수로 현재 사용자의 ID 토큰변경을 감지한다. 이 함수는 사용자의 ID토큰이 갱신되었을 때에도 호출되고 로그인된 사용자인 지 확인한다. 로그인 된 사용자일 경우 setUserState로 user정보를 userState에 담는다.
가장 중요한 부분! 사용자의 ID토큰을 담을 토큰 쿠키를 설정한다.
이제 모든 request (API요청 및 라우팅)에 사용자의 ID토큰이 쿠키로 포함된다.
토큰 갱신 refreshToken 10분마다 토큰을 새로고침하도록 했다.
Context API 사용 이유: Context를 사용하지 않고 그냥 useEffect를 사용한 useAuth hook을 만들 경우 이 hook을 사용할 때 마다 새로운 userState 값이 만들어진다. useState를 어플리케이션 전역에서 동일한 값으로서 전역에서 사용하기 위해 context가 필요하다.
마지막으로 useAuth 커스텀훅을 export 한다.
userState값을 전역에서 사용할 수 있다.
// src/pages/_app.tsx
...
return (
<AuthProvider>
<Layout>
<Component {...pageProps} />
</Layout>
</AuthProvider>
);
// src/pages/index.ts;
const HomePage = () => {
...
}
export const getServerSideProps = async (context: GetServerSidePropsContext): Promise<{ props: {} }> => {
try {
const cookies = nookies.get(context);
const token = await admin.auth().verifyIdToken(cookies.token);
const { uid, email } = token;
console.log('uid, email: ', uid, email);
return {
props: { message: `Your email is ${email} and your UID is ${uid}.` },
};
} catch (error)
// token 쿠키가 없을 경우 또는 token 인증(verifyIdToken)에 실패한 경우
// login 페이지로 redirect
context.res.writeHead(302, { Location: '/login' });
context.res.end();
// 'as never'는 InferGetServerSidePropsType의 참조 이슈를 방지한다.
// 위 코드에서 사용자는 이미 redirect됨으로 리턴되는 props는 중요하지 않다.
return {
props: {} as never,
};
}
};
사용자가 제대로 로그인하지 않은 경우(예: 토큰 쿠키가 없거나 토큰 확인에 실패한 경우) 사용자는 /login 페이지로 리디렉션된다.
console.log('uid, email: ', uid, email);는 vscode 터미널에 아래와 같이 잘 찍힌다. 🥹 🙌 
1 ~ 5단계 까지는 위에서 말한 포스팅의 내용을 번역해보았다. (+ 개인적인 부연 설명)
이제 uid로 Firestore DB에서 사용자가 저장한 글 목록을 가져와보자.
이 포스팅에서는 해당 설명이 없지만 이 프로젝트에서는 글을 생성, 수정 하여 Firestore db에 올릴 때에 로그인한 사용자의 uid를 collection명으로 사용하고 있다. 그러므로 로그인 한 사용자의 글 목록을 가져오려면 uid를 사용해 Firestore DB의 collection에 접근해야 한다.
참고로 여기서 글 이름은 Post이다.
// src/pages/index.ts;
export interface Post {
collectionId: string;
id: string;
title: string;
date: string;
image: string;
address: string;
description: string;
}
export const getServerSideProps = async (context: GetServerSidePropsContext): Promise<{ props: {} }> => {
try {
const cookies = nookies.get(context);
const token = await admin.auth().verifyIdToken(cookies.token);
// uid만 가져온다.
const { uid } = token;
const querySnapshot = await getDocs(collection(db, uid));
const userPosts: Post[] = querySnapshot.docs.map((doc) => {
return {
collectionId: doc.id,
id: doc.data().id,
title: doc.data().title,
date: doc.data().date,
image: doc.data().image,
address: doc.data().address,
description: doc.data().description,
};
});
return {
props: { posts: userPosts },
};
} catch (error) {
context.res.writeHead(302, { Location: '/login' });
context.res.end();
return {
props: {} as never,
};
}
};
이렇게 작성하면 getServerSideProps함수 안에서 로그인한 사용자의 uid로 post를 Firestore DB에서 가져올 수 있다! 🎉
이제 HomePage컴포넌트에서 posts를 props로 받아와 자식 컴포넌트로 전달한다. 아래는 전체 코드이다.
import { GetServerSidePropsContext } from 'next';
import Card from 'components/shared/Card';
import PostList from 'components/posts/PostList';
import { collection, getDocs } from 'firebase/firestore/lite';
import { db } from '../firebase/firebaseClient';
import { admin } from '../firebase/firebaseAdmin';
import nookies from 'nookies';
export interface Post {
collectionId: string;
id: string;
title: string;
date: string;
image: string;
address: string;
description: string;
}
export interface Posts {
posts: Post[];
}
// getServerSideProps에서 posts를 props로 받아온다.
const HomePage = ({ posts }: Posts) => {
// 아직 작성한 post가 하나도 없을 경우
if (!posts?.length)
return (
<div>
<Card>
<h1 style={{ margin: 50, fontSize: 25 }}>포스팅을 작성해보세요!</h1>
</Card>
</div>
);
// 작성한 post가 있을 경우 PostList컴포넌트로 데이터를 전달한다.
return <PostList posts={posts} />;
};
export const getServerSideProps = async (context: GetServerSidePropsContext): Promise<{ props: {} }> => {
try {
const cookies = nookies.get(context);
const token = await admin.auth().verifyIdToken(cookies.token);
const { uid } = token;
const querySnapshot = await getDocs(collection(db, uid));
const userPosts: Post[] = querySnapshot.docs.map((doc) => {
return {
collectionId: doc.id,
id: doc.data().id,
title: doc.data().title,
date: doc.data().date,
image: doc.data().image,
address: doc.data().address,
description: doc.data().description,
};
});
return {
props: { posts: userPosts },
};
} catch (error) {
context.res.writeHead(302, { Location: '/login' });
context.res.end();
return {
props: {} as never,
};
}
};
export default HomePage;
가져온 uid로 collection에서 데이터를 가져온다. getDocs 함수 사용법은 아래 공식문서에 나와있다.
📄 Cloud Firestore로 데이터 가져오기
기능은 간단한 어플리케이션이지만 직접 구현해보면서 배운 것이 정말 많았다. Next.js를 새로 접해보았다는 것도 의미있었고 기존 리액트만 사용했을 때, Next.js의 서버 사이드를 사용했을 때와의 차이점도 익힐 수 있었다. getServerSideProps, getStaticSideProps같은 함수를 공부할 때에는 서버사이드에서 어떻게 렌더링 되는 지, 그리고 CSR과 어떤 차이가 있는 지, SSG라는 렌더링 방식은 어떤 것인 지에 대해 알아보았다.
Firebase를 이용해 계정 생성 부터 CRUD의 일련의 기본적인 과정을 쭉 다뤄본 것도 좋은 경험이었다. 회원가입을 하면 사용자의 정보가 Firebase Auth에 추가되고, 로그인 후에 글을 생성하면 Firestore DB에 올라가고 (수정 ,삭제도 잘 되고!) 마지막으로 로그아웃을 하는 플로우를 다뤄보았다.
또 생각보다 자잘하게 신경써야 할 부분이 많았다. 예를 들어,
로그인/회원가입 후에는 쿠키에 토큰을 저장하고 주기적으로 갱신 한다
로그인된 사용자만 All Post(Home)와 Add New Post 페이지를 이용할 수 있도록 한다.
인증되지 않은 사용자의 경우 리다이렉트 시키거나 로그인 페이지 이동 안내 문구를 띄운다.
3번을 수행할 때에 화면 초기 렌더링 시 user 를 바로 감지하지 못해 로그인되었음에도 로그인 페이지 이동 안내 문구가 잠깐 떴다가 사라졌다. 이 경우 loading 상태값을 사용한다.
ex) 로그인된 사용자 → Add New Post로 이동 → 로딩 중.. 문구 띄움 → Add New Post페이지 렌더링
모바일, 태블릿, 데스크탑 화면 대응을 위해 각 스크린 사이즈 별로 css 설정을 달리한다.
등등..!
나중에 더 보완하고 싶은 것은 앞서 말했던 디자인적인 부분, 그리고 useMemo, useCallback등을 사용하여 최적화를 하는 부분이다. Light house에서 확인해보았을 때 퍼포먼스가 그리 좋지 못했다. (Firebase가 느린 것도 한 몫하고..) 그리고 UI가 화려하지 못한 것이 조금 아쉽다. 디자인을 천천히 추가할 생각이다.
Light House 👇
퍼포먼스 부분도 향상시켜보고 포스팅을 써볼 생각이다.

끝
이 포스팅의 GitHub Repository
📄 참고 포스팅, 온라인 강의 및 Docs
오 서버 인증 부분이 궁금했는데 감사합니다
저는 Next.js App Router 방식으로 구현하는 중이라 방식은 약간 다르긴 하지만
기본 사상은 같아서 도움이 됐어요 ㅎ