FE 기술/React

[React] 무한스크롤 구현하기 (useInfiniteQuery, Cursor-based Pagination)

2024. 11. 18. 14:49
목차
  1. 무한 스크롤 구현
  2. 공통 부분
  3. 호출 API
  4. useInfiniteQuery
  5. IntersectionObserver
  6. 정방향
  7. 역방향

팀바팀의 팀 피드 기능에 무한스크롤 기능이 있어서 구현해보았다. 사실 이 기능은 레벨3때 구현했는데 레벨4와서 요구사항이 바뀌는 바람에.. 정방향 무한스크롤(아래로 스크롤 할 때 무한스크롤)과 역방향 무한스크롤(위로 스크롤 할 때 무한스크롤) 둘다 구현하는 경험을 가져서 글을 작성하게 되었다.

무한 스크롤 구현

나는 useInfiniteQuery와 IntersectionObserver 사용하여 구현했다. 사실 스크롤 이벤트를 이미 사용하고 있기 때문에 스크롤 이벤트로 구현해도 되긴 하는데 계속 요구사항이 바뀌고 있어 현재 스크롤 이벤트를 사용하는 컴포넌트가 삭제가 될 수 있다는 점과 다음 fetch를 진행해야하는 트리거로 사용하기에 IntersectionObserver 가 좀 더 나에겐 편해서 사용하게 되었다.

추가적으로 Intersection Observer API 는 루트 요소와 타겟 요소의 교차점을 관찰한다. 그리고 타겟 요소가 루트 요소와 교차하는지 아닌지를 구별하는 기능을 제공해 페이지의 과부하를 줄일 수 있다.

공통 부분

무한스크롤은 tanstack-query에서 제공하는 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,
})

tastack-query에서 제공하는 정보이다. queryFn의 pageParam은

Query Functions | TanStack Query Docs

에서 알 수 있듯 현재 페이지를 가져오는 데 사용되는 페이지 매개변수이다. getNextPageParam을 사용하여 다음 호출시에 사용될 pageParam을 정할 수 있다.

우리 팀은 무한스크롤을 호출할 때 page단위가 아닌 직전 호출한 리스트 중 마지막 항목의 id 값을 기준으로 요청해야했다. 따라서 처음 요청시에는 마지막 항목의 id값이 없어서 다음과 값이 호출 api를 구현했다.

호출 API

export const fetchThreads = (teamPlaceId: number, lastThreadId?: number) => {
  const query = lastThreadId
    ? `last-thread-id=${lastThreadId}&size=${THREAD_SIZE}`
    : `size=${THREAD_SIZE}`;

  return http.get<ThreadsResponse>(
    `/api/team-place/${teamPlaceId}/feed/threads?${query}`,
  );
};

useInfiniteQuery

useInfiniteQuery를 사용한 부분은 다음과 같다.

export const useFetchThreads = (teamPlaceId: number) => {
  const {
    data: threadPages,
    hasNextPage,
    fetchNextPage,
  } = useInfiniteQuery(
    ['threadData', teamPlaceId],
    ({ pageParam = undefined }) => fetchThreads(teamPlaceId, pageParam),
    {
      enabled: teamPlaceId > 0,
      getNextPageParam: (lastPage) => {
        if (lastPage.threads.length !== THREAD_SIZE) return undefined;
        return lastPage.threads[THREAD_SIZE - 1].id;
      },
    },
  );

  return { threadPages, hasNextPage, fetchNextPage };
};

getNextPageParam의 return 값은 다음 호출시 별도로 설정해주지 않아서 자동적 pageParam에 값이 들어가 다음을 호출한다. 만약 getNextPageParam이 없다면 undefined을 무조건 반환해야한다. (공식문서에서 정해준거라 의문을 가져도 별 수 없다.)

우리팀은 말했듯 직전 아이디를 기준으로 요청을 보내면 백엔드에서 알아서 처리해주기로 해서 이렇게 구현했다.

enabled: teamPlaceId > 0 이 설정은 해당 쿼리가 돌아가는 조건이다. 우리는 teamPlaceId가 0인 경우는 팀이 선택되지 않았기 때문에 쿼리를 요청하면 안되었다. 그래서 teamPlaceId가 0이하 일 때 작동하지 않도록 했다.

IntersectionObserver

무한스크롤의 핵심 IntersectionObserver은 다른 컴포넌트에서도 사용하기 때문에 훅으로 분리했다.

import { useEffect, type RefObject, useRef } from 'react';

export const useIntersectionObserver = <T extends HTMLElement>(
  targetRef: RefObject<T>, //관찰하는 요소
  onIntersect: IntersectionObserverCallback, //관찰 되었을 때 실행하고 싶은 함수
  hasNextPage: boolean | undefined, //무한 스크롤로 더 불러올 요소가 있는지
) => {
  const observer = useRef<IntersectionObserver>();
  useEffect(() => {
    if (targetRef && targetRef.current) {
      observer.current = new IntersectionObserver(onIntersect, {
        root: null,
        rootMargin: '0px',
        threshold: 1.0,
      });

      if (!hasNextPage) { //다음 페이지의 유무로 관찰하고 있는 항목을 관찰 취소한다.
        observer.current?.unobserve(targetRef.current);
        return;
      }

      observer.current.observe(targetRef.current);
    }

    return () => observer && observer.current?.disconnect();
  }, [targetRef, onIntersect]);
};

observer.current?.unobserve(targetRef.current); 이건 꼭 해줘야한다. 처음에 unobserve를 하지 않았었는데 마지막 항목에서 계속 요소를 관찰하고 있어 fetch가 제대로 일어나지 않는 에러가 발생했기 때문이다.

 

https://github.com/woowacourse-teams/2023-team-by-team/pull/529

 

[FE] 피드의 마지막에서 스레드가 등록되지 않는 현상 by hafnium1923 · Pull Request #529 · woowacourse-teams/

[FE] 피드의 마지막에서 스레드가 등록되지 않는 현상 이슈번호 close #523 PR 내용 참고자료 의논할 거리

github.com

정방향

정방향은 무한스크롤을 넣고 싶은 컴포넌트에 이렇게 관찰 할 요소를 달아주면 된다.

컴포넌트의 최하단에 관찰할 요소인

<div ref={observeRef} /> 를 넣어주고 해당 요소가 화면에 노출되면 실행하는 함수를 아래와 같이 작성한다.

 const onIntersect: IntersectionObserverCallback = ([entry]) => {
    if (entry.isIntersecting && teamPlaceId > 0) {
      fetchNextPage();
    }
  };

전체 코드는 아래와 같다.

const ThreadList = (props: ThreadListProps) => {
	...
  const { threadPages, hasNextPage, fetchNextPage } =
    useFetchThreads(teamPlaceId);
  const observeRef = useRef<HTMLDivElement>(null);

  const onIntersect: IntersectionObserverCallback = ([entry]) => {
    if (entry.isIntersecting && teamPlaceId > 0) {
      fetchNextPage();
    }
  };

  useIntersectionObserver(observeRef, onIntersect, hasNextPage);

  return (
    <>
      {threadPages?.pages.map((page) =>
        page.threads.map((thread) => {
          //무한스크롤로 불러온 요소 랜더링
          );
        }),
      )}
      {!hasNextPage &&
        threadPages &&
        threadPages.pages[0].threads.length > 0 && (
          <Text size="lg" css={S.lastThreadText}>
            마지막 스레드 입니다.
          </Text>
        )}
      <div ref={observeRef} /> //관찰할 요소
    </>
  );
};

export default ThreadList;

역방향

역방향은 정방향과 큰 차이는 없다. 단지 조금 귀찮을 뿐..

일단 <div ref={observeRef} /> 를 관찰할 컴포넌트 최상단으로 올린다. 또한 요소를 렌더링 할 때 역순으로 해야하기 때문에 위에서 작성한 코드중 return 문을 아래와 같이 변경해준다.

<>
      <div ref={observeRef} />
      
      {!hasNextPage &&
        threadPages &&
        threadPages.pages[0].threads.length > 0 && (
          <Text size="lg" css={S.lastThreadText}>
            마지막 스레드 입니다.
          </Text>
        )}
      {threadPages?.pages
        .slice()
        .reverse()
        .map((page) =>
          page.threads
            .slice()
            .reverse()
            .map((thread) => {
              //무한스크롤로 불러온 요소 렌더링
        )}
    </>

이렇게하면 끝일까? 아니다. 문젠 스크롤이 최상단에 위치해있기 때문에 계속 <div ref={observeRef} /> 를 관찰해서 무한으로 api요청을 보낸다. 그렇기 때문에 우리는 컴포넌트가 첫 렌더링이 될 때 스크롤을 아래로 보내야한다.

ThreadList 컴포넌트의 코드에 아래와 같이 추가한다.

const [scrollHeight, setScrollHeight] = useState(0);
const containerRef= useRef<HTMLDivElement>(null);

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

    if (containerRef.current) {
      const scrollTop = containerRef.current.scrollHeight - scrollHeight;
      containerRef.current.scrollTop = scrollTop;
      setScrollHeight(containerRef.current.scrollHeight);
    }
  }, [threadPages?.pages.length]);

scrollTop은 스크롤이 현재 위치해있는 위치이고 scrollHeight는 총 content의 높이이다. 즉 스크롤 최하단으로 가려면 scrollHeight으로 가면 된다. 위 코드처럼 작성하면 처음 시도에는 최하단으로, 이후 fetch요청이 일어나면 직전 보고있던 스크롤 위치를 기억해 새로운 데이터가 불러와져도 보고있는 내용을 볼 수 있도록 한다.

그리고 observeRef와 렌더링 요소를 묶는 가장 상위에 containerRef를 연결한다.

<div ref={containerRef}>
      <div ref={observeRef} />
    //렌더링하는 로직
</>

전체코드는 아래 커밋에서 확인할 수 있다. 다만 우리팀 요구사항에는 공지를 ThreadList 최상단에 붙여야 하기때문에 ThreadList의 상위 컴포넌트에 containerRef를 연결하고 props로 받아오는 방식으로 구현했다.

feat: 역방향 무한스크롤 구현 · woowacourse-teams/2023-team-by-team@e2d72ce

 

feat: 역방향 무한스크롤 구현 · woowacourse-teams/2023-team-by-team@e2d72ce

hafnium1923 committed Sep 14, 2023

github.com

 

저작자표시 비영리 변경금지

'FE 기술 > React' 카테고리의 다른 글

SSE 로 구현한 실시간 채팅의 성능 최적화(24.04.03 내용추가)  (0) 2024.11.18
React와 SSE(Server Sent Events)로 실시간 채팅 구현하기  (0) 2024.11.18
Webpack 기반의 React & TypeScript 환경 세팅  (0) 2023.07.06
React) useEffect 사용방법, dependency array  (0) 2023.04.25
React) ThemeProvider로 필요한 스타일만 바꾸기 [재사용 가능한 Input 컴포넌트 2]  (0) 2023.04.24
  1. 무한 스크롤 구현
  2. 공통 부분
  3. 호출 API
  4. useInfiniteQuery
  5. IntersectionObserver
  6. 정방향
  7. 역방향
'FE 기술/React' 카테고리의 다른 글
  • SSE 로 구현한 실시간 채팅의 성능 최적화(24.04.03 내용추가)
  • React와 SSE(Server Sent Events)로 실시간 채팅 구현하기
  • Webpack 기반의 React & TypeScript 환경 세팅
  • React) useEffect 사용방법, dependency array
Rulu_
Rulu_
벨로그가 보기 편해요!! 티스토리는 md파일 그대로 복붙해서 올립니다ㅎ https://velog.io/@hafnium1923
Rulu_
루루의 개발 일지
Rulu_
전체
오늘
어제
  • 분류 전체보기 (61)
    • FE 기술 (28)
      • JavaScript (6)
      • React (9)
      • React Native (2)
      • Technic (7)
      • CSS (4)
    • BE 기술 (1)
    • 책 정리 (0)
      • 코어 자바스크립트 (7)
      • 이펙티브 타입스크립트 (6)
    • 우아한 테크 코스 회고 (14)
      • 프리코스 후기 (5)
      • 레벨 1 (2)
      • 레벨 2 (2)
      • 레벨 3 (5)
    • 우아한 테크 코스 피드백 (1)
      • 레벨 1 (1)
      • 레벨 2 (0)
    • 우아한 테크 코스 생활 (3)
      • 생활 이모저모 (1)
      • 글쓰기 (2)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

  • 레벨인터뷰
  • 우아한테크코스후기
  • 회고
  • props 여러개
  • 테코톡준비자료
  • 레벨로그
  • input 여러개
  • 한달생활기
  • spread operator
  • 5기
  • input 컴포넌트
  • 재사용 컴포넌트
  • react
  • 테코톡
  • 우아한테크코스
  • 프론트

최근 댓글

최근 글

hELLO · Designed By 정상우.
Rulu_
[React] 무한스크롤 구현하기 (useInfiniteQuery, Cursor-based Pagination)
상단으로

티스토리툴바

단축키

내 블로그

내 블로그 - 관리자 홈 전환
Q
Q
새 글 쓰기
W
W

블로그 게시글

글 수정 (권한 있는 경우)
E
E
댓글 영역으로 이동
C
C

모든 영역

이 페이지의 URL 복사
S
S
맨 위로 이동
T
T
티스토리 홈 이동
H
H
단축키 안내
Shift + /
⇧ + /

* 단축키는 한글/영문 대소문자로 이용 가능하며, 티스토리 기본 도메인에서만 동작합니다.