React Infinite scroll 구현하기 wtih IntersectionObserve

yeong·2022년 6월 30일
0

토이프로젝트

목록 보기
1/1
post-thumbnail

현재 토이프로젝트로 가드너를 위한 사이트를 만들고 있는데, 식물 검색결과를 가져오는 부분에서 무한 스크롤 구현 파트가 필요했다.

현재상황...

사실, 처음에는 일반스크롤도 구현된 상황이었다. 식물 검색결과, 여러 기관에서 데이터를 가져오는데, 사진-설명만 다르고 품종이 같은 식물정보가 많이 조회되었다.
예를들면,,
같다고도 다르다고도 볼 수 없는 데이터ㅠㅠ...
처음 구현당시에는 불필요하게 중복되는 것을 막고자 filter처리를 하였다.

초기 중복처리 코드

    let preVarieties = {};
    let varieties = [];
    if (result.body.items.totalCount < 1) return [];
    result.body.items.item.forEach((variety) => {
      if (!preVarieties[variety.prdlstCtgCode]) {
        preVarieties[variety.prdlstCtgCode] = {
          description: variety.mainChartrInfo,
          name: variety.svcCodeNm,
          plantId: variety.prdlstCtgCode,
          instt: variety.unbrngInsttInfo,
          imgLink: variety.imgFileLink,
        };
      }
    });
    for (let variety of Object.keys(preVarieties)) {
      varieties.push(preVarieties[variety]);
    }
    return varieties;

filter를 이중 루프를 도는것보다는 객체로 필터링하는게 더빠를 것 같아 위와 같이 처리했다.

이게 맞을까?..

그러나 프로젝트를 진행하면서 해당 부분에 대한 고민이 계속 생겼다. api의 호출결과는 228개인데 중복을 필터하여 10여개가 나온다. 품종코드로만 필터를 걸었기때문에 설명이 다른 작물도 존재한다.

결국 고민끝에 필터부분을 제외하고 모든 데이터를 가져오기로 하였다. 필터가 필요한 부분은 카테고리, 기관명 설정으로 사용자가 선택할 몫이라고 보았다.

😵‍💫 새로운 문제

많아야 10여개가 나오던 데이터가 100여개로 증가하였고, 모든데이터를 한번에 가져오기에는 너무 오랜시간이 걸렸다 (5초ㅠ..)
페이징 처리가 필요하였고 적절한 방법을 찾다가 스크롤이 내리면 데이터를 호출하는 방법으로 구현하기로 하였다.

Scroll vs IntersectionObserve

일반적으로 scroll을 이동하게 이벤트를 발생시킬때 두가지 방법을 사용한다.

  1. Scroll eventListener 스크롤 이동시마다 event가 발생하며 조건에 만족할 때 핸들러 실행
  2. IntersectionObserve로 지정 view와 타켓 view가 교차할 때 핸들러 실행

둘 중에 어떤것이 좋을 지 여러 리소스를 찾아보았는데 scroll시 위치에 대한 계산( getBoundingClientRect등)이 발생할 경우 reflow(화면을 다시 그림)가 발생한다고 한다. 본인은 스크롤 위치에 따라 api를 호출해야하므로 매번 현재위치를 가져와야한다. Intersection Observer는 교차할때 발생하는 콜백에서 getBoundingClientRect에 대한 정보를 제공해준다.
따라서 Scroll 이벤트가 아닌 IntersectionObserve 를 사용하기로 하였다.

Infinite scroll 구현하기

사실 IntersectionObserve는 과거 포트폴리오를 작성할때, section마다 감지시 사용했던 경험이 있어 어렵지 않게 구현할 수 있을 줄 알았다.
그러나 늘 기대는 배반하는 법. 역시나 많은 삽질의 과정이 필요했다.. 과정은 다음과 같았다.

1. root, target IntersectionObserver 생성하기

IntersectionObserver 기본 구성은 아래와 같다.

const observer = new IntersectionObserver(isIntersect, {
      threshold: 1,
    });
    observer.observe(target);

IntersectionObserver을 생성하기 위해서는 두개의 인수가 필요하다.
1. observer가 target을 감지했을때 호출되는 callback(isIntersect)
2. 관찰자? 역활을 하는 root , rootMargin, 교차 비율등을 설정하는 threshold을 속성으로 하는 option 객체.

root의 기본값은 viewPort로 threshold를 제외한것은 기본값으로 사용하였다.

2. custom hook으로 만들기

해당 코드를 여러군데에서 사용할 가능성이 있을 것 같아 별도로 훅으로 작성했다.

const useIntersectionObserver = ({ intersectionHandler }) => {
  // ref가 담길 state
  const [target, setTarget] = useState();

  // target element의 ref가 변경되면 호출.

  const isIntersect = useCallback(
    ([entry], observer) => {
      if (entry.isIntersecting) intersectionHandler(entry);
    },
    [intersectionHandler]
  );
  const serRef = useCallback((element) => {
    if (!element) return;
    setTarget(element);
  }, []);
  useEffect(() => {
    if (!target) return;
    const observer = new IntersectionObserver(isIntersect, {
      threshold: 1,
    });
    observer.observe(target);

    return () => observer.unobserve(target);
  }, [target, isIntersect]);

  return { serRef };
};

setTarget은 감지 대상이 될 target이 담긴다.
사실, observer 로 감시하기 위해서는 DOM요소가 필요하므로 target요소를 ref로 참조하여 가져와야한다.
그러나 ref의 변화는 react가 감지하지 못한다. 우리는 target이 바뀔때마다 observer를 해제하고 연결하고를 반복해야하므로 ref를 useState로 감시하여 대신 ref값을 받아와 state에 저장하였다.
해당 부분을 위한 함수가 serRef이며 대상 요소의 ref 속성에 지정하면,자동으로 요소가 생성, 해제될때마다 setRef가 호출되고 매개변수로 해당 요소를 참조해온다.
자세한것은 여기를 참조한다.

3. observer 연결하기

    const intersectionHandler = useCallback(
    async (entry) => {
      if (!loading) await loadMoreData();
    },
    [loading, loadMoreData]
  );

  const { serRef } = useIntersectionObserver({ intersectionHandler });
  let content = <PlantList plants={plants} />;

  if (currentPage.current === 1 && loading) content = <LoadingSpinner />;
  return (
    <Fragment>
      <SearchForm onSubmit={submitFormHandler} />
      {content}
      {!loading && plants && plants.length > 0 && <div ref={serRef} />}
    </Fragment>
  );
};

이제 바닥에 닿을때마다 호출할 intersectionHandler 생성하고 useIntersectionObserver에 전달한다.
그리고 ref를 참조하기 위해 target에 setRef를 연결한다.
현재 컴포넌트내에 Form 컴포넌트가 함께 있어서 분기처리가 복잡하다..ㅠ observer를 추가하며 컴포넌트가 단순 리스트의 성격을 벗어났으므로 , 이부분은 개선이 필요해보인다.

target에 해당하는 [div]에 setRef를 연결하여 div가 마운트, 언마운트 될때마다 ref가 setRef로 전달되도록한다.
주의할 것은 loading, plants가 없을경우는 해당 target이 언마운트 되어서 observer에 아무것도 연결되지 않도록 해야한다.
만약 계속 div가 떠 있다면 observe는 계속 연결된 상태이며, 리스트가 0일때 target이 가장 상단에 위치하여 계속해서 intersectionHandler이 호출된다.

목록에 위치에는 경우에 따라 초기값이 없고 로딩중인 경우는 [LoadingSpinner] 그외에는 [PlantList] 를 렌더한다.

4. 마무리

데이터를 추가 로드할 loadMoreData 함수를 호출한다. 초기호출과 추가 호출을 구분하기 위해 함수를 별도로 작성하였다.
추가 로드의 경우 현재 페이지와 totalCount와 비교해 유효성을 검증한다.
초기 로드의 경우 현재페이지의 값을 1로 초기화한다.

현재 parameter와 page는 ref로 관리한다. ref는 DOM요소 참조외에도 컴포넌트의 고유한 값을 저장하기 위해서도 사용된다. 단순히 값만 저장하고 렌더링과는 무관하므로 state보다는 ref를 사용하였다.

  const currentPage = useRef(1);
  const currentParams = useRef();
  
    // first Load
  const submitFormHandler = useCallback(
    async (config) => {
      currentParams.current = config;
      currentPage.current = 1;
      dispatch(searchThunk({ ...currentParams.current, pageNo: 1 }));
    },
    [dispatch]
  );

  
  // more Load
  const loadMoreData = useCallback(async () => {
    if (currentPage.current * 10 > totalCount) return;

    currentPage.current++;
    dispatch(
      getMoreSearchThunk({
        ...currentParams.current,
        pageNo: currentPage.current,
      })
    );
  }, [dispatch, totalCount]);

정리하기

사실 구현하면서 이것저것 리소스도 많이 참고하고 시간도 많이 들였다.
그럼에도 고칠점이 아직 많아보인다.

Ref :
scroll-listener-vs-intersection-observers
useRef vs useState
Infinite-scroll

profile
안녕하세요!

0개의 댓글