Firebase: 유저 프로필 구현하기

jonyChoiGenius·2023년 1월 24일
1

프로젝트 진행 당시에 작성했던 ERD이다. 데이터를 대부분 TMDB에서 가져오기에 ERD가 매우 단순하다.

주목할 부분은 user모델을 user_profile이라는 1:1참조 모델로 확장하고 있다는 점인데, 장고의 DRF에서는 기본 유저모델을 수정하기가 어려워 1:1참조로 확장하는 것을 택한 것이다.

파이어베이스에도 이와 같은 방식으로 userdata를 작성하고 수정하게 된다. 실시간 데이터베이스 : 웹에서 데이터 읽기 및 쓰기 파이어스토어: 읽기

파이어 베이스에는 Realtime Database와 Cloud Firestore가 존재하고 있는데, 사용처에 따라 갈리지만 일단 Firestore가 대세이다.

Realtime Database는 다운로드한 크기를 단위로 계산한다.(10GB/월)
Cloud Firestore는 네트워크 이그레스 방식(10GiB/월)과 더불어 문서의 읽기/쓰기/삭제 횟수에 따라 과금을 한다.

따라서 아주 적은 양의 데이터를 자주 읽기/쓰기/삭제 한다면 Realtime Database가 저렴한다.

하지만 많은 양의 데이터를 가끔 읽기/쓰기/삭제 한다면 Realtime Database보다 Cloud Firestore가 상대적으로 저렴하다.

그럼에도 Firestore가 대세인 이유는
1. 최신이다.
2. 토이 프로젝트 수준의 앱에서는 어짜피 둘 다 무료다
3. collection 개념 도입으로 Reatime Database보다 복잡한 쿼리 작성이 가능하다
정도로 조사되는 것 같다.

내가 firebase를 공부한 노마드코더의 강좌에서도 firestore를 쓰고 있기 때문에 firestore를 통해 구현하고자 한다.

시작하기

  1. firebase를 시작하면 보안 규칙을 먼저 설정한다.
    테스트 모드로 설정하면 30일간 별도의 보안규칙 없이 누구나 데이터베이스에 접근할 수 있다. (프로덕션 시에는 이 규칙을 변경하여야 한다.)

  2. Cloud Firestore 위치는 asia-northeast3 (서울 지역!)으로 한다.

public/fbase.ts 에 아래와 같이 추가한다

import "firebase/compat/firestore";


export const dbService = firebase.firestore();

기존 로그인 페이지(pages/login)은 /profile로 이동시키는 로직을 만든다

  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("");
      router.push("/profile")
    });
  }, []);

조회하기

전체적인 쿼리는 Fomagran [Firebase] Firebase 쿼리 알아보기(FireStore Query)파이어스토어 공식문서를 참조했다. 버전 9부터는 쿼리문이 아닌 모듈식 SDK를 권장한다고 한다. 일단 8버전 이전을 따라가보자.

(타입 에러 때문에 임시 중단.
인터페이스 씌우고 위의 문서 참고하면서 계속 진행하기. 위 블로그 아니라
간단한 쿼리 실행 부분 참조하면서 실행할 것. )

조건부 라우팅

pages/profile/index.js에는 조건부 라우팅을 추가한다.
next js 라우터에는 조건부 라우팅 기능이 아직 구현되어있지 않아 SSR을 이용하여 redirect를 시켜줘야 한다.

스택 오버 플로 Conditional redirection in Next.js를 참조했다. 한편 Router는 getServerSideProps에서 사용할 수 없어서 https://dubaiyu.tistory.com/284https://stackoverflow.com/questions/65784602/next-js-getserversideprops-redirection-err-http-headers-sent-error 를 참조하였다.

const profile = () => {
  return <div>profile</div>;
};

export default profile;

export const getServerSideProps = wrapper.getServerSideProps(
  (store) =>
    async ({ req }) => {
      const cookie = req.headers.cookie;
      const userObject = await store.getState().authSlice.userObject;
      const currentUser = authService.currentUser;
      console.log(cookie, userObject, currentUser);
      if (!cookie && !userObject && !currentUser) {
        return {
          redirect: {
            permanent: false,
            destination: "/login",
          },
          props: {},
        };
      }
      return { props: {} };
    },
);

여기서 uid를 따로 추출하고, 해당 uid값이 있는지의 여부로 진행하면 좋겠다는 생각이 들어 코드를 수정한다. 그리고 테스트 결과 Auth.currentUser는 getServerSide에서 항상 null을 반환하기에 로직에서 뺐다.

export const getServerSideProps = wrapper.getServerSideProps(
  (store) =>
    async ({ req }) => {
      const cookie = req.headers.cookie;
      let cookieUid;
      if (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",
        );
        cookieUid = uidValue;
      }
      const userObject = await store.getState().authSlice.userObject;
      const uid = cookieUid || userObject?.uid;
      if (!uid) {
        return {
          redirect: {
            permanent: false,
            destination: "/login",
          },
          props: {},
        };
      }
      return { props: {} };
    },
);

프로필 가져오기

규칙 설정하기

먼저 파이어 스토어에 규칙을 설정해야 한다.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if true;
    }
  }
}

이렇게 하면 누구나 조회하고 쓸 수 있다.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if
          request.auth != null;
    }
  }
}

이렇게하면 로그인 된 사용자만 조회하고 쓸 수 있다.

문서 만들기

테스트용으로 문서를 하나 만든다
user 컬렉션 - 문서id:INzjPLHgPvy
필드 내용

uid : '1234'
myMovie: [1, 2, 3, 4, 5],
followings: ['1'],
images: '/default.png'
nickname: '배트맨좋아'

쿼리 작성하기

getServerSideProps 부분에 테스트용 쿼리를 하나 만든다.

		//컬렉션을 지정한다.
      const userRef = dbService.collection("user");
		//가져올 조건을 지정한다.
      const query = userRef.where("uid", "==", "1234");
		//조건을 지정한 쿼리를 실행한다.
      query.get().then((Snapshot) => {
        //이때 res.docs라는 배열로 조건에 맞는 문서들이 담기며,
        //docs에 담겨있는 문서에서 .data()라는 메서드를 실행해야 변환이 된다.
        console.log(Snapshot.docs[0]?.data());
      });

응답의 docs 배열로 데이터가 담기며,
docs.forEach(doc=> doc.data())와 같이 각 문서에 대해 .data()라는 데이터 변환 메서드를 선언해주어야 정상적인 데이터가 된다는 점을 참고하자.

Services 폴더 분리하기

작동되는 것을 확인했으니, 파이어 베이스와 관련된 코드를 이쯤에서 한 번 분리하고 가자.

services폴더에 fbAuth, fbProfile 폴더를 만들었다.

services/fbAuth/index.ts

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

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

export const onLogut = async () => {
  await authService.signOut().catch();
  delCookie("uid");
};

const fbAuth = { onSocialLogin, onLogut };
export default fbAuth;

로그인 로직과 로그아웃 로직을 분리하였다.

이에 따라 pages/login.tsx는 아래와 같아진다.

import { onLogut, onSocialLogin } from "../services/fbAuth";
//...
return (
    <StyledContainer>
      <button onClick={onLogut} className={String(loginClass)}>
        로그아웃 하기
      </button>

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

services/fbProfile/index.ts는 아래와 같다

import { dbService } from "../../public/fbase";

export const fetchProfile = async (uid: string) => {
  const userRef = dbService.collection("user");
  const query = userRef.where("uid", "==", uid);
  return query
    .get()
    .then(async (Snapshot) => {
      const data = await Snapshot.docs[0]?.data();
      return Promise.resolve(data ? data : null);
    })
    .catch((err) => {
      return Promise.reject(err);
    });
};

const fbProfile = { getProfile };
export default fbProfile;

undefined를 반환하면 next js가 parse에러를 내보내기 때문에 삼항연산자로 null을 반환하도록 했다.

ssr에서 해당 쿼리를 불러와 사용하는 예시는 아래와 같다

import { fetchProfile } from "../../services/fbProfile";

export const getServerSideProps = wrapper.getServerSideProps(
  (store) =>
    async ({ req }) => {
      const uid = await store.getState().authSlice.userObject?.uid;
      if (!uid) {
        return {
          redirect: {
            permanent: false,
            destination: "/login",
          },
          props: { profile: null },
        };
      }

      const profile = await fetchProfile(uid);
      return { props: { profile } };

마지막으로 services/index.ts는 아래와 같이 구성했다.

import fbAuth from "./fbAuth";
import fbProfile from "./fbProfile";

export default { fbAuth, fbProfile };

프로필 CRUD 하기

FetchProfile 기능을 구현하였으니
나머지 CRUD도 구현해보자.

create는 간단하게 collection에 add를 해주면 된다.
이때 영화목록은 number를 5개 받는 enum타입으로 선언...하려고 하였는데 생각해보니 요청을 보낼 때 에러가 날 것 같다.

추가로 영화 목록처럼 팔로우 팔로워도 비정규화로 구현할까 하였지만 EXPO INSTAGRAM CLONE 만들기 (3) 글을 참고하여 콜렉션으로 구현할 예정이다.

Create

export const createProfile = async (
  uid: string,
  nickname: string,
  image: string,
  myMovies: [number, number, number, number, number],
) => {
  return dbService
    .collection("user")
    .add({
      uid,
      nickname,
      image,
      myMovies,
    })
    .then((res) => Promise.resolve(res))
    .catch((err) => Promise.reject(err));
};

글의 수정과 삭제를 원활히 하기 위해서는 문서의 id를 가지고 있는ㄱ ㅓㅅ이 좋다. 문서의 아이디는 docs.doc.id로 접근할 수 있다. 이를 이용하여 fetchProfile 로직을 수정하자. 문서가 있는 경우, 문서 데이터+doc.id를 리턴하고, 아닌 경우 null를 반환한다.

export const fetchProfile = async (uid: string) => {
  const userRef = dbService.collection("user");
  const query = userRef.where("uid", "==", uid);
  return query
    .get()
    .then(async (Snapshot) => {
      const documentId = Snapshot.docs[0]?.id;
      const documentData = await Snapshot.docs[0]?.data();
      const data = { documentId, ...documentData };
      console.log(data);
      return Promise.resolve(documentData ? data : null);
    })
    .catch((err) => {
      return Promise.reject(err);
    });
};

한편 문서 수정에 앞서 문서에 대한 타입을 types에 인터페이스로 만들어준다.
myMovies도 배열로 타입을 바꾸었다.

types/profile.d.ts

export interface ProfileType {
  uid: string;
  nickname: string;
  image: string;
  myMovies: Array<number>;
}

export interface UpdatingProfileType extends ProfileType {
  documentId: string;
}

createProfile도 수정해준다

export const createProfile = async (Profile: ProfileType) => {
  const { uid, nickname, image, myMovies } = Profile;

Update

업데이트는 documentId를 바탕으로 문서에 접근하여 업데이트 하면 된다.
이때 update는 아무것도 반환하지 않는데, 편의를 위헤 Promise.resolve에 프로필 정보를 담아서 리턴하기로 하였다. (이렇게 하면 자신의 페이지를 업데이트 하기 위해 fetch요청을 추가로 보내지 않아도 된다.)

export const updateProfile = (UpdatingProfile: UpdatingProfileType) => {
  const { documentId, uid, nickname, image, myMovies } = UpdatingProfile;
  return dbService
    .doc(`user/${documentId}`)
    .update({ uid, nickname, image, myMovies })
    .then(() => {
      return Promise.resolve({ uid, nickname, image, myMovies } as ProfileType);
    })
    .catch((err) => {
      return Promise.reject(err);
    });
};

create 페이지 만들기

create 페이지는 Static으로 생성한다. 이때 적절한 리다이렉트를 추가하기 위해 getStaticProps를 주었다.

수정 getStaticProps에서 조건부로 redirect를 주는 것은 올바른 방법이 아니다. stackoverflow에서 본 것과는 다르게, getStaticProps에서 redirect를 하는 경우에는 아직 build 되지 않은 페이지(fallback 상태인 페이지)를 방문하는 경우 사용하라고 next js에서 명시하고 있다.(그리고 실제로 빌드가 안된다.) 따라서 해당 로직들을 useEffect로 아래와 같이 옮겼다.

const create = () => {
  const userObject = useTypedSelector((store) => store.authSlice.userObject);
  const router = useRouter();
  useEffect(() => {
    const onMounted = async () => {
      if (!userObject) {
        const currentUser = authService.currentUser;
        if (!currentUser) return router.push("/login");
        dispatch(setUserOjbect(currentUser));
        setCookie("uid", currentUser.uid, 1);
      }
      const userProfile = await fetchProfile(userObject.uid);
      if (userProfile) {
        return router.push("/");
      }
    };
    onMounted();
  }, []);

  return ()
}
/* 아래 코드는 삭제함
export const getStaticProps = wrapper.getStaticProps((store) => async () => {
  const userObject = await store.getState().authSlice.userObject;
  if (!userObject) {
    return {
      redirect: {
        permanent: false,
        destination: "/login",
      },
      props: { userObject },
    };
  }
  const userProfile = await fetchProfile(userObject.uid);
  if (userProfile) {
    return {
      redirect: {
        permanent: false,
        destination: "/",
      },
      props: { userObject },
    };
  }
  return {
    props: {
      userObject,
    },
  };
});
*/

로그인이 된 상태로 profile에 접근하면 create 페이지로 잘 인도한다.

profile페이지에 간단한 form을 만든다.

const create = ({ userObject }) => {
  const [nickname, setNickname] = useState("");
  const [image, setImage] = useState("/default.png");
  const [movie, setMovie] = useState("");
  const [myMovies, setMyMovies] = useState([]);

  return (
    <div>
      <input
        type="text"
        id="nickname"
        name="nickname"
        value={nickname}
        onChange={(e) => setNickname(e.target.value)}
        placeholder="nickname"
      ></input>
      <input
        type="text"
        id="image"
        name="image"
        value={image}
        onChange={(e) => setImage(e.target.value)}
        disabled
      ></input>
      <input
        type="number"
        id="movie"
        name="movie"
        value={movie}
        onChange={(e) => setMovie(e.target.value)}
        placeholder="movie"
      ></input>
      <button
        type="button"
        onClick={(e) => {
          if (myMovies.length < 5) setMyMovies([...myMovies, movie]);
        }}
      >
        {" "}
        영화 추가하기
      </button>
      {myMovies.map((element, idx) => {
        return (
          <button
            type="button"
            key={idx.toString + Math.random().toFixed(20)}
            onClick={async (e) => {
              myMovies.splice(myMovies.indexOf(element), 1);
              setMyMovies([...myMovies]);
            }}
          >
            {element}번 영화 삭제하기
          </button>
        );
      })}
      <br/>
      <button type="button">프로필 작성 완료하기</button>
    </div>
  );
};

그리고 앞서 작성한 createProfile을 onClick이벤트에 연결해준다.

  const onRequest = () => {
    createProfile({ uid: userObject.uid, nickname, image, myMovies })
      .catch((err) => console.log(err));
  };
//...
<button type="button" onClick={onRequest}>

실행해보면

잘 작동하는 것을 확인할 수 있다.

이제 응답으로 온 데이터를 store에 저장하고 router로 main화면에 보내자

store의 authSlice에는 다음과 같이 profile데이터를 set할 수 있게 만들었다.

const authSlice = createSlice({
  name: "authSlice",
  initialState: {
    userProfile: null as null | ProfileDataType,
  },
  reducers: {
    setUserProfile(state, action: PayloadAction<ProfileDataType>) {
      state.userProfile = action.payload;
    },
  },

createProfile을 했을 때의 로직은 아래와 같이 바뀌었다.

  const router = useRouter();
  const onRequest = () => {
    createProfile({ uid: userObject.uid, nickname, image, myMovies })
      .then(async (res) => {
        const documentId = res.id;
        const profileData = await res.get().then((res) => {
          return res.data() as ProfileType;
        });
        await setUserProfile({ ...profileData, documentId });
        router.push("/main");
      })
      .catch((err) => console.log(err));
  };

(참고로 서비스와 스토어는 분리해야 한다. 둘 모두 '사이드 이펙트' 함수를 권장하지 않는다.)

위의 수정과정에서 updatingProfileType타입은 아래와 같이 변수명을 바꾸었다. (업데이트 때 말고도 쓸 일이 많아서 바꾸었음.)

export interface ProfileDataType extends ProfileType {
  documentId: string;
}

프로파일을 작성한 후, /profile로 접속해보자. 그 전에 profile을 다운받아 문자열로 렌더링하는 jsx를 만들었다.

const profile = ({ profile }: { profile: ProfileDataType | null }) => {
  return (
    <div>{profile.uid.slice(0, 5) + profile.image + profile.nickname}</div>
  );
};

접속하자 문제없이 잘 작동한다.

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

0개의 댓글