줍줍 프로젝트는 슬랙 메시지를 아카이빙하는 서비스입니다.
프로젝트 github
사용자의 슬랙 메시지를 보관해주는 줍줍 프로젝트 특성상 사용자가 특정 날짜를 기준으로 메시지를 조회했는데, 특정 날짜의 메시지 데이터만 조회되고 다른 날짜의 메시지는 볼 수 없어 다시 검색해야한다면 불편하지 않을까? 라는 생각이 들었다.
여기서 만약 양방향으로 무한 스크롤을 구현하게 된다면 특정 날짜의 메시지와 특정 날짜 이전 이후 메시지도 추가적으로 계속 불러올 수 있게되고, 그러면 사용자 입장에서도 더욱 편안한 서비스를 제공받는다는 생각할 수 있겠다고 생각이 들었다.
줍줍 프로젝트에서 양방향 무한스크롤을 구현하기 위해 어떤 고민들을 해서 어떤 방식을 채택했는지 확인하기 전에 단방향 무한 스크롤 구현에 대해 알아보도록 하자.
아래 코드는 InfiniteScroll 컴포넌트의 코드이다.
export interface Props {
callback: () => void;
threshold: number;
endPoint: boolean;
}
function InfiniteScroll({ children, ...props }: PropsWithChildren<Props>) {
const { targetRef: nextRef } = useIntersectionObserver(props);
return (
<>
{children}
<div ref={nextRef}></div>
</>
);
}
export default InfiniteScroll;
위 코드를 보면 알 수 있겠지만, InfiniteScroll 컴포넌트를 만들어 children 밑에 빈 div 테그를 만들어 ref를 붙여주었습니다.
그리고 추가 요청에 필요한 threshold와 endPoint 데이터 요청에 필요한 callback 함수를 props로 받아 useIntersectionObserver custom hook에 인자로 전달해준다.
useIntersectionObserver custom hook에는 ref설정해줄 수 있는 targetRef 와 Intersection Observer 객체를 생성해줍니다. onIntersect에는 데이터 요청시 필요한 callback함수를 조건에 맞게 실행할 수 있도록 설정해줬다.
function useIntersectionObserver({
callback,
threshold,
endPoint,
}: Props): ReturnType {
const targetRef = useRef<HTMLDivElement>(null);
const observer = useRef(
new IntersectionObserver(onIntersect, {
threshold,
})
).current;
function onIntersect([entry]: IntersectionObserverEntry[]) {
if (entry.isIntersecting) {
callback();
}
}
// some code...
}
export default useIntersectionObserver;
마지막으로 useEffect를 통해 endPoint, observer, targetRef를 dependency array에 삽입해준 후 endPoint에 도착했을 경우와 언마운트가 될 경우 observer를 disconnect 해주고 targetRef가 존재할 경우 targetRef를 observe 할 수 있도록 설정해줬다.
function useIntersectionObserver({
callback,
threshold,
endPoint,
}: Props): ReturnType {
// some code...
useEffect(() => {
if (endPoint) {
return observer && observer.disconnect();
}
if (targetRef && targetRef.current) {
observer.observe(targetRef.current);
}
return () => observer && observer.disconnect();
}, [endPoint, observer, targetRef]);
return { targetRef };
}
export default useIntersectionObserver;
이렇게 작성하게 되면, 하단에 targetRef가 보이게 될 경우 최하단의 데이터 아이디를 기준으로 과거의 데이터(하단 데이터)를 받아올 수 있게 된다.
하단 데이터 끝에 하단 targetRef를 위치시키고 intersectionObserver를 사용하게 되면 무한 스크롤은 큰 고민 없이 구현할 수 있다.
하단에 무한 스크롤을 구현하듯, 상단에도 targetRef를 설정해주고, useInfinityQuery를 2개를 사용해 상단 데이터를 요청하는 useInfinityQuery 하단 데이터를 요청하는 useInfinityQuery를 사용해보았다.
이 방식의 문제점은 초기 페이지 진입시 데이터 요청을 무한으로 보내는 문제가 있었다. 생각해보면 당연하다..!
초기 페이지를 진입하게 되면 각각의 query들이 데이터를 불러오게 되지만 스크롤은 상단에 고정되어 있기 때문에 상단에 targetRef는 데이터가 불러들여진 이후에도 계속해서 노출되게 된다. 따라서 상단 데이터 요청이 모두 소진 될때 까지 요청을 무한으로 보내게 되는 문제를 야기시키게 된다.
타이틀만 보면 무슨 소리인지 이해하기 힘들 수 있다.
상단의 targetRef가 App보다 -20px 가량 높은 위치에 위치 시킨다. 따라서 스크롤 위치가 최상단이 되더라도 상단의 targetRef는 intersecting되지 않기 때문에 초기 페이지 진입시 상단 데이터를 요청하는 문제는 해결할 수 있었다.
그러면 상단의 targetRef를 어떻게 intersecting 되게 할 수 있을까?! 데스크탑 기준 스크롤이 최상단이더라도, 스크롤을 추가로 내리게 되면 해당하는 페이지가 잡아당겨지는 모션을 보셨을 것이다.
모든 디바이스의 모든 브라우저가 최상단에서 위와 같이 동작한다면, 상단의 targetRef에 -20px를 주게 된 것도 노출되지 않을까?! 라는 생각을 했다. 하지만, window 기준 chrome 브라우저에서는 위와 같이 동작을 하지 않았고, 실제로 위와 같이 구현해보았지만 상단의 targetRef가 intersecting 되어 callback 함수가 호출되지도 않았다.
이 방법은 상단의 targetRef를 intersection 하는 방법이 아니라 사용자의 인터렉션에 따라 상단 데이터를 요청할 수 있도록 구현한 것이다. 이 방법에서는 useInfinityQuery는 하나만 사용해 상단 메시지 ID 기준으로 fetchPreviousPage, 하단 메시지 ID 기준으로는 fetchNextPage를 호출하도록 구현했다.
스크롤 높이를 계산해 특정 스크롤 높이보다 낮게 위치한 경우, 사용자가 스크롤을 특정 수치만큼 내릴 경우 상단 데이터를 요청할 수 있도록 했다.
먼저 onWheel 함수를 확인해보자.
onWheel 이벤트로 wheelDistanceCriterion 보다 사용자가 스크롤을 덜 움직이면서 scrollOffest 높이 보다 현재 스크롤의 높이가 낮을 경우 callback 함수를 호출하면 되는데, 같은 조건에서 데이터를 여러번 호출하는 것을 방지해주기 위해 isCalled라는 flag를 만들어 isCalled가 false일 경우만 callback 함수가 호출될 수 있도록 구현했다.
// some code...
function useTopScreenEventHandler({
// some code...
}: Props): ReturnType {
const wheelPosition = useRef({ default: 0, move: 0, scroll: 0 });
const scrollPosition = useRef({ default: 0 });
const isCalled = useRef(false);
const onWheel = (event: React.WheelEvent<HTMLDivElement>) => {
wheelPosition.current.move = event.deltaY;
if (
wheelPosition.current.move < wheelDistanceCriterion &&
scrollPosition.current.default < scrollOffset &&
!isCalled.current
) {
isCallable && callback();
isCalled.current = true;
return;
}
if (
wheelPosition.current.move > wheelDistanceCriterion &&
isCalled.current
) {
isCalled.current = false;
return;
}
};
// some code..
}
export default useTopScreenEventHandler;
다음은 useEffect hook 내부를 살펴 보자.
해당 페이지가 마운트 될 경우 handleScrollEvent를 통해 scrollPosition을 저장해주고, 언마운트 될 경우 이벤트를 해지 해주는 로직이다.
// some code...
function useTopScreenEventHandler({
// some code...
}: Props): ReturnType {
// some code...
useEffect(() => {
const handleScrollEvent = () => {
scrollPosition.current.default = window.scrollY;
};
window.addEventListener("scroll", handleScrollEvent);
return () => window.removeEventListener("scroll", handleScrollEvent);
}, []);
// some code ...
}
export default useTopScreenEventHandler;
하지만 줍줍 프로젝트는 모바일과 데스크탑 모두 지원하는 서비스였는데, onWheel 이벤트가 모바일에서는 동작하지 않아 정상적으로 상단 데이터를 요청하지 못하는 상황이 발생했다.
모바일을 지원해주기 위해 onTouchStart와 onTouchEnd 이벤트를 추가적으로 활용해주었다.
먼저 onTouchStart 함수 내부를 살펴보자.
내부적으로 계산되는 것은 onWheel 이벤트와 동일하지만 onWheel과 같은 동작을 하는 모바일 지원하는 이벤트가 존재하지 않기 때문에 onTouchStart와 onTouchEnd 를 활용해 주었다.
onTouchStart로 사용자가 처음 터치한 위치를 체크한 후 onTouchEnd일 경우 처음 터치한 곳과 끝에 터치를 띄어준 곳에 차이를 계산해 상단 데이터를 요청할 수 있도록 구현해주었다.
// some code...
function useTopScreenEventHandler({
// some code...
}: Props): ReturnType {
const touchPosition = useRef({ start: 0, end: 0 });
// some code...
const onTouchStart = (event: React.TouchEvent<HTMLDivElement>) => {
touchPosition.current.start = event.changedTouches[0].clientY;
};
const onTouchEnd = (event: React.TouchEvent<HTMLDivElement>) => {
touchPosition.current.end = event.changedTouches[0].clientY;
const { start: touchStart, end: touchEnd } = touchPosition.current;
if (
touchStart - touchEnd < touchDistanceCriterion &&
scrollPosition.current.default < scrollOffset
) {
isCallable && callback();
return;
}
};
}
export default useTopScreenEventHandler;
아래 이미지는 프로젝트에 적용된 양방향 무한 스크롤 GIF이다.
양방향 무한스크롤을 구현하는데 있어 누군가는 도움이 되길 바란다. 양방향 무한스크롤을 구현하는데 있어 더 좋은 아이디어가 있다면 공유해주면 좋을 것 같다.