📌 기존 코드의 문제점
메인 페이지에서 카테고리 별 영화 리스트를 불러올 때 SSR을 적용하였다.
(적용한 이유는 위 링크의 게시글에 기록해두었다)
기존 코드
처음 페이지 진입 시, Server-Side에서 불러온 데이터를 뿌려주고
사용자가 swiper를 넘기면 Client-Side에서 page=2, page=3..의 데이터들을 불러오게 된다.
불러온 데이터들은 state에 spread operator(스프레드 연산자)로 추가해주는 방식으로 동작했다.
그러나 이렇게 되면 다른 페이지에 있다가 돌아왔을 때
이전에 불렀던 데이터들이 리셋되어 다시 요청해야 하기 때문에
데이터 사용량이 늘어나게 되는 단점이 존재한다.
// 메인 페이지
import type { InferGetServerSidePropsType } from 'next'
import React, {useEffect, useState} from 'react'
// 해당 컴포넌트가 필요한 시점에만 로드
const MovieList = dynamic(() => import('components/MovieList'))
const movieResultInit: MovieResult = {
page: 1,
results: [],
total_pages: 0,
total_results: 0
}
const Home = ({popularListInit}: InferGetServerSidePropsType<typeof getServerSideProps>) => {
const [popularList, setPopularList] = useState<MovieResult>(popularListInit)
const movieListArr = [{
title: '실시간 인기 순위 영화',
item: popularList.results,
page: popularList.page
}, {}, {}, {}]
// 실시간 인기 순위 영화 리스트 목록 조회
const fnGetPopularMovie = async () => {
await GetApi(apiList.getPopularMovie, {page: popularList.page}).then(res => {
if (res !== 'FAIL') {
setPopularList({
...popularList,
results: [...popularList.results, ...res.results]
})
}
})
}
// Infinite Swiper (pagination)
const fnChangePage = ($page: number, $title: string) => {
if ($title === '실시간 인기 순위 영화') {
setPopularList({...popularList, page: $page})
} else if () {
} else {
}
}
useEffect(() => {
if (popularList.page !== 1) fnGetPopularMovie()
},[popularList.page])
return (
<>
{/* Movie List */}
{
movieListArr.map((data, idx) => (
data.item.length > 0
? <div key={idx}>
<p>{data.title}</p>
<MovieList
listItem={data}
fnChangePage={fnChangePage}
/>
</div>
: null
))
}
</>
)
}
export const getServerSideProps = async () => {
try {
const popularListInit = await GetApi(apiList.getPopularMovie)
return {
props: {
popularListInit: popularListInit === 'FAIL' ? movieResultInit : popularListInit
},
}
} catch (err) {
console.warn('server side err : ' + err)
}
}
export default Home
// Swiper
const MovieList = (props: MovieListProps) => {
return (
<Swiper
modules={[Virtual, Navigation]}
virtual
slidesPerView={7.5}
slidesPerGroup={7}
navigation={{
prevEl: '.swiper-button-prev',
nextEl: '.swiper-button-next'
}}
onReachEnd={() => props.fnChangePage(props.listItem.page + 1, props.listItem.title)}
>
<div className="swiper-button-prev">
<FontAwesomeIcon icon={faChevronLeft} />
</div>
<div className="swiper-button-next">
<FontAwesomeIcon icon={faChevronRight} />
</div>
{
props.listItem.item.map((items, index) => (
<SwiperSlide key={items.id} virtualIndex={index}>
</SwiperSlide>
))
}
</Swiper>
)
}
export default MovieList
→ Swiper가 있는 <MovieList/>
컴포넌트에서 onReachEnd
일 때,
메인 페이지에서 props로 넘긴 fnChangePage
함수가 실행되고, 파라미터로 page number를 보낸다.
그러면 메인 페이지에서 useEffect dependency의 page 값 변화를 감지하여 해당 page의 데이터를 요청한다.
📌 React-Query를 적용해 무한 스와이퍼 구현하기
기존 코드의 단점을 보완하기 위해 React-Query를 사용해보면 좋을 것 같았다.
React-Query는 캐싱 기능을 제공하기 때문에 중복된 요청을 방지할 수 있다.
따라서 서버의 부하와 데이터 사용량을 줄일 수 있게 된다.
초기 데이터를 가져올 때는 Next.js의 getServerSideProps
를 이용하여 Server-Side에서 가져오고
사용자가 Swiper를 통해 page=2, page=3 등을 요청했을 때 Client-Side에서 데이터를 가져오되,
React-Query를 사용하여 (Client-Side 데이터들을) 캐싱함으로써 중복된 요청을 방지하게끔 설계해보았다.
1. getServerSideProps 사용하기
- 메인 컴포넌트(index.tsx) 제일 하단에 아래 코드를 작성한다.
export const getServerSideProps = async () => {
try {
const popularListInit = await GetApi(apiList.getPopularMovie)
return {
props: {
popularListInit: popularListInit === 'FAIL' ? movieResultInit : popularListInit,
},
}
} catch (err) {
console.warn('server side err : ' + err)
}
}
- 메인 컴포넌트에 props로 popularListInit
을 전달한다.
const Home = ({popularListInit}: InferGetServerSidePropsType<typeof getServerSideProps>) => {}
2. _app.tsx에 React-Query 세팅하기
- _app.tsx에 아래 코드와 같이 세팅한다.
import 'styles/globals.css'
import type { AppProps } from 'next/app'
import Layout from "layout/layout"
import { useState } from 'react'
import { QueryClient, QueryClientProvider } from 'react-query'
export default function App({ Component, pageProps }: AppProps) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
cacheTime: Infinity
}
}
}))
return (
<QueryClientProvider client={queryClient}>
<Layout>
<Component {...pageProps} />
</Layout>
</QueryClientProvider>
)
}
* 이 프로젝트의 영화 리스트들은 정확한 시간에 실시간으로 업데이트가 필요한 데이터들이 아니라고 생각이 되어(실시간 인기 순위 영화 리스트는 제외하고) staleTime과 cacheTime을 Infinity로 설정했다.
이 부분은 좀 더 고민해보고 적당한 staleTime과 cacheTime을 정해야 될 것 같다.
* 여기서 useState를 사용하는 이유는?
: useState는 [state, setter함수]의 조합으로 구성되어 있는데, state는 setter를 호출하는 경우에만 업데이트 된다.
따라서 setter함수를 사용하지 않거나 선언하지 않음으로써 참조 동일성을 유지할 수 있다.
initialState에 함수를 전달하면 queryClient는 초기 한 번만 실행되고, 그 이후에는 참조 동일성을 유지하게 된다.
3. 컴포넌트에 React-Query (useInfiniteQuery) 세팅하기
useInfiniteQuery의 사용 방식은 useQuery와 동일하다.
const res = useInfiniteQuery(queryKey, queryFn);
위 1번에서 Server-Side에서 세팅하여 props로 전달받은 데이터(popularListInit
)를 useInfiniteQuery의 initialData에 넣는다.
// 실시간 인기 순위 영화 리스트 목록 조회
const popularListQuery: any = useInfiniteQuery(
['popularList'],
({ pageParam = 1 }) => GetApi(apiList.getPopularMovie, { page: pageParam }),
{
initialData: {
pages: [popularListInit],
pageParams: [1]
},
getNextPageParam: (res) => {
const nextPage = res.page + 1;
return res.page >= res.total_pages ? undefined : nextPage;
},
}
)
→ getNextPageParam
은 다음 페이지에 있는 데이터를 조회해올 때 사용한다.
여기서 첫 번째 인자(위 코드에서 'res')는 useInfiniteQuery를 이용해 호출된 가장 마지막에 있는 페이지 데이터를 의미한다.
+ return 값은 다음 페이지가 호출될 때 pageParam 값으로 사용된다.
(위 코드의 프로젝트에서 return 값은... 현재 페이지가 총 페이지보다 크거나 같으면 페이징을 멈추기 위해 undefined를 할당하였고, 그 외에는 현재 페이지에 + 1을 하여 다음 페이지로 넘어갈 수 있도록 하였다.)
여기서 popularListQuery
를 console에 찍어보면, 오른쪽 이미지와 같은 Object 데이터가 들어있다.
그 중 popularListQuery.data
는 위 코드 중 initialData
와 동일한 형태를 갖고 있다.
popularListQuery.data.pages
에는 axios get 요청으로 받아온 결과값들이 쌓이고, popularListQuery.data.pageParams
에는 pageParam이 Array 형태로 쌓인다.
4. 스와이퍼(Swiper)에 적용하기
메인 컴포넌트에서 popularListQuery.fetchNextPage
를 스와이퍼가 있는 컴포넌트로 전달한다.
여기서 fetchNextPage
는 getNextPageParam
과 동일하게 다음 페이지에 있는 데이터를 호출할 때 사용하면 된다.
// 메인 컴포넌트
const Home = ({popularListInit}: InferGetServerSidePropsType<typeof getServerSideProps>) => {
// 실시간 인기 순위 영화 리스트 목록 조회
const popularListQuery: any = useInfiniteQuery(
['popularList'],
({ pageParam = 1 }) => GetApi(apiList.getPopularMovie, { page: pageParam }),
{
initialData: {
pages: [popularListInit],
pageParams: [1]
},
getNextPageParam: (res) => {
const nextPage = res.page + 1;
return res.page >= res.total_pages ? undefined : nextPage;
},
}
)
const movieListArr = [{
title: '실시간 인기 순위 영화',
item: popularListQuery.data?.pages.map((page: MovieResult) => page.results).flat(),
page: popularListQuery.data?.pages.map((page: MovieResult) => page.page).flat(),
fetchNext: popularListQuery.fetchNextPage
}, {}, {}, {}]
return (
<>
{/* Movie List */}
{
movieListArr.map((data, idx) => (
<div key={idx}>
<p>{data.title}</p>
<MovieList
listItem={data}
/>
</div>
))
}
</>
)
}
→ 이 프로젝트에서는 여러 개의 useInfiniteQuery가 필요하여, movieListArr
라는 Array를 만들었다.
movieListArr
의 데이터들을 각각 <MovieList/>
컴포넌트로 전달하였다.
popularListQuery.data.pages
에 flat()
을 사용하는 이유는
map을 사용해서 list 결과값을 꺼내면 [[list], [list], [list], [list]] 같은 구조가 된다고 한다.
따라서 flat()
을 사용해서 [...list, ...list, ...list] 구조로 바꿔주는 작업이 필요하다. (* flat()
은 중첩 배열 평탄화 기능을 함)
interface MovieListProps {
detailList: boolean;
listItem: { title: string; item: MovieListItems[]; page: number; fetchNext?: () => void };
width: number;
height: number;
}
const MovieList = (props: MovieListProps) => {
return (
<Swiper
modules={[Virtual, Navigation]}
virtual
slidesPerView={7.5}
slidesPerGroup={7}
navigation={{
prevEl: '.swiper-button-prev',
nextEl: '.swiper-button-next'
}}
onReachEnd={() => props.listItem.fetchNext?.()}
>
<div className="swiper-button-prev">
<FontAwesomeIcon icon={faChevronLeft} />
</div>
<div className="swiper-button-next">
<FontAwesomeIcon icon={faChevronRight} />
</div>
{
props.listItem.item.map((items, index) => (
<SwiperSlide key={items.id} virtualIndex={index}>
</SwiperSlide>
))
}
</Swiper>
)
}
export default MovieList
→ Swiper에서 onReachEnd
일 때, fetchNextPage
함수가 실행되어 다음 페이지의 데이터들을 호출하고,
받은 데이터들이 배열 데이터의 맨 마지막에 쌓이게 되면서 무한 스와이퍼 기능이 동작하게 된다.
📌 전체 코드
메인 컴포넌트 (index.tsx)
// 메인 컴포넌트
const Home = ({popularListInit}: InferGetServerSidePropsType<typeof getServerSideProps>) => {
// 실시간 인기 순위 영화 리스트 목록 조회
const popularListQuery: any = useInfiniteQuery(
['popularList'],
({ pageParam = 1 }) => GetApi(apiList.getPopularMovie, { page: pageParam }),
{
initialData: {
pages: [popularListInit],
pageParams: [1]
},
getNextPageParam: (res) => {
const nextPage = res.page + 1;
return res.page >= res.total_pages ? undefined : nextPage;
},
}
)
const movieListArr = [{
title: '실시간 인기 순위 영화',
item: popularListQuery.data?.pages.map((page: MovieResult) => page.results).flat(),
page: popularListQuery.data?.pages.map((page: MovieResult) => page.page).flat(),
fetchNext: popularListQuery.fetchNextPage
}, {}, {}, {}]
return (
<>
{/* Movie List */}
{
movieListArr.map((data, idx) => (
<div key={idx}>
<p>{data.title}</p>
<MovieList
listItem={data}
/>
</div>
))
}
</>
)
}
export const getServerSideProps = async () => {
try {
const popularListInit = await GetApi(apiList.getPopularMovie)
return {
props: {
popularListInit: popularListInit === 'FAIL' ? movieResultInit : popularListInit,
},
}
} catch (err) {
console.warn('server side err : ' + err)
}
}
export default Home
스와이퍼(Swiper) 컴포넌트 (MovieList.tsx)
interface MovieListProps {
detailList: boolean;
listItem: { title: string; item: MovieListItems[]; page: number; fetchNext?: () => void };
width: number;
height: number;
}
const MovieList = (props: MovieListProps) => {
return (
<Swiper
modules={[Virtual, Navigation]}
virtual
slidesPerView={7.5}
slidesPerGroup={7}
navigation={{
prevEl: '.swiper-button-prev',
nextEl: '.swiper-button-next'
}}
onReachEnd={() => props.listItem.fetchNext?.()}
>
<div className="swiper-button-prev">
<FontAwesomeIcon icon={faChevronLeft} />
</div>
<div className="swiper-button-next">
<FontAwesomeIcon icon={faChevronRight} />
</div>
{
props.listItem.item.map((items, index) => (
<SwiperSlide key={items.id} virtualIndex={index}>
</SwiperSlide>
))
}
</Swiper>
)
}
export default MovieList
더 자세한 코드는 아래 주소(GitHub)에서 볼 수 있다.
댓글