[React] infinite scroll을 구현해보자

O_K·2022년 6월 10일
7

React

목록 보기
4/4
post-thumbnail

❓ infinie scroll이란

말 그대로 무한 스크롤이다!

한번에 모든 데이터를 불러와서 보여주는 형식이 아닌 사용자가 가장 하단까지 도달하게 된다면 새로운 데이터를 추가로 불러와서 보여주는 사용자 경험(UX) 방식
facebook, instagram이 이러한 방식으로 컨텐츠를 로드하고 있다고 한다



infinite scroll사용시 장점

  • 무한 스크롤이 클릭(페이지네이션)하는 것 보다 더 나은 사용자 경험을 제공
    → 다음 콘텐츠를 보기 위한 추가 클릭이 필요없고 페이지 로드 시간이 짧음

  • 터치스크린(모바일)일때 더 유용하게 적용
    → 화면이 작을수록 스크롤은 길어지기에 모바일 환경에서 콘텐츠를 보여주기 직관적이고 사용하기 쉬운 형식



🤔 왜 사용했나

프로젝트에서 인피니티 스크롤이 필요한 부분은 맥주 리스트를 불러오는 부분이었는데 100개가 넘는 데이터를 한 번에 불러오려고 하니 로딩시간이 길어져 데이터를 쪼개서 불러오는 형식을 도입해야 했다

페이지네이션? 인피니티 스크롤?

그리고 프로젝트가 모바일 기준으로 제작되고 있었다는 점이 가장 크게 작용했다

모바일 기준에서는 화면이 작기 때문에 페이지네이션으로 구현하게 된다면 많은 양의 클릭이 발생할 것이라고 생각했고 페이지네이션 보다는 인피니티 스크롤이 낫다고 판단했다



🧩구현하기 (feat. IntersectionObserver)

IntersectionObserver

간단하게 말하면 페이지 하단이 노출 되었나를 감지하는 API 이다!
-> N% 정도 교차하는 경우 원하는 작업이 이루어지도록 한다

let observer = new IntersectionObserver(callback, options);

intersection observer는 파라미터로 callbackoptions를 가진다


📌 콜백 함수

관찰할 대상(target)이 등록되거나 threshold 만큼 observer가 뷰포트(root)에 교차할 때 콜백이 실행

콜백은 2개의 인수(entries, observer)를 가진다

const io = new IntersectionObserver((entries, observer) => {}, options)

io.observe(element) // 관찰할 target 등록

entries

entries는 IntersectionObserverEntry 인스턴스의 배열
IntersectionObserverEntry인터페이스는 특정 전환 순간에 대상 요소와 루트 컨테이너 간의 교차를 설명

  • boundingClientRect: target의 경계를 사각형으로 반환
  • intersectionRatio: intersectionRectboundingClientRect가 교차한 비율 (target이 root에 교차한 비율)
  • intersectionRect: target과 교차한 부분의 영역 정보
  • isIntersecting: target의 교차 여부(Boolean)
  • rootBounds: root(options에 지정한 뷰포트) 정보
  • target: 관찰 대상 요소
  • time: 언제 교차가 발생했는지
const io = new IntersectionObserver((entries, observer) => {
  entries.forEach(entry => {
    console.log(entry) // IntersectionObserverEntry
  })
}, options)

io.observe(element)

observer

콜백이 실행되는 해당 인스턴스를 참조



📌 options

let options = {
  root: document.querySelector('#scrollArea'),
  rootMargin: '0px',
  threshold: 1.0
}

root

대상 객체의 가시성을 확인할 때 사용되는 뷰포트 요소(대상 객체의 조상 요소)
-> root 내에서 얼마나 보여질 것인지 확인 할 것임
기본값은 null (null일 경우 브라우저의 뷰포트가 기본 사용)

rootMargin

바깥 여백(Margin)을 이용해 Root 범위를 확장하거나 축소할 수 있음
사용 방법은 CSS와 동일 (단위 입력 필수 px %)

threshold

observer의 콜백이 실행될 대상 요소의 가시성 퍼센티지를 나타내는 단일 숫자 혹은 숫자 배열
0 ~ 1.0 사이의 값을 가진다

  • 0 : 요소가 1픽셀이라도 보이자 마자 콜백이 실행
  • 0.5 : 50% 만큼 요소가 보여졌을 때를 콜백이 실행
  • [0, 0.25, 0.5, 0.75, 1] : 배열의 요소만큼 보여졌을 때마다 콜백이 실행


💻 구현 해보자!

  const [beerList, setBeerList] = useState([]); // 보여줄 전체 리스트
  const [offset, setOffset] = useState(0); // back에 요청할 페이지 데이터 순서 정보 
// offset 이후 순서의 데이터부터 12개씩 데이터를 받아올 것임
  const [target, setTarget] = useState(null); // 관찰대상 target
  const [isLoaded, setIsLoaded] = useState(false); // Load 중인가를 판별하는 boolean
// 요청이 여러번 가는 것을 방지하기 위해서
  const [stop, setStop] = useState(false); // 마지막 데이터까지 다 불러온 경우 더이상 요청을 보내지 않기 위해서
// 마지막 부분까지 가버릴 때 계속 요청을 보내는 것 방지


  useEffect(() => {
    let observer;
    if (target && !stop) {
      // callback 함수로 onIntersect를 지정
      observer = new IntersectionObserver(onIntersect, {
        threshold: 1,
      });
      observer.observe(target);
    }
    return () => observer && observer.disconnect();
  }, [target, isLoaded]);


  // isLoaded가 변할 때 실행
  useEffect(() => {
    // isLoaded가 true일 때 + 마지막 페이지가 아닌 경우 = 요청보내기
    if (isLoaded && !stop) {
      axios
        .get( /* 요청 url */, {
          headers: /* token */,
        })
        .then((res) => {
          // 받아온 데이터를 보여줄 전체 리스트에 concat으로 넣어준다
          setBeerList((beerList) => beerList.concat(res.data));
          // 다음 요청할 데이터 offset 정보
          setOffset((offset) => offset + res.data.length);
          // 다음 요청 전까지 요청 그만 보내도록 false로 변경
          setIsLoaded(false);
          
          if (res.data.length < 12) {
            // 전체 데이터를 다 불러온 경우(불러온 값이 12개 보다 적다면 -> 매번 12개씩 불러오기로 했으므로 해당 값보다 작으면 마지막 페이지) 아예 로드를 중지
            setStop(true);
          }
        });
    }
  }, [isLoaded]);

  const getMoreItem = () => {
    // 데이터를 받아오도록 true 로 변경
    setIsLoaded(true);
  };

  // callback
  const onIntersect = async ([entry], observer) => {
    // entry 요소가 교차되거나 Load중이 아니면
    if (entry.isIntersecting && !isLoaded) {
      // 관찰은 일단 멈추고
      observer.unobserve(entry.target);
      // 데이터 불러오기
      await getMoreItem();
      
      // 불러온 후 다시 관찰 실행
      observer.observe(entry.target);
    }
  };

...

return (
  ...
  
  <div className="beerlist-body">
        <Box sx={{ flexGrow: 1 }} className="beerbox">
          <Grid container spacing={{ xs: 2 }}>
            {beerList.map((beer, index) => (
              <Grid item xs={4} sm={3} md={2} key={index}>
                <BeerItem beer={beer} />
              </Grid>
            ))}
            
            // 관찰 대상요소 terget 지정
            // 페이지의 가장 하단에 작성
            <div ref={setTarget}></div>
          </Grid>
        </Box>
	</div>
 ...
 
 )


🌟 결과

같은 요청이 여러 번 가는 문제 때문에 한참 고생했다..

비동기로 실행되기 때문에 다음 데이터를 불러올 때에 아직 offset 값이 변하지 않아서 같은 요청이 간다고 판단 했다

useEffect를 사용해서 각각의 상황을 다 쪼개서 해결!!!
수동적인 동기처리라고 해야할 것 같다😂😅



참고

profile
즐거운 개발자가 목표

2개의 댓글

comment-user-thumbnail
2022년 6월 29일

페이지네이션 적용 안 하신거면 한 번에 데이터 전부 불러온 다음에 렌더링만 순차적으로 하는건가용?

1개의 답글