무한 스크롤(Infinite Scroll)이란?
사용자가 특정 페이지 하단에 도달했을 때, 추가적인 데이터를 불러오는 API를 호출하여 콘텐츠가 끊기지 않고 계속 로드되는 방식을 말합니다.
한 번의 API 요청으로 모든 데이터를 불러오는 경우, 데이터 로딩 시간이 길어질 수 있습니다. 이러한 시간을 단축하고 사용자에게 더욱 많은 양의 데이터를 제공하기 위해 무한 스크롤 방식으로 데이터를 제공하고자 합니다.
사용자에게 데이터 리스트를 제공하는 방식은 크게 두 가지로 나누어집니다.
X | 페이지 네이션 (Pagination) | 무한 스크롤 (Infinite Scroll) |
장점 | 페이지 번호를 통해 원하는 정보가 있는 페이지로 이동 가능 → 원하는 정보 찾기 쉬움 | 콘텐츠가 끊기지 않고 계속 로드되는 방식 → 많은 양의 데이터를 스크롤 해서 볼 수 있음 |
단점 | 추가적인 데이터를 보기 위해 사용자가 행동을 취해야 함 (페이지 번호 터치) | • 원하는 데이터의 위치를 기억하기 어려움 • 스크롤 막대로 정확한 정보량을 알 수 없음 |
두 가지 방식 모두 장, 단점이 뚜렷해, 사용 용도나 상황을 고려하여 선택하는 하는 것이 가장 좋은 방법일 것 같습니다.
그렇지만 저의 생각으로는 단순히 사용자에게 더 많은 양의 데이터를 보여줘야 한다면, 무한 스크롤을 사용하는 게 더 유리하다고 생각합니다. 왜냐하면 단순히 흥미로 방문한 사용자에게 더 많은 데이터를 보기 위해서 추가적인 액션을 취해야 한다면, 사용자의 입장에서는 데이터 접근에 대한 허들로 느껴질 수 있다고 생각합니다. 또한 페이지 이동 시 발생하는 로딩 시간이 무한 스크롤에 비해 더 길게 느껴질 수 있습니다.
구현 환경
- TypeScript, Vite, React, loadash, React Query v4
- Macbook Pro 13 (M2)
구현 방법
Scroll Event 감지하는 방법
: 스크롤이 페이지 끝에 닿는 것을 감지해 추가 데이터를 요청해 받아오는 방식
스크롤이 페이지 끝에 닿는 것을 감지하기 위해 documentElement의 높이값 속성들에 대해 알아야 합니다.
scrollHeight : 페이지의 전체 높이
scrollTop : 스크롤 된 높이 ⇒ 총 스크롤 한 높이 값
- 화면 상단이 기준
- 상대적이고 가변적인 값
clientHeight : 현재 스크린 상에 보이는 화면 내부의 높이 ⇒ 실제 보이는 영역에 대한 높이
- content + padding 값을 의미
offsetHeight : 현재 스크린 상에 보이는 화면 내부의 전체 높이
- content + padding + border(테두리) + 스크롤바까지 포함한 전체 높이
- margin은 포함 X
※ clientHeight와 offsetHeight는 거의 비슷한 값을 나타내지만, 테두리가 페이지 레이아웃에 영향을 미치는 중요한 요소일 경우, offsetHeight를 사용할 수 있고, 다른 방법으로는 현재 보이는 부분, 즉 사용자가 실제 보는 부분에 대해 고려한다면 clientHeight를 사용할 수 있습니다. 어떤 속성을 사용할지는 구현하고자 하는 특정한 UI/UX 및 페이지 레이아웃에 따라 달라질 수 있습니다.
위의 값들을 기반으로 전체 페이지 높이 <= 지금까지 스크롤 한 길이 + 현재 보이는 부분 일 때, 끝에 닿았다고 인식할 수 있습니다.
const scrollHeight = document.documentElement.scrollHeight;
const scrollTop = document.documentElement.scrollTop;
const clientHeight = document.documentElement.offsetHeight;
if (scrollTop + offsetHeight >= scrollHeight) {
// 데이터 추가 요청 코드
};
이 코드를 기반으로 useInfiniteScroll이라는 Cutsom Hook을 만들어 위의 조건에 만족할 경우 데이터 추가 요청을 하는 코드를 작성해 보겠습니다.
hooks/useInfiniteScroll.ts
import React, { useEffect, useState } from 'react';
import { throttle } from 'lodash';
interface InfiniteScrollProps {
fetchCallback: () => void;
}
const useInfiniteScroll = ({ fetchCallback }: InfiniteScrollProps) => {
const [isFetching, setIsFetching] = useState<boolean>(false);
// throttle: 0.3초에 한번씩만 실행되도록 설정
const handleScroll = throttle(async () => {
const scrollHeight = document.documentElement.scrollHeight;
const scrollTop = document.documentElement.scrollTop;
const offsetHeight = document.documentElement.offsetHeight;
if (scrollTop + offsetHeight >= scrollHeight) {
setIsFetching(true);
try {
await fetchCallback();
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setIsFetching(false);
}
}
}, 300);
useEffect(() => {
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [handleScroll]);
return isFetching;
};
export default useInfiniteScroll;
- fetchCallback은 스크롤이 끝에 닿았을 경우 추가 데이터를 불러오는 함수입니다.
- isFetching은 데이터 로딩 상태를 나타내는 'boolean' 값으로, 데이터를 불러오는 동안 'true'로 설정되고, 데이터를 불러온 후 'false'로 변경합니다. 사용자 인터페이스에서 로딩 상태를 표시하는 데 사용할 수 있습니다.
- handleScroll 함수는 스크롤 이벤트에 대한 처리 함수로 throttle을 사용해 0.3초마다 한 번씩만 실행되도록 합니다. 이를 통해 scroll event 핸들러의 호출 빈도를 줄여 성능을 향상시켰습니다. (이 내용은 밑에서 추가로 설명하겠습니다.)
- 사용자가 페이지 하단에 도달했는지 확인하고, 페이지 하단에 도달한 경우 fetchCallback을 호출 해 추가 데이터를 불러옵니다.
- useEffect를 통해 컴포넌트가 마운트 될 때 window 객체에 scroll이벤트 리스너를 추가하고, handleScroll 함수를 이벤트 리스너로 설정합니다. 컴포넌트가 언마운트될 때 이벤트 리스너를 제거합니다. ( 메모리 누수 방지 )
컴포넌트에서 사용한 예시
const List = () => {
// 서버에서 데이터를 불러오는 함수
const fetchList = async ({ pageParam = 1 }) => {
try {
axios.get('url', {
params: { pageNo: pageParam, pageSize: 10 },
})
return {
data: response.data,
nextPage: response.data.length ? pageParam + 1 : undefined
};
} catch(error) {
return { data: [], nextPage: undefined };
}
};
// React Query의 useInfiniteQuery 사용
const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } = useInfiniteQuery(
['list'],
({ pageParam = 1 }) => fetchList({ pageParam }),
{
getNextPageParam: (lastPage) => {
return lastPage?.data.length ? lastPage.nextPage : undefined;
},
},
);
// 데이터를 추가로 불러올지 결정
const loadMore = async () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
};
useInfiniteScroll({ fetchCallback: loadMore });
return (
<div>
{isLoading ? (
<Loading />
) : data ? (
<ProductForm items={data?.pages.flatMap((page) => page.data)} />
) : (
<p>목록이 없습니다.</p>
)}
</div>
);
};
pageNo는 페이지 번호를 말하고, 데이터를 불러온 횟수입니다. (0 혹은 1부터 시작)
pageSize는 불러올 데이터의 수를 말합니다. 즉 데이터 목록의 개수입니다.
이를 통해 url?pageNo=1&pageSize=10 같은 url을 통해 데이터를 불러올 수 있고, 데이터를 불러온 횟수에 따라 pageNo 값이 증가합니다.
React Query 라이브러리의 useInfiniteQuery 훅은 무한 스크롤 기능 구현 시 많은 도움을 줍니다. 아래의 반환값들을 제공해 줍니다.
- 반환값
- data : 받아온 데이터 리스트 (['list']로 저장)
- isLoading : 로딩 상태로, 데이터를 불러오고 있는지 혹은 완료 여부
- hasNextPage : 다음 페이지 존재 여부
- fetchNextPage : 다음 페이지를 불러오는 함수
- isFetchingNextPage : 다음 페이지를 불러오는 여부
하지만 데이터를 사용할 때
data?.pages.flatMap((page) => page.data)
위의 코드와 같이 데이터를 평탄화해 전달해야 합니다.
loadMore 함수는 추가적인 데이터를 불러올지 결정하는 함수입니다. 다음 페이지가 있고, 다음 페이지를 불러오는 중이 아니라면, 다음 페이지를 호출합니다.
위 코드에서는 데이터를 모두 불러온 후 서버에서 Error를 반환하면 더 이상 데이터를 불러오지 않도록 설정했습니다.
예를 들어 설명하자면, 데이터가 있는 pageNo가 1부터 3까지 라면, pageNo가 4에 대한 요청을 서버에 보내면, 더 이상 데이터가 없으므로 404 Not Found 같은 에러를 반환합니다. 이 경우 try-catch 문을 통해, 빈 배열([])을 리턴하고 그 이후에는 더 이상 데이터를 불러오지 않습니다.
Scroll Event 최적화
"스크롤 이벤트가 발생할 때마다" 끝에 도달했는지 검사하기 때문에, 불필요하게 이벤트가 많이 발생해 성능 저하의 문제가 발생할 수 있습니다. 그래서 위의 코드에서는 lodash 라이브러리의 throttle을 적용해 이벤트 발생을 줄였습니다.
- Throttle : 일정 주기마다 이벤트를 모아 이벤트가 지정한 시간 단위당 한 번만 발생하도록 하는 기술
scroll을 하고 있는 동안 300ms마다 이벤트 핸들러가 호출되어 scroll event 핸들러의 호출 빈도를 줄였습니다.
또 다른 최적화 방법은 rAF(requestAnimationFrame)를 사용한 최적화입니다. rAF는 브라우저가 렌더링 하는 빈도 60fps(초당 60회)에 맞춰서 실행되어 초당 60회의 실행을 좀 더 보장해 줍니다.
여기서 좀 더 보장해 준다고 한 이유는 rAF와 throttle은 비동기 방식으로 동작되는 것이기 때문에 완벽하게 횟수를 보장하지 못하기 때문입니다.
자바스크립트의 콜 스택(call stack)은 싱글 스레드이기 때문에 발생하는 현상입니다.
- throttle 방식은 내부적으로 setTimeout을 기반으로 동작하기 때문에 콜 스택이 비워져야 실행 가능합니다. 하지만 콜 스택이 비워지지 않고 다른 기능에 밀려서 300ms보다 나중에 발생할 수 있어, 무조건 300ms마다 이벤트를 발생시켜 준다고 보장할 수 없습니다.
- setTimeout(), setInterval()과 같은 비동기 task들은 우선순위가 가장 낮은 Task Queue에 넣어둔 후 순차적으로 처리됩니다.
- rAF 방식을 Task Queue보다 우선순위가 높은 Animation Frames에서 처리되기 때문에 좀 더 실행을 보장할 수 있습니다.
throttle 방식 | rAF 방식 |
- 더 간단하게 구현이 가능하지만, 정확한 타이밍 보장 x | - 더 정확한 타이밍과 부드러운 경험을 제공하지만, 구현이 복잡 |
이 사이트에서 JS 비동기 처리와 비동기 처리 큐에 대해 애니메이션을 통해 설명해 줍니다. 참고하시면 도움이 될 것 같습니다!😀
이후에 무한 스크롤을 구현하는 다른 방식인 Intersection Observer API에 대해 작성해 볼 예정입니다.
'FE Study > React' 카테고리의 다른 글
[React] MSW로 API Mocking하기 (41) | 2023.12.06 |
---|