[React] React-Query useInfiniteQuery사용해 무한스크롤 기능 구현하기

김채운·2024년 3월 22일
0

React

목록 보기
24/26

최근에 React-query를 학습하고 이제 기존 프로젝트에 도입해서 사용하려 하는데 프로젝트의 메인 페이지에서는 무한스크롤 기능이 작동되고 있었다. 마침 React-query에 useInfiniteQuery라는 파라미터 값만 변경하여 새로운 데이터를 무한적으로 호출할 때 사용되는 hook이 있어 기존에 있던 Intersection Observer기능과 접목해서 사용해 보았다.

➡️ useInfiniteQuery란?

Data Fetching이 일어날 때 마다 기존 리스트 데이터에 Fetched Data 를 추가하고자 할 때 유용하게 사용할 수 있는 React-query hook으로, 파라미터 값만 변경하여 새로운 데이터를 무한적으로 호출할 때 사용된다.(동일한 useQuery를 무한정 호출). 더보기 UI 또는 무한스크롤 UI 에 사용하기에 적합하다.

➡️ useInfiniteQuery 작동방식

const {
  fetchNextPage,
  fetchPreviousPage,
  hasNextPage,
  hasPreviousPage,
  isFetchingNextPage,
  isFetchingPreviousPage,
  ...result
} = useInfiniteQuery({
  queryKey,
  queryFn: ({ pageParam = 1 }) => fetchPage(pageParam),
  ...options,
  getNextPageParam: (lastPage, allPages) => lastPage.nextCursor,
  getPreviousPageParam: (firstPage, allPages) => firstPage.prevCursor,
})
  1. 기본적인 형태는 useQuery와 비슷하다. 데이터를 캐싱하기 위한 고유한 식별자로 queryKey를 설정하고,

  2. queryFn은 data를 fetch하는 함수를 작성해주는데, 이때 queryFn의 매개변수로는 QueryFunctionContext가 전달이 된다. QueryFunctionContext는 React Query의 내부 컨텍스트(Context) 중 하나이다. 이 컨텍스트는 쿼리 함수에서 사용할 수 있는 추가적인 정보와 기능을 제공하고, queryKey, pageParam, meta, signal 속성이 있는 객체를 담고있다. 이러한 속성들은 QueryFunctionContext를 통해 쿼리 함수가 실행될 때 전달되며, 데이터를 가져오고 처리하는 데 사용된다. 이를 통해 React Query는 동적이고 유연한 방식으로 데이터를 관리하고 캐싱할 수 있다.
    이 중에 pageParam을 queryFn에 넣어주는데, 기본 설정으로 pageParam을 1로 지정해준다.

queryKey: 이는 쿼리를 식별하는 데 사용되는 키이다. 쿼리 함수가 실행될 때 이 키를 기반으로 데이터를 가져온다. 일반적으로 배열 형태로 전달되며, 쿼리 함수에 필요한 모든 매개변수와 관련된 정보가 포함될 수 있다.
pageParam: 이는 페이지네이션된 데이터를 처리할 때 다음 페이지를 가져오는 데 사용되는 매개변수이다. 현재 페이지의 데이터를 가져오기 위해 쿼리 함수에 전달된다. 일반적으로 숫자 형태이지만, 필요에 따라 다른 형식일 수도 있다.
meta: 이는 쿼리 함수의 실행에 관련된 기타 메타데이터를 포함하는 객체이다. 이 메타데이터는 쿼리 함수의 실행과 관련된 추가적인 정보를 제공할 수 있다. 예를 들어 캐싱, 요청 중단 등에 사용될 수 있다.
signal: 이는 쿼리 함수의 실행을 제어하는데 사용되는 신호 객체이다. 주로 요청의 취소나 중단을 관리하는 데에 사용된다. 일반적으로 AbortSignal 인터페이스의 인스턴스이며, 네트워크 요청을 취소하거나 중단하기 위해 사용될 수 있다.

  1. 그럼 pageParam 1의 데이터를 가져오게 되고, data를 console.log로 찍어보면 밑에처럼 pageParams,pages 속성을 가진 객체로 나오는데, 이 data의 pages배열의 첫 번째 요소로 추가가 되는 거고, 각 페이지의 데이터는 이렇게 순차적으로 캐시에 추가된다.


파라미터가 변경되면 data.pages에 저장된 배열의 끝에 새로운 데이터가 계속 삽입이 된다.

  1. getNextPageParam은 useInfiniteQuery 훅에서 사용되는 옵션 중 하나로, 이 옵션은 다음 페이지를 가져오기 위한 매개변수를 결정하는 함수이다. getNextPageParam 함수는 두 개의 매개변수를 받는다.

lastPage: 마지막으로 가져온 페이지의 데이터. 이는 페이지의 실제 데이터가 아니라 마지막 페이지의 데이터 객체를 나타낸다. (캐시 된 데이터 중 가장 최신의 데이터)
allPages: 이전에 가져온 모든 페이지의 데이터가 포함된 배열. 이 배열은 마지막 페이지를 포함하여 현재까지 가져온 모든 페이지를 담고 있다.

getNextPageParam 함수는 이전 페이지의 데이터를 기반으로 다음 페이지의 매개변수를 결정하고 반환한다. 일반적으로 이 함수는 마지막 페이지에서 다음 페이지로 이동하는 데 필요한 정보를 제공하는데 사용된다. 이 함수는 보통 다음 페이지의 매개변수를 반환하거나, 더 이상 가져올 페이지가 없는 경우 false를 반환한다.

  1. 이후 기술할 fetchNextPage 함수 (다음 페이지를 불러오는 함수)를 호출하면 변경된 pageParam을 기준으로 queryFn이 수행된다. 그렇게 되면 새로운 쿼리의 결과는 data에 저장된다.

➡️ 프로젝트에 useInfiniteQuery 적용

import { useNavigate } from 'react-router-dom';
import styled from "styled-components";
import useInfiniteScroll from '../hooks/use-infinitescroll';
import { apis } from '../shared/api';
import { Product } from './types/product';
import LazyLoadingImage from './LazyLoading';
import PlaceholderImg from '../assets/images/placeholderImg.svg';
import { useInfiniteQuery } from '@tanstack/react-query';

function MainGrid() {
    const navigate = useNavigate()

    const { data, fetchNextPage, hasNextPage } = useInfiniteQuery<Product[], Error>({
        queryKey: ['products'],
        queryFn: async ({ pageParam = 1 }) => { // pageParam의 형식을 직접 지정
            const response = await apis.getProduct(pageParam as number);
            return response.data.results;
        },
        getNextPageParam: (lastPage, pages): number | false => {
            const nextPage = pages.length + 1;
            return lastPage.length === 0 ? false : nextPage;
        },
        initialPageParam: 1
    });

    console.log("데이터", data)

    const target = useInfiniteScroll({
        hasNextPage,
        fetchNextPage,
    })

    return (
        <Container>
            {
                data?.pages.flat().map((p, i) => {
                    return <div key={p.product_id}>
                        <LazyLoadingImage
                            src={p.image}
                            onError={(e: React.ChangeEvent<HTMLImageElement>) => {
                                e.target.onerror = null; // 에러 핸들러 무한 루프 방지
                                e.target.src = p.image // 이미지 로드 실패 시 p.image 사용
                                e.target.width = 380;
                                e.target.height = 380;
                            }}
                            alt={p.product_name}
                            onClick={() => navigate(`/detail/${p.product_id}`)}
                            placeholderImg={PlaceholderImg}
                        />
                        <p className='product-name'>{p.store_name}</p>
                        <p className='product'>{p.product_name}</p>
                        <span className='product-price'>{p.price.toLocaleString()}</span>
                        <span></span>
                    </div>
                })
            }
            {hasNextPage ? <div ref={target}></div> : null}
        </Container>
    )
}

const Container = styled.div`
    width: 100%;
    display: grid;
    grid-template-columns:repeat(3,380px);
    justify-content: center;
    gap: 70px;
    padding: 80px 0 180px;
    @media screen and (max-width:1300px) {
    grid-template-columns:repeat(2,380px);
}
    @media screen and (max-width:932px) {
        grid-template-columns:repeat(1,380px);
    }
    div {
        cursor: pointer;
    }
    img {
        margin-bottom: 16px;
        /* border: 1px solid #C4C4C4; */
        border-radius: 10px;
        /* width: 380px; */
        height: 380px;
    }
    .product-name {
        font-size: 16px;
        color: #767676;
    }
    .product {
        font-size: 18px;
        margin: 10px 0;
    }
    .product-price {
        font-size: 24px;
        font-weight: bold;
    }
`

export default MainGrid
  1. 우선, initialPageParam을 1로 설정해뒀다. 그럼 pageParam의 초기 값을 1로 설정했다는 뜻이다. 그럼 pageParam이 1인 data를 받아오게 되고 이 data는 data.pages에 담기고 getNextPageParam옵션을 통해서 lastPage(최신 데이터)의 length가 0이면 false가 반환되고, 그렇지 않으면 allPage의 length에 1을 더한 값을 다음 pateParam값으로 반환되게 한다.

 const target = useInfiniteScroll({
        hasNextPage,
        fetchNextPage,
    })

무한스크롤 hook인 useInfiniteScroll에 hasNextPage와 fetchNextPage를 전달한다.

hasNextPage

  • hasNextPage는 useInfiniteQuery 훅에서 사용되는 속성 중 하나다. 이 속성은 현재 페이징된 데이터에서 가져올 수 있는 다음 페이지가 있는지 여부를 나타내는 불리언 값이다.
    다음 페이지가 존재하는지는 getNextPageParam에 의해 결정된다. getNextPageParam에서 false를 반환하면 hasNextPage는 false가 되고, nextPage가 있어서 새로운 값을 반환하면 hasNextPage는 true가 된다.

fetchNextPage

  • fetchNextPage는 다음 페이지의 데이터를 가져오는 데 사용된다. 이 함수는 일반적으로 무한 스크롤 또는 페이지네이션과 같은 사용자 인터랙션에서 호출돼서 스크롤 이벤트가 발생했을 때 해당 함수가 실행되게 코드를 작성하면 스크롤 할때마다 데이터가 불러와지는 무한 스크롤을 구현할 수 있다.
    이 함수를 호출하면 useInfiniteQuery가 새로운 데이터를 가져오기 위해 서버에 쿼리를 실행한다.

✨ fetchNextPage 함수를 호출시 작동순서:

  1. useInfiniteQuery는 다음 페이지의 데이터를 가져오기 위해 설정된 쿼리 함수를 호출한다.
  2. 쿼리 함수는 서버에 요청을 보내고 페이지의 데이터를 가져온다.
  3. 새로운 데이터가 성공적으로 가져와지면 useInfiniteQuery는 자동으로 데이터를 캐시에 추가하고 화면에 반영한다.

data?.pages.flat().map

data?.pages.flat().map()에서 flat()은 다차원 배열을 평탄화하여 1차원 배열로 만드는 JavaScript 배열 메서드이다.

여기서 data?.pages는 useInfiniteQuery에서 반환된 페이지 데이터를 나타내는 배열이다. 각 페이지마다 배열이 하나씩 있고, 각 배열은 해당 페이지에 있는 데이터를 포함한다.

만약 데이터가 페이지별로 나누어져 있는 경우, flat() 메서드를 사용하여 이러한 다차원 배열을 하나의 평탄한 배열로 만들 수 있다. 그 후에 map() 메서드를 사용하여 각 데이터를 순회하고 화면에 표시하게끔 한다.

따라서 data?.pages.flat().map()은 모든 페이지에서 가져온 데이터를 하나의 배열로 합쳐서 각 데이터를 순회하고 화면에 표시하기 위해 사용된다.

➡️ Intersection Observer코드와 접목

import { InfiniteQueryObserverResult } from '@tanstack/react-query';
import { useCallback, useEffect, useRef } from 'react';

type IntersectionObserverProps = {
    hasNextPage: boolean | false;
    fetchNextPage: () => Promise<InfiniteQueryObserverResult>
}

function useInfiniteScroll({ hasNextPage, fetchNextPage }: IntersectionObserverProps) {
    const ref = useRef<HTMLDivElement>(null);

    const handleIntersect: IntersectionObserverCallback = useCallback(([entry]: IntersectionObserverEntry[], observer: IntersectionObserver) => {
        if (entry?.isIntersecting && hasNextPage) {
            fetchNextPage();
        }
    }, [fetchNextPage, hasNextPage]);

    useEffect(() => {
        let observer: IntersectionObserver;
        if (ref.current) {
            observer = new IntersectionObserver(handleIntersect, { threshold: 0.6, });
            observer.observe(ref.current);
        }
        return () => observer && observer.disconnect();
    }, [ref, handleIntersect]);

    return ref;
}

export default useInfiniteScroll

entry는 관찰 중인 요소의 정보를 포함하는 객체이고, isIntersecting은 entry 속성 중 하나로, 해당 요소가 현재 뷰포트와 교차(intersect)하는지 여부를 나타내는 boolean 값이다.
간단히 말해, entry.isIntersecting이 true이면 관찰 중인 요소가 현재 화면에 보이고 있다는 것을 의미하며, false이면 요소가 화면에 보이지 않는다는 것을 나타낸다. 이 값을 사용하여 화면 스크롤과 관련된 작업을 수행할 수 있다.

Main페이지에서 전달해준 hasNextPage, fetchNextPage를 전달 받아서, target이 현재 뷰포트와 교차되고 있고, hasNextPage(다음 페이지의 여부)가 true이면 fetchNextPage함수를 호출하게 해준다. 그럼 useInfiniteQuery의 쿼리함수가 실행되고 데이터를 뷰포트에 교차하고 다음 페이지가 존재한다면 계속해서 데이터를 불러오게 되고 무한스크롤 기능을 수행하게 된다.

0개의 댓글