[React] Infinite Scroll을 구현하는 2가지 방법
🧐 서론
최근, 이직을 하게 되면서 많은 과제를 수행했었고, 처음 혹은 새로 구현하는 다양한 경험치들을 쌓을 수 있었다!
그 중에 하나가 기술할 Infinite Scroll(무한 스크롤) 로, 앱에서는 특히 이 기법을 통해 리스트 데이터를 추가하는 경우가 많다.
이전에 개인 프로젝트를 하면서 무한 스크롤을 구현했었고, 당시 window 객체의 스크롤값을 비교하는 보편적인 방법으로 구현했었다. (링크)
하지만, 이번에 Intersection Observer 라는 새로운 방법을 알게 되었고, 기존 방법이 스크롤 값을 정확하게 보장할 수 없음에 새로이 출시된 기능 및 방법이라는 것을 알게 되었다.
무한 스크롤을 구현하는 2가지 방법, 그리고 각각의 장단점에 대해 좀 더 정리해보도록 하겠다!
두 방법을 구현하는 원리의 차이는 있겠지만, React의 컨셉에 맞게 커스텀 훅으로 구현하는 방법을 채택했대.
💙 useInfiniteScroll() : Scroll 이벤트 (Scroll값 비교)
- 구현코드
우선, 보편적으로 사용되어왔던 Scroll Event를 감지하고, 해당 이벤트 핸들러에서 스크롤값을 비교하는 방법이다.
위 포스팅에서처럼, 이전에 JS함수로 구현한 경험이 있는데, 이를 Hooks로 분리하는 작업까지 포함하여 서술하겠다!
import { useEffect, useState, useCallback } from "react";
type OptionType = {
onScrollEnd?: () => void;
};
type ReturnType = {
isEnd: boolean;
};
const lockScroll = useCallback(() => {
document.body.style.overflow = "hidden";
}, []);
const unlockScroll = useCallback(() => {
document.body.style.overflow = "";
}, []);
const useInfiniteScroll = ({ onScrollEnd }: OptionType): ReturnType => {
const [isEnd, setIsEnd] = useState(false);
const handleScroll = async () => {
const scrollHeight = document.documentElement.scrollHeight;
const scrollTop = document.documentElement.scrollTop;
const clientHeight = document.documentElement.clientHeight;
if (scrollTop + clientHeight >= scrollHeight) {
setIsEnd(true);
lockScroll();
if (onScrollEnd) await onScrollEnd();
await unlockScroll();
await setIsEnd(false);
}
};
useEffect(() => {
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return { isEnd };
};
export default useInfiniteScroll;
- Option으로는 스크롤이 끝에 이르렀을 때 실행할 함수, 반환값은 isEnd(스크롤 완료여부) 를 설정했다.
- isEnd는 스크롤 완료여부인 state값이다. 일반값이나 Ref를 사용해도 무방하나, 완료여부에 따른 UI분기(로딩처리 등)까지 고려하여 우선 useState로 제작했다.
- handleScroll() 은 스크롤 이벤트에 대한 핸들러 함수이다. height 계산을 통해 스크롤이 끝나면 로직을 실행한다.
- scrollHeight : 페이지의 총 높이이다. 이는, 화면에 보이지 않는 높이까지 고려된 높이다.
- scrollTop : 이미 스크롤된 높이이다. 화면 상단이 기준이 된다.
- clientHeight : 브라우저 화면, 즉 사용자에게 보여지는 높이이다.
- 먼저, isEnd를 true로 바꾸고, lockScroll() 을 통해 스크롤을 방지한다.
- 다음으로, onScrollEnd() 메서드를 실행한다. 이것이 완료되면, unlockScroll() 을 통해 스크롤을 풀어주며, isEnd도 false로 바꾼다.
- useEffect() 를 통해, Mounted 시에 이벤트를 추가, Unmount 시에 이벤트를 제거해준다.
👉 컴포넌트에선 아래와 같이 사용할 것이다.
const Scroll = () => {
const idx = useRef<number>(0);
const [list, setList] = useState<object[]>([]);
const { isEnd } = useInfiniteScroll({ onScrollEnd: fetchList });
const fetchList = async () => {
const addList = await getList({ offset: idx, limit: 10 });
await setList([...list, addList]);
idx.current++;
};
return (
<ScScroll>
{list.map(item => <Item item={item}></Item>)}
{isEnd && <Loader />}
</ScScroll>;
)
};
- useInfiniteScroll() 커스텀 훅을 가져온다. onScrollEnd는 리스트 fetch함수인 fetchList를 넘겨준다.
- Hooks에서 isEnd 값을 활용할 것이다. 해당값이 true인 경우만, <Loader> 컴포넌트를 노출시킨다.
- Hooks 최적화
이 방법의 단점은, Scroll Event가 과다하게 발생한다는 것이다. 이를, throttle을 통해서 최적화한다.
import { useEffect, useState, useCallback } from "react";
import { throttle } from 'lodash';
// ...
const useInfiniteScroll = ({ onScrollEnd }: OptionType): ReturnType => {
const [isEnd, setIsEnd] = useState(false);
// throttle 설정
const handleScroll = throttle(async () => {
const scrollHeight = document.documentElement.scrollHeight;
const scrollTop = document.documentElement.scrollTop;
const clientHeight = document.documentElement.clientHeight;
if (scrollTop + clientHeight >= scrollHeight) {
setIsEnd(true);
lockScroll();
if (onScrollEnd) await onScrollEnd();
await unlockScroll();
await setIsEnd(false);
}
}, 300);
useEffect(() => {
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return { isEnd };
};
export default useInfiniteScroll;
위처럼 설정하면, 한 번 handleScroll이 실행된 다음에는 300ms라는 홀딩시간이 보장된다.
💙 useIntersection() : Intersection Observer API
두 번째는, Javascript에서 지원하는 Observer API를 활용하는 것이다. 구현방법에 앞서, 이 옵저버에 대해 알아보자.
- Observer API
Observer pattern은 객체 상태변화를 관찰하는 관찰자들(옵저버)을 등록하여 상태변화가 있을 때마다 각 옵저버에 알리는 패턴이다.
이 논리에 기반한 Javascript 내장 API가 Observer API 인 것이다.
1) MutationObserver
DOM 변경을 감지하는 옵저버이다. 요소의 삽입, 수정, 삭제 및 자식요소가 수정되는 경우 등을 감지한다.
2) ResizeObserver
타겟 요소가 리사이징 이벤트를 통해 크기가 변경될 때를 감지하는 옵저버다. 반응형으로 보이는데 유용하다.
3) IntersectionObserver
타겟 요소와 상위 요소(혹은 최상위 document)의 viewport 사이에 교차지점을 비동기적으로 관찰한다.
타겟요소가 화면에 얼마나 보이는지에 따라 다양한 이벤트를 줄 수 있다.
- Intersection Observer 자세히 알기
* 개요 및 개념
위에서 설명한대로, I.O는 타겟 요소가 viewport와 얼마나 교차하는지를 비동기적으로 감지하는 옵저버이다.
이는 2016년 4월 구글 개발자 페이지를 통해 소개된 기능이다. 이 API가 만들어진 이유는 스크롤 이벤트를 구현하는 방법들의 단점을 보완하기 위함이었다.
- Scroll Event는 throttle/debounce 등의 최적화가 필수적이다. 또한, 핸들러가 동기적으로 실행되면 메인 스레드에 부하를 준다.
- getBoundingClientRect() 를 통해 viewport 교차여부를 확인할 수 있다. 단, 이 메서드가 호출되면 reflow/repaint를 발생한다.
아래와 같이, viewport(하늘색)와 타겟요소(분홍색) 의 교차여부를 감시한다. 또한, 교차비율에 따른 행동도 설정할 수 있다.
또한, 감지를 비동기적으로 진행하기 때문에 메인 스레드에 부하를 주지 않는다는 장점도 존재한다
* 사용방법
Intersection Observer는 아래와 같은 문법으로 인스턴스를 생성한 뒤 사용한다.
let observer = new IntersectionObserver(callback, options);
- callback : 교차시에 실행되는 함수이다. 로딩구현이나 패치 등의 함수가 통상 할당된다.
- options : Intersection Observer에 관한 설정을 할 수 있는 부분이다.
- root : 교차를 감지하는 root 요소. observe로 등록할 요소의 상위요소여야 한다. 기본값은 null(이 땐 브라우저 viewport)
- rootMargin : root 요소의 마진값. 기본값은 0px.
- threshold : 0.0 ~ 1.0 사이의 숫자들을 배열로 받는다. 이는 %로 치환되어, 해당 비율만큼 교차된 경우 콜백이 실행된다.
// options 설정
const options = {
root: document.querySelector('.container'), // .container class를 가진 엘리먼트를 root로 설정. null일 경우 브라우저 viewport
rootMargin: '10px', // rootMargin을 '10px 10px 10px 10px'로 설정
threshold: [0, 0.5, 1] // 타겟 엘리먼트가 교차영역에 진입했을 때, 교차영역에 타켓 엘리먼트의 50%가 있을 때, 교차 영역에 타켓 엘리먼트의 100%가 있을 때 observe가 반응한다.
}
// IntersectionObserver 생성
const io = new IntersectionObserver((entries, observer) => {
// IntersectionObserverEntry 객체 리스트와 observer 본인(self)를 받음
// 동작을 원하는 것 작성
entries.forEach(entry => {
// entry와 observer 출력
console.log('entry:', entry);
console.log('observer:', observer);
})
}, options)
다음으로, observer 객체에 사용되는 메서드들이다. 타겟요소를 등록하거나, 제거하는 등의 동작을 담당한다.
- observer.observe(target) : 타겟요소에 대한 Intersection Observer 를 등록한다.
- observer.unobserve(target) : 타겟요소에 대한 옵저버를 멈춘다. Lazy Loading이 시작되면 관찰을 멈추는 등의 용도가 있다.
- observer.disconnect() : 다중 타겟요소들의 옵저빙을 동시에 멈추기 위해 사용되는 메서드다.
- observer.takerecords() : IntersectionObserverEntry 객체 배열을 반환한다. 타겟요소, root요소, 교차정보(비율, 교차여부, 시간 등) 을 담고 있다.
- boundingClientRect : 타겟요소 정보
- rootBounds : 루트요소 정보
- intersectionRect : 교차영역 정보
- intersectionRatio : 교차비율(threshold)
- isIntersecting : 교차여부(boolean)
- target : 타겟요소
- time : 교차된 기록 시간
- 구현코드
import { useCallback, useEffect, useState } from "react"
import throttle from 'lodash/throttle';
type OptionType = {
root?: null,
rootMargin?: string;
threshold?: number;
onIntersect: () => void;
}
const useIntersection = ({
root,
rootMargin = '0px',
threshold = 0.5,
onIntersect,
}: OptionType) => {
const [target, setTarget] = useState<HTMLElement | null>(null);
const ioCallback: IntersectionObserverCallback = useCallback(throttle(
async ([entry], io) => {
if (entry.isIntersecting) {
document.body.style.overflow = 'hidden';
io.unobserve(entry.target)
await onIntersect();
await io.observe(entry.target);
return document.body.style.overflow = 'auto';
}
}, 1000),
[])
useEffect(() => {
if (!target) return;
const io: IntersectionObserver = new IntersectionObserver(ioCallback, { root, rootMargin, threshold })
io.observe(target);
return () => io.disconnect();
}, [target, root, rootMargin, threshold, onIntersect])
return { setTarget }
}
export default useIntersection;
- Option은 onIntersect(실행함수, 필수) 및 I.O 옵션들(선택) 을 받으며, I.O의 타겟Ref를 설정하는 setTarget() 메서드를 반환한다.
- ioCallback은 I.O의 첫 번째 인자인 콜백함수이다. entry는 교차할 경우 isIntersecting 가 true가 된다. 이 때, Scroll Lock ➡️ unobserve() 감지중단 ➡️ onIntersect() 함수 실행 ➡️ observe() 재감지 ➡️ Scroll Unlock 순으로 진행한다.
- 마찬가지로, useEffect() 를 통해 io 변수에 Intersection Observer 인스턴드를 할당하고, observe() 를 시작한다.
- Unmount 시에는 disconnect() 를 통해 모든 옵저버를 제거한다.
👉 컴포넌트에선 아래와 같이 사용될 것이다.
const InfiniteScrollPage: GetNextPageParamFunction = () => {
const [productList, setProductList] = useState<ProductType[]>([]);
const pageRef = useRef<number>(0);
const [loaded, setLoaded] = useState<boolean>(false);
const [showLoader, setShowLoader] = useState(false);
const { initScroll } = useScrollSave();
useEffect(() => {
init();
}, [])
const init = async () => {
const savedPage: number = getStorage('productsPage', false) || 0;
await fetchProductList(savedPage ? savedPage * 16 : 16, true)
if (savedPage) {
pageRef.current = +savedPage;
await initScroll()
removeStorage('productsPage', false)
}
setShowLoader(true);
}
const fetchProductList = async (size = 16, isInit = false) => {
try {
if (!isInit && loaded) return;
pageRef.current++
const { products, totalCount } = await getProductList({ page: pageRef.current, size }, true)
setProductList(prev => isInit ? products : [...prev, ...products]);
if (productList.length >= totalCount) setLoaded(true);
}
catch(e) {
const err = e as AxiosError;
if (err.code === 'ERR_BAD_REQUEST') return setLoaded(true);
throw err;
}
}
const { setTarget } = useIntersection({ onIntersect: fetchProductList })
const beforeRouteItem = () => {
setStorage('productsPage', pageRef.current, false);
}
return (
<>
<Container>
<ProductList products={productList} beforeRouteItem={beforeRouteItem} />
{(showLoader && !loaded) && <FetchLoader ref={setTarget} />}
</Container>
</>
);
};
export default InfiniteScrollPage;
import 부분과 Styled-Components를 제외한 모든 코드이다. 상당히 난잡하나, useIntersection() 훅 위주로 참조해달라!
- useIntersection Hooks를 가져온다. onIntersect에는 리스트를 호출하는 fetchProductList() 메서드를 할당했다.
- setTarget은 타겟요소를 설정하기 위해 가져왔다. <FetchLoader> 컴포넌트에 ref로 설정해주면 타겟요소로 반영된다.
- 로더를 타겟요소로 쓴 만큼, 최초 init 시에 showLoader를 하거나, totalCount까지 완료되면 loaded로 다시 숨겨주거나 등의 조건부 제어들이 필요했다. (state를 하나로 가져가거나, unobserve() 등으로 최적화가 가능할 것 같다.)
위 2가지 경우 외에도 useRef나 getBoundingClientRect 등의 방법이 있다는 것도 알았으며,
각각의 단점때문에 현재는 Intersection Observer가 최선의 방법으로 채택되고있다.
특히, Observer의 종류가 여러가지 있다는 부분도 알게 됬으며, 특히 Callback이 entries 배열을 인자로 받기 때문에, 이 정보들을 통해 교차정도에 대한 디테일한 제어도 가능함을 명확하게 알 수 있었다!
📎 출처
- [2가지 방법비교] Ha-Young 님의 블로그 : https://ha-young.github.io/2021/frontend/infinite-scroll/
- [Scroll Event로 구현] diana lee 님의 블로그 : https://medium.com/@_diana_lee/react-infinite-scroll-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-fbd51a8a099f
- [Scroll Lock] logRocket 블로그 : https://blog.logrocket.com/create-advanced-scroll-lock-react-hook/
- [Intersection Observer API] mdn docs : https://developer.mozilla.org/ko/docs/Web/API/Intersection_Observer_API
- [Intersection Observer] elrion018 님의 블로그 : https://velog.io/@elrion018/%EC%8B%A4%EB%AC%B4%EC%97%90%EC%84%9C-%EB%8A%90%EB%82%80-%EC%A0%90%EC%9D%84-%EA%B3%81%EB%93%A4%EC%9D%B8-Intersection-Observer-API-%EC%A0%95%EB%A6%AC
- [I.O로 구현] egg-programmer 님의 블로그 : https://velog.io/@suyeonme/react-Infinite-Scroll-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0