-
MOVIE ROOM 회고Project 2023. 2. 12. 18:25
MOVIE ROOM이라는 영화 웹 서비스를 만들었다.
프론트엔드 개발 강의를 수강하면서 영화 웹 서비스를 만드는 강의가 많았고 나도 클론 코딩을 하면서 많이 배웠다.
하지만 코드를 똑같이 작성하는 것이 아닌 내가 처음부터 스스로 만들어 보고 싶은 마음이 컸다.
그래서 기존의 사용했었던 영화 API(The Movie Database (TMDB) API)를 바탕으로 영화 목록을 보여주는 기능 외의 로그인 기능과 영화의 짧은 리뷰를 쓸 수 있는 기능도 추가해보자는 생각이 들었다.
- 영화의 최근 개봉작, 인기 상영작, 최고 평점, 개봉 예정작을 보여주기
- 개별 영화 상세정보 보여주기
- 영화 자동 검색 기능
- 영화 검색 결과 목록 보여주기
- 영화 리뷰 쓰기/수정/삭제 기능
- 로그인/ 로그아웃/회원탈퇴 기능
- 내 정보 수정
- 마이페이지 : 내가 쓴 리뷰 보여주기
대략적으로 구현할 기능이다.
디자인
전체적인 웹 서비스의 컨셉을 찾으려고 Adobe Stock에서 무료 이미지를 검색하던 중 마음에 든 이미지가 있어 해당 이미지를 기준으로 대표 색상과 디자인을 하게 되었다. 메인 컬러는 #FEE0C8 #FD7D36 #1A3642 #50ADB5 이다.
피그마에서 Lo-fi 프로토타입으로 대략적인 레이아웃과 흐름들을 정했다. 이후 개발 중에 디테일한 부분들을 유연하게 조정했다.
개발
Front-End: React, Emotion, Redux-Toolkit, RTK Query
Back-End: Node.js, Express, MongoDB, Mongoose
프론트엔드에서는 React(create-react-app)을 사용하였다. 아무래도 React를 공부하였고 React 프로젝트를 만들고 싶었기 때문에 선택하였다. 자바스크립트로 작성을 하였는데 타입스크립트로 마이그레이션하여 타입스크립트 작성 연습을 해도 좋을 것 같다.
스타일링 라이브러리는 Emotion을 사용하였다. 현재 Styled-components와 Emotion를 둘 다 써보았지만 검색 후 둘의 차이가 있구나한다. 사실 개발하는데 있어서는 크게 다른점을 느끼진 못하였다.
상태 관리 라이브러리로 Redux-Toolkit을 선택한 이유는 이전의 리덕스로 리액트의 상태관리를 적용한 프로젝트들도 많고 리덕스에 관한 강의도 보았기 때문에 배운 내용을 적용을 해도 좋을 것 같아서 선택했다. 하지만 기존의 리덕스 자체는 많은 양의 보일러플레이트 작성과 같이 설치해야 하는 라이브러리, 복잡한 코드 등의 단점이 있었다. 그래서 기존의 리덕스의 단점을 보완하여 나온 Redux-Toolkit 패키지를 사용하게 되었다. 서버 데이터를 관리하는 부분도 store에서 관리하다가 Redux-Toolkit 패키지 안에 RTK Query가 포함된 것을 안 후 데이터 패칭 로직을 RTK Query로 리팩토링을 하였다. 그러다보니 전역 상태관리가 유저 상태 밖에 없어져서 Context API와 데이터 패칭을 요즘 많이 사용하는 react-query로 변경할까의 고민도 있었지만 라이브러리를 이미 설치하였기도 하고 굳이 명확하게 바꿀 이유도 없어서 그대로 사용하게 되었다. 하지만 RTK Query를 사용하였을 때 부족한 한글 문서 때문에 사용하는데 약간의 어려움이 있었다.
백엔드는 Node.js의 프레임워크인 Express를 사용하였고 데이터베이스로 MongoDB, Mongoose를 사용하였다. 자바스크립트를 사용하는 나로써는 백엔드를 Node.js로 사용하는게 적당하였고, Express, MongoDB, Mongoose를 사용한 강의 영상도 있었고 검색했을 때 참고할 자료가 많이 나오기도 했기 때문에 선택하게 되었다.
영화 목록 보여주는 커스텀 캐러셀 구현
캐러셀의 경우 라이브러리를 사용할까의 고민도 있었다. 하지만 영화 목록 보여주기는 메인 기능이기도 고 내 프로젝트에 맞게 커스텀하고 직접 구현해보면서 의미 있을 것 같아 구현하게 되었다.
// Carousel.jsx const [isShowLeftBtn, setIsShowLeftBtn] = useState(false); const [isShowRightBtn, setIsShowRightBtn] = useState(true); useEffect(() => { activeIndex ? setIsShowLeftBtn(true) : setIsShowLeftBtn(false); activeIndex + showCount < itemCount ? setIsShowRightBtn(true) : setIsShowRightBtn(false); }, [activeIndex, itemCount, showCount]); const handlePreClick = () => { setActiveIndex((activeIndex) => activeIndex - showCount); }; const handleNextClick = () => { if (activeIndex < itemCount) { setActiveIndex((activeIndex) => activeIndex + showCount); } };
Carousel 컴포넌트는 화면에 보여지는 수인 showCount에 따라 현재 활성화된 목록 인덱스를 나타낸 activeIndex에 빼거나 더해진다. 왼쪽 화살표 버튼과 오른쪽 화살표 버튼은 첫 목록이거나 마지막 목록일 경우 조건부 렌더링을 하였다.
버튼의 display를 absolute로 각각 "-48px"으로 지정하였는데 모바일 화면을 위한 반응형 작업을 할 때 화면의 너비가 작아지니 레이아웃이 기대한대로 나오지 않았다. 또한 화살표 버튼을 누르는 UI 보다는 캐러셀을 터치스크롤을 하는게 UX이 더 좋을 것이라 생각하였다. 버튼들은 따라서 scroll-snap-type 추가하여 사용자가 터치 스크롤을 할 수 있게끔 캐러셀을 구현하였다.
// Carousel.jsx scroll-snap-type: x mandatory; // CarouselItem.jsx scroll-snap-align: start;
검색어 자동완성 키보드 이벤트 기능 구현
이전에 작성한 글로 대신한다.
외부 영역 클릭 감지 hook 작성
사용자 경험을 고려하여 특정 요소 외의 영역을 클릭하면 특정 요소를 닫게 하도록 하고 싶었다.
특정 요소가 있고 내가 클릭한 event.target이 특정 요소에 포함되지 않으면 state를 false로 설정해주는 hook을 작성하였다. 하지만 특정 요소에서 모달을 여는 기능이 있어서 모달이 열려있는 상태면 화면을 클릭을 했을 때 특정 요소가 닫히면서 모달도 닫히게 된다. 이를 방지하기 위해 exceptionState를 받아 모달의 상태를 넘겨주어 return 처리를 해주었다.
const useOutsideClick = (ref, setState, exceptionState) => { useEffect(() => { const handleClickOutside = (event) => { if (exceptionState) { return; } if (ref.current && !ref.current.contains(event.target)) { setState(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => { document.removeEventListener("mousedown", handleClickOutside); }; }, [ref, setState, exceptionState]); };
react-hook-form을 이용한 폼 관리
const { register, handleSubmit, getValues, trigger, formState: { errors, isSubmitting }, } = useForm({ mode: "onChange" }); <AuthInput type="email" title="이메일" placeholder="이메일" autoFocus register={register("email", { required: ERROR_MSG.required, pattern: { value: EMAIL_REGEX, message: ERROR_MSG.invalidEmail, }, })} /> {errors.email && <ErrorMsg>{errors.email.message}</ErrorMsg>}
로그인 인풋, 회원가입 인풋 등 폼을 관리해야 하는 상황에서 react-hook-form 라이브러리를 사용하여 효율적으로 폼을 관리하였다. 회원가입 페이지에서 이메일을 입력하는 인풋일 경우 register에 필수적으로 입력해야하고 정의한 정규표현식 조건에 충족하지 않을 때 에러 메시지를 밑에 출력하게 하였다.
RTK Query
Automated Re-fetching 적용
RTK Query는 "cache tag" 시스템을 사용하여 mutation 엔드포인트에 영향을 받는 데이터가 있는 query 엔드포인트에 대해 re-fetching을 자동화한다. Providing tags query는 캐시된 데이터가 tag를 제공하도록 할 수 있다. 이렇게 하면 쿼리에서 반환하는 캐시된 데이터에 어떤 'tag'가 연결되는지 결정된다. Invalidating tags mutation은 tag를 기반으로 캐시된 특정 데이터를 무효화할 수 있다. 쿼리 캐시가 주는 태그를 무효화하는 mutation이 발생하면 캐시된 데이터는 무효화된 것으로 간주되고 캐시된 데이터에 대한 활성화된 구독이 있으면 re-fetch 된다.
const serverApi = createApi({ tagTypes: ["User", "Review"], endpoints: (builder) => ({ getReviewsByUser: builder.query({ query: (id) => ({ url: `review/user/${id}` }), transformResponse: (response) => response.review, providesTags: ["Review"], }), getReviewsByMovie: builder.query({ query: ({ id, limit }) => ({ url: `review/movie/${id}?limit=${limit}` }), transformResponse: (response) => response.review, providesTags: ["Review", "User"], }), createReview: builder.mutation({ query: (body) => ({ url: "review", method: "POST", body, }), invalidatesTags: ["Review"], }), deleteReview: builder.mutation({ query: ({ id }) => ({ url: `review/${id}`, method: "DELETE" }), invalidatesTags: ["Review"], }), updateReview: builder.mutation({ query: (body) => ({ url: "review", method: "PATCH", body }), invalidatesTags: ["Review"], }), }), });
Masonry Layout 리뷰 정렬 기능
Masony Layout이란 벽돌을 차곡차곡 쌓아낸 형태의 레이아웃을 나타낸다. 대표적으로 핀터레스트에서 볼 수 있는 레이아웃이다.
@egjs/react-grid 라이브러리를 사용하여 My Page에서 내가 쓴 리뷰들을 모아 보여줄 때 Masony Layout을 적용하였다. 또한 '최근작성순', '오래된순','별점높은순','별점낮은순'으로 정렬을 할 수 있도록 구현하였다. 참고로 날짜 관련 로직에서는 dayjs 라이브러리로 통일하여 사용하였다.
import dayjs from "dayjs"; export const sortArray = (array, mode) => { return sort[mode](array); }; const sort = { newest: (array) => array.sort((a, b) => dayjs(b.updatedAt) - dayjs(a.updatedAt)), oldest: (array) => array.sort((a, b) => dayjs(a.updatedAt) - dayjs(b.updatedAt)), starDesc: (array) => array.sort((a, b) => b.rating - a.rating), starAsc: (array) => array.sort((a, b) => a.rating - b.rating), };
최근 검색어 저장 기능
const setSearchHistory = (keyword) => { const serachHistory = new Set(JSON.parse(localStorage.getItem("search-history"))) ?? new Set([]); if (serachHistory.size === 5) { const first = [...serachHistory][0]; serachHistory.delete(first); } serachHistory.add(keyword); localStorage.setItem("search-history", JSON.stringify([...serachHistory])); };
최근에 검색한 키워드를 localStorage를 통해 저장하였다. Set 자료형을 사용하였다. 그냥 배열을 사용할까 했지만 Set은 중복을 허용하지 않기 때문에 중복 값 처리를 자동으로 해주고 사용할 속성과 메서드도 명확하고 편리하다 생각하여 사용하였다.
검색 페이지 무한 스크롤 구현
검색 결과를 보여주는 검색 페이지에서 무한스크롤을 적용하였다. useIntersectionObserver hook을 만들고 onIntersect prop을 받았다. 검색 페이지의 하단에 target을 등록하고 그 target에 가시성 변화가 생기면 page에 1을 더하도록 작성하였다.
const useIntersectionObserver = ({ onIntersect }) => { const [target, setTarget] = useState(null); useEffect(() => { if (!target) return; const observer = new IntersectionObserver(onIntersect); observer.observe(target); return () => observer.unobserve(target); }, [onIntersect, target]); return { setTarget }; }; export default useIntersectionObserver;
const onIntersect = useCallback( (entry, observer) => { if (entry[0].isIntersecting) { if ( searchData.total_pages >= currentPage && searchData.page === currentPage ) { setCurrentPage((page) => page + 1); } observer.unobserve(entry[0].target); } }, [searchData.total_pages, currentPage, searchData.page] );
또한 이전에 lodash 라이브러리의 도움을 받아 작성한 ToTop 컴포넌트도 Intersection Observer API로 리팩토링하였다.
// before useEffect(() => { window.addEventListener("scroll", throttle(handleScroll, 500)); return () => { window.addEventListener("scroll", throttle(handleScroll, 500)); }; }, []); // after const [isShowToTop, setIsShowToTop] = useState(false); const handleIntersect = useCallback((entry) => { entry[0].isIntersecting ? setIsShowToTop(false) : setIsShowToTop(true); }, []); const { setTarget } = useIntersectionObserver({ onIntersect: handleIntersect, });
Timer 구현
회원가입 로직에서 이메일과 비밀번호를 입력하고 버튼을 누르면 이메일 인증 모달이 뜨게 된다. 이메일 인증 3분 제한 시간이 있어 사용자가 알아볼수 있도록 타이머를 구현해야했다. 3:00으로 시작하여 1초 씩 줄어들고 0:00이 되면 인증 메일 재전송 버튼으로 변경된다.
const useTimer = () => { const [timeLimit, setTimeLimit] = useState(180); const [isRunning, setIsRunning] = useState(true); useInterval( () => { if (timeLimit === 1) setIsRunning(false); setTimeLimit((prev) => prev - 1); }, isRunning ? 1000 : null ); const reset = useCallback(() => { setTimeLimit(180); setIsRunning(true); }, []); return { timeLimit, setTimeLimit, isRunning, setIsRunning, reset }; };
useTimer hook을 작성하여 남은 시간의 상태인 timeLimit과 타이머가 작동하는 중인지 상태를 나타내는 isRunning을 이용하였다. 타이머가 1초마다 timeLimit이 1씩 줄어들어야 하는데, 일정한 시간을 두고 반복하는 함수인 setInterval을 사용해야한다.
useEffect를 이용하여 setInterval을 사용하면 기존의 setInterval은 이전의 props와 state를 계속 참조하게 된다. 아래의 글과 같이 useInterval로 callback이 바뀔 때마다 ref에 저장하여 항상 최근 렌더링 이후의 callback 읽게 해주고, setInterval로 delay 후 tick 함수(callback 함수)가 실행되게 한다.
const useInterval = (callback, delay) => { const savedCallback = useRef(); // Remember the latest callback. useEffect(() => { savedCallback.current = callback; }, [callback]); // Set up the interval. useEffect(() => { function tick() { savedCallback.current(); } if (delay !== null) { let id = setInterval(tick, delay); return () => clearInterval(id); } }, [delay]); };
JWT를 사용한 로그인 유지 기능
// 로그인 const token = await generateToken(user); setTokenCookie(res, token); // 토큰 체크 const validatedToken = await validateToken(token); const recentUser = await User.findOne({ id: validatedToken.id });
로그인 유지 기능을 위해 JWT을 이용하였다.
사실 아직도 100% 이해를 못하였기 때문에 조금 더 공부가 필요한 부분이었다. 보통 Refresh Token과 Access Tonken 방식으로 구현하는 방법을 사용하지만 아직 이해가 필요한 부분이고 이 기능에 시간이 너무 지체되는 느낌이 들어 토큰 하나만 발급하여 인증을 하였다. 로그인을 하면 JWT를 발급해주는 함수인 generateToken를 사용하여 토큰을 발급하여 res.cookie에 토큰을 설정하였다.
프론트엔드 쪽에서는 useCheckToken hook을 작성하여 앱이 리렌더링 될 때마다 체크해주도록 하였다. 토큰을 체크해주는 useCheckTokenQuery를 이용해 토큰 체크 후 user 정보를 보내주어 user가 없으면 로그아웃, 있으면 로그인을 해주었다.
export const useCheckToken = () => { const { data: user } = useCheckTokenQuery(); const dispatch = useDispatch(); useEffect(() => { if (!user) { dispatch(logout()); return; } dispatch(login({ user })); }, [dispatch, user]); };
- 쿠키를 전송하는 중에 생긴 cors 해결
회원가입을 위한 이메일 인증번호
// 랜덤 5자리 숫자 생성 certificationNumber = Math.floor(Math.random() * (99999 - 10000) + 10000); await mailSender(email, certificationNumber); setTimeout(() => { certificationNumber = null; }, 180000);
이메일을 전송하기 위한 라이브러리로 nodemailer를 사용하였다. 인증 번호를 생성하기 위해 certificationNumber 변수를 사용하였고 setTimeout을 사용하여 3이 지나면 null로 설정해 인증 번호를 전송해도 인증이 되지 않도록 하였다.
프로필 이미지 변경
const { register, handleSubmit, watch, formState: { errors, isSubmitting }, } = useForm({ mode: "onChange" }); const image = watch("image"); useEffect(() => { if (image && image.length > 0) { setIsChageThumbnail(true); const file = image[0]; setNewThumbnailUrl(URL.createObjectURL(file)); } }, [image]);
react-hook-form 라이브러리를 이용하여 image를 관리하였다. watch는 입력을 감시하고 값을 반환한다. watch를 통해 image가 바뀌면 Blob 객체를 url로 바꿔주는 URL.createObjectURL()을 사용해 화면에 사용자가 올린 이미지를 보여주게 하였다.
export const upload = multer({ storage: multer.diskStorage({ destination: (req, file, cb) => { cb(null, "uploads/"); }, filename: (req, file, cb) => { cb(null, `${Date.now()}_${file.originalname}`); }, }), limits: { fileSize: 5 * 1024 * 1024 }, });
Express에서는 multer 라이브러리를 이용하여 이미지를 uploads 폴더 내의 저장하게 하였다.
스타일 객체 생성
export const colors = { black: "#1A3642", beige: "#FEE0C8", red: "#D0433A", orange: "#FD7D36", orangeOpacity: "#fd7c3662", cyan: "#50ADB5", lightgray: "#afb5b8", greyOpacity: "#484a4c66", grey: "#484a4c", white: "#fff", }; export const fontSize = { xs: "0.5rem", sm: "0.875rem", base: "1rem", md: "1.25rem", lg: "1.5rem", xl: "2rem", "2xl": "4rem", }; export const breakpoint = { sm: "480px", md: "864px", };
색상, 폰트사이즈, 반응형 중단점에 대해 일관성을 유지하기 위해 common.js 파일을 생성하여 정리하였다.
jest와 react-testing-library를 사용한 테스트 코드 작성
jest.useFakeTimers(); describe("MailAuthentication Modal", () => { const setup = () => { return renderWithProviders( <MailAuthenticationModal email="email1234@mock.com" password="password1234" onClose={() => jest.fn()} /> ); }; const oldCreatePortal = ReactDOM.createPortal; beforeEach(() => { ReactDOM.createPortal = (node) => node; }); afterAll(() => { ReactDOM.createPortal = oldCreatePortal; }); it("renders correctly", () => { const { container } = setup(); expect(container).toMatchSnapshot(); }); it("should change after 180s the text of the button", async () => { setup(); const checkButton = screen.getByText("확인"); expect(checkButton).toBeInTheDocument(); act(() => jest.advanceTimersByTime(179000)); act(() => jest.advanceTimersByTime(1000)); const resendButton = await screen.findByText("인증 메일 재전송"); expect(resendButton).toBeInTheDocument(); }); });
위의 테스트는 회원가입을 위한 이메일 인증 모달 컴포넌트 테스트이다.
"should change after 180s the text of the button" 테스트의 경우 180초가 지난 경우 '인증 메일 재전송'이란 텍스트가 버튼에 존재해야한다는 내용을 담고 있다. jest.useFakeTimers()는 자바스크립트 타이머 함수에 대해 가짜 타미어 함수로 활성화하고 jest.advanceTimersByTime(ms)은 ms만큼 시간이 지나가게 한다. 처음에 180000ms을 사용하여 테스트를 진행하였지만 정의한대로 테스트가 진행되지 않아 다시 코드를 살펴보니 남은 타임 시간이 1초가 되었을 때 관련 상태를 바뀌어 179000ms 를 지나게 하고 이후에 1000ms를 지나게 하니 기대한 대로 버튼에 '인증 메일 재전송'이란 텍스트가 나오게 되었다.
테스트 코드를 작성해보며 작성법에 대해 알게된 시간인 것 같다.
배포
client는 github-pages에서 배포하였다.
나같은 경우 서버와 클라이언트 폴더가 같은 레포지토리에 있었고, 클라이언트 폴더를 gh-pages로 배포하고 싶었다. 클라이언트 폴더를 빌드하여 빌드된 클라이언트 폴더를 gh-pages 브랜치로 푸시하면 github-pages에 배포를 할 수 있었다.
이 과정을 GitHub Actions를 사용하여 자동화했다. 처음엔 어떻게 작성해야 하는지 관하여 모르니까 ChatGPT에 물어봤더니 친절하게 답해주었다. ChatGPT 제공한 코드을 보면서 secrets으로 환경 변수를 사용하는 등 추가적인 설정을 넣어주니 github-pages에 배포가 잘 되었다.
server는 GCP의 App Engine으로 배포하였다. PaaS로써 아주 간단하게 배포 환경을 구축할 수 있었다. (한 번에 배포가 정상적으로 돼서 오? 하고 놀란...) 이 또한 github actions를 통하여 메인 브랜치로 푸시 시에 App Engine으로 배포하도록 작성하였다.
배포까지 끝내고 나니 후련한 기분이 들었다. 시간이 꽤 오래 걸렸던 프로젝트였지만 그만큼 뿌듯한 기분도 많이 들었다.
아무래도 리액트 프로젝트이다 보니 SEO 관점으로 봤을 때 아쉬웠다. 영화의 상세페이지를 공유했을 때 그에 맞는 적절한 오픈그래프를 보여주고 싶었지만 그러지 못했다.
반응형'Project' 카테고리의 다른 글
마피아 G 마스터 회고 (0) 2023.11.02 [Chrome Extension] 크롬 확장 프로그램 Text Highlighter (2) 2023.06.09 ideal idea 회고 (0) 2023.04.03 인터랙티브 웹툰 Turn Off 회고 (0) 2023.02.06