ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 무한 스크롤을 구현하기 위한 몇 가지 방법들 (Intersection Observer API를 사용하는 이유)
    React 2024. 6. 17. 19:58

    무한 스크롤은 사용자가 페이지를 스크롤할 때 콘텐츠를 동적으로 로드하는 기법입니다.

    무한 스크롤을 구현하는 방식은 몇 가지가 있는데 그중 몇 가지 방법을 사용해 보고,

    어떤 방식이 가장 좋은지 확인해 보려 합니다.

    | json-sever

    해당 글에서 기본적으로 예시 코드를 구현할 때 json-server라이브러리를 사용하여 구현하였습니다.

    https://www.npmjs.com/package/json-server

     

    json-server

    [![Node.js CI](https://github.com/typicode/json-server/actions/workflows/node.js.yml/badge.svg)](https://github.com/typicode/json-server/actions/workflows/node.js.yml). Latest version: 1.0.0-beta.1, last published: 13 days ago. Start using json-server in y

    www.npmjs.com

    이후 나올 예시 코드에서 나오는 '/items?_page=${page}&_per_page=8' 해당 주소로 요청 보내면

    아래와 같이 데이터가 온다는 것을 알고 보시면 이해하기 더 쉬울 거 같습니다.

    {
      "first": 1,
      "prev": 1,
      "next": 3,
      "last": 5,
      "pages": 5,
      "items": 40,
      "data": 데이터 배열
    }

    | 기본 원리

    무한 스크롤의 기본 원리는 콘텐츠의 마지막 부분이 보일 때 클라이언트는 서버에 다음 데이터를 요청하고,

    데이터를 받으면 추가된 콘텐츠를 화면에 보여주는 방식입니다.

    | 스크롤 이벤트

    기본적으로 스크롤 이벤트를 사용하여 구현할 때는 매 스크롤 이벤트에 대해

    현재 document의 위치를 파악하여 데이터를 fetch 할지 여부를 결정하게 됩니다.

     

    먼저 무한 스크롤을 위한 상태들을 초기화해 주겠습니다.

    const [items, setItems] = useState<Item[]>([]); // fetch요청으로 가져 올 데이터
    const [page, setPage] = useState(1); // 현재 페이지 번호
    const [lastPage, setLastPage] = useState(null); // 마지막 페이지 번호
    const [loading, setLoading] = useState(false); // 로딩 상태

    다음으로 page가 변하면 새로운 데이터를 받아와 items 배열에 추가해 주기 위해

    useEffect에 page의존성을 추가해 주고 데이터를 받아오는 함수를 작성해 줍니다.

    useEffect(() => {
      const fetchItems = async () => {
        setLoading(true);
        const res = await fetch(
          `http://localhost:3001/items?_page=${page}&_per_page=8`
        ).then((res) => res.json());
    
        const data = res.data;
    
        setLastPage(res.last);
        setItems((prev) => [...prev, ...data]);
        setLoading(false);
      };
    
      fetchItems();
    }, [page]);

    다음으로 스크롤 이벤트를 작성해 줍니다.

    해당 이벤트에서 document의 위치를 파악하고,

    로딩 상태인지, 마지막 페이지인지 여부를 확인하여 

    데이터를 가져올지를 결정합니다.

    useEffect(() => {
      const handleScroll = () => {
        const { innerHeight } = window;
        const { scrollTop, offsetHeight } = document.documentElement;
    
        // 스크롤이 완전 끝까지 스크롤 된 후 데이터를 가져오는 것이 아닌,
        // 좀 더 위에서 미리 가져오기 위해 offsetHeight 에서 100을 뺀 후 비교합니다.
        if (
          loading ||
          (lastPage !== null && page >= lastPage) ||
          innerHeight + scrollTop < offsetHeight - 100
        )
          return;
    
        setPage((prevPage) => prevPage + 1);
      };
    
      window.addEventListener("scroll", handleScroll);
    
      return () => {
        window.removeEventListener("scroll", handleScroll);
      };
    }, [loading, lastPage]);

    이런 방식으로 하면 무한 스크롤을 구현할 수 있습니다.

    | 문제

    이런 식으로 스크롤 이벤트를 사용하여 무한 스크롤을 구현하면 이벤트가 너무 빈번하게 발생한다는 문제가 있습니다.

    또한 해당 이벤트에서 계속해서 document.documentElement를 참조하며

    리플로우가 빈번하게 발생할 수 있다는 문제점도 있습니다.

     

    따라서 이를 계선하기 위해서 몇 가지 방법을 도입할 수 있습니다.

    | Throttle

    가장 우선적으로 고려할 수 있는 방법은 throttle입니다.

    일정 주기마다 1번의 이벤트만 발생하도록 하며

    스크롤 이벤트가 계속해서 발생하는 것을 막으면 됩니다.

     

    이런 throttle 함수를 직접 구현해 도 되지만,

    편의를 위해 lodash의 throttle을 사용하여 구현할 수 있습니다.

    import { throttle } from "lodash";
    
    const handleScroll = throttle(() => {
      console.log(1);
      const { innerHeight } = window;
      const { scrollTop, offsetHeight } = document.documentElement;
    
      if (
        loading ||
        (lastPage !== null && page >= lastPage) ||
        innerHeight + scrollTop < offsetHeight - 100
      )
        return;
    
      setPage((prevPage) => prevPage + 1);
    }, 200);

    이런 식으로 기존의 이벤트 헨들러 함수를 lodash의 throttle의 첫 번째 인자로 주고,

    2번째 인자로 스크롤 이벤트를 처리하고 싶은 간격을 주면 됩니다.

     

    그럼 기존 코드와 throttle을 적용한 코드를 스크롤 이벤트를 콘솔에 출력해 비교해 보면

     

    기존 코드

     throttle 적용

    이벤트 호출 횟수가 확연하게 차이 나는 게 보입니다.

     

    하지만 이런 throttle도 한계가 있습니다.

    기본적으로 throttle은 setTimeout을 사용하기 때문에 콜 스택이 꽊 차있으면,

    타이머가 지연되며, 이벤트가 예상과 다른 타이밍에 동작될 수 있습니다.

     

    이를 위해 다른 방안을 생각해 볼 필요가 있습니다.

    | requestAnimationFrame

    대안으로 도입할 수 있는 방법으로 requestAnimationFrame을 사용하는 방법이 있습니다.

    requestAnimationFrame은 Animation Frames에서 처리되기 때문에 Task Queue보다 우선순위가 높고,

    브라우저가 화면을 다시 그릴 때마다 호출되므로, 주로 60 fps로 실행됩니다.

     

    따라서 기존 setTimeout을 사용한 throttle보다 실행 시간을 더 보장할 수 있습니다.

     

    일반적으로 lodash에서 throttle을 사용하면 2번째 인자의 timeout값을 주지 않으면

    requestAnimationFrame 기반으로 동작하도록 되어있다고 합니다.

     

    하지만 이번에는 함수를 직접 구현해 보도록 하겠습니다.

    const throttleWithRAF = (callback: VoidFunction) => {
      if (typeof callback !== "function") {
        throw new Error("Invalid required arguments");
      }
    
      let isThrottled = false;
    
      return () => {
        if (isThrottled) return;
    
        isThrottled = true;
        requestAnimationFrame(() => {
          isThrottled = false;
          callback();
        });
      };
    };

    해당 함수는 이벤트가 발생할 때 requestAnimationFrame을 사용하여 콜백이 애니메이션 프레임으로 들어가도록 합니다. 콜백이 실행되기 전까지는 isThrottled가 true이므로 이벤트가 다시 발생하더라도 무시됩니다.

    애니메이션 프레임이 처리되면 콜백이 실행되고 isThrottled를 false로 바꿔줍니다.

    이 과정을 반복하여 이벤트 발생을 제어하게 됩니다.

     

    그리고 스크롤 함수를 해당 함수의 콜백으로 준 후 이벤트를 등록하면 됩니다.

    const handleScroll = throttleWithRAF(() => {
      const { innerHeight } = window;
      const { scrollTop, offsetHeight } = document.documentElement;
    
      if (
        loading ||
        (lastPage !== null && page >= lastPage) ||
        innerHeight + scrollTop < offsetHeight - 100
      )
        return;
    
      setPage((prevPage) => prevPage + 1);
    });
    
    window.addEventListener("scroll", handleScroll);

     

    하지만 실질적으로 이벤트가 동작하는 것을 보면 큰 차이가 없는 것을 확인할 수 있습니다.

    다만 requestAnimationFrame는 브라우저의 리페인트 주기와 동기화되면서 애니메이션을 부드럽게 만들 수 있고,

    브라우저가 프레임을 렌더링 할 준비가 되었을 때 실행되므로,

    리플로우와 리페인트를 최소화하여 렌더링 성능을 최적화할 수 있습니다.

    | Intersection Observer

    requestAnimationFrame의 성능 상 제한을 극복할만한 또 다른 방법은

    Intersection Observer API를 사용하는 것입니다.

     

    이는 브라우저 viewport와 targert element의 교차점을 관찰하며

    target이 화면에 포함되는지 구별할 수 있는 기능을 제공합니다.

     

    먼저 화면에 보이면 다음 데이터를 가져올 요소 와,

    옵저버 요소를 위한 Ref를 만들어 줍니다.

      const loadMoreRef = useRef<HTMLDivElement | null>(null);
      const observerRef = useRef<IntersectionObserver | null>(null);

     

    그 후 기존 코드에서 스크롤 이벤트 대신 다음과 같은 Intersection Observer를 이용한 코드를 작성해 줍니다.

    그리고 rootMargin 옵션을 사용하여 데이터를 좀 더 빠른 시점에 가져올 수 있도록 해줍니다.

    useEffect(() => {
      if (loading || lastPage === page || !loadMoreRef.current) return;
      if (observerRef.current) observerRef.current.disconnect();
    
      observerRef.current = new IntersectionObserver(
        (entries) => {
          if (entries[0].isIntersecting) {
            setPage((prevPage) => prevPage + 1);
          }
        },
        {
          rootMargin: "100px",
        }
      );
    
      observerRef.current.observe(loadMoreRef.current);
    
      return () => {
        if (observerRef.current) observerRef.current.disconnect();
      };
    }, [loading, lastPage, page]);

    이제 다음 데이터를 가져오기 위해 Intersection Observer로 감시하고 있는 loadMoreRef 요소를 만들어 줍니다.

    return (
      <div>
        <h1 className="mb-6">Items</h1>
        <ul>
          {items.map((item) => (
            <li
              key={item.id}
              className="border border-red-300 border-solid p-10 mb-3"
            >
              {item.name}
            </li>
          ))}
        </ul>
        {loading && <p className="text-center">Loading...</p>}
        {items.length > 0 && (
          <div ref={loadMoreRef} className="h-5 bg-transparent" />
        )}
      </div>
    );

    이런 식으로 하면 성능적으로 다른 방법보다 좋게 무한 스크롤을 구현할 수 있습니다.

    | 끝

    보통 무한 스크롤을 구현할 때 저는 아무 이유 없이 Intersection Observer API를 사용해 왔습니다. 하지만 실제로 공부해 보니 다양한 방법들이 있었고, 왜 사람들이 이런 다양한 방법 중에 Intersection Observer API를 사용하는지 알 수 있었습니다.

    이렇게 자주 사용하는 기술들이 어떤 방식으로 최적화되는지 공부하는 것이 더 좋은 사용자 경험을 제공하는 데 매우 유익하다는 것을 깨달았습니다. 앞으로도 기술에 대해 깊이 이해하고 최적의 방법을 찾아가는 노력을 계속해야 할 것 같습니다.

Designed by Tistory.