티스토리 뷰

무한 스크롤(Infinite Scroll)이란? 

  • 사용자가 특정 페이지 하단에 도달했을 때, API가 호출되며 컨텐츠가 끊기지 않고 계속 로드되는 사용자 경험 방식을 말합니다. 페이지를 클릭하면 다음 페이지 주소로 이동하는 페이지네이션(Pagination)과 달리, 한 페이지에서 스크롤만으로 새로운 컨텐츠를 보여주게 되므로, 많은 양의 콘텐츠를 스크롤하여 볼 수 있는 장점이 있습니다. 

무한 스크롤을 구현하려면 가장 간단한 방법으로 스크롤 이벤트를 이용하여 구현할 수 있습니다. 하지만, 스크롤 이벤트를 사용하면 API 호출이 여러 번 갑니다. 이렇게 되면 documentElement.scrollTop과 documentElement.offsetHeight는 리플로우가 발생하기 때문에 개선하지 않으면 성능상 좋지 않게 됩니다.

🔥스크롤 이벤트는 짧은 시간에 수백번, 수천번 호출될 수 있고 '동기적'으로 실행되기 때문에 메인 스레드에 영향을 줍니다. 사용자가 스크롤할 때마다 이를 감지하는 이벤트가 끊임없이 호출되기 때문입니다. 

이때 적용할 수 있는 방안으로는 디바운스와 쓰로틀이 있습니다. 둘 다 이벤트를 컨트롤할 때 사용하는 방법입니다. 여러 이벤트가 발생했을 때 하나의 이벤트만 발생하도록 하거나 일정 주기마다 이벤트를 모아 한번씩 이벤트가 발생하도록 제어할 수 있습니다. 

 

Intersection Observer API 

Intersection Observer(교차 관찰자 API)는 타겟 엘리먼트와 타겟의 부모 혹은 상위 엘리먼트의 뷰포트가 교차되는 부분을 '비동기적'으로 관찰하는 API이다. 

🙋‍♂️viewport ? 현재 화면에 보여지고 있는 다각형의 영역을 말합니다. 
웹 브라우저에서는 현재 창에서 문서를 볼 수 있는 부분을 말합니다. 뷰포트 바깥의 컨텐츠는 스크롤하기 전엔 보이지 않습니다. 

스크롤 이벤트로 무한 스크롤을 구현하면 리플로우에 의해 좋지 않은 렌더링 성능과 상황에 따라 기대한대로 동작하지 않을 수 있는 문제점이 있었습니다. 최근에 이를 해결하기 위해 주로 사용하는 것이 바로 Intersection Observer API입니다.

 

기본적으로 브라우저 Viewport와 Target으로 설정한 요소의 교차점을 관찰하여 그 Target이 Viewport에 포함되는 지 구별하는 기능을 제공합니다. 이를 통해 성능적으로 더 나은 무한 스크롤을 구현할 수 있습니다. 

  • 동기적인 scroll이벤트와 달리 Intersetion Observer API는 요소를 비동기적으로 관찰하기 때문에 이벤트 컨트롤을 위해 debounce와 throttle을 사용하지 않아도 됩니다. 
  • reflow하지 않음 -> 스크롤 이벤트를 통해 무한 스크롤을 구현하면 항상 진짜 DOM에 접근하여 요소의 높이 값을 가져와야하는데 이렇게 되면 매번 새로운 layout을 새로 그리게 됩니다. 
🐱‍👤layout을 새로 그린다는 것은 렌더 트리를 재생성한다는 의미이며, reflow라고도 불리우는 일련의 과정이 반복되면 브라우저의 성능이 저하되고 화면의 버벅거림이 생긴다. 

*이 부분이 이해가 되지 않으신다면 브라우저 렌더링원리에 대해서  우선적으로 공부하는 것을 추천드립니다. 아주 중요한 개념입니다. 

reflow & repaint 관련 추가 정보

🌷  DOM의 속성을 얻거나 갱신하는 것은 거의 같은 비용을 발생시킬 뿐 딱히 DOM객체가 느린 것은 아닙니다. 
문제는 DOM을 건드리면 브라우저가 렌더링 트리를 재계산하고 그에 맞춰 렌더링을 일으킨다는 점입니다. 즉 느려진다는 것은 렌더링 부분입니다. 되도록 DOM의 속성을 건드리지않으려는 것은 렌더링이 느리기 때문입니다.
예를 들어, onClick의 이벤트리스너를 재설정하는 것은 렌더링에 큰 영향을 주지않기 때문에 굳이 가상돔을 경유할 이유가 없습니다. 

🌷 일반적인 통념과 다르게 reflow보다 repaint의 부하가 더 큽니다. 단지 리플로우가 일어나면 많은 부분에 리페인트를 유발하기 때문에 리플로우를 최소화하려는 것입니다. 하지만 리플로우를 최소화했다고 해도 원래 부하가 큰 리페인트가 있다면 소용없습니다. (페인팅 비용이 가장 많이 비용이 많이드는 공정)

Q.이 기능을 언제 적용할 수 있나요? 

  1. Lazy-loading : 페이지 스크롤 시 이미지를 레이지 로딩할 때
  2. 무한 스크롤을 통해 스크롤하며 새로운 콘텐츠를 불러올 때
  3. 광고의 수익 계산하기 위해 광고의 가시성을 참고할 때
  4. 사용자가 결과를 볼 것인지에 따라 애니메이션 동작 여부를 결정할 때

react-intersection-observer

리액트에서는 Intersection observer를 기반으로 만들어진 라이브러리가 있습니다.

이름부터 누가봐도 react-intersection-observer "리액트용 Intersection observer API..."

yarn add react-intersection-observer

를 통해 라이브러리를 추가해줍니다. 

 

사용방식(기존 API의 entry의 isInterscting과 같은 역할이라고 합니다.)

const [ref, inView] = useInView();

ref는 관찰할 대상을 말하고, inView는 타겟이 화면에 보이지 않으면 false, 화면에 보이면 true를 갖습니다. 

 

useEffect를 같이 사용하여 지정한 타겟이 화면에 보일 때마다 서버에 요청을 보내서 포스트를 받아올 수 있도록 합니다.

  useEffect(() => {
     if (inView && hasNextPage) {
       fetch();
     }
   }, [fetch, hasNextPage, inView]);

react-query hooks

useInfiniteQuery() 

  • 기본 사용방법은 useQuery와 비슷합니다. 
  • fetchPage함수는 (기본 쿼리 함수가 정의되지 않은 경우에만) 필수 입니다. 쿼리가 데이터를 요청하는 데 사용할 비동기 함수입니다. 따로 만들어줘야 겠죠? resolve되거나 rejected된 프로미스 객체를 반환해야 합니다.
  • getNextPageParam함수는 단일 변수를 리턴해야 합니다. 해당 단일 변수는 쿼리 함수의 마지막 인자로 전달됩니다. undefined를 리턴하게 되면 펫칭할 다음 페이지가 없다는 의미가 됩니다.
  • 완전 처음 fetchPage가 시작되기 전에 {pageParam = 1} 으로 단일 변수를 넘겨주는 것을 볼 수 있습니다. 간단히 말하면 이게 초기값이라고 생각하면 될 거 같습니다. 페이지를 0부터 호출하고 싶다면 0으로 넘겨주면 됩니다.

 

기본 문법( 옵션들이 굉장히 많기 때문에 해당 문법을 암기한다 생각하시지 말고 우선 코드를 작성하시면서 필요한 기능 문법에 대해서 찾아가며 작성하시는 것이 좋을 거 같습니다 )

const {
  fetchNextPage,
  fetchPreviousPage,
  hasNextPage,
  hasPreviousPage,
  isFetchingNextPage,
  isFetchingPreviousPage,
  ...result
} = useInfiniteQuery(queryKey, ({ pageParam = 1 }) => fetchPage(pageParam), {
  ...options,
  getNextPageParam: (lastPage, allPages) => lastPage.nextCursor,
  getPreviousPageParam: (firstPage, allPages) => firstPage.prevCursor,
})

 

아래 코드는 제가 구현한 무한스크롤 기능입니다. 

 

사용한 hooks

  • useInfiniteQuery() // from react-query
  • useInView() // from react-intersection-observer

src/components/InfiniteList.js

import React, { useEffect } from "react";

import { usePostA } from "../shared/api";
import { useInView } from "react-intersection-observer";

import Item from "./Item";
import Error from "./Error";
import Spinner from "./Spinner";
import NomoreContent from "./NomoreContent";

function InfiniteList() {
  const { ref, inView } = useInView({
    threshold: 0.5,
  });
  const { data, status, fetchNextPage, isFetchingNextPage, hasNextPage } =
    usePostA();

  useEffect(() => {
    if (!data) return;

    if (inView) fetchNextPage();
  }, [inView]);

  if (status === "error") return <Error>서버 에러</Error>;

  return (
    <>
      {data?.pages.map((page, index) => (
        <React.Fragment key={index}>
          {page.itemListA.map((item) => (
            <Item key={item.id} {...item}></Item>
          ))}
        </React.Fragment>
      ))}
      {isFetchingNextPage ? (
        <Spinner />
      ) : hasNextPage ? (
        <div ref={ref}></div>
      ) : (
        <NomoreContent>no more contents</NomoreContent>
      )}
    </>
  );
}

export default InfiniteList;

src/shared/api.js

import * as gvar from "./global_variables";
import { api } from "./axios";
import { useQuery, useInfiniteQuery } from "react-query";

// A포스트 가져오기
const getPostA = async (pageParam) => {
  const res = await api.get(
    `/recruit/${gvar.MY_TOKEN}/a-posts?page=${pageParam}`
  );
  const itemListA = res.data;
  let isLast = pageParam >= 9 ? true : false;

  return { itemListA, nextPage: pageParam + 1, isLast };
};

export const usePostA = () => {
  return useInfiniteQuery(
    "itemListA",
    ({ pageParam = 0 }) => getPostA(pageParam),
    {
      getNextPageParam: (lastPage) =>
        !lastPage.isLast ? lastPage.nextPage : undefined,
    }
  );
};

👾관련 이슈 

처음에 비동기 함수 getPostA만 따로 api 파일에 분리하고 리액트 쿼리로 데이터를 받아오는 함수 usePostA는 infiniteList.js파일에 두었습니다. infiniteList에서 getPostA를 임포트해와 usePostA에서 사용하였습니다.

 

문제상황: 마지막 페이지 리스트 이전까지는 정상적으로 스크롤이 마지막에 닿을 때마다 다음 리스트를 호출하여 가져왔으나  마지막 페이지를 펫칭할 때, 무한 로딩에 빠지게 되었습니다(브라우저 사망).

 

원인 예측

  1. 비동기 처리함수를 임포트해 불러와서 사용하기 때문에 해당 처리(비동기 요청을 끝내고 새로운 객체를 리턴함)가 완료되지 않은 시점에서 데이터를 뽑아와 렌더링하려 했기 때문에  
  2. 원래 리액트 쿼리의 훅에 들어가는 쿼리함수(e.g. axios요청함수)는 같은 파일에 존재해야 한다. 
  3. react query로직에 대해서 제대로 이해하지 못하고 사용했기 때문일 수도 있겠습니다. 이번에 처음 써보는 기능인지라 설명이 부족하겠지만 리액트 쿼리의 hooks들도 비동기적으로 실행되기 때문에 동기적으로 실행하려면 3번째 인자의 옵션 객체에 enabled: !!data 를 주면된다고 합니다. 제 코드에서 사용해보자면 아래와 같이 코드를 수정했다면 비동기 코드와 리액트 쿼리 훅이 다른 파일에 존재하더라도 위와 같은 에러가 해결되지 않을까 싶습니다.  
3번 추가설명: react-query 종속 쿼리입니다. 이전 쿼리의 실행이 끝나지 않으면(끝나야 pageParam이 존재하기 때문에), 혹은 실행 완료값이 falsy한 값이라면 getNextPageParam을 실행시키지 않는다는 의미입니다. 
enabled의 이러한 속성을 활용하여 쿼리를 원하는 때에만 호출할 수 있습니다.  
export const usePostA = (pageParam = 0) => {
  return useInfiniteQuery(
    ["itemListA" pageParam],
    getPostA(pageParam),
    {
      getNextPageParam: (lastPage) =>
        !lastPage.isLast ? lastPage.nextPage : undefined,
        enabled: !!pageParam,
    }
  );

해결방법: 

  • 리액트 쿼리 훅에 들어가는 쿼리 함수를 같은 파일에 같이 두니 해결됐습니다. (2번 원인으로 생각하여 적용해봄)

평가:

  • 결국 모든 원인들이 비동기와 관련되어 있고 리액트 쿼리의 동작 원리를 제대로 파악한다면 해결가능하지 않을까 싶습니다. 
해당 이슈에 대해서 의견이나 해답을 알고 계신분이 있다면 댓글로 둥글게 남겨주시면 감사하겠습니다! 😉
댓글