FE Study/React

[React] 무한 스크롤 구현 및 최적화 (Scroll Event)

jae1004 2023. 11. 16. 20:03

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

사용자가 특정 페이지 하단에 도달했을 때, 추가적인 데이터를 불러오는 API를 호출하여 콘텐츠가 끊기지 않고 계속 로드되는 방식을 말합니다.

한 번의 API 요청으로 모든 데이터를 불러오는 경우, 데이터 로딩 시간이 길어질 수 있습니다. 이러한 시간을 단축하고 사용자에게 더욱 많은 양의 데이터를 제공하기 위해 무한 스크롤 방식으로 데이터를 제공하고자 합니다.

 

사용자에게 데이터 리스트를 제공하는 방식은 크게 두 가지로 나누어집니다.

X 페이지 네이션 (Pagination) 무한 스크롤 (Infinite Scroll)
장점 페이지 번호를 통해 원하는 정보가 있는 페이지로 이동 가능 → 원하는 정보 찾기 쉬움 콘텐츠가 끊기지 않고 계속 로드되는 방식 → 많은 양의 데이터를 스크롤 해서 볼 수 있음
단점 추가적인 데이터를 보기 위해 사용자가 행동을 취해야 함 (페이지 번호 터치) • 원하는 데이터의 위치를 기억하기 어려움
• 스크롤 막대로 정확한 정보량을 알 수 없음

 

 

두 가지 방식 모두 장, 단점이 뚜렷해, 사용 용도상황을 고려하여 선택하는 하는 것이 가장 좋은 방법일 것 같습니다.


그렇지만 저의 생각으로는 단순히 사용자에게 더 많은 양의 데이터를 보여줘야 한다면, 무한 스크롤을 사용하는 게 더 유리하다고 생각합니다. 왜냐하면 단순히 흥미로 방문한 사용자에게 더 많은 데이터를 보기 위해서 추가적인 액션을 취해야 한다면, 사용자의 입장에서는 데이터 접근에 대한 허들로 느껴질 수 있다고 생각합니다. 또한 페이지 이동 시 발생하는 로딩 시간이 무한 스크롤에 비해 더 길게 느껴질 수 있습니다.


구현 환경

  • TypeScript, Vite, React, loadash, React Query v4
  • Macbook Pro 13 (M2) 

구현 방법

Scroll Event 감지하는 방법

: 스크롤이 페이지 끝에 닿는 것을 감지해 추가 데이터를 요청해 받아오는 방식

 

스크롤이 페이지 끝에 닿는 것을 감지하기 위해 documentElement의 높이값 속성들에 대해 알아야 합니다.

 

scrollHeight : 페이지의 전체 높이

 

scrollTop : 스크롤 된 높이 ⇒ 총 스크롤 한 높이 값

- 화면 상단이 기준

- 상대적이고 가변적인 값

 

clientHeight : 현재 스크린 상에 보이는 화면 내부의 높이 실제 보이는 영역에 대한 높이

- content + padding 값을 의미

 

offsetHeight : 현재 스크린 상에 보이는 화면 내부의 전체 높이

- content + padding + border(테두리) + 스크롤바까지 포함한 전체 높이

- margin은 포함 X

 

※  clientHeight와 offsetHeight는 거의 비슷한 값을 나타내지만, 테두리가 페이지 레이아웃에 영향을 미치는 중요한 요소일 경우, offsetHeight를 사용할 수 있고, 다른 방법으로는 현재 보이는 부분, 즉 사용자가 실제 보는 부분에 대해 고려한다면 clientHeight를 사용할 수 있습니다. 어떤 속성을 사용할지는 구현하고자 하는 특정한 UI/UX 및 페이지 레이아웃에 따라 달라질 수 있습니다.

 

위의 값들을 기반으로 전체 페이지 높이 <= 지금까지 스크롤 한 길이 + 현재 보이는 부분 일 때, 끝에 닿았다고 인식할 수 있습니다.

const scrollHeight = document.documentElement.scrollHeight;
const scrollTop = document.documentElement.scrollTop;
const clientHeight = document.documentElement.offsetHeight;

if (scrollTop + offsetHeight >= scrollHeight) {
 	// 데이터 추가 요청 코드
};

이 코드를 기반으로 useInfiniteScroll이라는 Cutsom Hook을 만들어 위의 조건에 만족할 경우 데이터 추가 요청을 하는 코드를 작성해 보겠습니다.

hooks/useInfiniteScroll.ts
import React, { useEffect, useState } from 'react';
import { throttle } from 'lodash';

interface InfiniteScrollProps {
  fetchCallback: () => void;
}

const useInfiniteScroll = ({ fetchCallback }: InfiniteScrollProps) => {
  const [isFetching, setIsFetching] = useState<boolean>(false);

  // throttle: 0.3초에 한번씩만 실행되도록 설정
  const handleScroll = throttle(async () => {
    const scrollHeight = document.documentElement.scrollHeight;
    const scrollTop = document.documentElement.scrollTop;
    const offsetHeight = document.documentElement.offsetHeight;

    if (scrollTop + offsetHeight >= scrollHeight) {
      setIsFetching(true);

      try {
        await fetchCallback();
      } catch (error) {
        console.error('Error fetching data:', error);
      } finally {
        setIsFetching(false);
      }
    }
  }, 300);

  useEffect(() => {
    window.addEventListener('scroll', handleScroll);

    return () => {
      window.removeEventListener('scroll', handleScroll);
    };
  }, [handleScroll]);

  return isFetching;
};

export default useInfiniteScroll;
  1. fetchCallback은 스크롤이 끝에 닿았을 경우 추가 데이터를 불러오는 함수입니다.
  2. isFetching 데이터 로딩 상태를 나타내는 'boolean' 값으로, 데이터를 불러오는 동안 'true'로 설정되고, 데이터를 불러온 후 'false'로 변경합니다. 사용자 인터페이스에서 로딩 상태를 표시하는 데 사용할 수 있습니다.
  3. handleScroll 함수는 스크롤 이벤트에 대한 처리 함수로 throttle을 사용해 0.3초마다 한 번씩만 실행되도록 합니다. 이를 통해 scroll event 핸들러의 호출 빈도를 줄여 성능을 향상시켰습니다. (이 내용은 밑에서 추가로 설명하겠습니다.)
    • 사용자가 페이지 하단에 도달했는지 확인하고, 페이지 하단에 도달한 경우 fetchCallback을 호출 해 추가 데이터를 불러옵니다.
  4. useEffect를 통해 컴포넌트가 마운트 될 때 window 객체에 scroll이벤트 리스너를 추가하고, handleScroll 함수를 이벤트 리스너로 설정합니다. 컴포넌트가 언마운트될 때 이벤트 리스너를 제거합니다. ( 메모리 누수 방지 )
컴포넌트에서 사용한 예시
const List = () => {
  // 서버에서 데이터를 불러오는 함수
  const fetchList = async ({ pageParam = 1 }) => {
  	try {
      axios.get('url', {
        params: { pageNo: pageParam, pageSize: 10 }, 
      })
      return { 
      	data: response.data, 
       	  nextPage: response.data.length ? pageParam + 1 : undefined 
        };
    } catch(error) {
      return { data: [], nextPage: undefined };
    }
  };

  // React Query의 useInfiniteQuery 사용 
  const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } = useInfiniteQuery(
    ['list'],
    ({ pageParam = 1 }) => fetchList({ pageParam }),
    {
      getNextPageParam: (lastPage) => {
        return lastPage?.data.length ? lastPage.nextPage : undefined;
      },
    },
  );

  // 데이터를 추가로 불러올지 결정
  const loadMore = async () => {
    if (hasNextPage && !isFetchingNextPage) {
      fetchNextPage();
    }
  };

  useInfiniteScroll({ fetchCallback: loadMore });

  return (
      <div>
        {isLoading ? (
          <Loading />
        ) : data ? (
          <ProductForm items={data?.pages.flatMap((page) => page.data)} />
        ) : (
          <p>목록이 없습니다.</p>
        )}
      </div>
  );
};

pageNo는 페이지 번호를 말하고, 데이터를 불러온 횟수입니다. (0 혹은 1부터 시작)

pageSize는 불러올 데이터의 수를 말합니다. 즉 데이터 목록의 개수입니다.

이를 통해 url?pageNo=1&pageSize=10 같은 url을 통해 데이터를 불러올 수 있고, 데이터를 불러온 횟수에 따라 pageNo 값이 증가합니다.

 

React Query 라이브러리의 useInfiniteQuery 훅은 무한 스크롤 기능 구현 시 많은 도움을 줍니다. 아래의 반환값들을 제공해 줍니다.

  • 반환값
    • data : 받아온 데이터 리스트 (['list']로 저장)
    • isLoading : 로딩 상태로, 데이터를 불러오고 있는지 혹은 완료 여부
    • hasNextPage : 다음 페이지 존재 여부
    • fetchNextPage : 다음 페이지를 불러오는 함수
    • isFetchingNextPage : 다음 페이지를 불러오는 여부

하지만 데이터를 사용할 때

data?.pages.flatMap((page) => page.data)

위의 코드와 같이 데이터를 평탄화해 전달해야 합니다.

 

useInfiniteQuery | TanStack Query Docs

tsx const { fetchNextPage, fetchPreviousPage, hasNextPage, hasPreviousPage, isFetchingNextPage, isFetchingPreviousPage, ...result} = useInfiniteQuery({ queryKey, queryFn: ({ pageParam = 1 }) => fetchPage(pageParam), ...options, getNextPageParam: (lastPage,

tanstack.com

loadMore 함수는 추가적인 데이터를 불러올지 결정하는 함수입니다. 다음 페이지가 있고, 다음 페이지를 불러오는 중이 아니라면, 다음 페이지를 호출합니다.

위 코드에서는 데이터를 모두 불러온 후 서버에서 Error를 반환하면 더 이상 데이터를 불러오지 않도록 설정했습니다. 

예를 들어 설명하자면, 데이터가 있는 pageNo가 1부터 3까지 라면, pageNo가 4에 대한 요청을 서버에 보내면, 더 이상 데이터가 없으므로 404 Not Found 같은 에러를 반환합니다. 이 경우 try-catch 문을 통해, 빈 배열([])을 리턴하고 그 이후에는 더 이상 데이터를 불러오지 않습니다.


Scroll Event 최적화

"스크롤 이벤트가 발생할 때마다" 끝에 도달했는지 검사하기 때문에, 불필요하게 이벤트가 많이 발생해 성능 저하의 문제가 발생할 수 있습니다. 그래서 위의 코드에서는 lodash 라이브러리throttle을 적용해 이벤트 발생을 줄였습니다. 

  • Throttle : 일정 주기마다 이벤트를 모아 이벤트가 지정한 시간 단위당 한 번만 발생하도록 하는 기술

 scroll을 하고 있는 동안 300ms마다 이벤트 핸들러가 호출되어 scroll event 핸들러의 호출 빈도를 줄였습니다.

 

또 다른 최적화 방법은 rAF(requestAnimationFrame)를 사용한 최적화입니다. rAF는 브라우저가 렌더링 하는 빈도 60fps(초당 60회)에 맞춰서 실행되어 초당 60회의 실행을 좀 더 보장해 줍니다.

여기서 좀 더 보장해 준다고 한 이유는 rAF와 throttle은 비동기 방식으로 동작되는 것이기 때문에 완벽하게 횟수를 보장하지 못하기 때문입니다. 

자바스크립트의 콜 스택(call stack)은 싱글 스레드이기 때문에 발생하는 현상입니다.
  • throttle 방식은 내부적으로 setTimeout을 기반으로 동작하기 때문에 콜 스택이 비워져야 실행 가능합니다. 하지만 콜 스택이 비워지지 않고 다른 기능에 밀려서 300ms보다 나중에 발생할 수 있어, 무조건 300ms마다 이벤트를 발생시켜 준다고 보장할 수 없습니다.
    • setTimeout(), setInterval()과 같은 비동기 task들은 우선순위가 가장 낮은 Task Queue에 넣어둔 후 순차적으로 처리됩니다.
  • rAF 방식을 Task Queue보다 우선순위가 높은 Animation Frames에서 처리되기 때문에 좀 더 실행을 보장할 수 있습니다.
 throttle 방식  rAF 방식
- 더 간단하게 구현이 가능하지만, 정확한 타이밍 보장 x - 더 정확한 타이밍과 부드러운 경험을 제공하지만, 구현이 복잡

 

이 사이트에서 JS 비동기 처리와 비동기 처리 큐에 대해 애니메이션을 통해 설명해 줍니다. 참고하시면 도움이 될 것 같습니다!😀

 

⭐️🎀 JavaScript Visualized: Promises & Async/Await

Ever had to deal with JS code that just... didn't run the way you expected it to? Maybe it seemed lik...

dev.to

 

이후에 무한 스크롤을 구현하는 다른 방식인 Intersection Observer API에 대해 작성해 볼 예정입니다.

 

 

728x90

'FE Study > React' 카테고리의 다른 글

[React] MSW로 API Mocking하기  (41) 2023.12.06