서비스를 운영하면서 사용자가 증가함에 따라 페이지네이션 처리가 필요해졌다. 현재는 서비스 사용자가 많지 않지만, 언젠가는 꼭 필요한 기능이므로 미리 구현하는 것이 좋다고 판단했다.
페이지네이션 처리 고민
처음에는 넘버링 페이지네이션과 무한스크롤의 선택지가 있었다. 결국에는 팀 회의를 통해서 서비스에 맞는 무한스크롤 개념을 도입하기로 결정했다.
현재는 CSR 기반 페이지로 구현이 되어있다. seo 개선을 위함과 초기 페이지 로딩 속도 향상을 위해서 SSR + ReactQuery로 마이그레이션을 예정되어 있어서, 마침 react-query의 useQuery의 useInfiniteQuery 기능으로 무한스크롤을 구현할 수 있다는 아이디어를 얻었다.
아이디어 고민
무한스크롤 적용이 필요한 페이지는 이미지와 같이 가장 상단에 6개의 프로필 카드를 고정값으로 이루어져있고, 그 나머지 프로필 카드는 하단 섹션에 나타나도록 설계가 되어 있다.
API 쪼개기
현재 이페이지는 하나의 API를 통해서 해당 데이터를 모두 받아오는 상태였다.
가장 먼저 생각났던 아이디어는 정적 데이터를 받아오는 섹션의 API를 따로 설계하고, 나머지 무한스크롤이 필요한 데이터의 경우 API로 쪼개는 것이 좀 더 효율적이라고 생각했다.
초기 데이터를 받아오고, 스크롤에 따라서 추가 데이터 요청 처리
대부분의 무한스크롤을 처리하는 서비스들을 보면, 초기 데이터들이 화면에 나타나고, 그 이후에 추가 요청을 보내서 화면에 나타내는 형식이였다.
Offset 기반 vs cursor 기반
무한스크롤에는 두 가지 페이지네이션 방식이 있다.
1. Offset 기반 페이지네이션
- ?page=1&limit=10 같은 쿼리 파라미터를 사용하여 데이터를 가져온다.
- 익숙한 방식이지만 데이터가 많아질수록 성능 저하가 발생할 수 있다.
- 데이터의 양이 많아질수록 OFFSET 연산 비용이 커지고, 성능이 저하된다.
2. Cursor 기반 페이지네이션
- ?cursor=lastItemId&limit=10 형태로 데이터를 요청한다.
- lastItemId를 기준으로 다음 데이터를 가져오기 때문에, OFFSET 연산이 필요하지 않아 성능이 더 좋다.
- 데이터 추가/삭제가 발생해도 페이지가 밀리지 않는다.
결국 Cursor 기반 페이지네이션을 채택하여 백엔드와 협의 후 구현하기로 결정했다.
무한스크롤 원리 이해
나는 무한스크롤 개념을 간단하게만 알고 있었고, 이해가 필요했다.
무한 스크롤을 구현하려면 기본적으로 두 가지 요소를 구려해야 한다.
1. 콘텐츠 영역
초기 20개의 데이터를 보여주고, 이후 20개씩 추가 데이터를 불러오는 방식으로 결정했다.
2. 호출 영역
화면 하단에 Intersection Observer를 활용하여 특정 영역이 보일 때 추가 데이터를 요청하는 방식으로 구현한다.
무한스크롤의 흐름을 정리하면 다음과 같다.
1. 첫 페이지 데이터를 불러와서 화면에 렌더링한다.
2. 사용자가 스크롤을 내려 호출 영역이 화면에 나타나면 새로운 데이터를 요청한다.
3. 새로운 데이터가 불러와지면 기존 데이터 리스트에 추가하여 업데이트한다.
4. 다음 데이터가 더 이상 존재하지 않으면 요청을 중단한다.
React Query의 InfiniteQuery 활용
다음으로는 tanstack-query의 infiniteQuery에 대해서 이해가 필요했다.
무힌 스크롤을 구현할 때 일반적인 API 요청 방식인 useQuery 대신, 여러 페이지의 데이터를 관리하고 추가 데이터를 불러올 수 있는 useInfiniteQuery 를 사용한다.
useInfiniteQuery의 주요 옵션
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, error } = useInfiniteQuery({
queryKey : ['posts']
quertFn : ({pageParam = 1}) => fetchPosts(pageParam),
getNextPageParam : (lastPage, allPage) => lastPage.nextCursor ?? undefinded,
initialPageParam : 1
}
옵션 설명
- queryKey : 캐싱을 위한 고유키
- queryFn : 데이터를 불러오는 함수
- getNextPageParam : 다음 페이지의 기준을 결정하는 함수
- initiaPageParam : 초기 페이지 번호(보통 null 또는 1)
- fetchNextPage() : 다음 페이지 데이터를 불러오는 함수
- hasNextPage : 다음 페이지가 있는지 여부
- isFetchingNextPage : 다음 페이지를 불러오는 중인지 확인
getNextPageParam
useInfiniteQuery에서 가장 중요한 부분은 getNextPageParam 설정이다. 이 값이 다음 페이지가 있는지 여부를 판단하는 기준이 되고, 다음 요청에 cursor을 전달한다.
getNextPageParam : (lastPage) => {
return lastPage.result?.content?.length > 0 ? lastPage.result.nextCursor : undefinded;
}
예를 들면 이렇게 작성할 수 있고, 마지막 데이터의 nextCursor 값을 사용하여 다음 요청 시 API에 전달한다. 그리고 더이상 데이터가 없다면 undefined 반환으로 요청을 중단한다.
fetchNextPage()를 사용하여 데이터를 불러오기
무한스크롤 구현을 위해 스크롤 이벤트를 감지하여 추가 데이터를 로드해야 한다.
스크롤 이벤트를 위해 Intersection Observer를 활용한다.
const observerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage) {
fetchNextPage();
}
},
{threshold : 1}
);
if (observerRef.current){
observer.observe(obserberRef.current);
}
return () => obserbver.disconnect();
}, [hasNextPage, fetchNextPage]);
// 호출 영역
<div ref={observerRef}> </div>
isLoading 상태 관리
이제 useInfiniteQuery의 {isLoading, isFetchingNextPage, error} 요소들을 가져와서 로딩 상태 관리를 처리하면 된다.
적용해보자
서버에서 데이터를 가져오는 함수 구현
기본적으로, 클라이언트가 API를 요청할 때 필요한 파라미터를 전달하고, 백엔드에서 cursor 기반으로 데이터를 반환하도록 설계했다.
// FindPrivateApi.ts
export async function getFindPrivateProfile(
params: SearchParams,
): Promise<ApiResponse<{ content: Profile[]; hasNext: boolean; nextCursor?: string }>> {
// URL 파라미터 구성
const queryParams = new URLSearchParams()
params.subPosition.forEach((pos) => queryParams.append('subPosition', pos))
params.cityName.forEach((city) => queryParams.append('cityName', city))
params.profileStateName.forEach((state) => queryParams.append('profileStateName', state))
params.skillName.forEach((skill) => queryParams.append('skillName', skill))
if (params.cursor) {
queryParams.append('cursor', params.cursor)
}
queryParams.append('size', params.size.toString())
// CSR 방식으로 데이터 가져오기
return fetchWithCSR(`/profile/search?${queryParams.toString()}`)
}
무한 스크롤을 위한 프로필 데이터 가져오기
const {
data: infiniteProfiles,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading: isInfiniteLoading,
} = useInfiniteQuery({
queryKey: ['infiniteProfiles', params],
queryFn: ({ pageParam }) => getFindPrivateProfile({ ...params, cursor: pageParam }),
initialPageParam: undefined,
getNextPageParam: (lastPage) => {
// 다음 페이지가 있는지 확인하고, 있다면 마지막 프로필의 emailId를 cursor로 사용
const profiles = lastPage.result.content
if (profiles.length > 0 && lastPage.result.hasNext) {
return profiles[profiles.length - 1].emailId
}
return undefined
},
staleTime: 1000 * 60 * 5, // 5분
})
React Query의 useInfiniteQuery를 활용한 프로필 데이터 불러오기
- getNextPageParam에서 lastPage.result.hasNext값을 확인하여 추가 데이터 요청 여부를 결정했다.
- staleTime을 5분으로 설정하여 캐싱된 데이터를 일정 시간 유지하도록 했다.
Intersection Observer로 스크롤 감지하기
스크롤 이벤트를 감지하기 위해 Intersection Observer를 활용하여, 특정 요소가 화면에 나타날 때 fetchNextPage()를 호출하도록 구현했다.
useEffect(() => {
if (loadMoreRef.current) {
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
},
{
threshold: 0.1,
// rootMargin을 사용하여 요소가 화면에 보이기 전에 미리 감지
// 아래쪽으로 20% 더 확장된 영역을 관찰 영역으로 설정
rootMargin: '0px 0px 20% 0px',
},
)
observerRef.current.observe(loadMoreRef.current)
}
return () => {
if (observerRef.current) {
observerRef.current.disconnect()
}
}
}, [fetchNextPage, hasNextPage, isFetchingNextPage])
Intersection Observer 설정
- thredhold -> 요소가 화면에 어느정도 들어올 때 이벤트가 트리거 되는지 설정
- entries[0].isIntersecting → 요소가 화면에 나타났는지 확인 후 fetchNextPage() 호출
- rootMargin을 사용하여 요소가 화면에 보이기 전에 감지하여 미리 데이터 패칭
- 아래로 20% 정도 영역을 관찰영역으로 지정했음.
데이터 렌더링
스크롤을 통해 가져온 데이터를 화면에 렌더링하고, 로딩 상태를 관리했다.
const allProfiles = infiniteProfiles?.pages.flatMap((page) => page.result.content) || []
return (
<div className="flex flex-col gap-16 md:px-12">
{/* 필터링된 프로필 리스트 */}
{allProfiles.length > 0 && (
<div>
<div className="text-lg font-semibold text-black">🔍 나에게 필요한 팀원을 더 찾아보세요!</div>
<div className="mt-6 grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3">
{allProfiles.map((profile, index) => (
<MiniProfileCard_2 key={`${profile.emailId}-${index}`} profile={profile} />
))}
</div>
</div>
)}
{/* 로딩 중 표시 */}
{isFetchingNextPage && (
<div className="py-4 text-center">
<p className="text-gray-500">더 많은 프로필을 불러오는 중...</p>
</div>
)}
{/* 무한 스크롤을 위한 관찰 요소 */}
<div ref={loadMoreRef} className="h-10" />
</div>
)
구현 데모
마무리
이렇게 React Query의 useInfiniteQuery와 Intersection Observer를 활용하여 효율적인 무한스크롤을 구현했다. Cursor 기반 페이지네이션을 활용하여 성능을 최적화하고, staleTime을 설정하여 캐싱을 효율적으로 활용할 수 있도록 했다.
고민
- 한참을 스크롤하여서 내려왔는데 여기서 새로고침을 하면 스크롤위치가 초기화된다는 점.
'프론트엔드 기록 > 리액트' 카테고리의 다른 글
<Link> 태그 페이지 이동 시 스크롤 유지 방지하기 (1) | 2025.03.25 |
---|---|
빈 화면 없이 자연스럽게 데이터 갱신하여 SSR UX 개선하기 (0) | 2025.03.03 |
리액트 Flux 패턴이란?( vs MVC 모델 ) (2) | 2024.10.29 |
[Next.js 14] FSD 기능 관점 폴더 아키텍처에 대한 생각 (2) | 2024.10.04 |
리액트의 동작 원리, Virtual DOM (1) | 2024.09.12 |