2022. 6. 7. 14:07ㆍFirebase
애플리케이션을 만들 때 매번 전체 문서를 반환하도록 로직을 짜게 되면 규모가 커질수록 수백 또는 수천 개의 고비용 쿼리가 발생하기 때문에 정해진 수의 결과만 반환하도록 별도의 처리를 해주는 것이 좋습니다.
이번 포스트에서는 다름 아닌 파이어 스토어에서 문서 데이터를 부분적으로 불러오도록 페이지 네이션 처리를 해보도록 하겠습니다.
아직 파이어 스토어에서 데이터를 불러오고 쓰는 작업이 익숙하지 않으신 분들은 공식문서를 참고하거나 아래 포스트를 참고해주시길 바랍니다.
페이지네이션을 구현하는 방법은 크게 오프셋 페이지네이션과 커서 기반 페이지네이션이 있지만 현재는 파이어스토어에서 오프셋(offset)을 통해 페이제네이션을 효율적으로 구현하는 방법이 없으므로 공식문서에서 권장하는 커서(cusor) 기반의 페이지네이션과 Intersection Observer API의 조합으로 무한 스크롤을 구현해보도록 하겠습니다.
커서(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를 꼭 써야 하는 건 아니지만 스크롤 이벤트가 일어날 때마다 현재 스크롤 위치과 화면의 크기를 비교하여 데이터를 요청하도록 직접 이벤트를 등록하는 방법보다 효율적이고, 무엇보다 예전부터 꼭 한번 사용해보고 싶었던지라 이번 기회에 적용하게 되었습니다.
Intersection Observer API는 말 그대로 뷰포트와 특정 요소의 교차점 (intersection)을 관찰하는 (observe) 하는 API로 스크롤 리스너를 등록함으로써 일어날 수 있는 불필요한 이벤트 호출을 최소화하기 때문에 랜더링 성능 측면에서도 우위를 점할 수 있습니다. 그리고 무엇보다도 빌트인 web api이기 때문에 별도 패키지를 추가로 설치할 필요도 없습니다.
일단 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에 대해 좀 더 자세히 알고 싶다면 아래 글을 한번 읽어보시는 걸 추천합니다.
재사용성을 위해 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
'Firebase' 카테고리의 다른 글
[Firebase 웹] 파이어베이스 스토리지 이미지 업로드 및 링크 가져오기 (0) | 2022.07.09 |
---|---|
[Firebase 웹] 파이어스토어 검색 기능 구현하기(feat. 쿼리문 & Algolia) (6) | 2022.06.19 |
[Firebase 웹] 파이어스토어에서 데이터 일괄 쓰기 및 삭제 (0) | 2022.05.26 |
[Firebase 웹] Cloud Function으로 RESTful API 만들기 (feat. Express) (2) | 2022.03.27 |
[Firebase 웹] 지정된 시간마다 Cloud Function 직접 호출 (2) | 2022.03.06 |