React 무한 스크롤 (w. useInfiniteQuery, MutationObserver, IntersectionObserver)

김 주현·2024년 2월 14일
0

무한 스크롤 자체는 많이 구현했다만~ React Query에서 제공하는 useInfiniteQuery는 안 써봐서 한번 써보려고 하고, 인터넷에서 굴러다니는 IntersectionObserver를 활용한 코드는 아무리 생각해도 영 비효율적인 것 같아서 내가 직접 구현해보려고 한다.


Pagination

무한 스크롤을 할 땐 Pagination이라는 개념에 대해서 알고 있어야 한다. 만약 보여줄 데이터가, 쉽게 예를 들어서 게시글이 100개가 있다고 해보자. 그러면 이 100개를 한 번에 다 보여주게 되면 어떻게 될까?

어떻게 되긴! 100개를 한 번에 조회하는 비용과, 100개를 한 번에 보여주는 비용이 합쳐져서 사단이 난다.

그래서 이런 사단을 막기 위해 10개씩 묶어서 보여주고, 다음 묶음을 요청하면 그때 또 10개를 보내주자는 개념이 바로 Pagination이다. 게시판 형 UI에서 많이 보이는 페이지 목록이 바로 그것이다.


예전 구우우우글의 페이지네이션

Offset 기반 페이지네이션

페이지네이션을 떠올렸을 때 직관적으로 생각할 수 있는 구조는 바로 요 Offset 기반 페이지네이션일 것이다. Offset이라는 건 위치를 뜻한다. 만약 10개씩 묶어서 보내주고, 10번째 위치에서 보냈다면, 그 다음엔 20번째 위치부터 10개를 보내주는 방식인 것.

당연히 이렇게 해야 하는 거 아냐?! 라는 생각이 들겠지만,, 이 방식은 맹점이 있다.

만약 아래와 같이 데이터(값(index)) 리스트가 있다고 해보자.
마(0) / 라(1) / 다(2) / 나(3) / 가(4)

그리고 2개씩 요청한다고 했을 때, 이상적인 건 다음과 같은 페이지일 것이다.
마(0), 라(1), 다(2), 나(3), 가(4)

여기에서 포인트는, 2개씩 요청이 시간상 연속적이지 않다는 것이다. 마(0), 라(1) 를 불러오고나서 1시간 뒤에 다음 요청을 할 수도 있는 것. 그런데 그 사이에 새로운 데이터가 추가가 된다고 가정해보자.
바(0) / 마(1) / 라(2) / 다(3) / 나(4) / 가(5)

우리가 다음 불러와야 할 Offset은 2번인데, 2번에서 2개를 가져오면 라(2), 다(3) 가 돼서 중복이 일어날 수도 있는 것이다.

Cursor 기반 페이지네이션

그래서 요런 이슈를 방지하기 위해 마지막으로 조회한 값을 가지고 있다가, 다음 요청 때 해당 값을 보내주면 해당 값으로부터 다음 묶음을 가져오는 방식이 커서 기반 페이지네이션인 것이다.

마지막으로 조회한 값이 였으니, 앞에 어떤 데이터가 추가가 됐더라도 그 다음 다, 나를 가져올 수 있는 것.

어떤 걸 선택해야 하나?

위에서는 Offset 기반 페이지네이션을 보완한 게 Cursor 기반 페이지네이션이다! 라는 뉘앙스를 풍겼지만, 뭐가 더 좋고 나쁘곤 전혀 관계 없다. 둘 다 장단점이 있다.

둘 중 하나를 선택해야 할 때 고려해야 하는 건,, 내 기준 데이터의 순차가 중요하냐 아니냐인 것 같다. 물론 데이터 크기라든가 서비스의 특성을 파악해야겠지만서도~

특정 페이지로 넘어가서 요청할 수 있는 것보다, 최신순 또는 과거순으로 사용자에게 나열해서 보여주는 게 더 중요한지 아닌지에 따라서 선택할 수 있을 것이다.

즉,, 기존 게시판 형 UI에선 당연히 Offset 기반이 더 적절할 것이고, 무한 스크롤 방식에선 Curosr 기반이 더 적절할 것이다.

useInfiniteQuery

React Query에서 제공하는 요 훅은 무한 스크롤에 대한 수요가 늘어나자 만들어준 훅이다. 원형은 다음과 같다.

const {
  fetchNextPage,
  fetchPreviousPage,
  hasNextPage,
  hasPreviousPage,
  isFetchingNextPage,
  isFetchingPreviousPage,
  ...result
} = useInfiniteQuery({
  queryKey,
  queryFn: ({ pageParam }) => fetchPage(pageParam),
  initialPageParam: 1,
  ...options,
  getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) =>
    lastPage.nextCursor,
  getPreviousPageParam: (firstPage, allPages, firstPageParam, allPageParams) =>
    firstPage.prevCursor,
})

뭘 많이 제공하고 있는데,, 크게 다음을 제공한다고 보면 된다.

  • 다음 페이지에 관한 것
    • 다음 페이지가 있냐? (hasNextPage)
    • 다음 페이지 가져오기 (fetchNextPage)
    • 다음 페이지 가져오는 중? (isFetcingNextPage)
  • 이전 페이지에 관한 것
    • 이전 페이지가 있냐? (hasPreviousPage)
    • 이전 페이지 가져오기 (fetchPreviousPage)
    • 이전 페이지 가져오는 중? (isFetcingPreviousPage)

다음 페이지를 가져오는 게 일반적이니까 이후 서술하는 건 다음 페이지를 가져오는 걸 기본 전제로 한다.

pageParam

이 녀석의 정체가 헷갈려서 삽질을 많이 했다.. 결론적으로, 'Offset'도 될 수 있고, 'Cursor'도 될 수 있다. Offset 기반 페이지네이션이라면 여기에 다음 Offset이 들어가게 되는 거고, Cursor 기반 페이지네이션이라면 마지막으로 조회한 ID가 들어가게 되는 것이다.

initialPageParam

요 위의 pageParam에 어떤 초기 값을 넣어줄 것인지 정해주는 녀석. 원래 아래와 같이 Default Value를 정해줬었는데, 최신 버전에선 initialPageParam을 필수로 입력하게 해주면서 초기값 가드를 해주었다.

기존의 초기값 지정 방식

queryFn: ({ pageParam = 1 }) => fetchPage(pageParam),

getNextPageParam

그리고 이건 다음 페이지를 요청(fetchNextPage)했을 때, 다음 페이지에 대한 값을 지정해주는 함수이다. Offset이라면 다음 Offset을, Cursor라면 마지막 아이디를 넘겨주는 것.

이를 위해 lastPage와 allPages라는 인자를 제공해주고 있다.

  • lastPage: 마지막으로 불러온 데이터
  • allPages: 그동안 불러온 데이터

'들' 이라는 것에 주의하자. 위에서 말했듯이 '목록 묶음'을 보내주는 거기 때문에, 마지막으로 보낸 '목록 묶음'을 가져오는 것이다. 그러므로 lastPage는 [] 타입이 되고, allPages는 [][]이 되는 것!

Offset 기반

getNextPageParam: (lastPage, allPages) => lastPage.length // 0번부터 시작한다 가정

Cursor 기반

getNextPageParam: (lastPage, allPages) => lastPage[lastPage.length -1].id

at() 메서드가 동작을 안 하는지...?

이때, 주의해야 할 점이 있다.

Return undefined or null to indicate there is no next page available.

다음 페이지가 있는지 없는지를 알려주기 위해, 다음 페이지가 없다면 undefinednull을 넘겨줘야 한다는 것. 즉, undefinednull 넘어오면 hasNextPagefalse로 변한다. 그러니 넘겨주어야 한다.

그리고 이건 내가 헷갈린 건데,, 예시에 lastPage.nextCursor 라고 적혀있어서 lastPage라는 녀석이 nextCursor를 기본적으로 제공하나? 근데 걔가 그걸 어떻게 알지? 라는 의문이 있었는데, 그냥 의사코드였다(...)

fetchNextPage

위에서 저렇게 잘 정의를 해주고 나면 다음 페이지 요청은 그냥 fetchNextPage() 만 호출해주면 된다!

서버의 응답 구조

페이지네이션을 구현할 때 보통 서버에서는 응답값 뿐만 아니라 페이지네이션에 관한 정보도 같이 제공해주는 것이 좋다. 예를 들어 총 페이지 수라든가, 다음 페이지가 있는지, 현재 마지막 페이지인지, 마지막으로 보내준 아이디 등등. 이걸 제공해주면 useInfiniteQuery에서는 그냥 가져다가 쓰면 되는 것이고, 없으면 프론트 측에서 처리를 따로 해주어야 하기 때문에~ 백엔드 측과 원만한 협의를 보는 것이ㅋㅋ

예제

간단한 예제는 다음과 같다. 서버에서는 다음과 같은 형식으로 값을 내려준다. 나는 원만한 협의를 보지 못했다

서버 응답

[
  {
    "id": 1,
    "title": "안녕!",
    "content" "안녕안녕!"
  },
  {
    "id": 2,
    "title": "그래 안녕!",
    "content" "그래그래 안녕안녕!"
  }
]

불러오는 건 다음과 같이 불러온다.

API/getPosts

const getPosts = async ({pageSize = 5, lastViewId}) => {
  const res = await axios.post<Post[]>(`/api/post?pageSize=${pageSize}&lastViewId=${lastViewId}`);
  return res.data;
}

그러면 다음과 같이 쓰면 된다! 작성된 코드는 커서 기반이다.

App

const { data, hasNextPage, fetchNextPage, isFetchingNextPage } = useInfiniteQuery({
  queryKey: ['post'],
  queryFn: ({ pageParam }) => getPosts({ pageSize: 5, lastViewId: pageParam }),
  initialPageParam: 0,
  getNextPageParam: (lastPage, allPages) => {
    const isLastPage = lastPage.length < 5;
    return isLastPage ? undefined : lastPage[lastPage.length - 1].id;
  },
});

return (
  <>
    <button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage}>다음 페이지</button>
    {data.page.map((posts) => posts.map((post) => <Post key={post.id} {...post />))}
  <\/>
)

아주 간단하지요?


무한 스크롤

다음 페이지를 가져오는 로직은 짰으니, 이제 마지막 게시글을 봤을 때 다음 페이지를 가져오게끔 하면 된다. 이건 IntersectionObserver 라는 녀석을 쓰면 되는데, 주로 통용되는 방법은 두 가지인 것 같다.

1. 목록 끝에 감시 객체를 배치

목록 끝에 빈 객체를 하나 삽입해서, 해당 객체가 뷰포트에 감지가 되면 다음 패칭을 시도하는 방식이다.

<ul>
  <li>아이템</li>
  <li>아이템</li>
  ...
  <div id="elementForObserve"/>
</ul>

대충 이런 느낌이다. 요 방식은 스크롤이 정말 '맨끝'에 닿을 때 실행된다. 나는 이 방법을 선호하지 않는데,, 그 이유는 첫 번째로 맨 끝에 닿고 나서 다음 페이지 패칭을 시도하면 너무 늦다. 사용자들은 끊임없이 콘텐츠를 보고 싶어하지, 기다릴 시간은 없기 때문.

2. 특정(주로 끝) 아이템을 감시

특정 아이템을 감지하면 다음 페이지를 불러오는 형식이다. 주로 불러온 데이터들의 끝 아이템을 감시하는데, 서버 상태에 따라 다르겠지만 끝에서 두세 번째의 아이템이 감지될 때 불러오는 것이 UX적으로 제일 좋은 것 같다.

로직 구조

내가 찾아본 무한스크롤 코드들은 죄다 뭔가 엉성한 느낌이었다. 필요하지 않은 entries를 받아서 for문을 돌린다거나,, IntersectionObserver 안에서 observe를 다시 진행한다거나.. 좀 그랬다.

그래서 내가 생각한 방식대로, 군더더기 없이 구현해보려고 한 구조는 다음과 같다.

내가 생각한 무한 스크롤 방식

  1. MutationObserver로 목록이 추가되는 부모 감시.
  2. 부모에 자식이 추가가 된다면, IntersectionObserver가 감시하던 녀석을 unobserve하고, 새로운 마지막 녀석을 감시.
  3. IntersectionObserver에서는 감지할 때마다 콜백함수 실행.

코드

그래서~ 전체 코드는 다음과 같다.

useInfiniteObserver

export const useInfiniteObserver = ({
  parentNodeId,
  onIntersection,
}: {
  parentNodeId: string;
  onIntersection: () => void;
}) => {
  const intersectionObserver = useMemo(
    () =>
      new IntersectionObserver(
        ([intersection]) => {
          const { isIntersecting } = intersection;
          isIntersecting && onIntersection();
        },
        { threshold: 0.1 },
      ),
    [],
  );

  const mutationObserver = useMemo(
    () =>
      new MutationObserver((mutations) => {
        const [lastNode] = mutations.pop()!.addedNodes;

        intersectionObserver.disconnect();
        intersectionObserver.observe(lastNode as Element);
      }),
    [],
  );

  const disconnect = () => {
    mutationObserver.disconnect();
    intersectionObserver.disconnect();
  };

  useEffect(() => {
    mutationObserver.observe(document.getElementById(parentNodeId)!, { childList: true });
    intersectionObserver.observe(document.getElementById(parentNodeId)!);

    return () => disconnect();
  }, []);

  return { disconnect };
};

App

  const { data, fetchNextPage, hasNextPage } = useGetPosts();
  const { disconnect: disconnectObserver } = useInfiniteObserver({
    parentNodeId: 'postList',
    onIntersection: fetchNextPage,
  });

  useEffect(() => {
    !hasNextPage && disconnectObserver();
  }, [hasNextPage]);

return (
  <ul id="postList">
    {data.page.map((posts) => posts.map((post) => <Post key={post.id} {...post />))}
  </ul>
)

IntersectionObserver 자체가 사실 여러 녀석을 감지할 수 있도록 만든 녀석이다. 그렇기 때문에 기본적으로 entries를 제공하는 건데, 어차피 한 녀석만 감시할 거라면 굳이 entries를 받을 필요가 없다고 생각했고, 새로운 아이템이 추가되는 건 MutationObserver가 담당해주면 되는 거라고 생각했다. InteresctionObserver 안에서 다시 새로운 끝 아이템을 찾아서 Observe해주는 게 역할이 분리가 안 된다고 생각했다.

또, React가 추적하지 않는, 그러니까 React 생태계 밖에 있는 녀석들이라 이것을 분리시켜주어야 한다고 생각했다. 그래서 인스턴스를 useRef로 감싸주었고, 굳이 ul을 ref로 받지 않고 getElementById()로 uncontrolled하게 다루었다. 전에 controlled하게 다룰려다가 이건 할 수 없는 일이구나.. 싶어서 알게 되었다.

아참, 그리고 Hook의 useEffect 부분에서 IntersectionObserver가 마지막 아이템이 아닌 부모를 감시하는데, 이는 렌더링이 되었을 때부터 마지막 객체가 감지되었을 때를 대응하기 위해서이다. 애초에 그렇게 불러오면 안 되겠지만

MutationObserver는 최초 렌더링 시에는 감지하지 않아서, 어떻게 할까 고민하다가 어차피 UX적으로 한 묶음이 한 화면에 보일 정도의 양이라면 애초에 두 개를 불러오는 게 맞다는 생각이 들었다. 물론,, 화면 밖에 있을 때도 동작하겠지만서도 기본 2개는 그냥 깔고 가자고...

세은님의 무수한 댓글 알림...


끝! 아주 아구가 잘 맞게 짠 것 같아서 기분이 좋다.


  • 수정: 최초 렌더링에 대한 처리법으로 부모를 검사하는 게 아니라, 코드를 아래처럼 바꾸면 말끔하게 해결된다!
      useEffect(() => {
        mutationObserver.observe(document.getElementById(parentNodeId)!, { childList: true });
        intersectionObserver.observe(document.getElementById(parentNodeId)!.lastElementChild!);
    
        return () => disconnect();
      }, []);
profile
FE개발자 가보자고🥳

0개의 댓글