[Next.js] react-query 이용한 무한스크롤 구현

JunSeok·2023년 1월 19일
1

Movie-inner 프로젝트

목록 보기
13/13
post-thumbnail

구현할 것

유저가 페이지의 바닥에 닿기 전에 방해없이 스크롤을 계속하게 하기 위해 새로운 데이터를 fetch 할 것
데이터를 한번에 불러오는 방법보다 효율적인 방법

구현 방법

react-query의 useInfiniteQuery를 사용한다.
이것은 하나의 query data에 새로운 데이터를 추가하는 방식이므로 하나의 query key에 하나의 query cache data만 있다.

useInfiniteQuery?

공식docs

useInfiniteQuery는 useQuery가 리턴하는 data와는 형태가 다르다.
useQuery의 경우 심플하게 하나의 data 객체가 나오지만, useInfiniteQuery의 data객체는 pageParam과 pages, 2개의 property를 갖는다.

  • pageParams는 fetch할 page를 react-query 차원에서 추적하고 관리한다. 이 pageParam이 query function에 순서대로 전달되는 매개변수이다.

  • pages는 페이지마다 받는 데이터 객체들을 요소로 하는 배열이다.


    대충 요렇게 생겼다.
    처음 렌더링했을 때의 모습인데, 첫 페이지 렌더링은 초기값으로 fetching하기 때문에 pageParams 첫번째 element는 undefined이고
    pages는 첫번째 페이지의 데이터와 페이지 번호가 나온다.

가장 중요한 option

useInfinitequery에도 option값이 있는데 가장 중요하다.
바로 getNextPageParam으로 next page를 설정하는 함수 역할을 한다.
아래 실제 구현할 때 다룸.

useInfiniteQuery의 return 값

첫번째로 실제 data를 담고 있는 data가 있다
그리고 fetchNextPage가 있는데 이는 유저가 데이터를 더 필요로 할 때 부르는 함수이다.
그러니까 데이터의 바닥 또는 화면의 바닥에 닿을 때 부르면 될 것이다.

그리고 hasNextPage가 있다. 이는 이름 그대로 pageParam의 값을 확인하여 다음에 fetch할 page가 있냐는 것이다. 없으면 undefined이고 있으면 true가 되어 fetchNextPage 함수를 부른다.

마지막으로 다룰 것은 isFetchingNextPage이다.
이는 useQuery에는 없는 개념으로 nextPage를 fetching하는 것인지 다른 것을 fetching하는 것인지
구별해준다. fetchNextPage 함수를 부를때 사용하면 유용하다.

Flow

전체적인 흐름을 살펴보자

  • component mount
    - 이 때 uneInfiniteQuery가 리턴하는 data property는 undefined이다.
  • fetch first page
    - useInfiniteScroll은 첫 번째 인자로 pageParam을 받는다.
    - 첫번째 pageParam 값은 default 값
    - pageParam으로 fetch를 하면 데이터의 page 객체 값을 세팅한다. => page 배열의 첫 번째 요소로 세팅
  • getNextPageParam, update pageParam
    - data를 세팅한 뒤 react-query는 pageParam을 update하는 getNextPageParam 함수를 실행시킨다.
    - 그리고 hasNextPage => true => 클릭 또는 스크롤 내렸을 때 => fetchNextPage한다.
    - 만약 마지막 page를 가져왔다면 getNextPageParam은 undefined이고 이후 pageParam도 undefined가 되면서 끝난다.

마지막으로 intersectionObserver

참고
hdpark의 도움을 받았습니다. 감사합니다!
스크롤 감지해주는 역할.

실제 구현

지금 진행 중인 토이 프로젝트에 실제로 구현했다.
영화를 검색했을 때, Netflix 처럼 무한스크롤로 영화들이 나오는 것을 목표로 했다.

데이터를 fetching하는 component와 데이터를 렌더링하는 component를 구별했다.
물론 각자 사용하는 api가 다르기 때문에 참고용으로만!

우선 데이터 fetching하는 component

지금 보여준 코드에서 가장 중요한 것은 useGetMovieSearch 함수이다.

  1. 실제 데이터를 가져오는 함수인 getMovieSearch에서 필요한 값은 search와 searchPage 로 총 2개이다.
    여기서 중요한 점은 실제 우리가 넣어줄 값은 search값이고 searchPage 값은 초기값을 1로 정해준 뒤부터는 react-query가 관리한다.

  2. 실제 렌더링할 때 사용할 함수이자, useInfiniteQuery를 사용하는 함수인 useGetMovieSearch함수는 매개변수가 search로 하나이고 그 안에서 데이터를 받아오는 getMovieSearch 함수를 실행하는 queryFn의 매개변수는 search와 searchPage로 총 두개이다. 그 이유는 searchPage는 초기값이 1로 정해져있고 react-query차원에서 getNextPageParam함수가 관리하기 때문에 실제 렌더링하는 컴포넌트에서 매개변수로 넣어줄 값은 search 값 하나이다.

  3. queryFn의 리턴값에는 받아오는 데이터와 현재 페이지를 넣어줘야 아래 useInfiniteQuery의 옵션값으로 getNextPageParam 적을 때 searchPage를 불러올 수 있고, pages의 요소로 데이터를 받을 수 있다.

  4. search 값이 바뀔 때마다 값이 변해야되니까 dependency 역할을 하는 array안에 query키와 함께 search값 파라미터를 넣어줘야 한다.
    그리고 searchPage는 초기값으로 1로 정해주는데, 변수 이름은 무조건 pageParam으로 해야 한다. 다른 이름으로 하면 안된다.

// MovieSearch.tsx
import { useInfiniteQuery } from "react-query";
import { apiInstance } from "../setting";

// data fetching 함수
// 필요한 매개변수는 총 2개!
export const getMovieSearch = (search: any, searchPage: any) => apiInstance.get(`/search/movie`, {
    params: {
        search: search,
        searchPage: searchPage
    },
    withCredentials: true
})

const useGetMovieSearch = (search: any) => {
    const queryFn = async (search: any, searchPage: any) => {
        const response = await getMovieSearch(search, searchPage)
        const { data } = response
        return { data, searchPage: searchPage }
    }

    
    return useInfiniteQuery(['movieSearch', (search)], ({ pageParam = 1 }) => queryFn(search, pageParam), {
        getNextPageParam: (lastPage) => lastPage.searchPage + 1 || undefined
    })
}

export default useGetMovieSearch

다음은 데이터 렌더링 component

import { SearchBox, SearchListItem } from "./Search.style"
import Image from "next/image"
import router from "next/router"
import useGetMovieSearch from "../../apis/MovieData/MovieSearch"
import { useRef } from "react"
import { useObserver } from "../Common/UseObserver"

const SearchResultMovie = (props) => {
    const { search, click } = props
    // useGetMovieSearch에서 필요한 값은 search 값 하나이다. searchPage의 초기값은 정해져있고 react-query 차원에서 관리하기 때문
    const { data, fetchNextPage } = useGetMovieSearch(search)
    const bottom = useRef(null)
    const onIntersect = ([entry]) => entry.isIntersecting && fetchNextPage()
    // useObserver로 bottom ref와 onIntersect를 넘겨 주자.
    useObserver({
        target: bottom,
        onIntersect,
    })
    return (
        <>
            {click && <SearchBox>
                {data?.pages?.map((pages) => (
                    pages?.data?.search?.map((list) => (
                        <SearchListItem key={list.id}>
                            <div>
                                <Image onClick={() => router.push(`/movie/${list.id}`)} src={list.poster_path ? `https://image.tmdb.org/t/p/w780${list.poster_path}` : '/no-photo-available.png'} alt={list?.title}
                                    width={150}
                                    height={180}
                                    objectFit='contain'
                                />
                                <div>
                                    <div onClick={() => router.push(`/movie/${list.id}`)}>{list.title}</div>
                                    <div>{list.genre[0]} &#183; {list.release_date}</div>
                                </div>
                            </div>
                        </SearchListItem>
                    ))
                ))}
                <div ref={bottom} />
            </SearchBox>}
        </>

    )
}

export default SearchResultMovie

스크롤 감지해주는 옵저버

참고
hdpark의 도움을 받았습니다.

// useObserver.tsx
import { useEffect } from "react"

export const useObserver = ({
    target, // 감지할 대상, ref를 넘길 예정
    onIntersect, // 감지 시 실행할 callback 함수
    root = null, // 교차할 부모 요소, 아무것도 넘기지 않으면 document가 기본이다.
    rootMargin = "200px", // root와 target이 감지하는 여백의 거리
    threshold = 1.0, // 임계점. 1.0이면 root내에서 target이 100% 보여질 때 callback이 실행된다.
}) => {
    useEffect(() => {
        let observer

        // 넘어오는 element가 있어야 observer를 생성할 수 있도록 한다.
        if (target && target.current) {
            // callback의 인자로 들어오는 entry는 기본적으로 순환자이기 때문에
            // 복잡한 로직을 필요로 할때가 많다. 
            // callback을 선언하는 곳에서 로직을 짜서 통째로 넘기도록 하겠다.
            observer = new IntersectionObserver(onIntersect, { root, rootMargin, threshold })
            // 실제 Element가 들어있는 current 관측을 시작한다.
            observer.observe(target.current)
        }

        // observer를 사용하는 컴포넌트가 해제되면 observer 역시 꺼 주자. 
        return () => observer && observer.disconnect()
    }, [target, rootMargin, threshold, onIntersect, root])
}

실제 구현 모습

카테고리 검색 무한스크롤

영화 제목, 영화 배우 검색 무한스크롤

깃허브 주소
https://github.com/CLOUDoort/movieinner-project-frontend

profile
최선을 다한다는 것은 할 수 있는 한 가장 핵심을 향한다는 것

0개의 댓글