졸업 프로젝트를 진행하며 여행 일정 목록을 보여주는 페이지를 개발했습니다.
사용자가 여행 일정과 함께 사진을 업로드하면, 사진 파일이 포함되어 이미지 렌더링 지연이 발생하고 이는 페이지네이션 기반의 구현에서 병목 현상을 일으킬 수 있고, 이는 사용자 경험이 저하되는 원인으로 작용합니다.
이 문제를 해결하기 위해, 무한 스크롤 방식으로 일정 목록 렌더링을 변경하고, 효율적인 서버 데이터 패칭을 위해 React Query를 활용하기로 했습니다.
useInfiniteQuery
는 React Query 라이브러리에서 제공하는 Hook 중 하나로, 데이터를 페이지네이션 없이 끊임없이 로드할 수 있게 해주는 무한 스크롤을 구현할 때 유용합니다.
import { useInfiniteQuery } from 'react-query';
const { data, fetchNextPage, hasNextPage, isFetching, isError } =
useInfiniteQuery(
['tripList', token],
({ pageParam = 0 }) => getEntireTripList(token, pageParam),
{
getNextPageParam: (lastPage, allPages) => {
const nextPageParam =
lastPage.data.content.length === 0 ? false : allPages.length;
return nextPageParam;
},
}
);
useInfiniteQuery
를 사용하여 "tripList" 쿼리 키와 함께 무한 스크롤을 구현하였습니다.
getNextPageParam
을 통해 다음 페이지를 불러올 조건을 정의하며, 이는 사용자가 페이지의 끝에 도달했을 때 더 이상 로드할 데이터가 없으면 더 이상 데이터를 요청하지 않도록 합니다.
IntersectionObserver
는 브라우저에서 제공하는 API로 웹 페이지의 어떤 요소가 뷰포트에 들어오거나 나갈 때 콜백 함수를 실행시킵니다.
useEffect(() => {
const observer = new IntersectionObserver(observerCallback, {
root: null,
rootMargin: '0px',
threshold: 0.1,
});
if (intersectionTarget) {
observer.observe(intersectionTarget);
}
return () => {
if (intersectionTarget) {
observer.unobserve(intersectionTarget);
}
};
}, [intersectionTarget, observerCallback]);
사용자가 특정 DOM 요소 (ex : 페이지 하단)에 도달하였는지를 감지합니다.
MainPage에서 useInfiniteQuery와 IntersectionObserver를 사용하였습니다. 먼저, useInfiniteQuery를 사용하여 서버로부터 여행 일정 데이터를 불러오고, IntersectionObserver를 사용하여 스크롤이 페이지 하단에 도달했을 때 추가 데이터를 불러오는 로직을 구현합니다. 이렇게 함으로써 사용자는 끊김 없는 스크롤 경험을 하며 여행 일정을 탐색할 수 있습니다
import { useState, useEffect, useCallback } from 'react';
...
import { useInfiniteQuery } from 'react-query';
...
import { getEntireTripList } from '@/application/api/main/getEntireTripList';
...
function MainPage() {
...
const [intersectionTarget, setIntersectionTarget] = useState(null);
const { data, fetchNextPage, hasNextPage, isFetching, isError } =
useInfiniteQuery(
['tripList', token],
({ pageParam = 0 }) => getEntireTripList(token, pageParam),
{
getNextPageParam: (lastPage, allPages) => {
const nextPageParam =
lastPage.data.content.length === 0 ? false : allPages.length;
return nextPageParam;
},
}
);
const travelList = data?.pages.flatMap((page) => page.data.content) || [];
...
const observeTarget = useCallback((node) => {
setIntersectionTarget(node);
}, []);
const observerCallback = useCallback(
async ([entry]) => {
if(entry.isIntersecting && hasNextPage) {
try {
await fetchNextPage();
} catch (error) {
console.error(error);
}
}
},
[fetchNextPage, hasNextPage]
);
useEffect(() => {
const observer = new IntersectionObserver(observerCallback, {
root: null,
rootMargin: '0px',
threshold: 0.1,
});
if (intersectionTarget) {
observer.observe(intersectionTarget);
}
return () => {
if (intersectionTarget) {
observer.unobserve(intersectionTarget);
}
};
}, [intersectionTarget, observerCallback]);
...
return(
<div className={styles.mainContainer}>
<Row>
...
<Col>
{isError && <div>오류가 발생하였습니다.</div>}
...
<List
dataSource={travelList}
renderItem={(
...
)}
/>
{isFetching && <Spin tip='Loading...' size='large' />}
<div ref={observeTarget} />
</Col>
</Row>
</div>
);
}
export default MainPage;