infinite scroll

주제 : 무한스크롤을 구현 할 때 고려해본 것들(react)

목차

  1. 무한스크롤 == pagination
  2. offset, limit 을 이용한 pagination
  3. cursor based pagination
  4. data fetch 의 타이밍
  5. scroll과 history
  1. 결국은 사용성 fake loading , lazy loading?
  2. 궁금하지만 아직 해결하지 못한 부분

1. 무한스크롤과 pagination

무한 스크롤은 스크롤이 끝나지 않고 쭉 내려가는 UI입니다. 사용자가 봤을 때는 데이터가 계속 보여지는 것 같지만 데이터를 어떻게 로드할까를 생각해본다면 페이지의 개념이라고 생각 할 수 있습니다.

image.png

하지만 다음페이지 버튼을 눌렀을 때 다음 페이지에 해당하는 데이터를 로드해 오는 것처럼 생각한다면 페이지네이션과 같다고 생각할 수 있습니다.

2. offset, limit을 이용한 pagination 구현

pagination 을 구현하기 위해서는 database의 offset, limit의 개념을 사용할 수 있습니다.
offset은 어느 index부터 데이터를 조회할 것인지 limit은 offset으로부터 어디까지의 데이터를 조회할 것인지를 의미합니다.

const OFFSET = 10;
const fetchMoreFeed = async () => {
    const { data: value } = await fetchMore({
      variables: {
        first: OFFSET,
        after: cursorIdx,
      },
      updateQuery: (prev, { fetchMoreResult }) => {
        if (!fetchMoreResult) {
          return prev;
        } else {
          const { feeds: feedItems } = fetchMoreResult;
          setCursorIdx(OFFSET + cursorIdx);
        }
        ...
      }
    });
    ...
  };
  • apollo useQuery Hook을 이용해서 데이터 요청

3. cursor based pagination

하지만 문제가 있습니다 내가 다음글을 보기 전에 새로운 글이 등록되거나, 삭제되었다면 DB에서는 이미 조회한 글이나 다다음 글을 조회해 올 수 있습니다.

저희가 진행하고 있는 facebook과 같이 사용자들의 글이 실시간으로 등록되고 보여져야 되는 경우도 이런 경우라고 생각했습니다. 그래서!

cusrsor based pagination을 적용하기로 했습니다. 지금 보고있는 피드의 마지막 item id 이후의 데이터만 조회하는 것 입니다.

여기서 feed item의 id는 고유한 값이여야 합니다. 그래야 데이터베이스쪽에서 잘못된 값을 조회하지 않으니까요.

 const [cursor, setCursor] = useState<string>("9999-12-31T09:29:26.050Z");
const OFFSET = 10;
const fetchMoreFeed = async () => {
    const { data: value } = await fetchMore({
      variables: {
        first: OFFSET,
        currentCursor: cursor
      },
      updateQuery: (prev, { fetchMoreResult }) => {
        if (!fetchMoreResult) {
          return prev;
        } else {
          const { feeds: feedItems } = fetchMoreResult;
          const lastFeedItem = feedItems[feedItems.length - 1];
 setCursor(lastFeedItem.createdAt);
        }

        return ...
      }
    });
  • 마지막으로 feed item의 시간을 cursor로 결정하고 해당 피드 이후의 피드들에서 offset으로 가지고오기로 했습니다
  • feed item의 등록 시간은 밀리초까지 저장되는 datetime type입니다.

data fetch 의 타이밍

그럼 데이터는 언제 불러서 언제 로드해야할까요? 무한 스크롤이므로 스크롤이 스크린의 바닥에 닿았을 때 새로운 데이터들을 로드해와야 합니다.

현재 저희는 react 와 graphql neo4j를 이용해서 개발을 진행하고 있습니다.
리엑트에서의 저희가 구현한 방법으로 예시를 들겠습니다.

  1. scroll 이벤트의 감지
    • document height : 문서 전체의 높이
    • window height : 화면의 높이.
    • scroll top :스크롤의 top이 위치하고 있는 높이.
document.documentElement.scrollTop + document.documentElement.clientHeight === document.documentElement.scrollHeight
  1. scroll의 끝을 감지하는 hook
    const useScrollEnd = () => {
    const [state, setState] = useState(false);
    const onScroll = () => {
     if (
       document.documentElement.scrollTop +
         document.documentElement.clientHeight ===
       document.documentElement.scrollHeight
     ) {
       setState(true);
     } else {
       setState(false);
     }
    };
    useEffect(() => {
     window.addEventListener("scroll", onScroll);
     // 스크롤 이벤트는 꼭 삭제해줍니다!
     return () => window.removeEventListener("scroll", onScroll);
    }, []);
    return state;
    };
  2. 해당 상황이 감지가되어서 상태값을 바꿀 때 마다 앞에서 구현한 fetchMoreFeed를 수행합니다.

intersectionObserver

그런데 스크롤 위치를 저렇게 감지하는 것은 동기적으로 작동됩니다. 하지만 intersectionObserver을 사용하면 비동기적으로 이벤트를 감지할 수 있게 됩니다.

정의 : provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document's *viewport.
=> 비동기적으로 타겟 엘리먼트와, 해당 엘리먼트의 부모나 뷰포트상에서의 교차 지점을 감지하는 방법

intersectionObserver web api 에서는 전체 화면을 viewport로 하고 그 안에 구독으로 등록 해 놓은 element 가 들어왔는지를 감지하고 이벤트를 발생시킵니다. 옵져버니까요.

그래서 페이지의 제일 끝에 감지용 element를 두고 해당 element가 viewport에 보여지면 새로운 데이터를 불러오는 방식을 적용하였습니다.

*viewport?
컴퓨터 그래픽상에서 현재보여지는 화면 영역

how to

let options = {
  root: document.querySelector('#scrollArea'),
  rootMargin: '0px',
  threshold: 1.0
}
// 1. IntersectionObserver를 만든다
let observer = new IntersectionObserver(callback, options);
// 2. tar 만든다
let target = document.querySelector('#listItem');
observer.observe(target)
  const [ref, setRef] = useState(null);

  const checkIntersect = (
    entry: IntersectionObserverEntry[],
    observer: IntersectionObserver
  ) => {
    if (entry[0].isIntersecting) {
      interSectAction();
    }
  };

  useEffect(() => {
    let observer: any;
    if (ref) {
      observer = new IntersectionObserver(checkIntersect, baseOption);
      observer.observe(ref);
    }
    return () => observer && observer.disconnect();
  }, [ref, option.root, option.threshold, option.rootMargin, checkIntersect]);
  return [ref, setRef];

ref : intersectionObserver mdn
https://tech.lezhin.com/2017/07/13/intersectionobserver-overview

scroll과 history

이제 스크롤에 따라 계속 데이터가 계속 이어져서 보이는 것처럼 보입니다.하지만 만약 다른 페이지로 갔다가 뒤로가기를 누른다면 어떻게 될까요? 다른 게시글에 갔다가 돌아왔을 때 다시 맨 위부터 다시 보이는 것 보다 보고 있던 페이지 근처의 데이터가 보이는 것이 더 좋을 것 같습니다.

그래서 하나의 목록을 쭉 불러온 다음 페이지의 끝에 페이지 정보를 저장하면 되지 않을까..??.

restoration

image.png

Fake Loading / Lazy Loading

intersection observer 를 사용해서 해당 이미지가 뷰포트에 들어올 때 로드하도록 설정

windowing

목록이 길어진 경우 뷰포트에서 사라질 경우 돔에서 랜더링하지 않도록 하는 기술로
https://ko.reactjs.org/docs/optimizing-performance.html#virtualize-long-lists 기법을 사용할 수 있다.

8.궁금하지만 아직 해결하지 못한 부분

  1. 캐싱 문제

  2. Scroll Optimzation

    throttling

    이벤트를 일정한 주기마다 발생하도록 하는 기술

    debouncing

    연이어 호출되는 함수들 중 마지막 함수(또는 제일 처음)만 호출하도록 하는 것

  3. fetch 해올 때 state가 변경된다면 컴포넌트가 전부 리렌더링 되지 않는지