무한스크롤(InfiniteScroll)

Jinmin Kim·2024년 2월 2일

📌 1. custom Hooks

  • callback: 데이터 로딩을 트리거하는 함수.
  • hasMore: 더 많은 데이터를 로드할 수 있는지 여부를 나타내는 값. 만약 false일 경우, callback이 호출되지 않음.
  • options: IntersectionObserver의 옵션으로, 루트 요소와 스크롤 관찰 경계(rootMargin, threshold) 등을 설정할 수 있음.

options

✔️ 1. root

  • null (기본값): 브라우저의 뷰포트를 기준으로 대상 요소가 보이는지 여부를 감지합니다.
  • 특정 DOM 요소: 지정한 요소를 기준으로 대상 요소가 해당 루트 요소 안에 있는지 확인합니다. 특정 스크롤 컨테이너 내에서 스크롤이 끝에 다다랐을 때 트리거하고 싶을 때 사용됩니다.

✔️ 2. rootMargin
위 예시는 뷰포트가 기준(root: null)이고, rootMargin을 '0px 0px -50px 0px'으로 설정하여 아래쪽 50px 마진을 추가했습니다. 이 설정은 대상 요소가 완전히 뷰포트에 진입하기 50px 전에 콜백이 호출되도록 합니다. 이렇게 하면 요소가 화면에 완전히 나타나기 전에 콜백을 실행할 수 있습니다.

const observer = new IntersectionObserver(callback, {
  root: null,
  rootMargin: '0px 0px -50px 0px',  // 아래쪽 마진을 -50px로 설정
  threshold: 1.0,
});

✔️ 3. threshold
threshold는 요소가 루트(뷰포트 또는 지정한 루트 요소) 내에서 몇 퍼센트 보일 때 콜백을 호출할지를 정의합니다. 이 값은 0.0에서 1.0 사이의 숫자이거나 배열일 수 있습니다.
0: 대상 요소가 한 픽셀이라도 루트 요소 내에 들어오면 콜백이 호출됩니다.
1.0: 대상 요소가 완전히 루트 요소 안에 들어와야 콜백이 호출됩니다.

⚠️ width를 줄이고, 개발자도구를 오른쪽으로 세팅했더니, 무한스크롤이 동작을 안해요...⚠️

만약 threshold: 0.5를 하게된다면 요소가 “반 이상 보여야” entry.isIntersecting === true가 됩니다.

무한스크롤을 적용하는 컴포넌트의 뷰포트가 들어 요소가 반 이하로 보이면 isIntersecting이 곧바로 false가 되어 콜백이 호출되지 않게되는 이슈가 발생하게되므로, 해당 옵션을 주의해서 사용해야겠습니다.

useInfiniteScroll.tsx (커스텀 훅) -> 타입 제네릭 추가

import { useCallback, useEffect, useRef } from "react";

interface InfiniteScrollOptions {
    root?: HTMLElement | null;
    rootMargin?: string;
    threshold?: number;
}


type UseInfiniteScrollHook = <T extends HTMLElement>(
    callback: () => void,
    hasMore: boolean,
    options?: InfiniteScrollOptions,
) => React.RefObject<T>;

export const useInfiniteScroll: UseInfiniteScrollHook = <T extends HTMLElement>(
    callback: () => void,
    hasMore: boolean,
    options: InfiniteScrollOptions = {
        root: null,
        rootMargin: "0px",
        threshold: 1.0,
    },
) => {
    const observerRef = useRef<T>(null);

    const observerCallback = useCallback(
        (entries: IntersectionObserverEntry[]) => {
            const [entry] = entries;
            if (entry.isIntersecting && hasMore) {
                callback();
            }
        },
        [callback, hasMore],
    );

    useEffect(() => {
        const target = observerRef.current;
        if (!target) return;

        const observer = new IntersectionObserver(observerCallback, options);
        observer.observe(target);

        return () => {
            if (target) observer.unobserve(target);
        };
    }, [observerCallback, options]);

    return observerRef;
};

useInfiniteScroll 사용

  //...
  // useInfiniteScroll 훅 사용, fetchMoreData 콜백과 hasMore를 전달
  const loaderRef = useInfiniteScroll<HTMLDivElement>(fetchMoreData, hasMore);

  return (
    <div>
      <ul>
        {items.map((item) => (
          <li key={item}>{item}</li>
        ))}
      </ul>
      {/* 스크롤이 끝에 도달할 때 이 div가 관찰됨 */}
      <div ref={loaderRef} style={{ height: '100px', background: 'lightgray' }}>
        {hasMore ? 'Loading more...' : 'No more items'}
      </div>
    </div>

최종코드

import React, { useState, useEffect, useRef } from 'react';

const InfiniteScrollObserver = () => {
  const [data, setData] = useState([]);
  const [loading, setLoading] = useState(true);
  const [page, setPage] = useState(1);

  const observer = useRef(null);
  const lastElementRef = useRef(null);

  const fetchData = async () => {
    // API 호출 또는 다른 방법으로 데이터를 가져옴
    const response = await fetch(`https://api.example.com/data?page=${page}`);
    const newData = await response.json();

    setData((prevData) => [...prevData, ...newData]);
    setLoading(false);
  };

  const handleIntersect = (entries) => {
    if (entries[0].isIntersecting) {
      setPage((prevPage) => prevPage + 1);
      setLoading(true);
    }
  };

  useEffect(() => {
    fetchData();
  }, [page]);

  useEffect(() => {
    if (lastElementRef.current) {
      observer.current = new IntersectionObserver(handleIntersect, {
        root: null,
        rootMargin: '0px',
        threshold: 1.0,
      });

      observer.current.observe(lastElementRef.current);
    }

    return () => {
      if (observer.current) {
        observer.current.disconnect();
      }
    };
  }, [lastElementRef]);

  return (
    <div>
      {/* 데이터 표시 */}
      {data.map((item, index) => (
        <div key={index}>{item}</div>
      ))}
      {/* 로딩 표시 */}
      {loading && <p>Loading...</p>}
      {/* IntersectionObserver를 사용한 무한 스크롤을 위한 ref */}
      <div ref={lastElementRef} style={{ height: '10px' }}></div>
    </div>
  );
};

export default InfiniteScrollObserver;

📌 2. react-query: useInfiniteQuery()

useInfiniteQuery() 옵션 정리

옵션명설명
queryKey캐싱을 위한 키 (배열 형태로 설정 가능)
queryFn데이터를 가져오는 함수 (fetcher)
getNextPageParam다음 페이지의 pageParam을 결정하는 함수
initialPageParam초기에 사용할 pageParam (기본값: undefined)
staleTime데이터를 신선하게 유지하는 시간
cacheTime데이터를 메모리에 유지하는 시간
enabledfalse이면 쿼리를 자동으로 실행하지 않음

useInfiniteScroll (커스텀 훅)

import { useInfiniteQuery } from "@tanstack/react-query";
import { useRef, useEffect } from "react";

const useInfiniteScroll = ({ queryKey, queryFn, getNextPageParam }) => {
    //    data,
    //     fetchNextPage:다음 페이지 데이터를 불러오는 함수
    //     hasNextPage:다음 페이지가 있는지 여부 (true | false)
    //     isFetchingNextPage: 다음 페이지를 불러오는 중인지 여부
    const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
        useInfiniteQuery({
            queryKey,
            queryFn,
            getNextPageParam,
            initialPageParam: 1, // 초기 페이지
        });

    const observerRef = useRef(null);

    useEffect(() => {
        const observer = new IntersectionObserver(
            (entries) => {
                if (entries[0].isIntersecting && hasNextPage) {
                    fetchNextPage();
                }
            },
            { threshold: 1.0 },
        );

        if (observerRef.current) {
            observer.observe(observerRef.current);
        }

        return () => observer.disconnect();
    }, [hasNextPage, fetchNextPage]);

    return {
        data,
        isFetchingNextPage,
        observerRef,
    };
};

export default useInfiniteScroll;

useInfiniteScrollQuery 훅 사용예제

import React from "react";
import useInfiniteScrollQuery from "@/services/hooks/useInfiniteScrollQuery";

// API 호출 함수 (React Query)
const fetchPosts = async ({ pageParam = 1 }) => {};

const PostList = () => {
    const { data, isFetchingNextPage, observerRef } = useInfiniteScrollQuery({
        queryKey: ["posts"],
        queryFn: fetchPosts,
        getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
    });

    return (
        <div>
            {data?.pages.map((page, i) => (
                <div key={i}>
                    {/*{page.items.map((post) => (*/}
                    {/*    <p key={post.id}>{post.title}</p>*/}
                    {/*))}*/}
                </div>
            ))}

            <div
                ref={observerRef}
                style={{ height: "20px", background: "transparent" }}
            />

            {isFetchingNextPage && <p>Loading more...</p>}
        </div>
    );
};

export default PostList;

여기서 의문! 그럼 다음 페이지는 누가 정해주는거야?
내부적으로 다음페이지가 어떤건지 알수잇는 라이브러리가 있는건가???

react-query는 서버에 "마지막 페이지냐?"고 직접 묻지 않는다

✔️ useInfiniteQuery()서버가 응답하는 데이터를 기반으로 getNextPageParam()에서 직접 판단

✔️ 즉, react-query 자체는 "이게 마지막 페이지냐?"라고 서버에 묻는 요청을 보내지 않음

✔️ 대신 서버가 반환하는 데이터(nextPage 등)를 보고 판단

nextPage는 누가 제공해야 할까?

react-querynextPage를 예측하는 것이 아님!

  • 클라이언트(react-query)는 서버의 응답을 기반으로만 동작
  • 따라서 서버가 nextPage 정보를 제공해야 함!
  • 만약 서버가 nextPage를 반환하지 않으면, 클라이언트는 "마지막 페이지인지" 판단할 방법이 없음

서버가 nextPage 값을 응답에 포함해야 함

{
  "items": [ { "id": 5 }, { "id": 6 } ],
  "nextPage": null  // 서버가 마지막 페이지면 null 또는 undefined 반환
}

✔️ 서버가 마지막 페이지면 nextPagenull, false, undefined로 반환해야 react-query가 판단 가능!

서버가 nextPage를 제공하지 않는다면?

nextPage 없이 마지막 페이지를 감지하는 방법

✔️ 서버가 nextPage를 제공하지 않는다면, 다른 방식으로 마지막 페이지를 감지해야 함

✔️ 예를 들어, items.length === 0이면 마지막 페이지로 간주하는 방식 사용 가능

예제

const getNextPageParam = (lastPage) => {
  return lastPage.items.length > 0 ? lastPage.nextPage ?? undefined : undefined;
};

✔️ 서버가 nextPage를 주지 않아도, 데이터가 없으면 마지막 페이지로 판단 가능

🔥 문제 상황:

useInfiniteQuery의 getNextPageParam 함수에서 마지막 페이지일 때 false를 반환하도록 했음.
하지만 API 호출 시 false가 pageParam으로 전달되어, 내부적으로 hasNextPage가 여전히 true로 판단되어 추가 호출이 발생함.

  • 분석 및 제안:
    React Query는 getNextPageParam 함수에서 undefined를 반환해야 “더 이상 페이지가 없음”으로 인식함. false는 값이 존재하는 것으로 판단되어, hasNextPage가 true로 남게 됨.
    추가로, queryKey의 안정성이나 API 응답 값 등도 함께 점검해봤지만, 문제의 근본 원인은 false를 반환한 점.

  • 해결:
    getNextPageParam 함수에서 마지막 페이지에 도달했을 때 false 대신 undefined를 반환하도록 수정함. 그 결과, hasNextPage가 올바르게 false로 설정되고 더 이상의 API 호출이 중단됨.
    결국, false 대신 undefined를 반환해야 React Query가 더 이상 다음 페이지가 없음을 인식하여 문제가 해결된 것.

내가 생각한 useInfiniteQuery의 단점

나는 useInfiniteQuery를 사용해서 데이터를 가져온뒤, 테이블에 넣어서 관리를 해야한다.
테이블에는 input, selectbox 등의 수정해야하는 부분들이 있다.
그래서 아래의 코드에서 data를 가져오고 난뒤, store에 그 데이터 값을 저장하여서
변경되는 데이터들을 관리하려고 하였다.

근데 아래의 모양 처럼
useInfiniteQuery의 return Data에는 아래와같이 나온다.

  • pageParams: 페이지 파라미터
  • pages: 각페이지의 데이터

근데 이 데이터들을 가져와서 store에 넣으려고하니,
내가 store에서 변경했던 데이터들이 전부다 유실되게 된다.
왜냐면 Pages안에 있는 데이터들을 전부 다 넣어주게 되는 구조가 될것이기 때문이다.

다른 구분값이 있다면, 수정 유무를 내가 판단해주면 되겠지만
구분 키가 없다면, 나는 수정유무 + 중복유무 같은 로직을 수행하기가 어려워진다.

    const {
        data: scrollData,
        isFetchingNextPage,
        observerRef,
        isLoading,
    } = useInfiniteScrollQuery({
        queryKey: ["func"],
        queryFn: ({ pageParam = 1 }: { pageParam: number | boolean }) => {
            return api.func({
                page: String(pageParam),
                ...param,
            });
        },
        getNextPageParam: (returnData) => {
            return totalCount - perPage * page >= perPage
                ? page + 1
                : undefined;
        },
    });
profile
Let's do it developer

0개의 댓글