신촌 연합 동아리 CEOS의 프론트엔드 스터디 마지막 과제로 next js를 활용한 넷플릭스 클론 코딩 프로젝트를 진행했다.
(11월 20일에 마무리했었는데, 이제서야 관련 글을 쓴다,,,)
해당 프로젝트에서는 메인 페이지, searchPage만 구현했다.
배포링크
GITHUB
폴더 구조는 다음과 같다.
src
|-api
|-components
|-elements (공유 컴포넌트)
|-homePage
|-landingPage
|-searchPage
|-icons
|-pages
|-home
|-index.tsx
|-[id].tsx
|-search
|-index.tsx
|-states
|-styles
이번 프로젝트에서는 새로운 라이브러리를 사용해보고 싶어 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);
};
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),
},
};
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);
React-Query 사용해보니 좋은 점
1. isLoading값도 함께 반환해줌 - 따로 set할 필요 없어서 편리함
2. 캐싱 기능이 있어서 한번 들어간 페이지 다시 들어가면 로딩시간 없이 바로 보임
3. 동일 데이터를 여러번 요청하면 알아서 걸러서 한번만 요청 → 효율 up
4. 리액트 훅들이랑 사용 구조가 비슷해서 배우기 용이함
결론부터 말하자면 클릭으로 검색하는 방식도 아닌 실시간 검색 기능을 서버사이드 렌더링으로 하려는 것은 말도 안되는 일이었다.
서버사이드 렌더링 방식은 서버에 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)
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>
입력 후 일정 시간이 지난 뒤에 렌더링하게끔 만들고 싶어 만든 기능이다.
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;
};
const debouncedSearchWord = useDebounce(searchWord, 500);
우선 reac-query의 내장함수인 useInfiniteQuery에 관한 공부를 많이 했다.
공부자료
무한 스크롤 기능을 구현할 때 사용한 라이브러리들은 다음과 같다.
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 받는 것도 함께 코드 안에 넣었다…(추후 리팩토링 예정)
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들에 관해서도 작성해본다..(아까워서)
사용예시
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)
을 사용하니 검색어가 바뀔 때마다 렌더링되었다 !!
const [ref, isView] = useInView()
const {
getBoard,
getNextPage,
getBoardIsSuccess,
getNextPageIsPossible,
isLoading
} = useInfiniteScrollSearchQuery(debouncedSearchWord);
useEffect(() => {
if (isView && getNextPageIsPossible) {
getNextPage();
}
}, [isView, getBoard]);
위와 같은 코드로 useInfiniteScrollSearchQuery 함수를 import 하고 return 한 값들을 받아왔다.
이후에는 useEffect 함수를 사용하여 다음 페이지를 가져오는 것이 가능하면 (해당 페이지가 마지막 페이지가 아니라면) 다음 페이지를 받아올 수 있도록 하였다.
렌더링
```
<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 클론 코딩 프로젝트에서 사용한 기술들을 정리해보았다.
너무 많은 효율적인 기술들을 사용해보고 경험해봤다는 점에서 좋은 경험이었던 것 같다.