본문 바로가기
항해99/프로젝트

[TIL-36] 무한 스크롤 기능 구현 - Intersection Observer API

by junvely 2023. 6. 19.

무한 스크롤의 도입 장점

페이지네이션은 사용자가 다음페이지로 넘어갈 때 마다 번호를 눌러줘야한다는 단점이 있다. 버튼을 눌렀을 때 페이지가 바뀌는 피로감과 눌러야하는 노동력이 들 수 있다.

무한 스크롤은 이런 단점을 극복할 수 있고, 사용자들이 덜 기다리고, 더 편리하게 웹서핑 할 수 있게 한다.

 

무한 스크롤의 단점

1. 사용자가 현재의 위치를 알기 힘들다.

2. 원하는 위치에 있는 자료를 찾기 힘들다. 

3. 웹페이지의 푸터 부분을 볼 수 없다.

4. 글을 읽고난 후 뒤로가기를 했을 때 원래 위치로 돌아가기 힘들다.

 

무한 스크롤의 도입 이유

우리 프로젝트의 경우, 특정 오피스 정보를 찾을 수 있도록 검색 서비스가 구현되어 있고, 그 외에 아무래도 여러가지 오피스 정보를 계속해서 둘러볼 수 있도록 하기 위해서는 무한 스크롤이 좋다고 생각하여 도입하게 되었다.

또 모바일 사이즈에 최적화된 사이트이기 때문에 터치로 스크롤을 계속해서 내리면서 새로운 정보들을 기다리지 않고 빠르고 편리하게 볼 수 있도록 할 수 있고, DOM을 사용해 빠르고 부드러운 사용성으로 사용자 경험을 고려하여 도입하게 되었다.

 

무한 스크롤의 원리 

1. 컨텐츠의 끝 부분을 감지했다면
2. 다음 페이지의 부분을 불러와 현재 페이지에 붙여 넣는다.

다음과 같은 방법들이 있다.

1. 전통적인 스크롤 감지 방법

2. Intersection Observer를 사용하는 방법

 

내가 선택한 방법은 2번 Intersection Observer를 사용하는 방법이다.

scroll 이벤트로 구현하였을 때의 문제점을 개선하고자 선택한 방법이다.

 

무한 스크롤의 구현에 사용할 API

1. Intersection Observer API란?

Intersection Observer API 는 루트 요소와 타겟 요소의 교차점을 관찰한다. 
그리고 타겟 요소가 루트 요소와 교차하는지 아닌지를 구별하는 기능을 제공하고 있다. 
scroll 이벤트와 다르게 교차 시 비동기적으로 실행되며 가시성 구분 시 reflow 를 발생시키지 않는다. 
여러모로 성능 상 유리하다.

이게 무슨 뜻일까?

scroll 이벤트는 성능에 악영향을 줄 수 있는데 스크롤시 짧은 시간 내에 수 백, 수 천의 이벤트가 동기적으로 실행될 수 있다. 그리고 페이지 내에 각 요소가 각기의 목적(광고, 레이지 로딩, 무한 스크롤 등)의 이유로 scroll 이벤트를 리스닝하기 때문에 이에 상응하는 콜백이 무수히 실행될 수 있다. 이는 메인 스레드에 큰 부하를 줄 수 있다.

기존에는 scroll 이벤트를 사용하여 요소의 가시성을 확인하곤 했다. 하지만 scroll 이벤트는 스크롤이 발생할 때마다 매번 실행되어 성능에 부담을 줄 수 있다. 또한, 요소의 가시성을 확인하기 위해 scroll 이벤트 핸들러 내에서 요소의 위치와 크기를 계산하는 작업을 수행해야 하므로 불필요한 리플로우(reflow)를 발생시킬 수 있다.

이에 비해 Intersection Observer API는 비동기적으로 실행되며, 가시성 변화 시 콜백 함수를 호출한다. 이렇게 함으로써 성능상의 이점을 얻을 수 있습니다. Intersection Observer는 브라우저 엔진 내부에서 최적화되어 있으며, 요소의 가시성에 대한 정보를 제공하기 때문에 개발자는 별도의 계산 작업 없이도 요소의 가시성 상태를 감지할 수 있다.

따라서 Intersection Observer API를 사용하면 스크롤 이벤트보다 효율적이고 성능상으로 유리한 방식으로 요소의 가시성을 감지하고 이에 따른 작업을 수행할 수 있는 것이다.

 

단점은 ?

1. 추가 구현 필요: Intersection Observer API는 교차 여부를 감지하는 기능만 제공하므로 데이터 페칭 및 상태 관리와 관련된 로직은 개발자가 직접 구현해야 한다.

2. 브라우저 호환성: 오래된 브라우저에서는 Intersection Observer API를 지원하지 않을 수 있으므로 브라우저 호환성에 대한 고려가 필요하다.

 

실무에서 느낀 점을 곁들인 Intersection Observer API 정리

실무에서 Intersection Observer API를 사용해보고 느낀 생각정리

velog.io

 

2. React-Virtualized 또는 React-window

스크롤 이벤트 또는 observer로 무한 스크롤을 구현하면 치명적인 단점이 있다. 스크롤을 계속 밑으로 내려서 데이터가 100개 1000개 쌓이게 되면 브라우저 dom은 100개 또는 1000개가 추가 되어 브라우저는 버벅이게 되고 사용자의 ux가 나빠진다. 

intersection observer api를 사용하더라도, 사용자가 스크롤을 아주 많이 내려서 많은 이미지들이 DOM에 렌더링되어있다면 이로 인한 성능저하가 발생할 수 있다는 점에서 한계점이 있다.

React-virtualized 를 사용하면,실제 보이는 컴포넌트만 DOM에 렌더링하여 이러한 문제를 해결 할 수 있다.

react-virtualized는 개발자가 사용할 만한 모든 기능을 다 넣었기 때문에 용량이 크다(CRA같이..) 때문에 

가장 기본적인 기능만 넣어 놓고(그만큼 용량이 작다), 개발자가 원하면 다른 라이브러리를 추가 받는

React-window를 사용하여 효율적이고, React스럽게 구현이 가능할 것 같다. 

우선 무한 스크롤 구현이 처음이기 때문에 Intersection Observer API를 이용해 구현해 보고, 나중에

React-Virtualized 또는 React-window를 이용해 구현해봐도 좋을 것 같다.

 

react-window로 대형 리스트 가상화

react-window는 대형 리스트를 효율적으로 렌더링할 수 있는 라이브러리입니다.

web.dev

 

 

무한 스크롤의 구현 방법

1. 바닥을 감지하기

2.컨텐츠를 다음페이지에서 추출해서 더하기

목표는 list의 내용물의 바닥을 만났을 때 axios로 다음페이지의 내용을 불러와 보여주는 것이다.

Intersection Observer API를 이용해 다음과 같이 구현해 보았다.

function MainPage() {
  const [posts, setPosts] = useState([]);
  const [currPage, setCurrPage] = useState(0);
  const observRef = useRef(null); // 옵저버 ref
  const preventRef = useRef(true); // 중복 방지 옵션
  const endRef = useRef(false); // 마지막 페이지면 옵저버 끄는 옵션
  const [displayObserv, setDisplayObserv] = useState(false); // 옵저버 display

  const { searchQuery, isSearched, updateSearchQuery, resetSearchQuery } =
    useContext(SearchQueryContext);
  const { sorting, district, keyword } = searchQuery;

  const { data, isLoading, isError, refetch } = useQuery(
    'mainPosts',
    () => {
      const result = getMainPosts(searchQuery);
      return result;
    },
    {
      onSuccess: postsData => {
        const { first, last, content } = postsData;
        preventRef.current = true; // 데이터 성공 시 다시 옵저버 실행 조건 true

        if (first) {
          setDisplayObserv(true); // 첫 페이지면 옵저버 버튼 생성(첫 로드 시 옵저버 실행 방지 위해 데이터 받은 후 보이게 설정)
          setCurrPage(0);
          setPosts(content);
          endRef.current = false;
        } else if (!first && last) {
          setDisplayObserv(false); // 마지막 페이지 시 옵저버 버튼 숨김
          endRef.current = true; // 마지막 페이지면 옵저버 실행 옵션 끄기
        } else {
          setDisplayObserv(true);
          setPosts(prev => [...prev, ...content]);
        }
      },
    },
  );

  // 옵저버 실행
  const handleObserver = entries => {
    const target = entries[0];
    // 옵저버 중복 실행 방지 위해 한번 실행 후 데이터 성공시 까지 preventRef.current 옵션 false설정
    if (!endRef.current && target.isIntersecting && preventRef.current) {
      preventRef.current = false;
      setCurrPage(curr => curr + 1);
    }
  };

  useEffect(() => {
    updateSearchQuery({
      ...searchQuery,
      page: currPage,
      sorting: sort,
    });
  }, [currPage, sort]);

  useEffect(() => {
    refetch();
  }, [searchQuery]);

  useEffect(() => {
    // 옵저버 생성, 연결(뷰포트 기준, 옵저버와 교차 시 실행)
    const observer = new IntersectionObserver(handleObserver, {
      threshold: 0,
      rootMargin: '100px',
    });
    if (observRef.current) observer.observe(observRef.current);
    return () => {
      observer.disconnect();
    };
  }, [handleObserver]);

옵저버가 계속해서 중복실행되는 바람에 몇 가지 문제들이 발생했고, 옵저버 실행을 컨트롤할 방법이 필요했다.

 

1. 페이지의 첫 로드 시 옵저버가 뷰포트 상에 노출되어 옵저버가 로드 되자마자 1 -> 2로 페이지가 증가되는 현상 발생, 마지막 페이지 시 옵저버 버튼이 계속해서 노출되어 페이지가 끝나도 옵저버를 실행 시킴

 

해결방법 : displayObserv 상태를 관리해 처음 페이지 로드 시 옵저버가 보이지 않고, 데이터 받기에 성공하면 옵저버가 보이도록 방지 마지막 페이지 시 옵저버 버튼이 계속해서 노출되어 옵저버를 실행시키지 않도록 방지

  const [displayObserv, setDisplayObserv] = useState(false); // 옵저버 display

const { data, isLoading, isError, refetch } = useQuery(
    'mainPosts',
    () => {
      const result = getMainPosts(searchQuery);
      return result;
    },
    {
      onSuccess: postsData => {
        const { first, last, content } = postsData;
        preventRef.current = true; // 데이터 성공 시 다시 옵저버 실행 조건 true

        if (first) {
          setDisplayObserv(true); // 첫 페이지면 옵저버 버튼 생성(첫 로드 시 옵저버 실행 방지 위해 데이터 받은 후 보이게 설정)
          setCurrPage(0);
          setPosts(content);
          endRef.current = false;
        } else if (!first && last) {
          setDisplayObserv(false); // 마지막 페이지 시 옵저버 버튼 숨김
          endRef.current = true; // 마지막 페이지면 옵저버 실행 옵션 끄기
        } else {
          setDisplayObserv(true);
          setPosts(prev => [...prev, ...content]);
        }
      },
    },
  );

 

 

❗2. 한 번 스크롤 시 옵저버 버튼이 뷰포트 상에 계속해서 노출되며 옵저버가 계속 중복 실행되는 현상 발생

해결 방법 : preventRef 옵션과 endRef 옵션을 이용해 옵저버 실행을 컨트롤 하여 중복 실행 방지

  const preventRef = useRef(true); // 중복 방지 옵션
  const endRef = useRef(false); // 마지막 페이지면 옵저버 끄는 옵션
  
   const { data, isLoading, isError, refetch } = useQuery(
    'mainPosts',
    () => {
      const result = getMainPosts(searchQuery);
      return result;
    },
    {
      onSuccess: postsData => {
        const { first, last, content } = postsData;
        preventRef.current = true; // 데이터 성공 시 다시 옵저버 실행 조건 true

        if (first) {
          setDisplayObserv(true); // 첫 페이지면 옵저버 버튼 생성(첫 로드 시 옵저버 실행 방지 위해 데이터 받은 후 보이게 설정)
          setCurrPage(0);
          setPosts(content);
          endRef.current = false;
        } else if (!first && last) {
          setDisplayObserv(false); // 마지막 페이지 시 옵저버 버튼 숨김
          endRef.current = true; // 마지막 페이지면 옵저버 실행 옵션 끄기
        } else {
          setDisplayObserv(true);
          setPosts(prev => [...prev, ...content]);
        }
      },
    },
  );
  
  // 옵저버 실행
  const handleObserver = entries => {
    const target = entries[0];
    // 옵저버 중복 실행 방지 위해 한번 실행 후 데이터 성공시 까지 preventRef.current 옵션 false설정
    if (!endRef.current && target.isIntersecting && preventRef.current) {
      preventRef.current = false;
      setCurrPage(curr => curr + 1);
    }
  };

오늘은 여기까지 정리하고, 성능에 관한 문제가 발생하여 다음 포스팅에서 정리하고자 한다.