[React] 무한 스크롤 구현 (조회, 필터링 시)

선유준·2024년 2월 3일
0

REACT

목록 보기
10/15
post-thumbnail
post-custom-banner

이 글에서는 무한 스크롤을 구현하는 방법에 대해서 서술하고자 합니다.

구현 방법은 몇 가지 존재하지만 저는 Intersection Oberver API를 이용하여 구현하였습니다.

참고 : MDN | Intersection Observer API

무한 스크롤이란?

웹사이트나 앱에서 사용되는 스크롤링 기술로, 사용자가 페이지 하단에 도달했을 때, 콘텐츠가 계속 로드되는 사용자 경험(UX) 방식이다.

무한 스크롤을 사용한 이유

서버에서 데이터를 불러올 때, 모든 데이터를 한 번에 불러온다면 데이터가 많으면 많을수록 페이지 로딩 시간이 길어질 것이다.

원래는 이런 상황에서 페이지네이션을 주로 사용해왔지만 제목, 태그, 날짜 필터링 기능이 있는 상황이고, 추후 모바일 환경을 생각하니 무한 스크롤도 좋을 것 같다는 생각이 들어 선택하게되었다.

또한 사용자의 클릭을 최소화 하려는 목적도 가지고 있다.

구현 과정

1. Intersection Observer 커스텀 Hook 구현

Intersection Observer는 브라우저 뷰포트(Viewport)설정한 요소(Element)의 교차점을 관찰하며, 요소가 뷰포트에 포함되는지, 포함되지 않는지 구별하는 기능을 한다.

// useIntersectionObserver.ts
import { useRef } from "react";

export default function useIntersectionObserver(callback: () => void) {
	const observer = useRef(
		new IntersectionObserver(
			(entries: IntersectionObserverEntry[]) => {
				entries.forEach((entry) => {
					if (entry.isIntersecting) {
						callback();
					}
				});
			},
			{ threshold: 1 },
		),
	);

	// Element는 DOM 요소를 나타내는 타입임
	const observe = (element: Element) => {
		observer.current.observe(element);
	};

	const unobserve = (element: Element) => {
		observer.current.unobserve(element);
	};

	return [observe, unobserve];
}

new IntersectionObserver()를 통해 생성한 인스턴스(observer)로 관찰자를 초기화하고 관찰할 대상(element)을 지정한다.

콜백 함수는 IntersectionObserver의 entries 매개변수를 받아 각 엔트리를 순회하며 가시성 변화가 발생한 경우 콜백을 호출한다.

  • observeruseRef를 사용하여 생성한 이유는 상위 컴포넌트의 생애주기 동안 유지되는 값으로 하기위해 사용했다.
  • threshold 옵션은 옵저버가 실행되기 위해 타켓의 가시성이 얼마나 필요한지 백분율로 표시한다. 여기서는 1로 설정하여 요소가 완전히 보일 때 가시성 변화를 감지하도록 하였다.

2. 무한 스크롤 적용하기

콜백함수 등록

 const [observe, unobserve] = useIntersectionObserver(() => {
    setPage((page) => page + 1);
  });

가시성 변화가 발생한 경우 실행 될 콜백함수이며 , 새로운 데이터를 불러오기 위해 page + 1을 해준다.

교차 대상 지정

<>
	<ListContainer>
 	...
	</ListContainer>
	{isLoading && <Loading />}
	<div ref={target} style={{ width: "100%", height: 30 }}></div>
 </>

div 태그가 교차 대상이며, 화면이 리스트의 끝에 왔을 때, 데이터를 추가로 불러와야 하기 때문에 해당 위치에 두었다.

기능 적용

 useEffect(() => {
    if (page === 1 && target?.current) {
      observe(target.current);
    }

    const N = diaries.length;
    const totalCount = pageInfo.totalElements;

    if ((N === 0 || totalCount <= N) && target?.current) {
      unobserve(target.current);
    }
  }, [diaries]);

diaries는 서버에서 Get요청으로 가져온 데이터 / totalCount는 해당 데이터가 총 몇 개 있는지 나타내는 변수이다.

해당 코드는 observeunobserve를 사용하였는데, 처음 데이터를 불러왔을 때, observe를 호출하며 / 모든 데이터를 불러왔으면 unobserve를 해준다.

마지막으로 page가 변화할 때 서버에서 데이터를 불러오는 useEffect문을 작성해준다.

  // page 변경 감지에 따른 API호출
  useEffect(() => {
    fetchData().catch((error) => {
      console.log(error);
    });
  }, [page]);

여기까지가 Intersection Oberver API를 이용한 무한스크롤 구현이였습니다.

추가로 쿼리를 이용한 태그, 제목, 날짜 필터링에서도 무한스크롤을 적용하였는데 나중에 참고할 부분이 있을 수도 있으니... 코드를 남겨두겠습니다.

const [searchVal, setSearchVal] = useState<string>("");
const debouncedSearchVal = useDebounce(searchVal, 300); // 300ms 딜레이로 디바운스 적용
const path = useLocation().pathname;
const search = useLocation().search;
// 이전 경로를 저장하기 위한 상태
const [previousPath, setPreviousPath] = useState(path);
const [previousSearch, setPreviousSearch] = useState(search);

					   ... 관련 없는 코드 생략 ...

  // page, path, search 변경 감지에 따른 API호출
  useEffect(() => {
    if (path !== previousPath || search !== previousSearch) {
      setPage(1); // path 또는 search가 변경된 경우에만 page를 1로 초기화
    }
    // 경로 변화를 확인한 후에는 현재 경로로 상태 최신화
    setPreviousPath(path);
    setPreviousSearch(search);

    fetchData().catch((error) => {
      console.log(error);
    });
  }, [page, path, search]);

 // API를 호출하는 부분
  const fetchData = async () => {
    setIsLoading(true);
    try {
      // 쿼리 없이 일기만 가져올 때
      if (search === "") {
        const API_URL = `${path}?page=${page}&size=10`;
        const response = await api.get(API_URL);

        // 페이지가 1 이상이면 기존 상태에 추가 / 1이라면 상태를 응답 데이터로 초기화
        if (page > 1) {
          setDiaries((prevData) => [...prevData, ...response.data.data]);
          //   setPageInfo(response.data.pageInfo);
        } else {
          setDiaries(response.data.data);
          setPageInfo(response.data.pageInfo);
        }
      }
      // 쿼리가 있을 때 && 쿼리에 ?title이 존재할 때 (제목 검색)
      else if (search.includes("?title")) {
        const API_URL = `${path}/search?page=1&size=10${search.replace(
          "?",
          "&"
        )}`;
        const response = await api.get(API_URL);
        // 페이지가 1 이상이면 기존 상태에 추가 / 1이라면 상태를 응답 데이터로 초기화
        if (page > 1) {
          setDiaries((prevData) => [...prevData, ...response.data.data]);
          //   setPageInfo(response.data.pageInfo);
        } else {
          setDiaries(response.data.data);
          setPageInfo(response.data.pageInfo);
        }
      }
      // 쿼리가 있을 때(태그, 날짜)
      else {
        const API_URL = `${path}?page=${page}&size=10${search.replace(
          "?",
          "&"
        )}`;
        const response = await api.get(API_URL);
        // 페이지가 1 이상이면 기존 상태에 추가 / 1이라면 상태를 응답 데이터로 초기화
        if (page > 1) {
          setDiaries((prevData) => [...prevData, ...response.data.data]);
          //   setPageInfo(response.data.pageInfo);
        } else {
          setDiaries(response.data.data);
          setPageInfo(response.data.pageInfo);
        }
      }
    } catch (error) {
      console.log(error);
    }
    setIsLoading(false);
  };

 useEffect(() => {
    if (debouncedSearchVal === "") {
      navigate(`${path}`);
    } else {
      navigate(`${path}?title=${debouncedSearchVal}`);
    }
  }, [debouncedSearchVal]);

pathsearch로 구별하는 이유는 useNavigate를 사용하여 필터링 시 url을 변경시켜주기 때문

profile
매일매일 발전하는 개발자를 목표로!
post-custom-banner

0개의 댓글