본문 바로가기
개발/React

React 무한 스크롤

by JunsC 2024. 6. 10.
728x90

리액트에서 페이징 처리를 해야하는 상황이였다...

주로 앱을 작업하였기 때문에 Swift 에서는 tablecell 로 스크롤이 마지막에 닿으면 그때 쓰레드를 통한 페이징처리를 작업하였고 

Java에서도 역시 recyclerview 를 이용한 페이징 처리를 진행하였다.

하지만 웹에서는 어떻게 스크롤 끝을 감지하여 페이징하는지 잘 몰라서 검색해보았다..

무한 스크롤을 통해서 페이징 처리를 하려고 했기 때문에 정보가 필요했다.

 

뭔가 스크롤 끝 감지해서 그 끝에서 로직을 처리해주면 되는데 스크롤 끝은 감지하는 부분을 몰랐다.

 

 

무한스크롤 구현 !

 

1. Scroll Event ( 스크롤 이벤트 )

처음에는 scroll 이벤트를 감지하다가, 페이지 가장 아래에 닿았을 때 쯤 API 요청을 하면 되지 않을까? 싶었다.
하지만 이렇게 구현하게 되면 scroll 이벤트를 계~속 감지해야하기 때문에 성능 저하로 이어질 수 있는 문제가 있다.

이를 해결하기 위해 debounce나 throttle를 적용해 일부 성능을 개선할 수 있다. 두가지 모두 시간의 텀을 두어 조금이나마 이벤트 감지 횟수를 줄일 수 있게 한다.

 

 

Debounce 적용

 

import React, { useState, useEffect, useRef, useCallback } from "react";
import _ from "lodash"; // lodash 라이브러리 사용

const fetchData = async (page) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(Array.from({ length: 10 }, (_, i) => `Item ${page * 10 + i + 1}`));
    }, 1000);
  });
};

const InfiniteScrollDebounced = () => {
  const [items, setItems] = useState([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const listRef = useRef(null);

  useEffect(() => {
    const loadMore = async () => {
      setLoading(true);
      const newItems = await fetchData(page);
      setItems((prev) => [...prev, ...newItems]);
      setLoading(false);
    };

    loadMore();
  }, [page]);

  // ✅ `debounce` 적용 (300ms 동안 스크롤 이벤트 연속 발생 시, 마지막 이벤트만 실행)
  const handleScroll = useCallback(
    _.debounce(() => {
      if (!listRef.current) return;
      const { scrollTop, scrollHeight, clientHeight } = listRef.current;

      if (scrollTop + clientHeight >= scrollHeight - 100 && !loading) {
        setPage((prev) => prev + 1);
      }
    }, 300),
    [loading]
  );

  useEffect(() => {
    const container = listRef.current;
    if (!container) return;

    container.addEventListener("scroll", handleScroll);
    return () => container.removeEventListener("scroll", handleScroll);
  }, [handleScroll]);

  return (
    <div
      ref={listRef}
      style={{
        height: "500px",
        overflowY: "auto",
        border: "1px solid black",
        padding: "10px",
      }}
    >
      <h2>Infinite Scroll (Debounce 적용)</h2>
      <ul>
        {items.map((item, index) => (
          <li key={index}>{item}</li>
        ))}
      </ul>
      {loading && <p>Loading...</p>}
    </div>
  );
};

export default InfiniteScrollDebounced;

 

throttle 적용

 

const handleScroll = useCallback(
  _.throttle(() => {
    if (!listRef.current) return;
    const { scrollTop, scrollHeight, clientHeight } = listRef.current;

    if (scrollTop + clientHeight >= scrollHeight - 100 && !loading) {
      setPage((prev) => prev + 1);
    }
  }, 500), // 500ms마다 실행
  [loading]
);

 

 

🔥 debounce vs throttle 차이

방식동작 방식사용 예시

Debounce 이벤트 발생 후 일정 시간이 지나면 실행 (마지막 이벤트만 처리) 검색창 입력, 스크롤 감지
Throttle 일정 간격마다 실행 (연속 실행 제한) 무한 스크롤, 마우스 이동 감지

💡 무한 스크롤에서는 throttle이 적합하지만, 너무 자주 호출되면 debounce도 고려할 수 있음!

 

 

 

 

2. Intersection Observer API ( 인터섹션 옵저버 )

우선은 Intersection Observer API를 사용하는 방법을 택하기로 한다.

Intersection Observer API는 타겟 요소와 상위 요소 또는 최상위 document 의 viewport 사이의 intersection 내의 변화를 비동기적으로 관찰하는 방법입니다. (출처: MDN)

해당 API를 통해 스크롤을 내리다가 타겟이 viewport에 들어오면 데이터를 호출하는 식으로 구현할 수 있다.

 

 

📌 주요 기능

  • 스크롤이 하단에 도달하면 다음 페이지의 데이터를 불러옴
  • IntersectionObserver를 사용하여 특정 요소가 화면에 보일 때 API 호출
  • React의 useState와 useEffect를 활용

✅ 코드 구현 (React + Intersection Observer)

import React, { useEffect, useState, useRef, useCallback } from "react";

const fetchData = async (page) => {
  // 임시 API 데이터 호출 시뮬레이션
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(Array.from({ length: 10 }, (_, i) => `Item ${page * 10 + i + 1}`));
    }, 1000);
  });
};

const InfiniteScroll = () => {
  const [items, setItems] = useState([]); // 아이템 목록
  const [page, setPage] = useState(1); // 현재 페이지
  const [loading, setLoading] = useState(false); // 로딩 상태
  const observerRef = useRef(null); // Intersection Observer 참조

  const lastItemRef = useCallback(
    (node) => {
      if (loading) return;
      if (observerRef.current) observerRef.current.disconnect();
      
      observerRef.current = new IntersectionObserver(([entry]) => {
        if (entry.isIntersecting) {
          setPage((prev) => prev + 1);
        }
      });

      if (node) observerRef.current.observe(node);
    },
    [loading]
  );

  useEffect(() => {
    const loadMore = async () => {
      setLoading(true);
      const newItems = await fetchData(page);
      setItems((prev) => [...prev, ...newItems]);
      setLoading(false);
    };

    loadMore();
  }, [page]);

  return (
    <div>
      <h1>Infinite Scroll with Intersection Observer</h1>
      <ul>
        {items.map((item, index) => (
          <li key={index} ref={index === items.length - 1 ? lastItemRef : null}>
            {item}
          </li>
        ))}
      </ul>
      {loading && <p>Loading...</p>}
    </div>
  );
};

export default InfiniteScroll;
 

✅ 코드 설명

  1. fetchData(page): 페이지 단위로 데이터를 가져오는 함수 (API 요청을 가정한 비동기 처리)
  2. useState:
    • items: 불러온 데이터 목록
    • page: 현재 페이지 번호
    • loading: 데이터를 불러오는 중인지 여부
  3. useEffect:
    • page가 변경될 때마다 새로운 데이터를 로드
  4. IntersectionObserver:
    • useCallback을 사용하여 lastItemRef가 마지막 아이템을 감지
    • 마지막 아이템이 화면에 보이면 setPage를 증가시켜 다음 데이터를 가져옴
    • 기존 observerRef.current를 disconnect 후 새로운 observer를 등록하여 중복 감지 방지

 

 

 

좀 더 살펴보자면,

 

1. Target

const [target, setTarget] = useState(null);
const targetStyle = { width: "100%", height: "200px" };

return (
  <div>
    <div ref={setTarget} style={targetStyle}>
      This is Target.
    </div>
  </div>
);

 

2. Observer 

useEffect(() => {
  let observer;
  if (target) {
    observer = new IntersectionObserver();
    observer.observe(target);
  }
}, [target]);

 

3. Callback 

const onIntersect = async ([entry], observer) => {
  if (entry.isIntersecting) {
    observer.unobserve(entry.target);
    await /* 데이터 페칭 함수*/
    observer.observe(entry.target);
  }
};

 

4. Fetching

const page = 1;
const fetchData = async () => {
  const response = await fetch(`/api/db/${page}`);
  const data = await response.json();
  setItems((prev) => prev.concat(data.results));
  page++;
};

 

이렇게 적용해보면 어떨지 싶다.. !!

 

 

 

진짜 많이 발전한듯하다 코드들이..

스크롤 감지 부분은 옛날 코드에서는 좀 길었던 것 같긴 했었다... ( 내가 정보력이 없었나 ??) 

 

근데 이 코드 하나만으로 맨 끝 감지할 수 있어서 정말로 편했다.

 

Swift , Aos 같은 경우는 Collectionview , Recyclerview 의 리스너에서 맨 밑 감지하도록 높이나 메소드를 가져와서 하는데 React 는 자바스크립트 기반이라 바로 한 코드로 할 수 있어서 편했다.

 

'개발 > React' 카테고리의 다른 글

React_ Semantic 구조  (2) 2024.08.07
React_ npm module @types 의 의미  (0) 2024.07.29
React 프로젝트 생성 feat. Typescript  (0) 2024.06.13
React 새로고침 데이터 저장  (1) 2024.06.09
React useMemo.. useCallback.. memo..  (1) 2024.06.09
"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다."