Intersection Observer와 useSWRInfinite를 이용해 무한스크롤 구현해보기 (feat. Rsuite Picker)

라리짱·2022년 8월 27일
1

React

목록 보기
1/1

Picker Menu로 무한 스크롤? 🤨

무한 스크롤... 사실은 피하고 싶었다🥹... 우리 회사에서 필요한 UI는 인스타그램 피드 처럼 쭉 쭉 내리면서 보는 형태도 아니고... Picker Menu의 아이템 리스트를 무한 스크롤로 보여달라는 요구사항이다.

구현 환경

  • Next.js typescript
  • React
  • Rsuite CheckPicker
  • useSWRInfinite

Next, useSWRInfinite, Intersection Observer 전부 처음 사용해봐서 요상하게 구현되었을 가능성이 있습니다. 피드백은 환영입니다 😉


완성된 모습 먼저

요구사항

  • CheckPicker 에 사용자가 등록한 Data List를 뿌려주세요 (등록은 제한이 없다)
  • 검색이 가능하게 해주세요
  • 선택 제한은 3개, 3개 선택 완료하면 더 이상 선택할 수 없게 해주세요

내가 설정한 컨셉

(밑 두 개는 Rsuite CheckPicker 컴포넌트에 관한 거라 나누었어요)

  • useSWRInfinite 를 사용해서 서버에서 데이터를 가져온다.
  • IntersectionObserver 를 사용해서 사용자가 현재 보고있는 화면에 내가 설정한 element가 보이는 지, 안 보이는지 판별한다.
  • 한 번 가져올 때 100개 씩, 90번째 즈음 아이템을 지나면 다음 100개를 가져온다.

  • Rsuite CheckPicker 는 search 기능을 제공하는데, 현재 불러와져 있는 데이터 내부에서 검색이 가능하다. search 이벤트가 발생하면 새로 데이터를 가져오고, 이 안에서 검색되도록 한다.
  • 3개 선택을 완료하면 disabledItemValues 를 설정해 더 이상 선택할 수 없도록 한다.

좋아 구현해보자!

useSWRInfinite

이 프로젝트는 Next 기반 어플리케이션이라 자연스럽게 useSWR 을 사용하고 있다. useSWRInfinite는 서버 요청을 통해 데이터를 가져오는데 페이지네이션이 필요할 때 쓴다.

 const getKey = (pageIndex: number, previousPageData: any[] | null) => {
    // if (previousPageData && !previousPageData.length) return null; // 끝에 도달
    return `/api/v1/brands?page=${
      pageIndex + 1
    }&size=100&search=${searchValue}`; // SWR 키
  };

  const { data: brands, size, setSize } = useSWRInfinite(getKey, infiniteFetcher, {
    revalidateOnFocus: false,
    revalidateFirstPage: false,
  });

useSWRInfinite 리턴 값

data: 서버에서 가져온 data 리스트
size: 서버에서 몇 페이지까지 가져온 상태인 지 알려줌, 디폴트 size는 1, size가 2 라면 2페이지 까지 들고왔다는 뜻!
setSize: size를 변경할 때 사용, 특정 엘리먼트가 화면에 보이면 setSize(size => size + 1) 이렇게 써서 다음 페이지를 불러올 것


data(brands) 는 가져온 데이터의 배열로 이루어져있다.
처음 페이지가 로딩될 때 1 페이지 데이터 100개가 들어있고

size 2가 되면 이렇게 보일 것이다.

100개짜리 데이터 배열 2개 묶음...!
첫 100개는 1번~100번 데이터, 두 번째 100개는 101번~200번 데이터가 있다.

useSWRInfinite 파라미터

getKey: api call 의 url 을 만든다고 생각하면 된다. getKey 함수에서 pageIndex는 당연히 0부터 시작하는데, 우리 회사 api는 page가 1부터 시작하도록 설계 되어있어 나는 pageIndex + 1 을 넣어주었다. previousPageData 는 null 로 시작, 두 번째 페이지를 불러올 땐 그 전에 불러왔던 데이터 100개가 들어있다.
보통 useSWRInfinite 만 사용하면 if (previousPageData && !previousPageData.length) return null 로 마지막 페이지인지 구분한다는데 이 구문을 주석 처리한 이유는 내 경우 IntersectionObserver 사용 측에서 마지막 페이지까지 불러왔으면 size를 올려주지 않도록 구현해놓아서 필요 없었다.

infiniteFetcher
이건 서버 response 인터페이스에 따라 조금씩 달라질거다!
나의 경우 아래처럼 정의해줬다.

export const infiniteFetcher = (url: string) =>
  Axios.create(config)
    .get(`${url}`)
    .then((res) => res.data.data);

options: 공식문서 를 참고 하길!
revalidateOnFocus:false 는 브라우저 포커스 마다 다시 불러오는 걸 막는 옵션, revalidateFirstPage:false 는 첫 페이지를 항상 다시 불러오는 걸 막는 옵션이다. brand 리스트를 가져오는 게 그리 자주 불러오지 않아도 되어서 설정해주었다. 아마 운영하면서 바뀔수도 있다.

리턴 값이나 파라미터 옵션은 더 많고 다양하므로 원하는 상황에 맞게 바꿔서 써야한다

IntersectionObserver

전에는 무한스크롤 구현할 때 scroll event 를 캐치해서 target element 와 viewport 위치를 계산해서 어쩌고 저쩌고 reflow도 많이 일어나고... 그걸 막기 위해서 throttle을 쓰고 뭐 그랬는데 IntersectionObserver 로 그런 문제 없이 간단히 구현할 수 있었다.

const io = new IntersectionObserver((entries, observer) => {
	entries.forEach((entry) => {
      if (entry.intersectionRatio > 0) {
      	// 관찰 대상이(targetElement) viewport 안에 들어온 경우
      }
    });
}, {
root: rootElement, threshold: 1.0
})
io.observe(targetElement)

간단히 적어보면 위와 같다. 중요한 포인트는
rootElement: viewport가 될 대상, 안 적으면 window 가 적용된다. 내 경우는 Picker 의 Menu 역할을 하는 div
targetElement: io가 관찰 하는 대상, 내 경우는 MenuItem 중에 뒤에서 10번째 정도를 설정했다. 즉 처음 100개를 불러왔으면 90번째 즈음의 아이템을 targetElement로 설정하고 그걸 지나면 다음 100개를 불러올 수 있도록 했다. 200 개를 불러왔으면 190번째 아이템으로 변경하고... 끝까지 반복

다른 옵션들은 공식문서를 참고해보세요

io.observe(targetElement) 꼭 이렇게 등록해줘야 제대로 동작한다. 필요 없어지면 io.unobserve(targetElement) 로 해제도 해줘야 한다.

그럼 내 코드

const io = useMemo(
    () =>
      new IntersectionObserver(
        (entries) => {
          entries.forEach((entry) => {
            // 관찰 대상이 viewport 안에 들어온 경우
            if (entry.intersectionRatio > 0) {
              setSize((size) => size + 1);
              if (targetElement) io.unobserve(targetElement);
            }
          });
        },
        { root: document.querySelector(rootElementSelector), threshold: 1 }
      ),

    [rootElementSelector, setSize, targetElement]
  );

useEffect(() => {
    if (targetElement) {
      io.observe(targetElement);
    }
  }, [io, targetElement]);

위에서 적은 코드에서 아래 구문이 추가 되었다.

if (entry.intersectionRatio > 0) {
  setSize((size) => size + 1);
  if (targetElement) io.unobserve(targetElement);
}

targetElement가 화면에 노출되었으면 size + 1 해서 다음 페이지 데이터를 가져오고,
현재 targetElement는 이제 관찰대상이 되지 못하니 unobserve 를 해준다.

root: document.querySelector(rootElementSelector) :
root를 props.rootElement 로 등록하지 않고 쿼리셀렉터로 바로 바로 등록하는 이유는 개발을 하다보니 Rsuite CheckPicker 가 search를 하거나 해서 데이터가 바뀌었을때 MenuElement 를 새로 그리는 경우가 있다.(그대로 일 때도 있다😧 영문을 모르겠다.) 그럼 지금 보고있는 메뉴가 처음 등록했던 props.rootElement 인지 아닌지 확실치 않기 때문에 new IntersectionObserver() 를 콜 할 때마다 등록하도록 구현했다.

그렇담 새 targetElement 를 등록하는 부분은?

useEffect(() => {
    if (brands?.length === size) {
      const items: NodeListOf<HTMLDivElement> | undefined =
        document.querySelectorAll(targetElementSelector);
      const lastNth: HTMLDivElement | undefined =
        items && items?.length > 10 ? items?.[items?.length - 10] : undefined;

      if (lastNth) setTargetElement(lastNth);
    }
  }, [brands?.length, size, rootElement, targetElementSelector]);

brands?.length === size

size = 1, brands.length = 0 처럼 size는 +1 됐고, fetching 중이라 brands.length 가 하나 적을 때가 있어 조건을 걸어주었다.

document.querySelectorAll 로 현재 MenuItem 리스트를 불러왔고,
items && items?.length > 10 ? items?.[items?.length - 10] : undefined MenuItem이 10개 이상 일때 targetElement로 MenuItem 중 뒤에서 10번째 Element를 넣어줬다.(물론 20개 이상일 때 10번째 Element이다. 그래서 lastNth 로 변수명을 정했다)

오 이제 핵심 기능은 다 됐다! 좀 더 코드를 정리해야 겠지만 전체적으로 보면 이렇게 된다.

최종 코드

useBrandsForPicker.ts

import useSWRInfinite from "swr/infinite";
import { infiniteFetcher } from "../api";
import { useEffect, useMemo, useState } from "react";

export default function useBrandsForPicker({
  rootElement,
  rootElementSelector,
  targetElementSelector,
  searchValue,
}: {
  rootElement: HTMLElement | undefined | null;
  rootElementSelector: string;
  targetElementSelector: string;
  searchValue: string;
}) {
  const [targetElement, setTargetElement] = useState<
    HTMLElement | undefined | null
  >(null);

  const getKey = (pageIndex: number) => {
    return `/api/v1/brands?page=${
      pageIndex + 1
    }&size=100&search=${searchValue}`; // SWR 키
  };

  const {
    data: brands,
    size,
    setSize,
  } = useSWRInfinite(getKey, infiniteFetcher, {
    revalidateOnFocus: false,
    revalidateFirstPage: false,
  });

  const io = useMemo(
    () =>
      new IntersectionObserver(
        (entries) => {
          entries.forEach((entry) => {
            // 관찰 대상이 viewport 안에 들어온 경우
            if (entry.intersectionRatio > 0) {
              setSize((size) => size + 1);
              if (targetElement) io.unobserve(targetElement);
            }
          });
        },
        { root: document.querySelector(rootElementSelector), threshold: 1 }
      ),

    [rootElementSelector, setSize, targetElement]
  );

  useEffect(() => {
    if (targetElement) {
      io.observe(targetElement);
    }
  }, [io, targetElement]);

  useEffect(() => {
    if (brands?.length === size) {
      const items: NodeListOf<HTMLDivElement> | undefined =
        document.querySelectorAll(targetElementSelector);
      const lastNth: HTMLDivElement | undefined =
        items && items?.length > 10 ? items?.[items?.length - 10] : undefined;

      if (lastNth) setTargetElement(lastNth);
    }
  }, [brands?.length, size, rootElement, targetElementSelector]);

  // [[Array(100)], [Array(100)], [Array(100)], ...] 형태인 brands 를 pickerData가 필요한 형태로 변경했다.
  const brandsData =
    brands?.flatMap((brandArr: { id: number; name: string }[]) => {
      return brandArr.map((b) => ({ label: b.name, value: b.id }));
    }) || [];

  return {
    data: brandsData,
  };
}

위 코드를 사용하는 곳

const Index = () => {
  const [menuElement, setMenuElement] = useState<
    HTMLElement | undefined | null
  >(null);
  const [searchValue, setSearchValue] = useState("");
  const [brandsValue, setBrandsValue] = useState<(number | string)[]>([]);
  const { data: brandsData } = useBrandsForPicker({
    rootElement: menuElement,
    searchValue: searchValue,
    rootElementSelector: "brands-menu > .rs-picker-check-menu-items",
    targetElementSelector: `.brands-menu > .rs-picker-check-menu-items > div[role="option"]`,
  });

  const disabledItemValues =
    brandsValue.length === 3
      ? brandsData
          .filter((b) => !brandsValue.includes(b.value))
          .map((b) => b.value)
      : [];

  return (
    <>
      <CheckPicker
        style={{ width: 300, padding: 10 }}
        placeholder={"브랜드/상품명 선택"}
        data={brandsData}
        renderValue={(value: any[], items: ItemDataType[]) => {
          return (
            <div>
              {value.length === 1 && items[0]?.label}
              {value.length > 1 &&
                `${items[0]?.label}${value.length - 1}`}
            </div>
          );
        }}
        onOpen={() => {
          setMenuElement(
            document.querySelector(
              ".brands-menu > .rs-picker-check-menu "
            ) as HTMLDivElement
          );
        }}
        menuClassName={"brands-menu"}
        disabledItemValues={disabledItemValues}
        value={brandsValue}
        onChange={setBrandsValue}
        onSearch={setSearchValue}
        onClose={() => setSearchValue("")}
      />
    </>
  );
};

export default Index;

요구사항을 만족하기 위한 추가 작업들

disabledItemValues : brandsValue.length가 3이 되면 현재 선택하고 있는 value 값 외 다른 아이템의 value는 disabled 되도록 설정했다.
onOpen: CheckPicker는 맨 처음 렌더될 때 Menu(div) 가 없다. Picker를 열어야만 해당 Element가 생기기때문에 onOpen 콜백에서 rootElement 를 세팅해줬다.
onSearch: onSearch 콜백에서 searchValue state 를 세팅하고 이를 useBrandsForPicker 에 넘겨주어 useSWRInfinite 의 key 값에 적용되도록 했다.

잘 된다~~👏

🧐 개선할 점

  • useBrandsForPicker 로 한 번에 묶었는데, 무한스크롤을 하는 부분(Intersection Observer)과 데이터를 가져오는 부분(useSWRInfinite) 을 나눠서 리팩토링할 수 있을 것 같다.
  • 처음 렌더될 때 메뉴가 닫힌 상태라 useBrandsForPicker 에서 document.querySelector(rootElementSelector)rootElement를 찾을 수 없기 때문에 처음 렌더될 때만을 위하여 props.rootElement를 넘겨 주었는데 props.rootElementSelector 도 넘기고 props.rootElement 도 넘기는 게 뭔가 마음에 들지 않는다. 어떻게 고치면 좋을까?

안 읽어도 되는 사족

  • 난 이제 이 CheckPicker를 테이블 Cell 안에 넣어야한다. 수 많은 CheckPicker 들이 생기고, 그를 관리하는 State가 생길텐데 잘 해결된다면 또 글로 남겨봐야겠다!

참고
https://swr.vercel.app/ko/docs/pagination
https://heropy.blog/2019/10/27/intersection-observer/
https://rsuitejs.com/components/check-picker/
https://intrepidgeeks.com/tutorial/use-intersection-observer-and-usswrinfinite-to-realize-unlimited-scrolling-steve-development-log-6


0개의 댓글