Firebase : 로그인 구현하기 with SSR

jonyChoiGenius·2023년 1월 24일
1

파이어베이스는 모바일 및 웹 개발 플랫폼이다.

완성형의 백엔드 인프라라고 볼 수 있는데,

Rules를 통해 접근 규칙을 설정하고,
간단한 쿼리문을 통해 데이터 베이스에 접근한다.

이러한 서비스는 프론트 엔드 단에서 파이어베이스 라이브러리를 이용해 간략화된 형태로 추상화된 메서드를 통해 파이어베이스와 통신할 수 있게 해준다.

다시 말하면
1. 서버를 만들고
2. 보안을 설정하고
3. DB를 설계하고
4. DB를 구축하고
5. 쿼리를 작성하고
6. 테스트하고
7. API를 작성하고
8. 배포하고
의 복잡했던 백엔드 과정을

  1. Rules 설정
  2. DB 연결
  3. 파이어베이스 라이브러리로 사용

이라는 간단한 과정 만으로 완벽하게 작동하고, 배포가 된, 그리고 보안 수준이 높은 서버를 만들 수 있게 된다. 또한 파이어베이스 라이브러리를 통해 쿼리의 작성과 DB접근을 프론트엔드 단에서 처리할 수 있게 되어 개발의 통합성과 편의성이 크게 증가한다.

기존에 노마드 코더의 트위터 클론 코딩 책을 구매해 파이어 베이스를 배웠고, 소셜 로그인 등이 매우 쉽게 구현하고 안전하게 작동하는 것을 확인할 수 있었다.

지난번 만든 영화앱은 장고로 서버사이드를 구현했는데 1. 배포가 생각보다 너무 어려웠다. 2. 배포를 해도 구버전 장고+내가 짠 코드의 조합으로는 해킹이나 개인정보 유출에 노출될 것 같았다. 그래서 결국 배포에 실패한 경험이 있다.

이번에는 파이어베이스를 이용해 소셜로그인과 NoSQL 기반 데이터 베이스를 이용해보려 한다. 백엔드를 공부한 분들은 파이어베이스의 쿼리가 지나치게 단순하다고 하는데....그건 나도 그런데?...ㅎ (영화앱에 사용된 쿼리가 'M:N으로 참조해줘!' '시간 순으로 정렬해줘!' 정도 밖에 없어서 파이어베이스로 구현하기에 어려움이 없어보인다.)

시작하기

먼저 파이어 베이스-시작하기-새프로젝트(신규 회원인 경우 이용약관 동의)-프로젝트명 설정-구글애널리틱스 사용여부(사용하게 되면 구글 애널리틱스 계정을 만들어야 한다. 나는 하나 있지렁)-기다리기.

기다린후 </> 이렇게 생긴 웹앱 설정에서 프로젝트 명을 설정한다. (firebase호스팅은 일단 꺼둔다.)

설정 후 App등록을 하면 NPM을 통해 SDK를 연동하는 방법이 나온다.

yarn add firebase

이후 public/fbase.ts 파일을 하나 만들었다.

// Import the functions you need from the SDKs you need
import { initializeApp } from "firebase/app";
import { getAnalytics } from "firebase/analytics";
// TODO: Add SDKs for Firebase products that you want to use
// https://firebase.google.com/docs/web/setup#available-libraries

// Your web app's Firebase configuration
// For Firebase JS SDK v7.20.0 and later, measurementId is optional
const firebaseConfig = {
  apiKey: "AIzaSyABCDABCDN6cxqqASDFASDFA-ASDFASDF",
  authDomain: "teal-and-orange-next-js.firebaseapp.com",
  projectId: "teal-and-orange-next-js",
  storageBucket: "teal-and-orange-next-js.appspot.com",
  messagingSenderId: "5744812341234",
  appId: "1:57412342134:web:31d82asdx1234234e1234c8",
  measurementId: "G-ABCD1ABCDABCA",
};

// Initialize Firebase
const app = initializeApp(firebaseConfig);
const analytics = getAnalytics(app);

API키 등은 환경변수로 빼낸다.
.env.local 확장자가 NEXT JS의 환경변수 중 가장 우선순위가 높다.
루트 디렉토리에 .env.local파일을 만들고 변수를 선언하자
NEXT_PUBLIC을 붙여준다.

NEXT_PUBLIC_API_KEY = "AIzaSyABCDABCDASDFSDFSDFA-ASDFASDF"
NEXT_PUBLIC_AUTH_DOMAIN = "teal-and-orange-next-js.firebaseapp.com"
NEXT_PUBLIC_PROJECT_ID = "teal-and-orange-next-js"
NEXT_PUBLIC_STORAGE_BUCKET = "teal-and-orange-next-js.appspot.com"
NEXT_PUBLIC_MESSAGING_SENDER_ID = "571234441234"
NEXT_PUBLIC_APP_ID = "1:57412342134:web:31d82edfs1234234e1234c8"
NEXT_PUBLIC_MEASUREMENT_ID = "G-ABCD1ASDCBCA"

firebaseConfig는 아래와 같이 바꿔준다

const firebaseConfig = {
  apiKey: process.env.NEXT_PUBLIC_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_AUTH_DOMAIN,
  projectId: process.env.NEXT_PUBLIC_PROJECT_ID,
  storageBucket: process.env.NEXT_PUBLIC_STORAGE_BUCKET,
  messagingSenderId: process.env.NEXT_PUBLIC_MESSAGING_SENDER_ID,
  appId: process.env.NEXT_PUBLIC_APP_ID,
  measurementId: process.env.NEXT_PUBLIC_MEASUREMENT_ID,
};

.env.local 파일은 .gitignore에 의해서 gitpush가 되지 않는다.
많일 여러 환경에서 테스트해야 한다면 해당 key들을 보관하거나, 유출되어도 괜찮은 test key를 삽입하자.

인증 모듈 import하기

이제 fbase.ts에 인증 서비스를 import하자. 버전별로 변수명이 조금씩 다르다. 9.16기준 아래와 같이 compat에서 불러와 삽입한다. 공식문서 참조

//public/fbase.ts
import firebase from "firebase/compat/app";
import "firebase/compat/auth";
// ... //
firebase.initializeApp(firebaseConfig);

export const authService = firebase.auth();

/page/login.tsx를 만들어 테스트 해보자.
이때 authService라는 모듈만 import하는 것이 좋다.

import React from "react";
import { authService } from "../public/fbase";

const login = () => {
  console.log(authService.currentUser);
  return <div>login</div>;
};

export default login;
null
null

current user가 null로 잘 표시된다.

구글 소셜 로그인 설정하기

이메일/비밀번호 방식은 사용하지 않고 소셜 로그인만 적용할 것이다.
구글 소셜 로그인을 이용하자

제품 카테고리 - 빌드 - Authentication 으로 접속하여 '시작하기'를 누른다.

우측 상단 '사용설정'을 누르고,

프로젝트 지원 이메일에서 사용가능한 이메일을 선택한다.

저장을 누르면

로그인 제공업체에 등록이 완료되었다.

소셜 로그인 기능을 위해서는 firebase 전체가 필요하다.

fbase.ts의 하단에 아래와 같이 export default 설정을 해주자

const firebaseInstance = firebase;
export default firebaseInstance;

login 함수를 만들자. (노마드 코더에서는 button tag의 name을 아래와 같이 구조분해 할당으로 받아오고 있다.)

import firebaseInstance, { authService } from "../public/fbase";

const login = () => {
  const onSocialLogin = async (event) => {
    //button tag의 event.target.name을 구조분해 할당으로 별도의 식별자에 설정
    const {
      target: { name },
    } = event;
    let provider;
    
    //button tage의 name이 loginWithGoogle이면
    if (name === "loginWithGoogle") {
      //firebase의 GoogleAuthProvider로 새로운 provider 생성
      provider = new firebaseInstance.auth.GoogleAuthProvider();
    }
    //provider를 이용하여 팝업창에서 로그인
    const data = await authService.signInWithPopup(provider).catch();
    console.log(data);
  };
  return (
    <button onClick={onSocialLogin} name="loginWithGoogle">
      구글로 로그인 하기
    </button>
  );
};

export default login;

signInWithPopup()에 catch()를 걸지 않으면 로그인 전에 창을 닫으며 발생하는 에러가 unhandled된다.

로그인 된 데이터는 아래와 같은 객체 형식이다

{operationType: 'signIn', credential: OAuthCredential, additionalUserInfo: GoogleAdditionalUserInfo, user: User}
	additionalUserInfo: GoogleAdditionalUserInfo {isNewUser: false, providerId: 'google.com', profile: {…}}
	credential:OAuthCredential {providerId: 'google.com', signInMethod: 'google.com', pendingToken: null, idToken: 'ㅁㄴㅇ', accessToken: 'ㅁㄴㅇ'}
	operationType: "signIn"
	user:User
		multiFactor:MultiFactorUserImpl {user: UserImpl, enrolledFactors: Array(0)}
		_delegate:UserImpl {providerId: 'firebase', proactiveRefresh: ProactiveRefresh, reloadUserInfo: null, uid: 'ㅁㄴㅇ', reloadListener: ƒ, …}
		auth:AuthImpl
		displayName: "jonghyun choi"
		email: "bluecoolgod80@gmail.com"
		emailVerified: true
		isAnonymous: false
		metadata: UserMetadata
		phoneNumber: null
		photoURL: "https://lh3.googleusercontent.com/a/ㅁㄴㅇ"
		providerData: Array(1)
		providerId: "firebase"
		refreshToken: "ㅁㄴㅇ"
		tenantId: null
		uid: "ㅁㄴㅇ"
	[[Prototype]]: Object
[[Prototype]]: Object

user에 다양한 유저 프로필 정보와 refreshToken 토큰이,
credential에 accessToken이 저장되어 있다.

로그인 / 로그아웃

한편 파이어베이스는 로그인된 유저 정보에 대한 양방향 메서드도 제공한다.

  useEffect(() => {
    authService.onAuthStateChanged((user) => {
      console.log(user);
    });
  }, []);

위와 같이 마운트 시켜주면 user가 로그인/로그아웃 될 때의 상태를 observing하여 user객체를 반환해준다.

엑세스 토큰은 user.multiFactor.user.accessToken),
리프레시 토큰은 user.multiFactor.user.stsTokenManager.refreshToken,
유저의 displayName은 user.multiFactor.user.displayName로 접근할 수 있다.

마찬가지로 signout메서드도 제공한다.

authService.signOut()

이를 이용해서 회원 정보가 없는 경우에는 '구글로 로그인하기'를,
회원 정보가 있는 경우에는 '로그아웃 하기'를 하도록 만들어보자.

완성된 코드는 아래와 같다.

import React, { useEffect, useState } from "react";
import firebaseInstance, { authService } from "../public/fbase";

const login = () => {
  //useEffect를 통해 로그인 여부를 토글시킴
  const [isLoggedIn, setIsLoggedIn] = useState(false);
  
  //소셜 로그인을 클릭했을 때에 signInWithPopup으로 로그인을 실행함
  const onSocialLogin = async (event) => {
    const {
      target: { name },
    } = event;
    let provider;
    if (name === "loginWithGoogle") {
      provider = new firebaseInstance.auth.GoogleAuthProvider();
    }
    const data = await authService.signInWithPopup(provider);
  };
	
  
  useEffect(() => {
    //로그인 혹은 로그아웃 등으로 유저 정보가 바뀔 때 실행될 리스너
    authService.onAuthStateChanged((user) => {
      //user가 null이 아니면 로그인 된 것으로 판단, null이면 로그아웃 된 것으로 판단
      if (user) setIsLoggedIn(true);
      else setIsLoggedIn(false);
    });
  }, []);
  return (
    <div>
      {isLoggedIn ? (
       	//로그인 된 경우에는 로그아웃하기 버튼이 보이며, 클릭하면 auth.sighOut()메서드가 실행되어 로그아웃 됨
        <button onClick={() => authService.signOut()}>로그아웃 하기</button>
      ) : (
        <button onClick={onSocialLogin} name="loginWithGoogle">
          구글로 로그인 하기
        </button>
      )}
    </div>
  );
};

export default login;

리덕스-툴킷으로 유저 정보 관리하기

먼저 간단하게 유저 정보 타입을 선언해준다.
/types/user.d.ts

export type UserType = object | null;

store/authSlice.tsx
1. userObject라는 유저 정보 객체를 만든다
2. userObject라는 유저 정보를 바꿔주는 리듀서를 만든다.
3. isAuth는 userObject가 존재하는지(로그인이 되었는지)를 확인하는 일종의 getters이다.

  • Object를 redux-toolkit 액션의 payload로 전달할 때에, 역직렬화 여부를 redux-toolkit의 middleware에서 체크한다. 나는 유저 객체를 serialize를 할 일이 없기 때문에 user객체에 해당 미들웨어 옵션을 꺼두어야 한다. 스택 오버플로 참조 공식문서 getDefaultMiddleware
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { HYDRATE } from "next-redux-wrapper";
import { UserType } from "../types/user";

const authSlice = createSlice({
  name: "authSlice",
  initialState: {
    userObject: null as UserType, //(1)
    isAuth: false as boolean, //(3)
  },
  reducers: {
    setUserOjbect(state, action: PayloadAction<UserType>) {
      //(2)
      state.userObject = action.payload;
      state.isAuth = !!action.payload;
    },
  },
  extraReducers: {
    [HYDRATE]: (state, action) => {
      return {
        ...state,
        ...action.payload,
      };
    },
  },
});

export default authSlice;

store/index.tsx

import { configureStore } from "@reduxjs/toolkit";
import { createWrapper } from "next-redux-wrapper";
import {
  TypedUseSelectorHook,
  useSelector as useReduxSeletor,
} from "react-redux";
import authSlice from "./authSlice";

const store = configureStore({
  reducer: {
    authSlice: authSlice.reducer,
  },
  //serializableCheck 끄기
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: false,
    }),
});
const makeStore = () => store;

const wrapper = createWrapper(makeStore);
export default wrapper;

export type RootState = ReturnType<typeof store.getState>;
export const useSelector: TypedUseSelectorHook<RootState> = useReduxSeletor;

이제 useSelector와 useDispatch를 이용하여 유저 객체 정보와 로그인 여부를 store에서 가져오도록 수정하자.
(이때 useSelector는 TypedUseSelectorHook을 통해 반환된 useSelector이다)

import React, { useEffect } from "react";
import { useDispatch } from "react-redux";
import firebaseInstance, { authService } from "../public/fbase";
import { useSelector } from "../store";
import authSlice from "../store/authSlice";

const login = () => {
  const dispatch = useDispatch();
  // state에서 userObject, isAuth를 구조분해 할당으로 받음. 어짜피 두 개 밖에 없거든 state가.
  const { userObject, isAuth } = useSelector((state) => state.authSlice);

  const onSocialLogin = async (event) => {
    const {
      target: { name },
    } = event;
    let provider;
    if (name === "loginWithGoogle") {
      provider = new firebaseInstance.auth.GoogleAuthProvider();
    }
    const data = await authService.signInWithPopup(provider);
  };

  useEffect(() => {
    //dispatch를 async-await로 처리해야 userObject, isAuth에 순차적으로 반영되는 듯
    authService.onAuthStateChanged(async (user) => {
      await dispatch(authSlice.actions.setUserOjbect(user?.multiFactor?.user));
    
    });
  }, []);

  return (
    <div>
      {isAuth ? (
        <button onClick={() => authService.signOut()}>로그아웃 하기</button>
      ) : (
        <button onClick={onSocialLogin} name="loginWithGoogle">
          구글로 로그인 하기
        </button>
      )}
    </div>
  );
};

export default login;

타입 씌우기

위 코드의 dispatch(authSlice.actions.setUserOjbect(user?.multiFactor?.user 부분이 타입 에러를 뱉어낸다. MiltiFactor가 기본적으로 지닌 MultiFactorUser 인터페이스에 'user'부분이 선언되어 있지 않기 때문이다.
편법이긴 하지만 'user가 있을 '수'도 있다고 MultiFactorUser인터페이스를 확장하자.

먼저 user.d.tx의 userType을 인터페이스 형식으로 수정하자. 앞으로 자주 접근하게 될 프로퍼티는 uid와 accessToken이므로 해당 부분을 인터페이스에 넣어주자

export interface UserType {
  uid?: string;
  accessToken?: string;
}

/fbase.ts 부분이다. MultiFactorUser 인터페이스를 확장하고 유저 객체에 UserType을 넣어주었다.

export interface CustomMultiFactorUserType
  extends firebase.User.MultiFactorUser {
  user?: UserType;
}

login.tsx의 수정된 부분이다.
multiFactor 부분만 따로 빼서 확장된 인터페이스를 씌워주었다.

  useEffect(() => {
    authService.onAuthStateChanged(async (user) => {
      const multiFactor: CustomMultiFactorUserType = user?.multiFactor;
      await dispatch(authSlice.actions.setUserOjbect(multiFactor.user));
    });
  }, []);

코드 수정

위 코드는 multiFactor에 user프로퍼티가 없으면 오류를 내뱉는다.

firebase 공식 문서에 따르면
onAuthStateChanged 이벤트가 fire되면 currentUser 객체도 업데이트 된다고 한다.

  useEffect(() => {
    authService.onAuthStateChanged(async (user) => {
      const currentUser = authService.currentUser;
      await dispatch(authSlice.actions.setUserOjbect(currentUser));
      console.log(currentUser);
    });
  }, []);

위와 같이 currentUser를 조회하면, user가 없는 경우 null 객체가 반환되어 dispatch된다.

트러블 슈팅 : Hydration 이슈

로그인 된 상태로 해당 페이지에 접속하면 문제점을 발견할 수 있는데,
'로그인 창'이 떴다가 사라지는 문제이다.

build 파일을 확인해보면 login 페이지의 html이 isAuth=false인 경우를 기준으로 이미 빌드되어있는 것을 확인할 수 있다.
이렇게 빌드된 페이지가 먼저 로딩이 된 후, 일정시간 후에 자바스크립트가 Hydrate되고 useEffect가 실행이 되면서 state가 업데이트 되는 것이다.

NextJS를 다룰 때 항상 유의해야 하는 점은, NextJS는 State의 initial값을 기준으로 build를 실행한다는 점이다. 위 문제에서도 isAuth=false는 authSlice의 initialState에 근거하고 있다.

  name: "authSlice",
  initialState: {
    userObject: null as UserType,
    isAuth: false as boolean, 
  },

위처럼, 스토어의 initialState가 false이기 때문에 false를 기준으로 빌드하고, 그렇게 빌드된 파일이 useEffect가 실행되기 전에 먼저 보여지는 것이다.

해당 문제를 해결하는 방법은 크게 1. getServerSideProps를 이용해 사용자가 요청할 때 HTML을 새로 빌드하거나, 2. Contional Routing을 이용해서 로그인 된 사용자는 해당 페이지에 접근하지 못하도록 하는 것이다.

2번의 해결방법은 컴포넌트의 재사용성이 떨어지는 다소 무식해보이는 방법이지만, 서버사이드렌더링을 다룰 때에는 놀랍도록 효과적이며, 정석적인 방법이다. 서버사이드 렌더링이 Generic한 페이지를 구성하기 위한 도구임을 기억하자.

하지만 이번 '감성' 영화일기 앱에서는 '감성'을 위해 랜딩 페이지를 적극적으로 활용하고, 랜딩페이지단에서 일부 API 요청을 미리 처리하여 사용자 경험 향상에 쓸 예정이다. 그러니 getServerSideProps를 이용하여 동적으로 랜딩페이지를 빌드하는 방법을 사용하자.

getServerSideProps 기본 사용법

SSR을 위해 서버사이드에서 props를 먼저 넘겨줄 수 있다.
이 때의 템플릿은 아래와 같은 형태로, getServerSideProps라는 async-await 함수를 export하면, 그 반환값이 props로 넘어가게 된다. 참조 - 넥스트 공식문서

import React from 'react'

const login = (props) => {
  return (
    <div>login</div>
  )
}

export default login

export const getServerSideProps = async (context) => {
  await function(){}
  return {
    props: {}
  }
}

next-redux-wrapper를 사용할 때에는 조금 다른데, getInitialProps단에서 dispatch를 하고(async-await를 하지 않는다.) 이를 useSelector로 불러오면 된다.

import React from 'react';
import {NextPage} from 'next';
import {useSelector} from 'react-redux';
import {wrapper, State} from '../store';

export const getServerSideProps = wrapper.getServerSideProps(store => ({preview}) => {
  console.log('2. Page.getStaticProps uses the store to dispatch things');
  store.dispatch({
    type: 'TICK',
    payload: 'was set in other page ' + preview,
  });
});

// you can also use `connect()` instead of hooks
const Page: NextPage = () => {
  const {tick} = useSelector<State, State>(state => state);
  return <div>{tick}</div>;
};

export default Page;

해당 방식은 useEffect를 이용하여 마운트 시에 dispatch하는 것과 비슷해보이지만 '마운트 되기 전에(정확히는 HTML이 생성되기 전에)' 실행된다는 점이 차이를 만들어낸다.

로그인 로직에 getServerSideProps 끼얹기

useEffect에 있는 onAuthStateChanged나 currentUser를 getServerSideProps에 끼얹으면 작동을.....

할 줄 알았겠지만 아니다.

firebase/auth의 currentUser는 최초에 null을 갖는다. 이후 비동기적으로 사용자 환경을 구축한 후, 구축 결과에 따라 null 혹은 true/false를 반화한다.

이에 따라 onAuthStateChanged가 fire된 후 currentUser가 null이 아닌 값을 갖게 된다.

이를 해결하는 간단한 방법은 localStorage를 확인하는 것이다.

하지만...SSR은 서버사이드에서 작동하기 때문에 window.localStorage를 사용할 수 없다.

해당 내용으로 검색할 때마다 nookies와 같은 서버사이드 쿠키에 대한 언급이 있었고, 결정적으로 나와 같은 문제를 겪은 이 글 에서도 많은 방법을 찾은 끝에 nookies를 통해서 authState를 추적하는 방식으로 해결했다고 하였다. (사실 랜딩 페이지에서firebase가 사용자 인증환경을 구성할 때까지 기다리는 방법도 있으나... 오늘 목표가 SSR 사용해보기니까)

nookies가 어떻게 동작하나 살펴보는 중에
Next.js - SSR: cookie 넣어주기라는 글을 발견하여, '로그인 후 쿠키 저장 -> 서버사이드 렌더링에서 쿠키 받기'로 진행하는 방식으로 직접 구현해보기로 하였다.

쿠키 설정하기

먼저 자바스크립트 쿠키 저장 및 관리 총정리라는 글을 따라 쿠키 생성 및 삭제에 관한 함수를 만든다.

const handleCookies = {
  setCookie(key: string, value: string, expiredays: number): void {
    let todayDate = new Date();
    todayDate.setDate(todayDate.getDate() + expiredays); // 현재 시각 + 일 단위로 쿠키 만료 날짜 변경
    //todayDate.setTime(todayDate.getTime() + (expiredays * 24 * 60 * 60 * 1000)); // 밀리세컨드 단위로 쿠키 만료 날짜 변경
    document.cookie =
      key +
      "=" +
      // escape(value) + //'escape' is deprecated. encodeURI로 대체합니다.
      encodeURI(value) +
      "; path=/; expires=" +
      // todayDate.toGMTString() + // toGMTString()도 더 이상 사용되지 않습니다. UTC로 변경합니다.
      todayDate.toUTCString() +
      ";";
  },
  delCookie(key: string): void {
    let todayDate = new Date();
    document.cookie =
      key + "=; path=/; expires=" + todayDate.toUTCString() + ";"; // 현재 시각 이전이면 쿠키가 만료되어 사라짐.
  },
};

export default handleCookies;
export const { setCookie, delCookie } = handleCookies;

onAuthStateChanged 이벤트가 발생했을 때의 로직을 수정한다.
currentUser가 있으면, uid를 토큰에 저장한다.
(파이어 스토어와 연동되는 것은 uid 이므로 최소한의 정보만 저장하기 위해 uid만 저장한다.)

  useEffect(() => {
    authService.onAuthStateChanged(async (user) => {
      const currentUser = authService.currentUser;
      await dispatch(authSlice.actions.setUserOjbect(currentUser));
      if (!currentUser) return;
      setCookie("uid", currentUser.uid, 1);
    });
  }, []);

로그아웃 버튼도 수정한다.

        <button
          onClick={() => {
            authService.signOut();
            delCookie("uid")
          }}
        >
          로그아웃 하기
        </button>

로그인이 완료되자 uid가 저장되는 것을 확인할 수 있다.
로그아웃 역시 잘 삭제된다.
(해당 로그아웃을 delCookie가 아니라 setCookie("uid", '', -100000)과 같은 방식으로 실행시켜도 동작한다.)

쿠키를 받기

wrapper.getServerSideProps의 예제를 보면
wrapper.getServerSideProps=> store => context => {} 와 같은 식으로 진행되는 것을 확인할 수 있다.
getServerSideProps은 context 객체를 가지고 있는데, 해당 객체의 파라미터 중 req를 통해 cookie를 확인할 수 있다.

[next.js] getServerSideProps에서 context로 cookie 가져 오기라는 글과 next.js에 정의된 타입을 이용하여 아래와 같이 cookie를 추출하는데에 성공했다.

export const getServerSideProps = wrapper.getServerSideProps(
  (store) =>
    ({ req }) => {
      const cookie = req.headers.cookie;
      console.log(cookie);
    },
);

uid=8wSwmY.....

쿠키를 파싱하는 로직을 짜고 key가 uid인 값을 구조분해 할당으로 받아와 디스패치했다.

export const getServerSideProps = wrapper.getServerSideProps(
  (store) =>
    async ({ req }) => {
      const cookie = req.headers.cookie;
      const cookieArr = cookie.split("; ").map((cookie) => {
        const [key, value] = cookie.split("=");
        return { key: key, value: value };
      });
      const { key: uidKey, value: uidValue } = cookieArr.find(
        (e) => e.key === "uid",
      );
      await store.dispatch(setUserOjbect({ [uidKey]: uidValue }));
      return { props: {} };
    },
);

이렇게 진행한 결과...

hydration 에러를 만나게 되었다.

jsx가 조건부 렌더링을 허용하지 않는다는 의미가 구체적으로 이런 것이구나를 알게 되었다.

이 문제를 해결하는 법은 간단한데, jsx를 모두 작성해두고 이를 jsx의 어트리뷰트(실제로는 프로퍼티)를 주고 이를 useEffect로 변환시켜주는 것이다.

쿠키에 따라 Styled-Components 적용하기

조금 억지스럽긴 하지만, 컴포넌트에 class명을 추가해서 css의 display 속성을 바꾸어 주는 것으로 문제를 해결하자.

먼저 새로운 style을 만들어준다

const StyledContainer = styled.div`
  .display-false {
    display: none;
  }
`;

이후 props로 로그인 된 상태인지를 내려준다.

export const getServerSideProps = wrapper.getServerSideProps(
  (store) =>
    async ({ req }) => {
      const props = {
        isLoggedIn: true,
      };
      const cookie = req.headers.cookie;
      if (!cookie) {
        props.isLoggedIn = false;
        return { props };
      }

      const cookieArr = cookie.split("; ").map((cookie) => {
        const [key, value] = cookie.split("=");
        return { key: key, value: value };
      });
      const { key: uidKey, value: uidValue } = cookieArr.find(
        (e) => e.key === "uid",
      );
      await store.dispatch(setUserOjbect({ [uidKey]: uidValue }));
      return { props };
    },
);

내려받은 props에 따라 useState로 초기 상태를 변경해준다.

const login = ({ isLoggedIn }) => {
  const DISPLAY_FALSE = "display-false";
  const [loginClass, setLoginClass] = useState(isLoggedIn || DISPLAY_FALSE);
  const [logoutClass, setLogoutClass] = useState(!isLoggedIn || DISPLAY_FALSE);
  //...
}

이제 해당 초기 상태를 기반으로 jsx를 렌더링해준다. 초기 상태들은 className으로 들어간다. (타입스크립트에서 className을 문자열로 넣으라고 하여 String으로 감쌌다.)

  return (
    <StyledContainer>
      <button
        onClick={() => {
          authService.signOut();
          delCookie("uid");
        }}
        className={String(loginClass)}
      >
        로그아웃 하기
      </button>

      <button
        onClick={onSocialLogin}
        name="loginWithGoogle"
        className={String(logoutClass)}
      >
        구글로 로그인 하기
      </button>
    </StyledContainer>
  );
};

이후 렌더링해보면 최초 로딩은 미세하게 느려졌지만, 이후에는 HTML에 두 버튼 모두 잘 파싱되어 있는 것을 확인할 수 있다.

이후 로그인 로그아웃을 토글하기 위해 useEffect를 만든다.

최대한 깔끔하게 코드를 짜기 위해 기존 onAuthStateChanged 이벤트에서는 loginClass에 대한 로직을 추가하고,
loginClass를 의존자로하여 logoutClass를 변경하는 useEffect를 따로 만든다.

  useEffect(() => {
    authService.onAuthStateChanged(async (user) => {
      const currentUser = authService.currentUser;
      await dispatch(authSlice.actions.setUserOjbect(currentUser));
      if (!currentUser) {
        setLoginClass("display-false");
        return;
      }
      setCookie("uid", currentUser.uid, 1);
      setLoginClass("");
      console.log();
    });
  }, []);

  useEffect(() => {
    if (loginClass === "display-false") setLogoutClass("");
    else setLogoutClass("display-false");
  }, [loginClass]);

이로서 로그인 로그아웃의 SSR 기능도 구현했다.

앞으로 할 거

  • 메인 페이지를 ISR로 구성하기
  • 영화 디테일을 SSG(getStaticPath)로 구성하기
  • 게시글과 프로필 페이지를 SSG(getStaticPash + fallback)로 구성하기
  • 랜딩 페이지를 이용하여 API와의 요청을 미리 처리하기
  • Next.js에서 이미지 로딩 속도를 높이는 방법은..?

알아둘 점 : SSG, SSR

SSG와 SSR의 가장 큰 차이점은 빌드시에 해당 HTML 파일을 만드느냐, 만들지 않느냐에 있다.
금번 작업한 로그인 페이지의 경우, 쿠키 사용을 위해 SSR로 구현해보긴 했으나, 미리 HTML파일을 만들어두는 것이 바람직하다.
추후 실제 랜딩페이지 제작시에는 쿠키에 접근하여 라우팅을 하는 로직은 클라이언트 사이드로 이동시키고, 랜딩페이지는 SSG를 이용해 새로 제작해야 할 것 같다.

한편 SSR은 SSG와 비교할 것이 아니라 getStaticPath와 비교되어야 한다. getStaticPath를 통해 영화 디테일 페이지를 만든다고 가정하면, 영화 디테일에 대한 페이지가 무수히 많이 생성될 수 있다. 이를 SSR로 변경하면, 생성되는 영화 페이지의 수를 줄일 수 있다.
한편 SSR의 또다른 용도는 게시판의 글 목록을 로딩하는 것이다. 게시판 글 목록은 매번 업데이트 되어야 한다. 이러한 용도로는 SSG와 getStaticPath를 사용하기 어렵다.

profile
천재가 되어버린 박제를 아시오?

0개의 댓글