[React] 무한 스크롤 구현

정정환정정환
2 min read

React로 개발하는 웹 페이지에서 매 렌더링마다 데이터를 가져오게 된다면 불필요하게 많은 데이터 읽기가 발생할 수 있다. 이를 방지하기 위해 Pinterest 무한스크롤처럼 스크롤이 하단에 닿을 때마다 일정 개수만큼 데이터를 가져오도록 구현한 과정이다.

Scroll Sensor 구현

<div className={styles.pageEnd} ref={pageEnd}/>

보이기 쉽게 빨간색으로 표현한 부분이 sensor div이다.

화면에서 sensor div가 보이는지는 IntersectionObserver api을 사용하여 관찰한다.

IntersectionObserver 객체와 콜백함수 생성

const pageEnd = useRef(null);
const observer = new IntersectionObserver(onIntersect, { threshold: 0 });

useEffect(() => {
    if (pageEnd.current) observer.observe(pageEnd.current);
  }, []);

페이지가 완전히 렌더링된 후 useEffect 안에서 observer 객체가 pageEnd.current를 대상으로 관찰할 수 있도록 렌더링한다. threshold는 대상 div의 어느 정도만큼 보일 때 함수를 호출할지를 결정하는 인자이다. (0.5면 절반이 보일 때 함수 호출)

const onIntersect = async ([entry], observer) => {
    if (entry.isIntersecting) {
      observer.unobserve(entry.target);
      await getMorePhotos();
      setTimeout(() => {
        observer.observe(entry.target);
      }, 800);
    }
  };

observer 객체에게 전달하는 콜백함수는 화면과 sensor div가 intersect할 때 호출되는 함수이다.

우리는 다음 데이터를 더 로드하도록 하는 getMorePhotos()를 호출한다.

이 때, 순간적으로 콜백함수가 여러 번 호출되는 것을 막기 위해 onIntersect 안에서 잠시 observe를 해지하고, 일정시간 setTimeout 후 다시 observe를 걸어주었다.

getMorePhotos 구현

let timeStamp = useRef(null); 

const getMorePhotos = async () => {
    console.log("photo request");

    let queryTemp;
    if (!timeStamp) {
      //first query
      queryTemp = query(
        collection(db, "Photos"),
        orderBy("timestamp", "desc"),
        limit(10)
      );
    } else {
      queryTemp = query(
        collection(db, "Photos"),
        orderBy("timestamp", "desc"),
        startAfter(timeStamp),
        limit(10)
      );
    }
    setIsLoading(true);

    let dataSnapShot;
    try {
      dataSnapShot = await getDocs(queryTemp);
    } catch (error) {
      console.log(error);
    }

    const dataList = dataSnapShot.docs.map((doc) => doc.data());

    const length = dataList.length;
    if (length) {
      timeStamp = dataSnapShot.docs[length - 1];
      setPhotos((prev) => [...prev, ...dataList]);
    } else {
      setEndOfData(true);
    }
    setIsLoading(false);
  };

가져오는 데이터에는 timestamp field가 존재한다. 데이터를 시간순으로 display하기 위해 timestamp를 기준으로 쿼리를 작성한다.

if (!timeStamp) {
      //first query
      queryTemp = query(
        collection(db, "Photos"),
        orderBy("timestamp", "desc"),
        limit(10)
      );

가장 처음 데이터를 읽을 때는 시간순으로 정렬된 데이터를 처음부터 (최대)10개를 읽어온다.

timestamp는 useState로 상태관리를 하려했지만, 미상의 이유로 잘 작동하지 않아 useRef로 저장하며 사용하였다.

else {
      queryTemp = query(
        collection(db, "Photos"),
        orderBy("timestamp", "desc"),
        startAfter(timeStamp), //timestamp 뒤에 있는 데이터들
        limit(10)
      );
    }

이후 데이터를 읽을 때는 timeStamp를 기준으로 (최대)10개의 데이터를 읽어온다.

const length = dataList.length;
    if (length) {
      timeStamp = dataSnapShot.docs[length - 1];
      setPhotos((prev) => [...prev, ...dataList]);
    else {
      setEndOfData(true);
    }

그 이후 가져온 데이터의 길이를 받아 timeStamp를 업데이트한다.

만약 받아온 데이터가 10개보다 적다면 endOfData flag를 업데이트한다.

모든 데이터 로드가 끝났을 때

{!endOfData && <div className={styles.pageEnd} ref={pageEnd} />}
{endOfData && (<div
          className={styles.footer}>

모든 데이터가 로드됐을 때는 sensor div의 관찰이 중지되어야 한다.

useEffect를 사용하여 unobserve 처리를 해주어도 좋고, 나는 단순히 요소를 제거해버렸다.

그리고 최종적으로 스크롤이 하단에 닿았으므로 그 자리에 footer를 생성한다.

edited by 정정환

2
Subscribe to my newsletter

Read articles from 정정환 directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

정정환
정정환