Tanstack query 무한 스크롤 구현

Singsoong·2023년 12월 29일
0

react

목록 보기
5/7
post-thumbnail

개발하고 있는 각 강의 영상에 유저들이 미션을 수행한 것을 볼 수 있는 미션광장 이 있는데, 이 공간을 무한스크롤로 구현하려고 한다.

무한스크롤 (Infinite Scroll) : 게시판 글 리스트처럼 많은 데이터를 배열로 받아오는 경우, 그 데이터가 너무 방대해지면 api 요청으로 데이터를 받아오는 시간이 오래걸릴 수 밖에 없다. 이 문제를 해결하기 위해 일반적으로 페이지네이션(Pagination)을 사용하여 데이터를 일정 개수씩 분리해 받아오는 방식으로 해결했다. 페이지네이션 또한 좋은 선택지이지만, 오늘 날 모바일 기기를 이용해 웹에 접속하는 경우가 매우 많다보니 터치횟수를 최소화하고 콘텐츠를 끊김없이 보여줄 수 있는 무한스크롤이 더 좋은 대안이 될 수 있다.

📌 1. 스크롤의 바닥에 도달했는지 감지하기

무한스크롤은 한 페이지의 스크롤의 바닥에 도달할 때 API를 호출해 데이터를 받아오는 방식이다.

어떻게 스크롤 바닥에 도달했는지 알 수 있는가?

  1. Scroll Event: 콘텐츠의 전체 길이와 현재 스크롤 한 길이를 비교하여 스크롤 바닥을 감지하는 방법
  2. Intersection Observer API로 바닥 감지하기: 타겟 요소와 상위요소 또는 최상의 document의 viewport 사이의 intersection 내의 변화를 비동기적으로 관찰하는 방법

2번의 방법을 쉬운 인터페이스로 구현된 라이브러리를 사용할 예정이다.
(프로젝트에서 이미 이 라이브러리를 설치하여 사용중이었다!)

react-intersection-observer

관찰하는 객체(observer) 하나를 ref로 설정한 후 해당하는 객체가 화면에 보이면 특정 코드를 실행시킬 수 있다.

https://github.com/thebuilder/react-intersection-observer#readme

  • 설치
npm install react-intersection-observer
  • Import
import { useInView } from 'react-intersection-observer';
  • 특정 <div>가 보일 때 console.log를 호출하는 코드
  • 추적할 엘리먼트에 ref를 연결하고, 해당 엘리먼트가 화면에 보이면 inView의 값은 true가 된다.
const AssignmentPlaza = () => {
  const [ref, inView] = useInView();
  
  useEffect(() => {
    if (inView) console.log("get Data API Call,")
  }, [inView]);
  
  return (
    <div>
      <span>API 호출 영역</span>
    </div>
  );
}

📌 2. useInfiniteQuery

바닥을 감지하는 것을 구현했으니, 이제 바닥을 감지했을 때 API를 call하면 된다. tanstack-query에서는 무한스크롤을 편하게 구현할 수 있도록 돕는 useInfiniteQuery라는 훅을 제공하고 있다.

https://tanstack.com/query/v4/docs/react/reference/useInfiniteQuery

  • 하나의 미션광장은 각 VOD(curriculum_id)마다 데이터가 생성되므로 curriculum_id를 query key로 사용

  • 구현

const useGetAssignmentPlaza = ({ curriculum_id }) => {
  // fetch API
  const getAssignmentPlaza = async ({ pageParam = 1 }) => {
    const response = await client.get(`${BASE_URL}/${curriculum_id}/${pageParam}`);

    return {
      current_page: pageParam,
      isLast: response.data.results.total_page === response.data.results.page,
      page_data: response.data.results,
    };
  };

  // useInfiniteQuery Hook
  const {
    data: getData,
    fetchNextPage: getNextPage,
    isSuccess: getDataIsSuccess,
    hasNextPage: getNextPageIsPossible,
  } = useInfiniteQuery(['VOD_ASSIGNMENT', curriculum_id], getAssignmentPlaza, {
    getNextPageParam: (lastPage, pages) => {
      if (!lastPage.isLast) return lastPage.current_page + 1;
      return undefined;
    },
    enabled: !!curriculum_id,
  });
  return { getData, getNextPage, getDataIsSuccess, getNextPageIsPossible };
};

📌 3. 전체 코드

  • 스크롤을 감지하는 영역을 로딩 인디케이터로 했다. 이 로딩인디케이터는 마지막 미션이고, 마지막 페이지가 아니라면 렌더링되게 구현했다.
  • 등록된 미션이 없으면 다른 컴포넌트를 렌더링한다.
const AssignmentPlaza = ({ curriculumData }) => {
  const { getData, getNextPage, getDataIsSuccess, getNextPageIsPossible } = useGetAssignmentPlaza({
    curriculum_id: curriculumData.id,
  });
  const [ref, inView] = useInView();
  const [imgSrc, setImgSrc] = useState('');

  useEffect(() => {
    // 스크롤 맨 밑에 도달했을 때 데이터를 가져옴
    if (inView && getNextPageIsPossible) {
      getNextPage();
    }
  }, [inView, getData]);


  return (
    <div>
      {getDataIsSuccess &&
        getData?.pages.map((page, pageIdx) => {
          const plazaPage = page.page_data.list;
          const isComment = page.page_data.list.length > 0;

          // 댓글이 없을 때
          if (!isComment)
            return (
              <div className="assignment_plaza_no_comment_container" key={pageIdx}>
                <Text text="등록된 미션이 없습니다." weight="400" size="15px" lineHeight="21px" color={color.grey100} />
              </div>
            );
          return plazaPage.map((comment, idx) => {
            const isLastComment = getData.pages.length - 1 === pageIdx && plazaPage.length - 1 === idx;
            return (
              <div key={comment.id} className="assignment_plaza_comment_container">
                <div className="assignment_plaza_profile_container">
                  <img className="assignment_plaza_profile_img" src={comment.user_photo} />
                  <div className="assignment_plaza_profile_title">
                    <Text text={comment.user_name} weight="500" size="14px" lineHeight="19.6px" color={color.grey25} />
                    <Text
                      text={dayjs(comment.created_at).format('YYYY년 MM월 DD일 a h:mm')}
                      weight="400"
                      size="12px"
                      lineHeight="16.8px"
                      color={color.grey200}
                    />
                  </div>
                </div>
                <div>
                  <Text
                    text={comment.original_content}
                    weight="400"
                    size="14px"
                    lineHeight="19.6px"
                    color={color.grey25}
                    marginBottom="12px"
                  />
                  {comment.photo_list.length > 0 && (
                    <div className={`assignment_plaza_image_attachment_container size${comment.photo_list.length}`}>
                      {comment.photo_list.map((item, idx) => {
                        return (
                          <img
                            onClick={() => commentImageOnClick(item)}
                            className="assignment_plaza_image"
                            key={item}
                            src={item}
                          />
                        );
                      })}
                    </div>
                  )}
                </div>
                {isLastComment && !page.isLast && <div ref={ref} className="loading_indicator" />}
              </div>
            );
          });
        })}
    </div>
  );
};

export default AssignmentPlaza;

📌 4. 최종 구현


  • 로딩인디케이터가 출력되고 바로 데이터를 패칭하여 데이터를 렌더링한다.

참고 : [React] react-query useInfiniteQuery로 무한스크롤 구현하기
[React] react-Intersection-Observer 라이브러리를 이용해 무한스크롤 구현하기

profile
Frontend Developer

0개의 댓글