영화앱7. 프로필 생성 페이지 (feat. 파이어스토어 서브컬렉션)

jonyChoiGenius·2023년 2월 12일
0

부트캠프 프로젝트를 하느라 영화앱이 지연되고 있다.
빠르게 본 영화앱 프로젝트를 완성시키자.

앞으로 할 것.
1. 프로필 작성하기,
2. 메인 페이지 디자인 (ISG 적용하기)
3. 게시판 기능(요청 횟수 최소화 하기)
4. 팔로우, 팔로잉, 및 프로필 페이지 (팔로우 팔로잉 기능 비정규화로 최적화하기)

오늘은 프로필 페이지를 완성시키겠다.

컴포넌트 분리하기

컴포넌트를 기능단위로 분리하자.
이때 각 컴포넌트가 사용하는 모든 useState 함수들을 내려주고,
formData, setFormData라는 state를 새로 만들어 자식요소에 내려주려 했는데..

formData가 객체 타입인 바, 불필요하게 객체를 생성할 필요가 없어 보였다.

인풋태그별로 분리하다가...
인풋태그를 재사용할 수 있는 방안이 생각나서 아래와 같이 인풋태그에 Props를 받도록 하였다.

import React from "react";

interface iProps {
  name?: string;
  type?: "text" | "email" | "password";
  placeholder: string;
  disabled?: boolean;
  state?: any;
  setState?: Function;
  label?: string;
  children?: any;
}

const Input = ({
  name = "text",
  type = "text",
  placeholder,
  disabled = false,
  state = null,
  setState = () => {},
  label = "",
  children,
}: iProps) => {
  return (
    <div className="form-floating">
      <input
        className="form-control text-center"
        type={type}
        name={name}
        placeholder={placeholder}
        disabled={disabled}
        value={state}
        onChange={(e) => setState(e.target.value)}
      />
      <label htmlFor="floatingInput">{label || placeholder}</label>
      {children}
    </div>
  );
};

export default React.memo(Input);

영화 타입 지정

영화 객체가 파이어스토어의 유저 프로필에 비정규화되어 들어갈 예정이다.

먼저 영화 타입을 지정해주었다.
types/movie.d.ts

export interface MyMovie {
  backdrop_path: string;
  title: string;
  id: number;
  genre_ids: Array<number>;
}

export interface Movie extends MyMovie {
  adult: boolean;
  backdrop_path: string;
  original_language: string;
  original_title: string;
  overview: string;
  popularity: number;
  poster_path: string;
  release_date: string;
  video: boolean;
  vote_average: number;
  vote_count: number;
}

MyMovie는 비정규화되어 들어갈 영화의 내용이다.
기존에 만들었던 profile.d.ts도 수정해주자

import { MyMovie } from "./moive";

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

export interface ProfileDataType extends ProfileType {
  documentId: string;
}

영화를 클릭했을 때 발생하는 콜백함수를 만들었다.

  const onSearchResultClikced = (movie: Movie) => {
    if (myMovies.find((element) => element.id === movie.id)) return;
    if (myMovies.length >= 5) {
      return toastInfo("다섯개까지 선택 가능합니다.");
    }
    setMyMovies([
      ...myMovies,
      {
        backdrop_path: movie.backdrop_path,
        id: movie.id,
        title: movie.title || movie.original_title,
        genre_ids: movie.genre_ids
      },
    ]);
  };

해당 콜백함수의 규칙을 Search 컴포넌트와 MovieRow의 프롭 타입으로 지정해주었다.

interface iProps {
  label?: string;
  onResultClick?: (movie: Movie) => any;
  disabled?: boolean;
}

const Search = ({ label, onResultClick=(movie)=>{}, disabled = false }: iProps) {
  const [movies, setMoives] = useState<Array<Movie>>([]);

그리고 MovieRow에는 아래와 같이 이어받았다.

  const handleClick = (movie) => {
    if (onResultClick) {
      onResultClick(movie);
      return;
    }
    setMovieSelected(movie);
  };
//...
            movies.map((movie) => {
              return (
                movie.backdrop_path && (
                  <SwiperSlide key={movie.id}>
                    <div
                      onClick={() => {
                        handleClick(movie);
                      }}
                    >
                      <MovieFigure movie={movie} />
                    </div>
                  </SwiperSlide>

꽤나 깊게 들어가는 Props인데도 타입스크립트를 이용하니 한결 자료형을 추적하지 편했다.

닉네임 정규표현식

이제 무결성 체크를 한 후 Submit 버튼을 활성화시킬 차례이다.

닉네임 정규표현식 참조

useEffect를 이용해 인풋값이 바뀌면 경고문구가 보이도록 하였다.

  const [integrityMsg, setIntegrityMsg] = useState("모든 항목을 작성해주세요");

  useEffect(() => {
    if (!nickname || !myMovies.length || !image) {
      return setIntegrityMsg("모든 항목을 작성해주세요");
    }
    if ((nickname.length < 2, nickname.length > 8)) {
      return setIntegrityMsg("닉네임은 2글자 이상, 8글자 이하입니다.");
    } else if (!/^(?=.*[a-z0-9가-힣])[a-z0-9가-힣]{2,8}$/.test(nickname)) {
      // } else if (!nickname.match(/^(?=.*[a-z0-9가-힣])[a-z0-9가-힣]{2,16}$)/)) {
      return setIntegrityMsg("사용할 수 없는 닉네임입니다.");
    } else if (myMovies.length < 5) {
      return setIntegrityMsg("인생 영화를 5개 선택해주세요.");
    }
    setIntegrityMsg("");
  }, [nickname, myMovies, image]);

모든 조건을 충족하면 IntergirtyMsg가 없어지며 버튼이 보인다.

            <div className="form-floating text-center">
              <button
                className={`btn btn-primary btn-block ${
                  integrityMsg ? "disabled" : ""
                }`}
                type="submit"
              >
                작성 완료하기
              </button>
              <div className="forgot mt-2">{integrityMsg || " "}</div>
            </div>

영화 추천받기

프로필 생성을 진행하기 전에, 영화를 추천 받는 로직을 만들어보았다.

추천 영화를 받는 로직은 TMDB API에 영화 ID로 요청을 보낸다.
각 요청당 20개의 영화를 추천해준다.

그리하여 최대 100개의 영화를 추천받는데,

이 중
1. 중복되는 영화가 없게
2. 최대 20개를
3. 무작위 순서로

받아야 한다.

이전에 작업할 때 위의 로직을 forEach로 했더니 의외로 시간이 오래 걸려서 이번엔 set과 map을 적극적으로 활용해보기로 했다.

  const newRecommendations = (myMovies:Array<MyMovie>) => {
    //추천 영화를 영화id와 영화정보로 저장할 맵이다.
    const map:Map<number, MyMovie> = new Map();
    //myMoives의 uid를 저장할 set이다.
    const set:Set<number> = new Set();
	//모든 추천 영화를 받을 배열이다
    const newArray = [];
    //최종적으로 추천영화를 필터링하여 리턴될 배열이다.
    let recommendations;
    
    return Promise.allSettled(
      //myMoives를 map하여 영화를 불러오는 요청을 5개 만들고, 이를 allSettled로 동시에 처리한다.
      myMovies.map((movie) => {
        set.add(movie.id);
        return getRecommenations(movie.id).then((res) => {
          newArray.push(...res.data.results.slice(0, 10));
        });
      }),
    ).then(() => {
      //영화를 불러오는 요청으로 최대 50개의 영화를 받았다. 
      //각 영화를 forEach로 순회하여 1. 중복되지 않았고(맵 객체에 없고), 2. myMoives에 없는 영화라면 맵 객체에 추가한다.
      //추가 수정 : backdrop_path가 있는지 여부를 확인 + myMovie 타입에 맞게 저장
      newArray.forEach((movie) => {
    newArray.forEach((movie) => {
      if (!set.has(movie.id) && !map.has(movie.id) && movie.backdrop_path)
        map.set(movie.id, {
          id: movie.id,
          title: movie.title,
          backdrop_path: movie.backdrop_path,
          genre_ids: movie.genre_ids,
        });
    });
      });
      // 맵 객체의 keys만 받아 이를 무작위로 소팅한다.
      const keys = Array.from(map.keys());
      keys.sort(() => 0.5 - Math.random());
      //무작위 소팅 후 20개만 슬라이스해 최종 결과물로 리턴한다.
      recommendations = keys.slice(0, 20).map((key) => map.get(key));
      return recommendations;
    });
  };

이렇게 짜고 나니....... 반응 속도가 의외로 빠르다?

본래는 이렇게 만들어진 추천 영화를 파이어스토어에 저장해두려 했는데,
이 정도 반응속도면 굳이 파이어스토어에 저장하지 않아도 될 듯 하다.
(하지만 상대방 프로필을 조회했을 때를 염두에두어 파이어스토어에 저장하기로 최종 결정)

map과 set를 써서 반응속도가 빨라졌다기보다는, Promise.allSettled로 한 방에 5개의 리퀘스트를 처리해서 빨라진 듯 하다.

프로필 생성하기

Firebase: 유저 프로필 구현하기에서 만들었던 함수를 이용해 그대로 적용하면....!!!

새로고침 되어 엄청나게 고생한다......;;
event.preventDefault()를 빼먹지 말자.

위 추천영화 로직을 이용하기 위해 먼저 Profile 타입을 수정해준다.

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

프로필을 생성하는 로직과 수정하는 로직에도 myRecommendations을 넣어주었다.

onRequest 이벤트에 추천영화 로직을 포함한 후,
로딩중임을 보여주기 위해 메시지를 수정하는 로직도 추가하였다.

결과물은 아래와 같다.

  const onRequest = async (e) => {
    e.preventDefault();
    setIntegrityMsg("잠시만 기다려 주세요");
    const myRecommendations = await newRecommendations(myMovies);

    createProfile({
      uid: userObject?.uid,
      nickname,
      image,
      myMovies,
      myRecommendations,
    })
      .then(async (res) => {
        const documentId = res.id;
        const profileData = await res.get().then((res) => {
          return res.data() as ProfileType;
        });
        setIntegrityMsg("");
        toastSuccess("프로필이 생성되었습니다.");
        router.push("/main");
      })
      .catch((err) => {
        toastError("프로필 생성에 실패하였습니다.");
        setIntegrityMsg("");
      });
  };
profile
천재가 되어버린 박제를 아시오?

0개의 댓글