ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Toy Project 기록하기 10] React-Query로 무한스크롤 구현하기
    Frontend/Projects 2023. 3. 21. 16:00

    서버에서 데이터를 가져올 때 모두 한꺼번에 가져올 수 없기 때문에 필요한 데이터 개수만큼 잘라서 보여주는 '페이지네이션'이 필요합니다. 보통 필요한 개수를 정하고 상황에 맞춰 정렬 기준이 조건에 추가됩니다. 이러한 페이지네이션에는 오프셋 기반 페이지네이션과 커서 기반 페이지네이션이라는 두 가지 방식이 존재하는데요. 두 방식의 개념과 각각의 특징에 대해 알아보겠습니다.

     


     

    Offset-based Pagination

    구글의 오프셋 기반 페이지네이션

    DB의 offset 쿼리를 이용해 페이지 단위로 구분하여 특정 페이지에 대한 데이터를 가져옵니다. 일반적으로 구글이나 커뮤니티 게시판에서 자주 다루는 페이징 처리 방식입니다. 

     

    장점

    페이지에 대한 데이터를 빠르게 검색할 수 있으며 페이지 이동이 쉽습니다. 

     

    단점

    페이지 이동 시마다 데이터베이스에서 전체 데이터를 새로 가져와야 하므로, 데이터가 많을 경우에는 불필요한 처리를 발생시킬 수 있습니다. 또한, 데이터의 추가 또는 삭제가 일어날 때마다 모든 페이지의 오프셋이 변경되기 때문에, 데이터가 누락되거나 중복될 수 있습니다.

     

     

     

    Cursor-based Pagination

     

    인스타그램의 커서 기반 페이지네이션

    사용자가 마지막으로 봤던 게시물의 정보를 기준으로 커서로 저장해 다음 게시물을 n개만큼 보여주는 방식입니다. 주로 실시간으로 대량의 데이터를 다루는 인스타그램, 트위터 등에서 다루는 페이징 처리 방식입니다.

     

    장점

    페이지 이동이 빠르고, 전체 데이터를 새로 가져오는 것이 아니기 때문에 대량 데이터를 처리함에 있어서도 효율적입니다. 또한, 커서를 이용하여 다양한 검색 조건을 적용할 수 있으며, 데이터의 추가 또는 삭제하더라도 유연한 처리가 가능합니다.

     

    단점

    커서의 정보를 저장해야 하기 때문에 일정한 메모리를 필요로 하며, 페이징 처리를 위한 별도의 컬럼이나 인덱스를 생성해야 하는 경우가 있습니다. 또한, 데이터의 정렬 조건이 변경되는 경우 중복된 값이 존재하면 안되고 순차적이어야 하기 때문에 처리가 복잡할 수 있습니다.

     

     

    정리

    데이터 변화가 거의 없어 중복 데이터가 나올 가능성이 적은 경우이거나 데이터의 수가 많지 않아 높은 성능을 기대하지 않는 경우에는 오프셋 기반 페이지네이션을 활용해도 좋으며, 대량의 데이터를 다루고 실시간으로 자주 변화하는 서비스에서는 커서 기반 페이지네이션을 활용하는 것이 좋겠습니다.

     

     

    React-Query로 무한스크롤 구현하기

    React-Query는 무한 스크롤를 위한 useInfiniteQuery 훅을 제공합니다.

    // Home.tsx
    
    export default function Home() {
      const fetchFeeds = ({ pageParam }: { pageParam?: string }) => {
        return getFeeds({ cursor: pageParam, limit: 5 });
      };
    
      const {
        isError,
        data,
        error,
        fetchNextPage,
        hasNextPage,
        isFetchingNextPage,
      } = useInfiniteQuery(
        // queryKey: 데이터를 캐실할 때 사용하는 Key 값
        'getFeeds', 
        // fetchFn: 응답받은 값을 반환
        fetchFeeds, 
        // options: 추가적으로 데이터 fetch 시,두 번째 인수인 콜백 함수가 반환한 값을 가져와 사용할 수 있습니다. 
        {
          // 다음 pageParam을 가져오는 부분
          getNextPageParam: (data) => data.data.nextCursor,
        }
      );
    
      if (isError && isAxiosError(error)) {
        return <NotFound />;
      }
    
      return (
        <div>
          <Layout>
            <div>
              {data?.pages.map((group, i) => (
                <React.Fragment key={i}>
                  {group.data.items.map((feed) => (
                    <Feed key={feed._id} feed={feed} />
                  ))}
                </React.Fragment>
              ))}
              {hasNextPage && !isFetchingNextPage && (
                <InfiniteScroll callback={fetchNextPage} />
              )}
            </div>
          </Layout>
        </div>
      );
    }

     

    피드 조회 함수에 클라이언트에서 파라미터로 cursor, limit을 담아 getFeeds 요청을 보냅니다. 

    // feed.ts
    
    export const getFeeds = ({ cursor, limit }: TCursorPagingVariables) => {
      return axiosInstance.get<TCursorPagingVariables, TCursorPaging<TFeed>>(
        '/feed',
        {
          params: { cursor, limit },
        }
      );
    };

     

    서버에서는 다음 보여줄 피드 페이지 데이터와 마지막으로 조회된 피드를 보내줍니다.

    // feedController.ts
    
    export const getFeeds = async (req: Request, res: Response) => {
      try {
        const limit = req.query.limit || 5;
        const cursor = req.query.cursor;
    
        const feeds = await Feed.find(
          cursor
            ? {
                _id: {
                  $lt: new mongoose.Types.ObjectId(cursor as string),
                },
              }
            : {}
        )
          .limit(Number(limit) + 1)
          .sort({ _id: -1 })
          .populate('likes')
          .populate('comments')
          .populate('owner');
    
        const isEnd = feeds.length <= Number(limit);
    
        !isEnd && feeds.pop();
    
        const nextCursor = isEnd ? null : feeds[feeds.length - 1]?._id;
    
        return res.status(200).send({
          items: feeds,
          nextCursor,
        });
      } catch (error) {
        return res.status(500).send({ message: DEFAULT_ERROR_MESSAGE });
      }
    };

     

    Intersection Observer API는 브라우저에서 DOM 요소의 교차점을 관찰하고 감지하는 JavaScript API입니다. 이 API는 요소의 가시성에 따라 특정 동작을 실행하도록 트리거를 설정할 수 있습니다.

    화면에 맨 마지막 피드가 보이면 콜백 함수를 통해 다음 피드를 불러오게 합니다.

    // InfiniteScroll.tsx
    
    import { useRef, useEffect } from 'react';
    
    type TProps = {
      callback: () => void;
    };
    
    const InfiniteScroll = ({ callback }: TProps) => {
      // 관찰할 요소 ref로 설정하기
      const $target = useRef<HTMLDivElement>(null);
    
      useEffect(() => {
        if (!$target.current) return;
        const observer = new IntersectionObserver((entries) => {
          entries.forEach((entry) => {
            // 화면에 관찰 요소가 보이면
            if (entry.isIntersecting) {
              callback();
            }
          });
        });
    
        observer.observe($target.current);
    
        return () => observer.disconnect();
      }, [$target, callback]);
    
      // 관찰할 요소 ref로 설정하기
      return <div ref={$target}>loading...</div>;
    };
    
    export default InfiniteScroll;

     

     

    🧗‍♀️ 제가 진행한 프로젝트가 궁금하다면

    🔽 프론트엔드는 이곳에서 확인하실 수 있습니다.

    https://github.com/Team-Madstone/doljabee-fe

     

    GitHub - Team-Madstone/doljabee-fe: Climbing Community

    Climbing Community. Contribute to Team-Madstone/doljabee-fe development by creating an account on GitHub.

    github.com

     

    🔽 백엔드는 이곳에서 확인하실 수 있습니다.

    https://github.com/Team-Madstone/doljabee-be

     

    GitHub - Team-Madstone/doljabee-be: Climbing Community

    Climbing Community. Contribute to Team-Madstone/doljabee-be development by creating an account on GitHub.

    github.com

     

     

    참고 자료

    https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API

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

    https://medium.com/swlh/how-to-implement-cursor-pagination-like-a-pro-513140b65f32

    https://velog.io/@minsangk/%EC%BB%A4%EC%84%9C-%EA%B8%B0%EB%B0%98-%ED%8E%98%EC%9D%B4%EC%A7%80%EB%84%A4%EC%9D%B4%EC%85%98-Cursor-based-Pagination-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0

    반응형

    댓글

Designed by Tistory.