Next.js - 리액트 쿼리 useInfiniteQuery와 react intersection observer를 사용한 무한스크롤 구현하기

    Next.js에서 React-query로 무한스크롤 구현하기

    리액트 쿼리를 사용한 인피니트 스크롤은 처음 구현해보네요.

     

    이번에 무한스크롤이 필요한 부분이 있지만 백엔드 API를 개선하고 계셔서 응답 값이 변경될 수 있기 때문에 기능만 우선 구현해놓고자 Mock data를 사용해서 만들어보고 필요한 응답 값을 요청드릴 것 같아요. 

     

    응답 값으로 데이터 말고 뭐 필요하냐고 물어보시길래 무한스크롤 예전에 구현해보긴 했었는데 너무 오래돼서 기억이 가물가물...ㅋㅋ

    당시 생각으로는 다음 페이지 존재 여부 외에 필요한 게 있나? 싶어서 다음 페이지 존재 여부만 boolean으로 던져주세요..라고 했는데 미리 만들어보고 다른 데이터 더 필요 없는지 체크할 겸, 리액트 쿼리를 잘 다루지 못하고 있어서 조금 친해질 겸, 리액트 쿼리로 무한스크롤 구현한다는 얘길 들어본 것 같아서 저도 시도해보았습니다...

     

    [미리 쓰는 완성 후기]

    - API 응답에 다음 페이지 존재 여부가 있으면 편하다. 없어도 만들 수는 있다. 다만 로직을 추가해야 해서 귀찮을뿐

    - 다음 페이지 존재 여부 bool 타입을 사용했는데, 다음 페이지에 해당하는 숫자를 응답으로 받는 게 리액트 쿼리 사용할 때는 살짝 더 편할지도.

    - 완성된 거랑 로직만 보면 굉장히 간단한데, 목데이터 백엔드 응답 값이랑 똑같이 만들었다가 괜히 자료구조 엄청 복잡해져서 단순화함(주객이 전도될 뻔) + 리액트 쿼리와 친하지 않음 + useInfiniteQuery 처음써 봄 이슈로 생각보다 시간이 쪼끔 더 걸림.

     

     

    버전을 체크해주세요! 🙋‍♀️

    제 사이드 프로젝트에서는 현재 리액트 쿼리 4.33 버전을 사용중입니다.

    Next.js는 14.0.3 버전, react intersection observer 9.5.3 버전입니다.

     

    버저닝 이슈가 있을 것 같은 경우는 아마 리액트 쿼리의 infiniteQuery 사용 시 키 이름이 있을 것 같은데

    예를 들면, 리액트 쿼리 5버전에서는 initialPageParam도 사용할 수 있는 것 같아요.

    5버전 자세한 내용은 공식 문서에서 확인하기~.


     

    1. Mock 데이터 만들기

    저는 실제 백엔드 API를 사용하지 않고 목데이터와 API를 만들어서 구현할 예정이에요.

    백엔드 API가 존재한다면 이 과정은 생략해도 좋아요!

     

    한 페이지 요청 시 기본적으로 가져올 데이터 개수를 10으로 정해서

    최대 10개까지만 가져오는 형태로 구현할 예정이에요.

     

    목데이터의 형태는 필요한 사양에 따라 만들어서 사용하면 되는데, 기존 백엔드에서 던져주는 데이터와 완전히 똑같이 만들게 되면 다소 복잡한 형태의 자료구조를 만들어야 해서 이번에 인피니트 스크롤에 집중하기 위해 간단한 형태로 만드려고 해요.

     

    app라우터를 사용하고 있으므로 아래 파일의 경로는 app/api/mock/route.ts 입니다.

    import { NextResponse } from 'next/server';
    
    export async function GET(req: Request) {
      const { searchParams } = new URL(req.url);
      const page = Number(searchParams.get('page')) || 0;
    
      const MAX_PAGE = 10;
      const datas = Array(100)
        .fill(0)
        .map((_, i) => {
          return {
            qaId: i,
            question: `목 데이터 ${i}`,
            answer: `${i}번째 응답`,
          };
        });
    
      const hasNextPage = MAX_PAGE - 1 > page;
    
      return NextResponse.json({
        data: datas.slice(page * 10, page * 10 + 10),
        hasNextPage,
      });
    }

     

    코드를 간단히 설명하면 params를 사용하여 page를 넘겨주는 형태로 API를 호출할 예정이기 때문에

    (예를 들면 http://localhost:3000/mypage?page=0 이런 형태)

    여기서 searchParams의 'page'만 떼와서 Number로 형변환을 해주었어요.

     

     

    hasNextPage가 false일 경우 더이상 API를 호출하지 않도록 할 거예요.

     

    만약 백엔드 API에서 마지막 페이지의 유무를 알려주지 않을 경우는 다음과 같은 방법을 고려해볼 수 있을 것 같아요.

    백엔드 응답 값 활용하기

      - 1. 없는 페이지 파람스 요청 시 에러 응답(요청 실패)을 뱉을 경우: 존재하지 않는 페이지이므로 마지막 페이지로 간주

      - 2. 없는 페이지 요청에 빈 값을 반환 : 빈 값이 오면 마지막 페이지로 간주

     

    하지만 지금은 hasNextPage 값을 사용해서 다음 값이 존재해서 API를 더 호출할지 말지를 결정하고자 해요.


    2. infiniteQuery 만들기

    Mock data를 받을 API를 만들었으니 이제 이를 호출할 때 사용할 인피니트 쿼리를 사용할 거예요.

    리액트 쿼리 버전에 따라 사용 방법에 조금 차이가 있을 수도 있으니 버전 확인 필수..!

     

    저는 커스텀 훅을 만들어서 비즈니스 로직과 분리해서 사용할 거예요.

    import { useInfiniteQuery } from '@tanstack/react-query';
    
    export const useMypageInfiniteQuery = () => {
      return useInfiniteQuery({
        queryKey: ['mypage'],
        queryFn: async ({ pageParam = 0 }) => {
          const res = await fetch(`/api/mock?page=${pageParam}`);
          const data = await res.json();
          return data;
        },
        getNextPageParam: (page, allPages) => {
          return page.hasNextPage ? allPages.length : undefined;
        },
      });
    };

     

    마이페이지에서 사용할 쿼리라서 useMypageInfiniteQuery 커스텀 훅을 만들었습니다.

     

    useQuery와 사용법이 크게 다르지 않지만, 특별해 보이는 부분은 역시 getNextPageParam이 되겠네요.

    이 콜백 메서드로 들어오는 인자는 현재 페이지, 전체 누적 페이지를 인자로 받을 수 있어요.

     

    리턴 값을 살펴보면 page의 hasNextPage가 true일 경우 다음 페이지가 존재한다는 의미가 되지만

    다음 페이지가 몇 페이지인지 우리의 Mockdata는 제공해주지 않고 있기 때문에

    전체 페이지의 길이를 알아오면 다음 페이지를 의미하는 것이기 때문에 allPages.length를 리턴하도록 했어요.

    (이 부분은 다음 페이지의 번호를 데이터에서 받은 값을 이용해도 돼요!)

     

    hasNextPage가 false인 경우 undefined를 리턴시켜주도록 하면

    리액트 쿼리에서는 다음 페이지가 존재하지 않다는 것으로 인식합니다.

     

    그렇다면, 매 API를 호출할 때 동적으로 변경할 pageParam은 어디에서 오는 값일까요 ? 😮

    기본적으로 queryFn의 인자로 들어오는 meta, pageParam, queryKey 등의 키를 가진 객체를 사용할 수 있는데

    저는 다른 값은 필요하지 않고 pageParam 값만 필요하기에 구조분해할당을 하고 초기 값이 undefined를 가지고 있어서 0으로 세팅해주었어요. 

     

    그리고 추가 페이지를 요청하게 될 때 getNextPageParam에서 반환한 값을 사용하게 돼요.

    저는 다음 페이지가 존재한다면 allPages.length를 사용하도록 했으니 0으로 시작해서 1씩 증가된 값이 pageParam자리로 넘어오게 되겠죠!


    3. 데이터 뿌려주기

    쿼리를 만들었으니 Mockdata를 불러와서 렌더링 시켜볼 거예요.

    import { useInView } from 'react-intersection-observer';
    import {
      useMypageInfiniteQuery
    } from '@/store/react-query/hooks/mypage';
    
    export default function MyPage() {
      const { data: mockDatas, fetchNextPage } = useMypageInfiniteQuery();
    
      const { ref, inView } = useInView();
    
      useEffect(() => {
        if (inView) {
          fetchNextPage();
        }
      }, [inView]);
      
      
      // 그외 로직

     

     

    react intersection observer 라이브러리를 설치해주었어요.

    useInView를 import 한 후에 inView를 의존성배열에 담아줍니다.

    그리고 ref는 무한스크롤이 적용되어야 할 컴포넌트의 가장 하단에 보이지 않는 요소를 깔아서 이 요소가 사용자에게 보여질 때 다음 쿼리를 실행하도록 할 거예요.

     

      <div className={styles.contentsContainer}>
          <>
            {
                // 여기에 무한스크롤 데이터를 뿌려줄 예정
            }
           <div ref={ref} style={{ height: '20px' }}>제가 보인다면 무한스크롤이 동작할 거예요.</div>
          </>
      </div>

     

    코드는 정말 최대한 간단하게..

    동작을 확인할 수 있도록 임의의 스타일과 텍스트를 살짝 넣어보았어요.

     

      const { data: mockDatas, fetchNextPage } = useMypageInfiniteQuery();

     

     

    인피니트 쿼리에서 리턴하는 다양한 함수( hasNextPage, hasPreviousPage, fetchPreviousPage 등...)가 있으니 기호에 따라 사용해보세요~!

     

    저는 지금은 data와 fetchNextPage만 사용해보겠습니다..!

    아마 나중에 백엔드 API로 변경해서 로딩 컴포넌트 정도만 추가해볼 것 같아요.

     

     <>
        {mockDatas?.pages.map(({ data }: { data: any[] }) =>
          data?.map((d, idx) => (
            <div
              key={`${d} ${idx}`}
              style={{ background: '#eee', marginBottom: '10px' }}
            >
              <div>{d.qaId}</div>
              <div>{d.question}</div>
              <div>{d.answer}</div>
            </div>
          ))
        )}
        
        <div ref={ref} style={{ height: '20px' }}>
          제가 보인다면 무한스크롤이 동작할 거예요.
        </div>
    </>

     

    저는 임시로 사용할 코드라 인라인 스타일과 key, 인자 네이밍을 대충 지었놓았지만 잘 지어줍시다..!

    특히 map을 사용할 때 요소를 삭제/추가하는 등 변경 여지가 있다면 key값은 가급적 유니크하게 넣어주세요.

    (특히 정렬 기능까지 있다면 동작 버그 걸릴 수 있으니 key에 index를 사용하는 것은 피합시당)

     

    저는 이 데이터 각각에 삭제 기능이 들어갈 거라 실제 프로젝트 적용 시에는 백엔드 데이터에서 전달 받은 유니크 값을 key로 설정해 줄 거예요. :9

     

    이제 무한스크롤이 동작하는지 확인해보겠습니다.


    ✅ 무한 스크롤 동작 확인

     

    정상적으로 동작하는 것을 확인했어요. 🙌

    리액트 쿼리도 확인해볼게요.

     

     

    보고 있는 쿼리키 외에 있는 친구들은 다른 로직으로 인해 생성된 것이므로 무시해주세요..!

    아무튼 현재 동작중인 쿼리 데이터를 보면 응답 값을 누적해서 쌓는 것을 알 수 있었어요.

     

    콘솔로도 찍어봅시다.

     

    원했던 대로 동작을 하고 있네요.

    리액트 쿼리를 사용했을 때의 장점은 역시 리액트 쿼리가 알아서 다 해준다..고 생각해요..ㅋㅋ

    캐싱이 필요할지는 아직 모르겠어서 Stale time을 설정하지 않았는데 무한스크롤에 프리패칭은 넣어봐도 좋을 것 같단 생각이에요.

     

    저는 리액트 쿼리 공식문서의 예시 코드를 참고해서 구현했는데,

    다른 방법으로도 무한스크롤을 구현하는 방법은 많으니 본인에게 맞는 방법을 선택하면 좋을 것 같아요. 🤗


    참고 자료

    리액트 쿼리 공식문서 - React Example: Load More Infinite Scroll

    댓글