부트캠프 프로젝트를 하느라 영화앱이 지연되고 있다.
빠르게 본 영화앱 프로젝트를 완성시키자.
앞으로 할 것.
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("");
});
};