Photoverse 프론트

Photoverse 프론트

생성일
Sep 23, 2024 10:20 AM
상위 항목
최종 편집 일시
Last updated October 24, 2024
태그
next.js
FE
하위 항목
(진행 중 레포지토리)
나는 프론트 개발은 현업에서 유지보수만 했다보니까 백엔드처럼 다 알고 한다기보다는 이미 만들어진 코드들을 보고 적당히 이해해서 하는 식이었다.
따라서 이번 프로젝트의 프론트를 AtoZ로 새로 만들면 시간이 너무 오래걸릴 것 같았다.
인스타그램을 클론코딩 하는 수준이니까 분명 어디 찾아보면 템플릿이 있을 것 같았다.
이것저것 찾아보다보니 위의 레포가 가장 쓸만해 보였다.
next.js와 firebase를 사용한 템플릿이다. 이 템플릿을 좀 입맛대로 여기저기 수정해서 사용할 생각이다.
먼저 이 레포는 next.js 답게 백엔드 서버와 통신하는 게 아니고 fire store를 써서 DB 작업을 처리해 놨다.
나는 내 코프링 백엔드서버와 통신해야 하기 때문에 로그인 부분, 백엔드와 통신하는 부분을 먼저 고쳐야 겠다는 생각이 들었다.
 

로그인 문제

이 레포는 firebase를 DB 쪽만 사용하고 firebase Auth로 로그인 처리하는 부분은 임포트만 하고 사용하고 있지 않다. 왜냐하면 로그인을 next-auth를 사용하고 있기 때문이다.
만약 내가 provider를 google만 쓴다면 이대로 그냥 써도 아무 문제가 없다. 백엔드에 구글 OAuth2 라이브러리를 추가해서 accessToken을 검증하면 끝나기 때문에 백엔드 코드 유지보수에 딱히 비용이 더 추가되지 않는다.
그런데 2개 이상의 provider를 사용할 예정이라면 각 provider에 맞게 라이브러리 들을 추가하고 백엔드 토큰 검증 로직을 provider별로 분기를 타게 해야되기 때문에 상당히 귀찮은 작업이 될 것 같았다.
따라서 next-auth를 들어내고 firebase Auth로 변경하기로 했다. firebaseAuth를 사용하면 firebaseToken으로 모든 provider를 통합적으로 관리할 수 있기 때문에 아주 나이스한 선택이 될 수 있다.
먼저 firebase.ts에 provider별 로그인 함수를 추가해 주어야 한다.

firebase Authentication 설정하기

// firebase.ts import { initializeApp, getApp, getApps } from "firebase/app"; import {getAuth, GoogleAuthProvider, signInWithPopup} from "firebase/auth"; import { getFirestore } from "firebase/firestore"; import { getStorage } from "firebase/storage"; // Your web app's Firebase configuration // For Firebase JS SDK v7.20.0 and later, measurementId is optional 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, }; // Initialize Firebase const app = !getApps().length ? initializeApp(firebaseConfig) : getApp(); const firestore = getFirestore(app); const auth = getAuth(app); const storage = getStorage(app); const loginWithGoogle = async () => { const provider = new GoogleAuthProvider(); const result = await signInWithPopup(auth, provider); // useFirebaseAuth가 알아서 상태 감지해서 userState 갱신함. const idToken = await result.user.getIdToken(); console.log("loginWithGoogle token: ", idToken); return idToken; } // export export { app, auth, firestore, storage, loginWithGoogle };
나는 일단은 구글만 사용해서 프로토타입을 만들 것이기 때문에 loginWithGoogle 함수를 만들어서 export 해줬다.
firestore, storage는 결론적으로 안 쓸 거기 때문에 지워야 하지만 일단은 기존에 이걸로 작성되어 있는 부분들이 많기 때문에 일단은 살려둔다.
idToken을 리턴하고 있지만 사실상 필요는 없다.

로그인 페이지 수정

/pages/auth/login.tsx 페이지가 로그인을 담당한다.
notion image
일단은 현재 상태는 원래 코드 그대로 인스타그램 로고가 뜨고 있는데 나중에 로고 만들어서 수정할 예정이다.
밑에 로그인 안내문구 수정해주고 “Sign in with Google” 눌렀을 때 내가 새로 만든 함수를 실행할 수 있도록 변경해야 한다.
import { motion } from "framer-motion"; import Head from "next/head"; import React from "react"; import { loginWithGoogle } from "../../firebase/firebase"; import Header from "../../components/Header"; import {useRouter} from "next/router"; const Login: React.FC = () => { const router = useRouter(); const handleGoogleLogin = async () => { try { const token = await loginWithGoogle(); if (token) { await router.push("/"); } } catch (error) { console.error("Google Login Error", error); } }; return ( <motion.div initial={{ opacity: 0 }} whileInView={{ opacity: 1 }} viewport={{ once: true }} className="h-screen overflow-hidden" > <Head> <title>Photoverse Sign In</title> <meta name="description" content="Generated by create next app" /> <link rel="icon" href="https://drive.google.com/uc?export=download&id=1eqWbbCrvcw6w6W-3eCEFkDu-9L2MbRaH" /> </Head> <Header /> <div className="flex flex-col items-center justify-center min-h-screen py-2 -m-56 px-14 text-center pt-60"> <img className="w-70" src="https://www.pngmart.com/files/13/Instagram-Logo-PNG-Transparent.png" alt="" /> <p className="font-xs italic"> Sign in with your Google account to continue </p> <div className="mt-20"> <div> <button className="p-3 bg-blue-500 rounded-lg text-white" onClick={() => handleGoogleLogin()} > Sign in with Google </button> </div> </div> </div> </motion.div> ); }; export default Login;
loginWithGoogle이 firebase.ts에 만들었던 그 함수이다.
이렇게 로그인을 동작시키고 next router를 써서 /로 라우팅을 시키면 index.tsx로 라우팅되게 된다.
 

index.tsx 페이지 수정

여기서 가장 많이 해맸다. (수많은 로그의 흔적은 아직 안지웠다…)
import { motion } from "framer-motion"; import Head from "next/head"; import React, { useEffect, useState } from "react"; import Feed from "../components/Feed"; import Header from "../components/Header"; import {createUserInBack} from "./api/userApi"; import {useFirebaseAuth} from "../hooks/useFirebaseAuth"; const Home: React.FC = () => { const { user, backendUser } = useFirebaseAuth(); // firebase login 상태 전역 관리. const [userCreated, setUserCreated] = useState<boolean>(false); const getUserData = async () => { console.log("Recoil user: ", user); if (user && backendUser) { console.log("getUserData: ", user); console.log("User already created"); setUserCreated(false); } else { console.log("User not found, creating user"); setUserCreated(true); return; } }; const createUser = async () => { if (userCreated && user && !backendUser) { try { await createUserInBack().finally(() => window.location.reload()); // 유저 생성 후 백엔드 유저 정보를 갱신을 못함. 부득이하게 리로드. } catch (error) { console.error("Error creating user", error); } } }; useEffect(() => { getUserData(); }, [user, backendUser]); useEffect(() => { if (userCreated) { createUser(); } else return; }, [userCreated]); return ( <motion.div initial={{ opacity: 0 }} whileInView={{ opacity: 1 }} viewport={{ once: true }} > <Head> <title>Instagram Clone</title> <meta name="description" content="Generated by create next app" /> <link rel="icon" href="https://i.postimg.cc/wjt4vkWx/pngwing-com-1.png" /> </Head> <Header /> <Feed user={user} /> </motion.div> ); }; export default Home;
최종 로그인 기능 완성상태는 위와 같다.
next-auth를 모두 제거하고 FirebaseAuth로 갈아 끼는 데 생각보다 문제가 많았다.
  1. 일단 로그인 상태를 어떻게 감지할 것인가?
  1. 백엔드 DB에도 유저 정보를 따로 저장해서 관리해야 되는데 firebase user와 backend user의 상태 관리는 어떻게 해야 되는가?
  1. 유저가 가장 처음 firebase 로그인을 해서 새로 백엔드에 User 정보를 등록해야 하는 때, 그 이후 일반적으로 로그인 하는 때는 어떻게 분기 및 관리할 것인가? (여기는 매번 할 때마다 고통받는 부분이었음)
이 정도 문제들을 마주하게 됐다.
그런데 참 아이러니 한 건 다 지나고 보면 이 정도 문제인데 만들 때는 끝도 없이 고통받는다. 그러다가 와 됐다 하고나서 테스트 몇 번 돌려보고 또 안되네 하다가 결국 진자 됐을 때 이렇게 글을 쓸 때 생각해보면 기분좋아져서 수 많은 시도들은 잘 기억안나고 큰 문제들만 정리해서 쓰게 된다.

로그인 상태 감지 문제

일단 useFirebaseAuth()라는 hook을 만들었다.
import {useEffect} from "react"; import {getAuth, onAuthStateChanged} from "@firebase/auth"; import {useRecoilState} from "recoil"; import {backendUserState, userState} from "../utils/atoms"; import {getUserFromBack} from "../pages/api/userApi"; export const useFirebaseAuth = () => { const [user, setUser] = useRecoilState(userState); const [backendUser, setBackendUser] = useRecoilState(backendUserState); const auth = getAuth(); useEffect(() => { const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => { console.log("Firebase user changed: ", firebaseUser) if (firebaseUser) { const idToken = await firebaseUser.getIdToken(); const userCopy = { uid: firebaseUser.uid, email: firebaseUser.email, displayName: firebaseUser.displayName, photoURL: firebaseUser.photoURL, idToken: idToken } setUser(userCopy); try { console.log("fetching backend with user token", idToken); const backendUser = await getUserFromBack(idToken); setBackendUser(backendUser); console.log("Backend user fetched: ", backendUser); } catch (error) { console.error("Error fetching backendUserData: ", error); } } else { setUser(null); setBackendUser(null); } }); return () => unsubscribe(); }, [auth, setUser, setBackendUser]); return { user, backendUser }; };
이제 여기서 로그인 상태를 전역으로 관리해서 여기저기서 가져다 쓰기 위해 상태관리도구로는 recoil을 추가 했다. ReactNative 할 때는 기본 템플릿에서 mobx를 채택해놔서 써봤는데 이번에는 다른 걸 써보고 싶어서 recoil을 골랐다. 가장 최신이기도 하다.
recoil은 전역상태를 위해 atom이란 걸 만들어야 한다.
import { atom } from 'recoil'; import {LoginResponse} from "../pages/api/types/LoginResponse"; export const userState = atom<any>({ key: "userState", default: null, }); export const backendUserState = atom<LoginResponse | null>({ key: "backendUserState", default: null, });
firebase Auth의 상태를 받을 userState, 내 백엔드 서버에서 받아온 유저 데이터를 관리할 backendUserState를 만든다.
그리고 recoil을 쓰기 위해서는 _app.tsx 또는 layout.tsx에 RecoilRoot를 한 번 감싸줘야 한다. 보통의 next.js로 SSR, SEO 구성을 하면 layout.tsx가 있어서 여기에 한 번 감싸주는데 이 템플릿은 _app.tsx가 루트 역할이기 때문에 _app.tsx를 아래와 같이 수정해줬다.
Component에 TS2786: Component cannot be used as a JSX component. 이런 경고를 띄워주긴 하지만 잘 동작한다.
import "../styles/globals.css"; import type { AppProps } from "next/app"; import { RecoilRoot } from "recoil"; function MyApp({ Component, pageProps, }: AppProps) { return ( <RecoilRoot> <Component {...pageProps} /> </RecoilRoot> ); } export default MyApp;
useFirebaseAuth.tsx에서 atom에 작성한 state들을 구독하도록 해준다.
onAuthStateChanged()가 중요한데 이 함수가 firebase user의 상태가 변할 때마다 감지하는 역할이다.
firebase 인증(로그인)에 성공해서 firebaseUser가 있다면 userCopy를 만들어준다.
처음에는 userCopy 대신에 firebaseUser를 그대로 userState에 담아서 썼었는데 이러면 firebaseUser 객체가 가진 readOnly들 때문에 에러가 난다.
실질적으로 필요한 건 idToken 뿐이지만 데이터들이 제대로 들어오는지 확인하기 위해 적당히 필요해 보이는 값들을 넣어줬다.
이 idToken은 백엔드 요청의 Authorization 헤더에 들어갈 값이다. 이를 통해 백엔드를 STATELESS하게 돌릴 수 있다.
여기서 auth, setUser, setBackendUser로 firebaseUser 의 상태가 변할 때마다 감지해서 상태를 계속 업데이트 해주도록 한다.
 

백엔드 통신용 api.ts 만들기

늘 그래왔듯 http 통신은 axios를 사용하기로 했다.
/pages/api/api.ts를 인덱스로 만들어 준다.
사실 이 부분도 약간 애매 했는데 보통 검색해보면 use client를 사용해줘야 한다는 이야기가 있다. 이 프로젝트에서는 next.js에서 제공하는 백엔드 node.js를 백엔드 서버처럼 활용하는 부분이 없어서 그런지 전혀 문제가 되지 않았다.
import axios from 'axios'; import {getAuth} from "firebase/auth"; const api = axios.create({ baseURL: "http://localhost:8080/api/v1", headers: { "Content-Type": "application/json", } }); api.interceptors.request.use( async (config) => { const user = getAuth().currentUser; if (user) { const token = await user.getIdToken(); config.headers.Authorization = `Bearer ${token}`; } console.log("requestConfig: ", config); return config; }, (error) => { return Promise.reject(error); } ); api.interceptors.response.use( (response) => { return response; }, (error) => { console.error("API Error", error); return Promise.reject(error); } ); export default api;
각 요청마다 Authorization 헤더를 자동으로 넣어줄 수 있게 하고 요청과 응답 모두 에러처리를 간단하게 해두었다.
user 도메인과 관련된 요청을 할 때 사용할 userApi.ts를 만든다.
// pages/api/userApi.ts import api from "./api"; import {LoginResponse} from "./types/LoginResponse"; export const getUserFromBack = async (idToken: string): Promise<LoginResponse> => { try { console.log("getUserFromBack") const response = await api.get<LoginResponse>("/users", { headers: { Authorization: `Bearer ${idToken}` } }); return response.data; } catch (error) { console.error("Error fetching userData: ", error); throw error; } }; export const createUserInBack = async () => { try { // 계정 생성에 필요한 내용은 백엔드에서 firebaseToken을 뜯어서 처리할 것임. console.log("createUserInBack") const response = await api.post("/users"); return response.data; } catch (error) { console.error("Error creating user: ", error); throw error; } }

다시 index.tsx를 보자

const Home: React.FC = () => { const { user, backendUser } = useFirebaseAuth(); // firebase login 상태 전역 관리. const [userCreated, setUserCreated] = useState<boolean>(false); const getUserData = async () => { console.log("Recoil user: ", user); if (user && backendUser) { console.log("getUserData: ", user); console.log("User already created"); setUserCreated(false); } else { console.log("User not found, creating user"); setUserCreated(true); return; } }; const createUser = async () => { if (userCreated && user && !backendUser) { try { await createUserInBack().finally(() => window.location.reload()); // 유저 생성 후 백엔드 유저 정보를 갱신을 못함. 부득이하게 리로드. } catch (error) { console.error("Error creating user", error); } } }; useEffect(() => { getUserData(); }, [user, backendUser]); useEffect(() => { if (userCreated) { createUser(); } else return; }, [userCreated]); return ( <motion.div initial={{ opacity: 0 }} whileInView={{ opacity: 1 }} viewport={{ once: true }} > <Head> <title>Instagram Clone</title> <meta name="description" content="Generated by create next app" /> <link rel="icon" href="https://i.postimg.cc/wjt4vkWx/pngwing-com-1.png" /> </Head> <Header /> <Feed user={backendUser} /> </motion.div> ); }; export default Home;
getUserData에서는 backendUser를 갱신하거나 하지 않는다.
왜냐하면 useFirebaseAuth()에서 이미 user의 변화에 따라 갱신해주고 있기 때문이다.
이 안에서는 실질적으로 user, backendUser가 값이 잘 들어왔는지 확인하고 만약 맨 처음으로 로그인을 한거라 백엔드에 유저를 새로 만들어줘야 하는 경우를 userCreated상태를 통해 분기하는 동작을 한다.
그래서 만약 맨 처음으로 로그인했다면 createUser를 실행하게 된다.
userApi.ts에 있는 createUserInBack()으로 새로운 유저를 백엔드 DB에 등록해주고 finally로 강제 새로고침을 해준다.
useFirebaseAuth()에서는 firebaseUser의 상태 변화만 감지한다. 따라서 createUserInBack() 하고나서 강제로 페이지를 초기화해서 onAuthStateChanged안의 getUserFromBack과 setBackendUser를 실행해야 페이지에 정상적으로 출력된다.
솔직히 지금도 이게 맞는 방법인지는 모르겠다. 한참을 헤매다가 고육지책으로 생각해낸 방법이다. 이 부분까지 완전히 파헤치기에는 나에게는 시간이 없다.
 

signOut 변경

next-auth의 signOut을 쓰던 부분을 다 찾아서 아래와 같이 바꿔준다.
const signOut = () => { const auth = getAuth(); auth.signOut().then(() => { console.log("Sign out successful"); }).catch((error) => { console.error("Sign out error", error); }); };
firebase Auth의 signOut()을 써주면 끝이다.
 

이미지 업로드 기능