Next Js를 활용한 Netflix Clone 프로젝트

장영준·2022년 12월 28일
0

Frontend-Project

목록 보기
1/2

신촌 연합 동아리 CEOS의 프론트엔드 스터디 마지막 과제로 next js를 활용한 넷플릭스 클론 코딩 프로젝트를 진행했다.
(11월 20일에 마무리했었는데, 이제서야 관련 글을 쓴다,,,)

해당 프로젝트에서는 메인 페이지, searchPage만 구현했다.
배포링크
GITHUB

I. 폴더 구조

폴더 구조는 다음과 같다.

src
|-api
|-components
	|-elements (공유 컴포넌트)
	|-homePage
	|-landingPage
	|-searchPage
	|-icons
|-pages
	|-home
		|-index.tsx
		|-[id].tsx
	|-search
		|-index.tsx
|-states
|-styles

II. HomePage

React-Query 도입기

이번 프로젝트에서는 새로운 라이브러리를 사용해보고 싶어 react-query를 도입했다.
이전에 노마드코더 강의를 통해 react-query를 잠깐 공부한 적이 있는데, 다음 글에서 볼 수 있다.
React-Query 공부자료

react-query를 도입한 과정은 다음과 같다.
1. 우선 api 폴더를 따로 만들어 api 호출 함수들을 따로 관리했다.

export const getNowPlaying = () => {
  return client.get(`movie/now_playing?api_key=${API_KEY}`).then((res) => res.data);
};

export const getTopRated = () => {
  return client.get(`movie/top_rated?api_key=${API_KEY}`).then((res) => res.data);
};

export const getPopular = () => {
  return client.get(`movie/popular?api_key=${API_KEY}`).then((res) => res.data);
};

export const getUpcoming = () => {
  return client.get(`movie/upcoming?api_key=${API_KEY}`).then((res) => res.data);
};
  1. 서버사이드 렌더링
  • react-query의 서버사이드 렌더링에는 두 가지 방법이 있다. (InitialData, Hydration)
    이중 나는 Hydration 방법을 사용했다.
  • Hydrate란?
    • Next.js에서는 Pre-Rendering된 웹 페이지를 클라이언트에게먼저 보내고, React가 번들링된 자바스크립트 코드들을 클라이언트에게 전송
    • Next.js로 제작된 웹페이지를 방문하게 되면 맨 처음 document 타입의 파일을 전송받고, 그 이후에 렌더링된 React.js 파일들이 Chunk 단위로 다운로드 되는 것을 확인할 수 있음
    • 위 리액트 코드들이 이전에 보내진 HTML DOM 요소 위에 한번 더 렌더링 하면서 렌더링을 하게 되는데 이 과정을 Hydrate라고 합니다.
  • Hydration 방식을 사용하면 getServersideProps에서 prefetch를 통해 데이터를 요청한 뒤, queryClient를 dehydrate하여 props에 dehydratedState로 준다.
export async function getServerSideProps() {
  const queryClient = new QueryClient();
  await queryClient.prefetchQuery(['now-playing'], getNowPlaying);
  await queryClient.prefetchQuery(['top-rated'], getTopRated);
  await queryClient.prefetchQuery(['popular'], getPopular);
  await queryClient.prefetchQuery(['up-coming'], getUpcoming);
  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  };
  • getServerSideProps() 함수 안에 prefetchQuery를 사용했고, props에 dehydratedState를 주어 return했다.
const { isLoading: nowPlayingLoading, data: nowPlayingData } = useQuery(['now-playing'], getNowPlaying);
const { isLoading: topRatedLoading, data: topRatedData } = useQuery(['top-rated'], getTopRated);
const { isLoading: popularLoading, data: popularData } = useQuery(['popular'], getPopular);
const { isLoading: upComingLoading, data: upComingData } = useQuery(['up-coming'], getUpcoming);

setNowPlayingMovies(nowPlayingData.results);
setTopRatedMovies(topRatedData.results);
setPopularMovies(popularData.results);
setUpComingMovies(upComingData.results);
  • 이후에는 useQuery로 각 함수의 isLoading 값과 data 값을 받아왔다. (useQuery는 기본적으로 isLoading과 data 값을 반환한다.)
  • const 의 인자 뒤에 쓰인 따옴표는 그 속성에 이름을 부여한다는 뜻이다. (js 문법)

React-Query 사용해보니 좋은 점
1. isLoading값도 함께 반환해줌 - 따로 set할 필요 없어서 편리함
2. 캐싱 기능이 있어서 한번 들어간 페이지 다시 들어가면 로딩시간 없이 바로 보임
3. 동일 데이터를 여러번 요청하면 알아서 걸러서 한번만 요청 → 효율 up
4. 리액트 훅들이랑 사용 구조가 비슷해서 배우기 용이함

III. SearchPage

1. 서버사이드 렌더링을 어떻게?

결론부터 말하자면 클릭으로 검색하는 방식도 아닌 실시간 검색 기능을 서버사이드 렌더링으로 하려는 것은 말도 안되는 일이었다.

서버사이드 렌더링 방식은 서버에 html 파일을 저장해 두었다가, 후에 클라이언트의 요청을 받고 html 파일을 변환하여 브라우저에 출력하는 방식이다. html 파일을 서버에 미리 저장해야 하는데, 실시간으로 계속 api를 새로 요청해서 페이지를 만들게 시키는 방법은 맞지 않는 방법이었다.

useEffect(() => {
    setIsLoading(true);
    if (debouncedSearchWord) {
      searchMovies(searchWord).then((res) => {
        setSearchedMovies(res.data.results);
        setIsLoading(false);
        return res.data;
      });
    } else {
      getPopular().then((res) => {
        setSearchedMovies(res.results);
        setIsLoading(false);
        return res.data;
      });
    }
  }, [debouncedSearchWord]);

그래서 작성된 코드. 일반적인 api 호출과 다를 것 없다.
초기에 작성된 코드라 실시간으로 데이터 불러오기 기능에만 집중했다. (스크롤, 스켈레톤 X)

2. 스켈레톤 이미지

useQuery가 받아오는 값 중 isLoading 값을 통해, 해당 값이 true인지 false 인지에 따라 true면 스켈레톤 이미지를, false면 영화에 해당하는 썸네일 이미지를 띄워줄 수 있었다.

let arr = new Array(20).fill(1);

const { 
	getBoard, 
	getNextPage, 
	getBoardIsSuccess, 
	getNextPageIsPossible, 
	isLoading 
} = useInfiniteScrollSearchQuery(debouncedSearchWord);
<div>
      <ListTitle>Top Searches</ListTitle>
      {!isLoading
        ? // 데이터를 불러오는데 성공하고 데이터가 0개가 아닐 때 렌더링
          getBoardIsSuccess && getBoard!.pages
          ? getBoard!.pages.map((page_data: any, page_num: any) => {
              const board_page = page_data.board_page;

              return board_page?.map((item: any, idx: any) => {
                if (
                  // 마지막 요소에 ref 달아주기
                  getBoard!.pages.length - 1 === page_num &&
                  board_page.length - 1 === idx
                ) {
                  return (
                    // 마지막 요소에 ref 넣기 위해 div로 감싸기
                    <div ref={ref} key={item.board_id}>
                      <SkeletonItem key={item.board_id} />
                    </div>
                  );
                } else {
                  return <SearchItem key={item.board_id} {...item} />;
                }
              });
            })
          : null
        : arr.map((arr, index) => {
            return <SkeletonItem key={index} />;
          })}
    </div>

3. Debounce

입력 후 일정 시간이 지난 뒤에 렌더링하게끔 만들고 싶어 만든 기능이다.

  • hooks 폴더 아래에 useDebounse.tsx 파일을 만들어 다음과 같이 코드를 작성했다.
  import { useState, useEffect } from 'react';

  export const useDebounce = (value: string, delay: number) => {
    const [debouncedValue, setDebouncedValue] = useState(value);

    useEffect(() => {
      const handler = setTimeout(() => {
        setDebouncedValue(value);
      }, delay);

      return () => {
        clearTimeout(handler);
      };
    }, [value, delay]);
    return debouncedValue;
  };
  • props로 value와 delay를 받아 다음과 같이 값과 시간을 입력해주어 사용할 수 있다.
const debouncedSearchWord = useDebounce(searchWord, 500);

4. 무한 스크롤

우선 reac-query의 내장함수인 useInfiniteQuery에 관한 공부를 많이 했다.
공부자료

무한 스크롤 기능을 구현할 때 사용한 라이브러리들은 다음과 같다.

  • 사용 라이브러리 및 훅: useInfiniteQuery(react-query), useInView(react-intersection-observer)
  1. useInfiniteQuery
    우선 useInfiniteScrollQuery라는 컴포넌트 파일을 하나 생성하여 다음과 같이 작성했다.
import { useInfiniteQuery } from '@tanstack/react-query';
import client from '../../api/client';
const API_KEY = process.env.NEXT_PUBLIC_TMDB_API_KEY;

export function useInfiniteScrollSearchQuery(debouncedSearchWord: string) {
  const getSearchData = async ({ pageParam = 1, searchWord = debouncedSearchWord }) => {
    const res = await client.get(`search/movie/?api_key=${API_KEY}&query=${searchWord}&page=${pageParam}`);

    return {
      board_page: res.data.results,
      current_page: pageParam,
      isLast: res.data.total_pages === pageParam, //미쳤다.
      current_word: debouncedSearchWord,
    };
  };

  const {
    data: getBoard,
    fetchNextPage: getNextPage,
    isSuccess: getBoardIsSuccess,
    hasNextPage: getNextPageIsPossible,
    isLoading: isLoading,
  } = useInfiniteQuery(['search', debouncedSearchWord], getSearchData, {
    getNextPageParam: (lastPage: any, pages: any) => {
      if (!lastPage.isLast) return lastPage.current_page + 1;
      return undefined;
    },
  });

  return {
    getBoard,
    getNextPage,
    getBoardIsSuccess,
    getNextPageIsPossible,
    isLoading,
  };
}

해당 코드는 검색 후 검색어가 바뀔 때마다 api를 불러오고, 그곳에 무한스크롤 기능을 넣는 것이다.
우선적으로 기능 구현 하는것에 집중하여 api 받는 것도 함께 코드 안에 넣었다…(추후 리팩토링 예정)

  • 처음에 getSearchData 함수를 통해 api를 호출하고, return 값으로
    board_page (페이지의 결과),
    current_page (현재 페이지: 1부터 1씩 증가되는 값),
    isLast (마지막페이지인지 아닌지 확인해주는 boolean 값),
    hasNextPage (다음 페이지를 가지고 있는지 알려주는 boolean 값),
    isLoading(로딩중인지 확인해주는 boolean 값) 을 받았다.
  • 다음으로는 useInfiniteQuery 함수를 사용했다.
    해당 함수는 다음과 같은 props를 가진다.
    useInfiniteQuery(queryKey: 고유의 key값, queryFunction: 함수, option: 그 외 옵션들)

option 항목에 getNextPageParam을 아래 코드와 같이 작성해주면 props 항목의 lastPage와 pages는 콜백 함수에서 리턴한 값을 의미한다고 한다.
(lastPage: 직전에 반환된 리턴값, pages: 여태 받아온 전체 페이지)

const {
    data: getBoard,
    fetchNextPage: getNextPage,
    isSuccess: getBoardIsSuccess,
    hasNextPage: getNextPageIsPossible,
    isLoading: isLoading,
  } = useInfiniteQuery(['search', debouncedSearchWord], getSearchData, {
    getNextPageParam: (lastPage: any, pages: any) => {
      if (!lastPage.isLast) return lastPage.current_page + 1;
      return undefined;
    },
  });

여기서 로딩된 페이지가 마지막 페이지인지 검사 후에 맞으면 undefined를, 아니면 current_page 값에 +1 을 해주어 다음 페이지가 반환되도록 하였다.

이외, useInfiniteQuery를 공부하며 알게 된 다른 option들에 관해서도 작성해본다..(아까워서)

  • 새로운 쿼리 인스턴스가 마운트했을 때 (refetchOnMount)
    • 예를 들어 데이터를 fetch하는 컴포넌트가 있고, 이 컴포넌트가 다른 데에서도 마운트되면 캐시된 데이터를 쓰는게 아니라 그 때 또 다시 refetch한다는 뜻.
  • 윈도우가 refocus됐을 때 (refetchOnWindowFocus)
  • 네트워크가 다시 연결됐을 때 (refetchOnReconnect)
  • refetch interval 설정을 해줬을 때 (refetchInterval)

사용예시

const {
    data: getBoard,
    fetchNextPage: getNextPage,
    isSuccess: getBoardIsSuccess,
    hasNextPage: getNextPageIsPossible,
    isLoading: isLoading,
  }: any = useInfiniteQuery(['popular'], getInitialData, {
	   refetchonMount: true,
		 refetchOnWindowFocus: true,
		 ...
    },
  });

원래는 검색 단어가 변경될 때마다 useInfiniteQuery를 어떻게 재호출하면 좋을 지에 관한 고민을 정말 오래동안 했다.
찾다가 아래 useInfiniteQuery의 첫 인자인 queryKey에 배열로 변수를 넣으면 변수가 달라질 때마다 렌더링된다고 하더라…
(나중에 알고보니 key값이 변해서 다시 캐싱을 관리하려고 렌더링한다고 한다.)
<결과>
useInfiniteQuery(['search', debouncedSearchWord], getSearchData)
을 사용하니 검색어가 바뀔 때마다 렌더링되었다 !!

  1. useInview
    다음은 react-intersection-observer 라이브러리를 설치 후 import 한 useInView라는 함수이다.
  • 사용법
    const [ref, isView] = useInView()
    아래 코드에서 div의 ref prop에 useInView의 ref를 주면,
    사용자가 div 요소를 보면 isView가 true, 안보면 false가 반환된다.
const { 
	getBoard, 
	getNextPage, 
	getBoardIsSuccess, 
	getNextPageIsPossible, 
	isLoading 
} = useInfiniteScrollSearchQuery(debouncedSearchWord);

  useEffect(() => {
    if (isView && getNextPageIsPossible) {
      getNextPage();
    }
  }, [isView, getBoard]);

위와 같은 코드로 useInfiniteScrollSearchQuery 함수를 import 하고 return 한 값들을 받아왔다.

이후에는 useEffect 함수를 사용하여 다음 페이지를 가져오는 것이 가능하면 (해당 페이지가 마지막 페이지가 아니라면) 다음 페이지를 받아올 수 있도록 하였다.

  1. 렌더링

    ```
    	<div>
      <ListTitle>Top Searches</ListTitle>
      {!isLoading
        ? // 데이터를 불러오는데 성공하고 데이터가 0개가 아닐 때 렌더링
          getBoardIsSuccess && getBoard!.pages
          ? getBoard!.pages.map((page_data: any, page_num: any) => {
              const board_page = page_data.board_page;
    
              return board_page?.map((item: any, idx: any) => {
                if (
                  // 마지막 요소에 ref 달아주기
                  getBoard!.pages.length - 1 === page_num &&
                  board_page.length - 1 === idx
                ) {
                  return (
                    // 마지막 요소에 ref 넣기 위해 div로 감싸기
                    <div ref={ref} key={item.board_id}>
                      <SkeletonItem key={item.board_id} />
                    </div>
                  );
                } else {
                  return <SearchItem key={item.board_id} {...item} />;
                }
              });
            })
          : null
        : arr.map((arr, index) => {
            return <SkeletonItem key={index} />;
          })}
    </div>
    ```

    렌더링 시, 조건 삼항연산자를 통해 페이지의 마지막 원소일 경우 다음 페이지 원소들을 불러올 수 있게 하였다.
    삼항 연산자는 조금 더 정리해야 할 것 같다,,,너무 더러운 코드

이렇게 이번 netflix 클론 코딩 프로젝트에서 사용한 기술들을 정리해보았다.
너무 많은 효율적인 기술들을 사용해보고 경험해봤다는 점에서 좋은 경험이었던 것 같다.

profile
배움의 개발자

0개의 댓글