배경
커뮤니티 서비스를 만들고 있는데, 피드를 만들어야 했다.
이전에 당근마켓 인턴십의 사전 질문이기도 했던 세부 글보기 에서 좋아요/댓글이 추가/삭제된 경우 어떻게 피드에 반영할 것인가?라는 질문에 직접 구현해보며 답할 수 있는 좋은 기회라고 생각해서 구현하게 됐다.
무한스크롤에 필요한 것들을 하나씩 확인해보면서 구현할 것이다.
필요한 것 알아내기
무한 스크롤은 사용자가 페이지 하단에 도달했을때 콘텐츠가 계속 로드되는 사용자 경험 방식이다.
그럼 구현에 필요한 두가지 필수 조건을 알 수 있다.
- 사용자가 페이지 하단에 도달했을때
- 콘텐츠가 계속 로드
이 두 가지 미션을 해결하면 무한스크롤이 되는 것이다.
이 게시글에서는 1번 항목에 대해 어떻게 구현할 것인지를 고민해보았다.
사용자가 페이지 하단에 도달했음을 감지하기
Scroll Event (onScroll)
간단하게 생각했을때, scroll Event가 일어났을때마다 최하단에 도달했는지를 확인한다면 해결되는 문제다.
scrollHeight, scrollTop, clientHeight 세가지 속성을 사용해서 페이지 하단에 도달했음을 확인할 수 있다.
scrollHeight, scrollTop, clientHeight
이해하기 쉽게 그림을 그려왔다.
clientHeight은 요소의 보이는 높이값이다.
그리고 scrollTop은 스크롤바의 위치를, scrollHeight는 scrollBar가 없을때(요소를 쫙 폈을때) 요소의 높이값이다.
그럼 이 세가지 요소를 조합해서 맨 아래에 도착했는지 확인할 수 있다.
최대스크롤 가능요소까지 스크롤하게되면 scrollHeight-scrollTop이 clientHeight와 같아지는 것이다.
그럼 이걸 활용하면 이렇게 작성할 수 있다.
const scrollTop=document.documentElement.scrollTop;
const scrollHeight=document.documentElement.scrollHeight;
const clientHeight=document.documentElement.clientHeight;
if(clientHeight===scrollHeight-scrollTop){
fetch();
}
그런데 사파리 브라우저에서는 오버스크롤이 된다고한다.
이러면 clientHeight보다 scrollHeight-scrollTop한 크기가 더 작아진다.
부등호를 넣어주자.
const scrollTop=document.documentElement.scrollTop;
const scrollHeight=document.documentElement.scrollHeight;
const clientHeight=document.documentElement.clientHeight;
if(clientHeight>=scrollHeight-scrollTop){
fetch();
}
그럼 이걸 window.onScroll에 달아주면된다.
useEffect(() => {
window.addEventListener(() => {
const scrollTop = document.documentElement.scrollTop;
const scrollHeight = document.documentElement.scrollHeight;
const clientHeight = document.documentElement.clientHeight;
if (clientHeight >= scrollHeight - scrollTop) {
fetch();
}
});
}, []);
문제점
onScroll의 콜백함수는 스크롤이 일어날때마다 호출된다.
알다시피 JS는 싱글스레드 언어이다.
즉, 스크롤 할때마다 콜스택이 계속 쌓이게되는 것이다.
이러면 무의미한 함수호출을 계속하는 것 뿐 아니라 이론적으로는 CallStack이 쌓이면서 다른 함수 호출, 비동기 처리에도 문제를 일으킬 수 있다. (실제 성능 비교 실험 글을 읽어보면 이론에 그치지 않는다는 것을 알 수 있다.)
이런 문제를 해결하기 위해서 쓰로틀링, 디바운싱을 걸 수 있다...만 우리가 원하는건 마지막 부분에 왔을 때만, fetch시키는 것이다.
결국 디바운싱이든, 스로틀링이든 무의미한 호출을 하게되는 것이다.
무의미한 호출을 하지 않는 것이 BEST라고 할 수 있다.
유의미한 호출을 하기위한 방법이 있다.
바로 Intersection Observer API이다.
Intersection Observer API
Intersectin Oberserver API는 특정 요소가 다른 요소의 교차에 관한 API다.
즉, 교차되는 것과 관련한 일이 일어났을때 콜백함수를 호출시킬 수 있는 것이다.
간단한 사용법을 알아보자. (자세한 사용법은 mdn을 참고하면된다)
IntersectionObserver는 다음과 같이 새로운 인스턴스를 만들고, 요소를 관찰할 수 있다.
const observer = new IntersectionObserver(콜백함수, 옵저버옵션);
const element=관찰할요소;
observer.observe(element); // 해당 요소 관찰 시작
옵저버 옵션
여기서 옵저버 옵션으로 다양한 값을 줄 수 있는데
가장 중요한게 root와 threshold다.
const observer = new IntersectionObserver(콜백함수, {
root: "관찰할 요소의 상위요소",
threshold: "관찰 요소가 얼마나 보여야하는가?" //1 -> 100%다.
});
이때 root 값이 없으면 브라우저 뷰포트가 된다.
콜백함수
콜백함수는 지정된 형태를 갖는데 두개의 인자를 갖는다.
entries는 해당 옵저버가 관찰하고 있는 대상 전체를 말한다. (여러개의 대상을 관찰하고 있을 수 있으므로)
observer는 옵저버 객체다.
let callback = (entries, observer) => {
entries.forEach((entry) => {
// 각 엔트리는 observe하고있는 요소 하나의 교차 변화를 설명한다.
});
};
이 entry에서 다양한 정보를 얻어올 수 있다.
- boundingClientRect: 관찰 대상의 사각형 정보(DOMRectReadOnly)
- intersectionRect: 관찰 대상의 교차한 영역 정보(DOMRectReadOnly)
- intersectionRatio: 관찰 대상의 교차한 영역 백분율(intersectionRect 영역에서 boundingClientRect 영역까지 비율, Number)
- isIntersecting: 관찰 대상의 교차 상태(Boolean)
- rootBounds: 지정한 루트 요소의 사각형 정보(DOMRectReadOnly)
- target: 관찰 대상 요소(Element)
- time: 변경이 발생한 시간 정보(DOMHighResTimeStamp)
이중 isIntersecting을 사용하면 무한스크롤을 구현할 수 있다.
useRefFocusEffect
이걸 활용해서 ref요소가 화면에 들어왔을때(focus) 콜백함수가 수행되는 훅을 만들어주었다.
import { useEffect, useRef } from 'react';
export default function useRefFocusEffect<T extends HTMLElement>(onFocusCallback: () => void, deps?: React.DependencyList) {
const ref = useRef<T>(null);
useEffect(() => {
if (ref.current) {
const observer = new IntersectionObserver((entries) => entries.forEach((entry) => entry.isIntersecting && onFocusCallback()), {
threshold: 1,
});
observer.observe(ref.current);
return () => {
if (ref.current) observer.unobserve(ref.current);
};
}
}, [deps]);
return { elementRef: ref };
}
그럼 요소에 ref만 달아주면 ref가 화면에 보일때 콜백이 수행된다.
...
const { elementRef } = useRefFocusEffect<HTMLDivElement>(fetch, [data]);
return <div ref={elementRef}>이 요소가 보이면 fetch</div>
}
이렇게 하면 해당 요소가 화면에 100%보일때 fetch가 일어나게 할 수 있다.
즉, 이전 onScroll처럼 무의미하게 호출을 하지 않고 필요할때만 콜백을 호출할 수 있는 것이다.
다음 게시글에서는 피드 데이터를 어떻게 받아오고, 관리할 것인지를 알아보자.