FE

useInfiniteQuery로 무한스크롤 구현기

jvn4dev 2024. 8. 22. 01:59

Scroll Event vs Intersection Observer

Scroll Event로 구현 순서

1. scroll 이벤트를 감지한다.
2. 현재 스크롤 영역의 `위치를 계산`한다. with Throttle
3. 영역 계산을 통해 페이지 아래에 위치하면 API 요청을 진행한다.
4. 받아온 데이터를 추가하여 다시 렌더링한다.
5. 무한 반복

const handleScroll = () => {
   const { scrollTop, offsetHeight } = document.documentElement
   if (window.innerHeight + scrollTop >= offsetHeight) {
     setFetching(true)
   }
 }

쓰로틀을 걸어서 실질적인 이벤트감지 수를 최적화하더라도 documentElement.scrollTop과 documentElement.offsetHeight는 리플로우(Reflow)가 발생한다.

Reflow란?

레이아웃 계산을 다시 하는 것으로, Reflow가 발생하면 Repaint는 필연적으로 발생한다. 리플로우는 HTML 요소들의 위치와 크기를 다시 계산해야 하기 때문에, 리페인트에 비해서 시간이 오래걸린다. 즉, 변경하려는 특정 요소의 위치와 크기뿐 아니라, 연관된 다른 요소들의 위치와 크기까지 재계산해야 하기 때문이다. 따라서 리플로우가 자주 발생하도록 하는 코드는 지양해야한다.

Intersection Observer API

브라우저는 Viewport와 Target으로 설정한 요소의 교차점을 관찰 및 Target이 Viewport에 포함되는지 구별하는 기능 제공한다.

Intersection Observer의 옵션값으로는 아래와 같음.

interface IntersectionObserverInit {
   root?: Element | Document | null;
   rootMargin?: string;
   threshold?: number | number[];
}

new IntersectionObserver(callback, options: IntersectionObserverInit)

root

target의 가시성을 확인할 때 사용되는 상위 속성 이름, null 입력 시, 기본값으로 브라우저의 Viewport가 설정된다.

rootMargin

root에 마진값을 주어 범위를 확장 가능하다.

  • 기본값은 0px 0px 0px 0px이며, 반드시 단위 입력 필요

threshold

콜백이 실행되기 위해 target의 가시성이 얼마나 필요한지 백분율로 표시.

  • 기본값은 배열 [0] 이며, Number 타입의 단일 값으로도 작성 가능하다.

// useIntersect.ts
import { RefObject, useEffect, useState } from 'react';

export const useIntersect = (ref: RefObject<HTMLDivElement>, options) => {
  const [observerEntry, setObserverEntry] = useState<IntersectionObserverEntry | null>(null);

  useEffect(() => {
    if (!ref?.current) return;

    const observer = new IntersectionObserver(([entry: IntersectionObserverEntry]) => {
      setObserverEntry(entry);

      if (entry.isIntersecting) {
        observer.unobserve(entry.target);
      }
    }, options);

    observer.observe(ref.current);

    return () => observer.disconnect();
  }, [ref, options.rootMargin]);

  return observerEntry;
};

// lib.dom.d.ts
interface IntersectionObserverEntry {
    readonly boundingClientRect: DOMRectReadOnly;
    readonly intersectionRatio: number;
    readonly intersectionRect: DOMRectReadOnly;
    readonly isIntersecting: boolean;
    readonly rootBounds: DOMRectReadOnly | null;
    readonly target: Element;
    readonly time: DOMHighResTimeStamp;
}

 

먼저 반환할 entry를 상태로 관리하고 커스텀훅으로 전달받은 ref, rootMargin이 변경될 때 마다 새로운 observer를 생성한다.

원래 observer의 target을 SpaceGrid 의 바로 밑에 1px의 div를 만들어서 바라보게 하는 방식으로 구현했었는데 그려지는 스페이스의 마지막 썸네일을 기준으로 처리하면 좋을 것 같다는 생각으로 위와 같이 처리했다.

그리고 useIntersect가 언마운트될때는 observer.disconnect()를 해준다.

리팩토링

리액트 컴포넌트에 대해 공부하다보니 컴포넌트간의 의존성을 최대한 줄이는 것이 좀 더 나은 방법이라는 생각이 들었다.

카드 컴포넌트의 썸네일을 forwardRef로 커스텀훅의 ref로 넘겨서 별도의 로직에서 처리하다보니 상위 컴포넌트에서 자식컴포넌트간의 의존성이 추가됐고 어쩌면 내가 생각한 첫번째 구현 방식이 맞았다는 생각이 들었다.

하지만 기존에 구현하려던 SpaceGrid 하위에 1px의 가짜 div를 사용한다면 미세하더라도 디자이너의 요구사항에 1px이 맞지 않는 상황이 발생할 수 있다. 이에 대해 찾아보니 1px이 아닌 0px의 div를 사용하더라도 intersectionObserver api의 root는 해당 div를 감지할 수 있다.

결론적으로 SpaceGrid 아래에 가짜 div를 통해 배열을 갱신하여 ref와 카드 컴포넌트의 썸네일의 의존성을 없애고 카드 컴포넌트는 자신의 역할에 맞게 그냥 SpaceGrid가 하나씩 내려주는 스페이스의 정보만 카드로 보여주도록 처리할 수 있었다.