[Firebase] 파이어스토어 페이지네이션 + 무한 스크롤 구현하기 (feat. React)

2022. 6. 7. 14:07Firebase

728x90

애플리케이션을 만들 때 매번 전체 문서를 반환하도록 로직을 짜게 되면 규모가 커질수록 수백 또는 수천 개의 고비용 쿼리가 발생하기 때문에 정해진 수의 결과만 반환하도록 별도의 처리를 해주는 것이 좋습니다.

 

이번 포스트에서는 다름 아닌 파이어 스토어에서 문서 데이터를 부분적으로 불러오도록 페이지 네이션 처리를 해보도록 하겠습니다.

 

아직 파이어 스토어에서 데이터를 불러오고 쓰는 작업이 익숙하지 않으신 분들은 공식문서를 참고하거나 아래 포스트를 참고해주시길 바랍니다.

 

 

[Firebase 웹] 파이어스토어에서 데이터 불러오고 쓰기 feat. React

​​이번 포스트에서는 파이어 베이스의 파이어 스토어(firestore) 사용법에 대해 알아보도록 하자. 파이어 베이스에 대한 포스트를 아직 읽지 않았다면 한번 보고 오길 바란다. 2022.02.21 - [Firebase]

mingeesuh.tistory.com

 

 

페이지네이션을 구현하는 방법은 크게 오프셋 페이지네이션과 커서 기반 페이지네이션이 있지만 현재는 파이어스토어에서 오프셋(offset)을 통해 페이제네이션을 효율적으로 구현하는 방법이 없으므로 공식문서에서 권장하는 커서(cusor) 기반의 페이지네이션과 Intersection Observer API의 조합으로 무한 스크롤을 구현해보도록 하겠습니다. 

 

firebase-admin sdk에서 구글처럼 offset 방식으로 페이지네이션을 구현할수 있도록 offset() 메서드가 존재하긴 하지만 offset(3000)을 하면 3000번의 읽기 요청이 발생하기때문에 효율적이지 못하다는 단점이 있고 admin sdk를 사용하기 위해서는 Cloud Function을 사용해야하기 때문에 번거롭다

 


커서(Cursor) 기반의 페이지네이션

 위와 같은 이유로 파이어스토어에서 페이지네이션 구현을 위해서는 커서 쿼리를 사용을 권장하고 있는데, 해당 방법은 마지막으로 불러온 커서를 기억해두고 또 다른 요청 (스크롤이 특정 위치에 도달하거나, 더 보기 클릭)이 들어왔을 때 마지막 불러온 커서로부터 계속해서 데이터를 받아오는 방식입니다. 얼핏 보면 offset 방식이랑 비슷할 수도 있지만 10, 20, 100 이런 고정된 값으로 데이터를 추가 요청하지 않고 마지막 불러온 유니크한 문서의 스냅샷(snapshot)을 기반으로 추가 요청을 하기 때문에 사용자가 페이지를 넘기는 중에 여러 개의 문서가 추가되었다고 해도 중복 데이터를 요청할 일이 많지는 않습니다. 

 

startAfter(), startAt() 

그럼 특정 문서의 스냅샷을 어떻게 전달하는지는 공식문서에 있는 예제를 조금 변경해서 살펴보도록 하겠습니다. (예제에서는 파이어스토어웹 9버전인 모듈화 버전을 사용했습니다).

import { collection, query, orderBy, startAfter, limit, getDocs } from "firebase/firestore";

// 첫번째 post 컬렉션의 스냅샷을 작성날짜 기준 내림차순 (orderBy 2번째 인자 생략시 기본 내림차순)으로 정렬해 10개의 문서만 받아오기
const first = query(collection(db, "post"), orderBy("timestamp"), limit(10));
const documentSnapshots = await getDocs(first);

// 마지막 문서 스냅샷 기억해해두기 (쿼리결과 스냅샷 크기 - 1 = 마지막 문서 위치)
const lastVisible = documentSnapshots.docs[documentSnapshots.docs.length - 1];

// 앞서 기억해둔 문서값으로 새로운 쿼리 요청 
const next = query(collection(db, "post"),
    orderBy("timestamp"),
    startAfter(lastVisible),
    limit(10));

 

위 예제에서 보이는 바와 같이 startAfter 메서드에 쿼리 시작점을 정의하기 위해 마지막으로 불러온 문서 스냅샷을 전달해줬습니다. startAfter() 메서드는 말 그대로 lastVisible 문서를 포함하지 않고 그다음 문서부터 불러오는 메서드이기 때문에 만약 lastVisible 문서를 포함시키고 추가로 10개의 문서들을 더 불러오려면 startAt()를 사용하시면 됩니다. 일단 파이어스토어 API는 이 정도만 알아두셔도 무한 스크롤이 충분히 구현 가능합니다.

 


 

Intersection Observer API

 

무한 스크롤을 구현하기 위해서 해당 API를 꼭 써야 하는 건 아니지만 스크롤 이벤트가 일어날 때마다 현재 스크롤 위치과 화면의 크기를 비교하여 데이터를 요청하도록 직접 이벤트를 등록하는 방법보다 효율적이고, 무엇보다 예전부터 꼭 한번 사용해보고 싶었던지라 이번 기회에 적용하게 되었습니다. 

 

현재 인터넷 익스플로러를 제외한 모든 브라우저에서 지원이 가능하다 (MDN)

 

Intersection Observer API는 말 그대로 뷰포트와 특정 요소의 교차점 (intersection)을 관찰하는 (observe) 하는 API로 스크롤 리스너를 등록함으로써 일어날 수 있는 불필요한 이벤트 호출을 최소화하기 때문에 랜더링 성능 측면에서도 우위를 점할 수 있습니다. 그리고 무엇보다도 빌트인 web api이기 때문에 별도 패키지를 추가로 설치할 필요도 없습니다.

 

 

교차점을 관찰하는 intersection observer api (Heropy Tech)

 

일단 MDN에 있는 예제를 한번 살펴보겠습니다.

const io = new IntersectionObserver(entries => {
  // intersectionRatio가 0이라는 것은 대상을 볼 수 없다는 것이므로 아무것도 하지 않음
  // 또는 isIntersecting이 false 일때도 마찬가지로 볼 수 없다는 것이므로 아무것도 하지않음
  if (entries[0].intersectionRatio <= 0) return;
  // 또는 if (!entries[0].isIntersecting) return;
  // 10개의 항목씩 불러옴
  loadItems(10);
});

// footer라는 요소를 주시하도록 설정 (주시 해제시 unobserve())
io.observe(document.querySelector('.footer'));

 

위에 보이는 바와 같이 IntersectionObserver 생성자 함수로 새로운 인스턴스를생성할 수 있습니다.

생성된 io 인스턴스에 observe메서드를 통해서 특정 요소를 주시하도록 설정한 뒤, 해당 요소가 뷰포트에 감지되었을 때 특정 콜백 함수 (loadItems)가 실행되도록 할 수 있습니다.

 

이 정도만 알아도 무한 스크롤을 구현하는 데는 큰 문제가 없지만 해당 API에 대해 좀 더 자세히 알고 싶다면 아래 글을 한번 읽어보시는 걸 추천합니다.

 

 

Intersection Observer - 요소의 가시성 관찰

Intersection observer는 기본적으로 브라우저 뷰포트(Viewport)와 설정한 요소(Element)의 교차점을 관찰하며, 요소가 뷰포트에 포함되는지 포함되지 않는지, 더 쉽게는 사용자 화면에 지금 보이는 요소인

heropy.blog

 


재사용성을 위해 React Hook 만들기

이제 파이어스토어에서 부분적으로 데이터를 요청하는 방법과 특정 요소가 뷰포트에 보이기 시작했을 때 특정 콜백 함수가 실행될 수 있도록 해주는 Intersection Observer API에 대해 알아보았으니 이 두 개를 합쳐주기만 하면 됩니다. 예를 들어서   처음에는 10개의 문서만 불러오고 컨텐츠 영역 하단 부분에 도달했을 때 startAfter 메서드를 통해 마지막으로 불러온 문서 이후의 10개 문서를 추가적으로 불러오면 됩니다. 

 

 

필자는 해당 페이지네이션 로직을 여러번 사용할 것 같기 때문에 usePagination이라는 커스텀 hook으로 관리를 해봤습니다. 필자는 현재 최신순으로 피드를 나열하기만 하면 되기 때문에 최신 작성 순 기준으로 정렬을 했지만 세부 필터링 적용을 하셔야 한다면 정렬(orderBy)도 입력값으로 받아 사용하길 바랍니다.

 

hooks/usePagination.js

import { db } from "fb/init";
import {
  collection,
  getDocs,
  limit,
  orderBy,
  query,
  startAfter,
} from "firebase/firestore";
import { useEffect, useState, useCallback } from "react";

// collectionName -> 컬렉션 이름,
// limitCount -> 총 몇개의 데이터를 끊어서 요청할건지, 
// target -> 교차 요소 (요소의 ref 전달) 

const usePagination = (collectionName, limitCount, target) => {
  const [data, setData] = useState([]); // 불러온 문서들 상태
  const [loading, setLoading] = useState(false); // 로딩 상태 
  const [loadingMore, setLoadingMore] = useState(false); // 추가 요청시 로딩 상태
  const [key, setKey] = useState(null); // 마지막으로 불러온 스냅샷 상태
  const [noMore, setNoMore] = useState(false); // 추가로 요청할 데이터 없다는 flag

  // 첫번째 페이지 요청 함수
  const getFirstPage = useCallback(async () => {
    const queryRef = query(
      collection(db, collectionName),
      orderBy("timestamp", "desc"), // 최신 작성순으로 정렬
      limit(limitCount)
    );
    try {
      setLoading(true);
      
      const snap = await getDocs(queryRef);
      const docsArray = snap.docs.map((doc) => ({
        id: doc.id,
        ...doc.data(),
      }));
      
      // 문서 저장
      setData(docsArray);
      
      // 커서로 사용할 마지막 문서 스냅샷 저장
      setKey(snap.docs[snap.docs.length - 1]);
    } catch (err) {
      console.log(err);
    }
    setLoading(false);
  }, [collectionName, limitCount]);

  // 추가 요청 함수
  const loadMore = useCallback(
    async (loadCount) => {
      const queryRef = query(
        collection(db, collectionName),
        orderBy("timestamp", "desc"),
        startAfter(key), // 마지막 커서 기준으로 추가 요청을 보내도록 쿼리 전송
        limit(loadCount)
      );
      try {
        const snap = await getDocs(queryRef);
        snap.empty === 0
          ? setNoMore(true) // 만약 스냅샷이 존재 하지 않는다면 더이상 불러올수 없다는 flag 설정
          : setKey(snap.docs[snap.docs.length - 1]); // 존재한다면 처음과 마찬가지고 마지막 커서 저장
        const docsArray = snap.docs.map((doc) => ({
          id: doc.id,
          ...doc.data(),
        }));
        setData([...data, ...docsArray]); // 기존 데이터와 합쳐서 상태 저장
      } catch (err) {
        console.log(err);
      }
    },
    [collectionName, data, key]
  );

  // 지정된 요소가 화면에 보일때 실행할 콜백함수
  const onIntersect = useCallback(
    async ([entry], observer) => {
     // 만약에 지정한 요소가 화면에 보이거나 현재 데이터를 더 불러오는 상황이 아닐경우,
     // 기존 요소의 주시를 해체하고 추가로 3개의 문서를 더 불러오도록 설정
      if (entry.isIntersecting && !loadingMore) {
        observer.unobserve(entry.target);
        setLoadingMore(true);
        await loadMore(3);
        setLoadingMore(false);
      }
    },
    [loadMore, loadingMore]
  );

 // 처음 화면이 랜더링 되었을때 첫번째 페이지를 문서를 가져오도록 설정
  useEffect(() => {
    getFirstPage();
  }, [getFirstPage]);

 // target 요소의 ref가 전달되었을때 해당 요소를 주시할수 있도록 observer 인스턴스 생성후 전달
  useEffect(() => {
    let observer;
    if (target && !noMore) {
      observer = new IntersectionObserver(onIntersect, {
        threshold: 0,
      });
      observer.observe(target);
    }
    // 메모리 해제 작업
    return () => {
      setLoading(false);
      setLoadingMore(false);
      observer && observer.disconnect();
    };
  }, [target, onIntersect, noMore]);

  return { data,loading, loadingMore, noMore };
};

export default usePagination;

 

이제 반환되는 data, loading, loadingMore, noMore 값으로 원하는 페이지에서 원하는 문서 컬렉션에 대한 무한 스크롤을 구현할 수 있습니다.

 

필자의 경우, 피드 페이지에 적용을 할 것이기 때문에 아래와 비슷한 방식으로 작성을 했습니다.

 

const AllFeed = () => {
  const [target, setTarget] = useState(null);
  const INITIAL_FETCH_COUNT = 5;
  
  const {
    data: feeds,
    loading,
    loadingMore,
    noMore,
  } = usePagination(
    "feeds",
    INITIAL_FETCH_COUNT,
    target
  );
    // .... 컨텐츠 (피드 목록) 생략
        {feeds?.length > 0 && (
          <>
            <div ref={setTarget} />
            <div className={styles.loader}>
              {noMore && (
                <InlineMessage className={styles.message}>
                  더이상 불러올 피드가 없어요
                </InlineMessage>
              )}
            </div>
          </>
        )}
        <div className={styles.loadMore}>{loadingMore && <Loader />}</div>
    </>
  );
};

CSS는 입맛에 맞게 작성을 해주시면 무한 스크롤이 완성됩니다! 

 

잘 작동하는지 한번 확인해보겠습니다.

 

 

 

보시는 것처럼 처음에는 10개를 불러오고 이후에 3개씩 추가 요청이 들어가는 걸 확인할 수 있습니다.

 

워낙 로딩이 빠르게 되어서 잘 보이진 않지만 추가 요청을 할 때 하단 부분에 Spinner가 돌아가기 때문에 유저들도 추가 요청 중이라는 사실을 알 수 있습니다.

 

그럼 이걸로 무한 스크롤이 완성되었는데요.

 

혹시 이해가 되지 않았던 부분이 있거나, 제대로 동작하지 않는 부분이있다면 댓글을 남겨주시길 바랍니다. 

 

 

출처:

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

https://heropy.blog/2019/10/27/intersection-observer/