무한스크롤(Infinite Scroll)?
게시판 글 리스트처럼 많은 데이터를 배열로 받아오는 경우, 그 데이터가 너무 방대해지면 api 요청으로 데이터를 받아오는 시간이 오래걸릴 수 밖에 없게됩니다. 우리는 페이지네이션(pagination)으로 데이터를 일정 개수씩 분리해 받아오는 방식을 통해 이러한 문제를 해결해왔습니다. 페이지네이션 또한 좋은 선택지이지만, 오늘 날 모바일 기기를 이용해 웹에 접속하는 경우가 매우 많다보니 터치횟수를 최소화하고 콘텐츠를 끊김없이 보여주는 무한스크롤이 더 좋은 대안이 될 수 있습니다.
무한스크롤 구현방법
무한스크롤은 한 페이지의 스크롤의 바닥(세로 끝부분)에 도달할 때 api를 요청하는 방식입니다. 자 그럼 문제는 '어떻게 스크롤 바닥에 도달했는지 알 수 있는가?' 입니다. 방법은 대표적으로 onScroll
을 이용하는 방식과 Intersection Observer API
를 활용하는 방식이 있습니다. 저는 오늘 Intersection Observer API와 react-query를 이용하여 구현하는 방법과 코드를 소개할 예정이지만, 두 방식이 어떻게 작동하는지도 간단히 준비해봤습니다.
1. Scroll Event
콘텐츠의 전체 길이와 현재 스크롤 한 길이를 비교하여 스크롤 바닥을 감지하는 방법
scrollHeight
: 수직 스크롤 없이 콘텐츠를 전부 나타냈을 때 그 길이
clientHeight
: 현재 스크린 상에 보이는 화면의 높이 (css높이 + css padding - 수평스크롤바 높이)
scrollTop
: 현재 스크롤 위치에서 가장 윗부분의 위치, 스크롤을 맨 끝으로 내리면 scrollHeight - UI높이 값이 scrollTop값이 됨.
사진으로 더 설명해보자면 현재 전체 콘텐츠에서 중간정도를 보고 있는 상황입니다. 스크롤을 맨끝까지 내렸을 때 비로소 scrollHeight - scrollTop값이 clientHeight이 되기 때문에, 끝에 닿기 전까지는 scrollHeight - scrollTop값이 항상 clientHeight보다는 큽니다. 이를 이용해서 스크롤이 바닥에 닿는 때를 감지할 수 있게되는 거죠. 쉽게말해 '전체 콘텐츠의 길이'와 '현재 스크롤 한 길이'를 비교해서 구하겠다 이 말입니다!
하지만 이 방식은 스크롤이 움직일 때마다 이벤트 핸들러가 호출되므로 성능이슈가 있기때문에 이벤트를 한번만 호출시켜주는 Throttle이나 Debounce를 함께 구현해주어야 합니다. Intersection Observer Api를 활용하면 그런 불편함을 피할 수 있습니다.
2. Intersection Observer API로 바닥 감지하기
타겟 요소와 상위요소 또는 최상위 document의 viewport 사이의 intersection 내의 변화를 비동기적으로 관찰하는 방법
1. 인스턴스 생성하기
let options = {
root: document.querySelector('#scrollArea'),
rootMargin: '0px',
threshold: 1.0
}
let observer = new IntersectionObserver(callback, options);
root
: 대상 객체의 가시성을 확인할 때 사용되는 뷰포트 요소, 기본값은 브라우저 뷰포트이며 root값이 null이거나 지정되지 않을 때 기본값으로 설정됨
rootMargin
: root가 가진 여백, 교차성을 계산하기 전에 적용됨
threshold
: target의 가시성 퍼센티지, 예를들어 target이 root에서 50%만크 요소가 보여졌을때를 탐지하고 싶다면, 0.5로 설정하면 됨. 기본값은 0으로, 요소가 1px이라도 보이면 콜백이 실행됨. 반면 1.0으로 설정할 시 target의 전체가 화면에 노출되기 전엔 콜백이 실행되지 않음.
2. 콜백함수
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
});
};
entries
: IntersectionObserverEntry의 인스턴스 배열
entry.isIntersecting
: 관찰 대상의 교차상태 (boolean)
정리를 해보면, target요소가 root요소를 교차하는 시점에 콜백함수를 실행시킬 수 있습니다. 콘텐츠 최하단 부분을 target으로 설정해두면, 스크롤이 맨 아래 닿았을 때 콜백함수가 실행되는 것입니다.
useInfiniteQuery란?
바닥을 감지하는 방법을 알았으니, 이제 api 호출을 통해 데이터를 한페이지 씩 받아오면됩니다. react-query에서는 무한스크롤을 편하게 구현할 수 있도록 돕는 useInfiniteQuery라는 훅을 제공하고 있습니다. 각 옵션에 대한 자세한 내용은 공식문서에서 확인할 수 있습니다. 오늘은 제가 구현할 때 사용한 옵션들만 소개해드리겠습니다.
const {
data,
fetchNextPage,
hasNextPage,
} = useInfiniteQuery(
queryKey,
queryFn: ({ pageParam = 1 }) => fetchPage(pageParam),
{
select: data => ({
pages: data.pages,
pageParams: data.pageParams,
}),
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
}
)
returns
data
: 서버에 요청해서 받아온 데이터
fetchNextPage
: 다음페이지를 불러옵니다.
hasNextPage
: 가져올 다음페이지가 있는지 여부를 나타냅니다(boolean). getNextPageParam옵션을 통해 알 수 있습니다.
option
queryKey
: 쿼리를 구별하여 캐시를 관리하기위한 이름, key입니다.
queryFn
: 쿼리가 데이터를 요청하는 데 사용할 함수, API입니다.
select
: 받아온 데이터를 원하는 형태로 가공할 수 있습니다. useInfiniteQuery의 경우 페이지가 쌓이는 형태이기 때문에 구조가 복잡해서 select로 가공해서 사용하는 편이 코드가 깔끔합니다.
getNextPageParam
: 새 데이터를 받아올 때 마지막페이지와 전체페이지 배열을 함께 받아옵니다. 더 불러올 데이터가 있는지 여부를 결정하는데 사용합니다. 반환값이 다음 API호출할때의 pageParam으로 들어갑니다.
구현하기
1. intersection observer api Hook 분리하기
import React, { useEffect, useState } from 'react';
import { InfiniteQueryObserverResult } from '@tanstack/react-query';
//hook props interface
interface IuseIntersectionObserverProps {
threshold?: number;
hasNextPage: boolean | undefined;
fetchNextPage: () => Promise<InfiniteQueryObserverResult>;
}
export const useIntersectionObserver = ({
threshold = 0.1,
hasNextPage,
fetchNextPage,
}: IuseIntersectionObserverProps) => {
//관찰할 요소입니다. 스크롤 최하단 div요소에 setTarget을 ref로 넣어 사용할 것입니다.
const [target, setTarget] = useState<HTMLDivElement | null | undefined>(null);
const observerCallback: IntersectionObserverCallback = (entries) => {
entries.forEach((entry) => {
//target이 화면에 관찰되고, 다음페이지가 있다면 다음페이지를 호출
if (entry.isIntersecting && hasNextPage) {
fetchNextPage();
}
});
};
useEffect(() => {
if (!target) return;
//ointersection observer 인스턴스 생성
const observer = new IntersectionObserver(observerCallback, {
threshold,
});
// 타겟 관찰 시작
observer.observe(target);
// 관찰 멈춤
return () => observer.unobserve(target);
}, [observerCallback, threshold, target]);
return { setTarget };
};
2. 적용하기
export default function PostList({ filterProp }: { filterProp: IFilter }) {
const { data, isLoading, fetchNextPage, hasNextPage } = useInfiniteQuery<
AxiosResponse,
AxiosError,
IArticleData
>(
['articles', filterProp],
({ pageParam = 1 }) => getArticles(filterProp, { pageParam }),
{
getNextPageParam: (lastPage, allPages) => {
const nextPage = allPages.length + 1;
return lastPage.data.length === 0 ? undefined : nextPage;
},
select: (data) => ({
pages: data?.pages.flatMap((page) => page.data),
pageParams: data.pageParams,
}),
},
);
// 커스텀 훅에 hasNextPage와 fetchNextPage를 넣어 setTarget을 받아옵니다.
const { setTarget } = useIntersectionObserver({
hasNextPage,
fetchNextPage,
});
return (
<>
<ul>
{/* 페이지 ui 생략*/}
</ul>
{/* 페이지 최하단에 작은 div요소 만들어 ref에 setTarget적용 */}
<div ref={setTarget} className="h-[1rem]" />
</>
작동 모습
스크롤이 바닥에 닿으면 다음 페이지를 제대로 불러오고 있습니다.
References
https://tech.kakaoenterprise.com/149
'FE > react' 카테고리의 다른 글
[React] 스크롤 특정 요소로 이동하기 (0) | 2023.06.01 |
---|---|
[React] 토스트팝업/스낵바(toast popup/snack bar)직접 구현하기 (0) | 2023.05.24 |
[React] 모바일 웹 100vh 스크롤 버그 + tailwind 적용법 (2) | 2023.05.08 |
[React] 모바일 웹 스크롤 시 주소창, 하단바 없애기 (5) | 2023.05.06 |
[React] 리액트 carousel 라이브러리 없이 직접 구현하기 (0) | 2023.05.06 |
댓글