useInfiniteQuery를 활용한 무한 스크롤 구현하기

 

🤔 useInfiniteQuery를 사용한 이유

무한 스크롤을 적용시키는 방법은 여러가지가 있다. 하지만 구조는 비슷할 것이다. Intersection-observer를 이용해 특정 영역을 감시하고, 감시된 영역이 보이면 데이터 요청을 보내는 방식이 대부분일 것이다. 이를 구현하는 방식의 차이가 있다고 생각한다. useState를 사용해서 observer가 발동되면 page를 1씩 늘려주는 방법, 또는 useInfiniteQuery를 사용해 자동으로 page를 늘려주는 방식이 있다. 

 

하지만 내가 useInfiniteQuery를 사용한 이유는 우선 useState는 간단한 데이터 구조와 UI 상태 변경을 위해 사용되고, 이경우 페이지 번호를 state로 관리하고 무한 스크롤 로직에서 페이지 번호를 직접 증가시키면서 API 호출을 수동으로 하게 된다. 하지만 useInfiniteQuery는 캐싱, 동기화, refetch 등의 복잡한 네트워크 로직을 처리하며, 페이지 파라미터를 자동으로 처리하고, 'fetchNextPage' 함수를 호출해 다음 페이지 데이터를 가져올 수 있다.

 

1. 복잡한 네트워크 로직 처리(캐싱, 동기화, 자동 refetch)

2. 페이지 파라미터를 자동으로 처리

 

고려해야 할 점

제작을 하기 앞서 고려해야 할 점이 있다. 내가 목표로 하는 기능 구현은 아래와 같다.

 

1. 카테고리 재 클릭 시(A->B->A) 기존 캐시가 삭제되어 무한 스크롤이 처음부터 다시 발생해야 한다.

2. 각 카테고리별 페이지 수는 모두 다르다.

 

이 두가지를 고려해서 제작을 해볼 것이다.

 

1️⃣ Intersection-observer  커스텀 훅 만들기

interface IuseIntersectionObserverProps {
    threshold?: number;
    hasNextPage: boolean | undefined;
    fetchNextPage: () => Promise<InfiniteQueryObserverResult>;
}

 

나는 타입스크립트도 사용하고 있기 때문에 타입을 먼저 지정해 준다.

총 3개의 매개변수를 갖는다.

 

threshold : 타켓의 가시성에 따라 observer가 실행되는데 이 때의 가시성의 정도를 threshold라 한다. threshold : 0.1은 10%로 나타내기 때문에 숫자 타입인 number로 지정해준다.

hasNextPage : 다음 페이지에 데이터가 있는지 없는지 확인해주는 요소다. boolean 값으로 확인할 수 있다.

fetchNextPage :  다음 페이지를 호출하는 함수다. 리턴값은 다음 페이지가 호출될 때 pageParam으로 사용된다. 

 

const [target, setTarget] = useState<HTMLDivElement | null | undefined>(
        null,
    );

const observerCallback: IntersectionObserverCallback = (entries) => {
    entries.forEach((entry) => {
        //target이 화면에 관찰되고, 다음페이지가 있다면 다음페이지를 호출
        if (entry.isIntersecting && hasNextPage) {
            fetchNextPage();
        }
    });
};

 

타겟 할 Dom요소를 정하기 위해 useState를 만들어준다. 그리고 Intersection-observer에 필요한 콜백함수를 만들어 준다. 콜백함수 안에서는 타겟이 관측되고 다음 페이지가 있다면 다음 페이지를 호출했다.

 

useEffect(() => {
    if (!target) return;

    //ointersection observer 인스턴스 생성
    const observer = new IntersectionObserver(observerCallback, {
        threshold,
    });

    // 타겟 관찰 시작
    observer.observe(target);

    // 관찰 멈춤
    return () => observer.unobserve(target);
}, [observerCallback, threshold, target]);

 

useEffect를 사용해서 타겟을 관측하고 타겟이 없으면 리턴을 있다면 Intersection-observer 인스턴스를 생성했다. 

 

전체 코드

import React, { useEffect, useState } from 'react';
import { InfiniteQueryObserverResult } from 'react-query';

interface IuseIntersectionObserverProps {
    threshold?: number;
    hasNextPage: boolean | undefined;
    fetchNextPage: () => Promise<InfiniteQueryObserverResult>;
}

export const useIntersectionObserver = ({
    threshold = 0,
    hasNextPage,
    fetchNextPage,
}: IuseIntersectionObserverProps) => {
    //관찰할 요소
    const [target, setTarget] = useState<HTMLDivElement | null | undefined>(
        null,
    );

    const observerCallback: IntersectionObserverCallback = (entries) => {
        entries.forEach((entry) => {
            //target이 화면에 관찰되고, 다음페이지가 있다면 다음페이지를 호출
            if (entry.isIntersecting && hasNextPage) {
                fetchNextPage();
            }
        });
    };

    useEffect(() => {
        if (!target) return;

        //ointersection observer 인스턴스 생성
        const observer = new IntersectionObserver(observerCallback, {
            threshold,
        });

        // 타겟 관찰 시작
        observer.observe(target);

        // 관찰 멈춤
        return () => observer.unobserve(target);
    }, [observerCallback, threshold, target]);

    return { setTarget };
};

 

 

2️⃣ useInfiniteQuery 만들기

//무한 스크롤(react-query)
const { data, hasNextPage, fetchNextPage } = useInfiniteQuery({
    queryKey: ['product', category],
    queryFn: ({ pageParam = 0 }) => getAllProduct(category, pageParam),
    getNextPageParam: (lastPage, allPages) => {
        return lastPage.length === 12 && allPages.length + 1; // 마지막 페이지의 데이터가 12개 시 현재 페이지 + 1
    },
    select: (data) => ({
        pages: data?.pages.flatMap((page) => page),
        pageParams: data.pageParams,
    }),
});

 

useInfiniteQuery에는 pageParma 값이 있어서 페이지를 관리하기에 훨씬 수월했다. 이 값을 Axios 요청 함수인 getAllProduct 인자에 넣어줘서 api 호출을 쉽게 해결했다. 

getNextPageParam의 리턴값은 카테고리 별로 페이지 수가 다르기 때문에 한번에 요청되는 데이터의 양이 12개면 page를 +1 씩 해주는 방식으로 구현했다. (왜냐하면 한번 요청 시 최대 12개의 데이터가 오기 때문이다.)

 

그리고 한번 요청 시 여러개의 배열이 계속 쌓이기 때문에 하나로 select 옵션을 써서 하나로 합치는 작업을 했다. 

 

// Axios 함수
const getAllProduct = async (category: string, page: number) => {
    const res = await axios.get(
        `${process.env.REACT_APP_API_KEY}/shop/getAll/${category}?page=${page}`,
    );
    return (await res.data) as Produdct_list_t[];
};

 

pageParam으로 보낸 인자값은 page의 value값으로 들어간다.

 

// intersectionObserver 호출
const { setTarget } = useIntersectionObserver({
    hasNextPage,
    fetchNextPage,
});

....
return (
	<>
    	....
        <div
            ref={setTarget}
            id="observer"
            style={{ height: '10px' }}
        ></div>
    </>
)

 

커스텀 훅으로 만든 Intersection-observer는 리턴값으로 DOM요소를 보내줬기 때문에 ref 값으로 들어갔다.

 

// 카테고리 클릭 이벤트
const handleClickCategory = (e: React.MouseEvent<HTMLElement>) => {
    if (e.currentTarget.lastChild?.textContent) {
        const categoryName = e.currentTarget.lastChild?.textContent;
        queryClient.removeQueries({ queryKey: 'product' }); // 이전 카테고리 캐시를 삭제
        setCategory(categoryName);
    }
};

 

카테고리 클릭 시 무한스크롤이 처음부터 다시 발생해야 하기 때문에 해당 쿼리키를 찾아 쿼리를 지워줬다.