front-end/Nextjs

[NextJS] useInfiniteQuery이용해 무한스크롤 구현하기 2 - IntersectionObserver

hojung 2022. 8. 11.
728x90
반응형

1. 무엇을 하려하는가?

앞선 포스팅에서 무한스크롤을 구현했을 때 단순히 페치버튼을 누를 때마다 다음 데이터들을 불러오도록 설계를 하였다. 하지만 이렇게 되면 내가 애초에 무한 스크롤을 구현하려던 취지와 맞지 않는다. 클릭할 때마다 다음 데이터를 불러오게 된다면 결국 페이지네이션과 크게 다를 바가 없기 때문이다. 따라서 인스타그램과 네이버와 같은 큰 플랫폼에서는 이러한 문젤를 어떻게 해결했는지 참고하기로 하였다. 

인스타그램이나 네이버와 같은 플랫폼에서 데이터를 검색하거나 많은 데이터들을 보여주는 페이지가 있을 때 페이지네이션ui가 사라진 건 꽤 오래된 얘기이다. 이렇게 되면 이들이 어떤 방식으로 데이터를 불러오는 지가 궁금했는데 구글링을 하던 중 IntersectionObserver라는 것이 있어 깊게 알아보았다. 

 

2. 어떻게 할 것인가?

문서를 읽어보던 중 intersectionObserver는 대상 요소와 그 상위 요소 혹은 최상위 도큐먼트인 viewport와의 교차영역에 대한 변화를 비동기적으로 감지할 수 있도록 도와준다고 했다. 나는 이것을 화면에 어떤 특정한 html요소가 보이는 지 안보이는 지로 판별할 수 있는 기능을 제공해준다고 이해했다. 이 것이 기본적으로 가능하다면 네이버와 같은 큰 플랫폼이 무한 스크롤을 어떻게 구현하는 지에 대해 힌트가 될 수 있다고 생각했다. 실제로 서비스에 접속하여 스크롤을 내리면 페이지의 마지막에서 fetch를 더 하겠다고 하는 버튼과 같은 요소는 존재하지 않는다. 다만 스크롤을 모두 내리면 그 상태를 감지하여 다음 데이터들을 더 불러

네이버의 무한 스크롤

오는 것이다. 

 

그렇다면 이 intersectionObserver를 이용하면 불러온 페이지 맨 마지막에 보이지 않는 html요소를 하나 만들어서 그 요소가 보이게 되면 앞선 포스팅의 useInfiniteQuery가 제공하는 기능 중 fetchNextPage()함수를 사용하게 하면 어떨까?

이 생각이 내가 이 문제를 해결할 수 있도록 해주었다. 

 

3. 실제 구현

위에서 생각한 아이디어를 구현하기 위해서 일단 intersectionOberserver를 사용하는 방법을 알아야했다. 놀랍게도 아무런 라이브러리가 필요없이 javascript내부에서 자동으로 제공되는 기능으로 internetExplore를 제외한 모든 브라우저에서 동작한다고 한다. (저주받은 explore...) 

MDN문서를 참고한 결과 intersectionObserver는 다음과 같이 사용한다고 한다. 

우선 root, rootMargin, threshold와 같은 옵션을 줄 수 있다. 이 옵션들에 대한 설명은 다음과 같다. 

  • root: 대상 객체의 가시성을 확인할 때 사용하는 뷰포트 요소이다. 이는 감지할 대상 html요소의 조상 요소여야한다. 기본값은 브라우저 뷰포트이며, root값을 주지 않을 때나 null로 설정할 때 기본값으로 설정된다. 
  • rootMargin: root가 가진 여백이다. /이 속성은 css의 margin과 유사하다. css의 margin은 두 컴포넌트나 html태그들 사이의 거리와 비슷한데 root요소로 부터 얼만큼 떨어진 값을 감지해낼 수 있다. 
  • threshold: observer의 콜백 함수가 실행될 요소의 가시성 퍼센티지를 나타내는 수치이다. 숫자 혹은 숫자 배열이 들어갈 수 있다. 만일 50%만큼 대상 요소가 보여졌을 때 로직을 실행하고 싶다면 값을 0.5로 설정하면 된다. 혹은 25% 단위로 요소의 가시성이 변경될 때마다 콜백이 실행되게 하고 싶다면 [0,0.25,0.5,0,75,1]과 같은 배열을 설정하면 된다. 이 문구를 보고 나중에 홈 화면을 스크롤할 때마다 스크롤을 빨리 내려 ppt 슬라이드처럼 보여줄 수도 있겠다라는 생각을 했다. 
  • 기본 값은 0이면 이는 해당 요소가 1픽셀이라도 화면에 보이자마자 콜백함수가 실행됨을 의미한다. 
let options = {
  root: document.querySelector('#scrollArea'),
  rootMargin: '0px',
  threshold: 1.0
}

let observer = new IntersectionObserver(callback, options);

다음 코드는 MDN문서에 존재하는 intersectionObserver기본 사용 법이다. 앞서 언급한 옵션들의 값을 모두 설정한다음 new연산자를 통해 새로운 IntersectionObserver객체를 만들고 이 객체 안에 실행할 로직 callback함수와 아까 설정한 option을 넣어준다. 

let target = document.querySelector('#listItem');
observer.observe(target);

// the callback we setup for the observer will be executed now for the first time
// it waits until we assign a target to our observer (even if the target is currently not visible)

그 후 대상 target을 정하는 방법으로는 vanilla javascript에서는 querySelector를 통해 설정해주고 IntersectionObserver객체의 observe함수를 이용해 감지할 대상 target을 매개변수로 넣어준다. 

let callback = (entries, observer) => {
  entries.forEach(entry => {
    // Each entry describes an intersection change for one observed
    // target element:
    //   entry.boundingClientRect
    //   entry.intersectionRatio
    //   entry.intersectionRect
    //   entry.isIntersecting
    //   entry.rootBounds
    //   entry.target
    //   entry.time
  });
};

또한 callback함수의 구성도 살펴보니까 entries가 발견되면이라고 하는 것을 보니 target이 여러개여도 되는 듯 하다. 또한 속성을 살펴보니 time과 isIntersecting과 같은 상태도 제공하고 intersectionRatio와 같은 속성도 제공하니 이런 것들을 이요해서 많은 것들을 할 수 있겠다라는 생각이 들었다. 

 

NextJs에서 IntersectionObserver의 사용

NextJs는 React기반 프레임워크이기 때문에 코드의 재사용성을 높이기 위해 컴포넌트 안에서 그 때 그 때 사용하기 보다 하나의 hook으로 만들어서 반복적으로 무한 스크롤이 필요한 곳에 사용할 수 있게 하면 좋겠다라는 생각이 들었다. 

따라서 나는 다음과 같이 useIntersectionObserver훅을 만들었다. 

import { useEffect, useState } from "react";

interface useIntersectionObserverProps {
	root?: null;
	rootMargin?: string;
	threshold?: number;
	onIntersect: IntersectionObserverCallback;
}

const useIntersectionObserver = ({
	root,
	rootMargin = "0px",
	threshold = 0,
	onIntersect,
}: useIntersectionObserverProps) => {// 옵션을 props로 받는다. 
	const [target, setTarget] = useState<HTMLElement | null | undefined>(null);//html 요소를 target으로 지정할 수 있게 타입을 지정해주었다. 

	useEffect(() => {
		if (!target) return; // target이 없다면 리턴해준다. 

		const observer: IntersectionObserver = new IntersectionObserver(onIntersect, { root, rootMargin, threshold }); // props로 받은 옵션을 넣어주어 intersevtionObserver객체를 만들어준다. 
		observer.observe(target); // target을 observe함수를 통해 발견해준다. 그러면 callback함수 onIntersect를 실행하게 된다. 

		return () => observer.unobserve(target); // 다시 unobserve상태로 만들어준다. 
	}, [onIntersect, root, rootMargin, target, threshold]);// useEffect의 의존성 배열로 모든 옵션 변수들을 지정해주었다. 

	return { setTarget }; setTarget함수를 return 하여 훅의 매개변수로 target을 지정할 수 있게 한다. 
};

export default useIntersectionObserver;

다음과 같이 훅을 작성하고 컴포넌트에서 실제로 사용해보자. 

import { API_HOST } from "apis/api";
import AllAlbumCard from "components/AllAlbumCard";
import { useInfiniteQuery } from "react-query";
import { AlbumListComponent, AlbumListComponentContainer, FetchMoreButton } from "./styled";
import axios from "axios";
import { multiAlbumImage } from "types/multiAlbum/index";
import blankProfile from "public/assets/images/emptyProfile.png";
import useIntersectionObserver from "hooks/useObserver";

type GetAllAlbumUserType = {
	nickname: string;
	profileImage: string | null;
};
type GetAllAlbumItemType = {
	Images: multiAlbumImage[];
	User: GetAllAlbumUserType;
	UserId: number;
	content: string;
	createdAt: string;
	id: number;
	title: string;
	updatedAt: string;
};

const AlbumList = () => {
	const OFFSET = 4;
	const { data, error, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, status } = useInfiniteQuery(
		"getAllAlbumList",
		({ pageParam = 0 }) =>
			axios.get(`${API_HOST}/multiAlbum/getAllList`, {
				params: {
					limit: OFFSET,
					offset: pageParam,
				},
			}),
		{
			getNextPageParam: (lastPage) => {
				const { nextOffset, hasMore } = lastPage?.data;
				if (!hasMore) return false;
				else {
					return Number(nextOffset);
				}
			},
		},
	);

	const changeButtonText = (hasMore: boolean) => {
		return hasMore ? "load More ..." : "finish";
	};

	const loadMore = () => {
		if (hasNextPage) {
			fetchNextPage();
		}
	};
	const AllAlbums = data?.pages;
	const onIntersect: IntersectionObserverCallback = ([{ isIntersecting }]) => {
		fetchNextPage();
	}; // intersecionObserver의 콜백 함수를 지정해주는 모습 isIntersecting 상태이면 다음 페이지를 불러온다. 

	const { setTarget } = useIntersectionObserver({ onIntersect }); // 아까 지정한 onIntersect함수를 옵션으로 준 useIntersectionObserver의 setTarget함수를 구조파괴할당으로 받아온다. 
	return (
		<>
			<AlbumListComponentContainer>
				<AlbumListComponent>
					{status === "loading" && <div>loading...</div>}
					{status === "error" && <div>error</div>}
					{status === "success" &&
						AllAlbums?.map((page) => {
							return page.data.multiAlbumList.map((item: GetAllAlbumItemType) => (
								<AllAlbumCard
									key={item.Images[0].src}
									src={item.Images[0].src}
									title={item.title}
									content={item.content}
									nickname={item.User.nickname}
									linkUrl={`/multiAlbum/${item.id}`}
									profileImage={item.User.profileImage ? item.User.profileImage : blankProfile.src}
								/>
							));
						})}
					<FetchMoreButton onClick={loadMore}>
						{changeButtonText(data?.pages[data?.pages.length - 1].data.hasMore)}
					</FetchMoreButton>
				</AlbumListComponent>
			</AlbumListComponentContainer>
			<div ref={setTarget}></div> // target 설정 아까 훅에서 setTarget을 리턴했기 때문에 타겟으로 지정할 때 setTarget을 사용할 수 있다. 
		</>
	);
};

export default AlbumList;

다음과 같이 컴포넌트 안에서 사용하면 앞선 포스팅과 함께 스크롤을 통한 무한 스크롤을 구현할 수 있다. 

 

 

728x90
반응형

댓글