본문 바로가기
프로젝트 기록

[Next.js] React-Query로 무한 스와이퍼 구현하기 (ft. useInfiniteQuery, getServerSideProps, react swiper)

by dygreen 2023. 8. 14.

📌 기존 코드의 문제점

영화 추천 프로젝트 리팩토링 과정에서

메인 페이지에서 카테고리 별 영화 리스트를 불러올 때 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를 스와이퍼가 있는 컴포넌트로 전달한다.

여기서 fetchNextPagegetNextPageParam과 동일하게 다음 페이지에 있는 데이터를 호출할 때 사용하면 된다.

// 메인 컴포넌트
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.pagesflat()을 사용하는 이유는

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)에서 볼 수 있다.

 

GitHub - MovieApplication/cinemate: [Next.js] 영화 리뷰 커뮤니케이션 서비스

[Next.js] 영화 리뷰 커뮤니케이션 서비스. Contribute to MovieApplication/cinemate development by creating an account on GitHub.

github.com

 

728x90

댓글