velog
graduation
Infinitescroll

Intro

졸업 프로젝트를 진행하며 여행 일정 목록을 보여주는 페이지를 개발했습니다.

사용자가 여행 일정과 함께 사진을 업로드하면, 사진 파일이 포함되어 이미지 렌더링 지연이 발생하고 이는 페이지네이션 기반의 구현에서 병목 현상을 일으킬 수 있고, 이는 사용자 경험이 저하되는 원인으로 작용합니다.

이 문제를 해결하기 위해, 무한 스크롤 방식으로 일정 목록 렌더링을 변경하고, 효율적인 서버 데이터 패칭을 위해 React Query를 활용하기로 했습니다.

순서도

useInfiniteQuery

useInfiniteQuery 는 React Query 라이브러리에서 제공하는 Hook 중 하나로, 데이터를 페이지네이션 없이 끊임없이 로드할 수 있게 해주는 무한 스크롤을 구현할 때 유용합니다.

import { useInfiniteQuery } from 'react-query';
 
 const { data, fetchNextPage, hasNextPage, isFetching, isError } = 
        useInfiniteQuery(
          ['tripList', token],
          ({ pageParam = 0 }) => getEntireTripList(token, pageParam),
          {
              getNextPageParam: (lastPage, allPages) => {
              const nextPageParam = 
                    lastPage.data.content.length === 0 ? false : allPages.length;
              return nextPageParam;
           
              },
          }
       );
 

useInfiniteQuery 를 사용하여 "tripList" 쿼리 키와 함께 무한 스크롤을 구현하였습니다.

getNextPageParam 을 통해 다음 페이지를 불러올 조건을 정의하며, 이는 사용자가 페이지의 끝에 도달했을 때 더 이상 로드할 데이터가 없으면 더 이상 데이터를 요청하지 않도록 합니다.


IntersectionObserver

IntersectionObserver는 브라우저에서 제공하는 API로 웹 페이지의 어떤 요소가 뷰포트에 들어오거나 나갈 때 콜백 함수를 실행시킵니다.

 
useEffect(() => {
    const observer = new IntersectionObserver(observerCallback, {
      root: null,
      rootMargin: '0px',
      threshold: 0.1,
    });
    
    if (intersectionTarget) {
      observer.observe(intersectionTarget);
    }
    
    return () => {
      if (intersectionTarget) {
        observer.unobserve(intersectionTarget);
      }
    };
  }, [intersectionTarget, observerCallback]);
 

사용자가 특정 DOM 요소 (ex : 페이지 하단)에 도달하였는지를 감지합니다.


전체 코드 보기

MainPage에서 useInfiniteQuery와 IntersectionObserver를 사용하였습니다. 먼저, useInfiniteQuery를 사용하여 서버로부터 여행 일정 데이터를 불러오고, IntersectionObserver를 사용하여 스크롤이 페이지 하단에 도달했을 때 추가 데이터를 불러오는 로직을 구현합니다. 이렇게 함으로써 사용자는 끊김 없는 스크롤 경험을 하며 여행 일정을 탐색할 수 있습니다

import { useState, useEffect, useCallback } from 'react';
...
import { useInfiniteQuery } from 'react-query';
...
import { getEntireTripList } from '@/application/api/main/getEntireTripList';
...
 
function MainPage() {
  ...
  const [intersectionTarget, setIntersectionTarget] = useState(null);
  
  const { data, fetchNextPage, hasNextPage, isFetching, isError } = 
        useInfiniteQuery(
          ['tripList', token],
          ({ pageParam = 0 }) => getEntireTripList(token, pageParam),
          {
              getNextPageParam: (lastPage, allPages) => {
              const nextPageParam = 
                    lastPage.data.content.length === 0 ? false : allPages.length;
              return nextPageParam;
           
              },
          }
       );
  
  const travelList = data?.pages.flatMap((page) => page.data.content) || [];
  
  ...
  
  const observeTarget = useCallback((node) => {
   setIntersectionTarget(node);
  }, []);
  
  const observerCallback = useCallback(
    async ([entry]) => {
      if(entry.isIntersecting && hasNextPage) {
        try {
          await fetchNextPage();
        } catch (error) {
          console.error(error);
        }
      }
    },
    [fetchNextPage, hasNextPage]
  );
  
  useEffect(() => {
    const observer = new IntersectionObserver(observerCallback, {
      root: null,
      rootMargin: '0px',
      threshold: 0.1,
    });
    
    if (intersectionTarget) {
      observer.observe(intersectionTarget);
    }
    
    return () => {
      if (intersectionTarget) {
        observer.unobserve(intersectionTarget);
      }
    };
  }, [intersectionTarget, observerCallback]);
  
  ...
  
  return(
    <div className={styles.mainContainer}>
  	  <Row>
        ...
        <Col>
          {isError && <div>오류가 발생하였습니다.</div>}
         ...
          <List
            dataSource={travelList}
            renderItem={(
               ...
            )}
          />
          {isFetching && <Spin tip='Loading...' size='large' />}
          <div ref={observeTarget} />
        </Col>
      </Row>
    </div>
  );
}
 
export default MainPage;