나는 드림카드라는 서비스를 개발하며 후기페이지에서 무한스크롤이라는 기능을 개발하게 되었다. 이 기능을 개발했던 구현방법, 무한스크롤 기능을 스크롤 이벤드에서 Intersection Observer API를 이용하여 리펙토링 했던 무한스크롤에 모든것에 대하여 이야기해보려고 한다.
무한스크롤(Infinite Scroll)
은 사용자가 페이지를 스크롤할 때 콘텐츠를 자동으로 로드하여, 추가적인 조작 없이 새로운 데이터를 지속적으로 보여주는 기능이다. 이는 사용자 경험(UX)
을 향상시키는 데 유용하며, 특히 피드 형태의 콘텐츠를 제공하는 플랫폼에서 널리 사용된다.
무한스크롤을 구현하기 위해 Tanstack Query에서 제공하는 useInfiniteQuery 훅을 사용했다. 이 훅은 무한스크롤 구현에 필요한 복잡한 로직(데이터 캐싱, 상태 관리 등)을 간단하게 처리할 수 있다.
처음 무한스크롤 기능을 구현했을 떄, useInfinity Query와 Scroll event를 사용하여 사용자의 스크롤에 따라 데이터를 불러오도록 로직을 작성하였다.
useInfiniteQuery
는 캐싱, 동기화, 재요청 같은 복잡한 네트워크 로직을 깔끔하게 처리할 수 있다.
페이지 매개변수를 자동으로 관리하고, fetchNextPage 함수 호출로 다음 페이지 데이터를 가져올 수 있다는 점도 장점이다.
useInfiniteQuery
가 동작하려면 API에서 아래 3가지를 제공해야 한다.
/* useInfiniteQuery 기본 구조 */
const {
data: reviewsData, // 렌더링 할 데이터
fetchNextPage, // 다음페이지 실행 함수
fetchPreviousPage, // 이전 페이지 가져오기
hasNextPage, // 다음페이지 판단 여부
hasPreviousPage, // 이전 페이지가 있는지 확인
isFetchingNextPage, // 다음페이지 로딩중 판단
isFetchingPreviousPage, // 이전 페이지를 가져오는 중인지 확인
...result
} = useInfiniteQuery({
queryKey,
queryFn: ({ pageParam = 1 }) => fetchPage(pageParam),
...options,
getNextPageParam: (lastPage, allPages) => lastPage.nextCursor,
getPreviousPageParam: (firstPage, allPages) => firstPage.prevCursor,
})
무한스크롤에서 가장 중요한 것은 데이터를 페이지 단위로 불러오는 API 요청 함수이다. 나는 Supabase
를 사용해 데이터를 가져왔으며, 각 요청에서 필요한 범위를 설정했다.
import browserClient from './supabase/client';
type ReviewProps = {
pageParam: number;
row: number;
};
export const getReview = async ({ pageParam = 0, row }: ReviewProps) => {
const { data, error } = await browserClient
.from('reviews')
.select('*')
.range(pageParam, pageParam + row - 1);
if (error) throw new Error('데이터를 가져오는 데 실패했습니다.');
return data as Review[];
};
1. 데이터 페칭과 스크롤 이벤트 처리
클라이언트 컴포넌트에서 useInfiniteQuery
를 사용해 데이터를 가져오고, 사용자가 페이지 하단에 도달했을 때 다음 데이터를 불러오도록 스크롤 이벤트를 추가했다.
'use client';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useEffect } from 'react';
import { getReview } from '@/utils/getReview';
const ReviewPage = () => {
const row = 10;
const {
data: reviewsData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isFetchingPreviousPage,
} = useInfiniteQuery({
queryKey: ['reviews'],
queryFn: ({ pageParam = 0 }) => getReview({ pageParam, row }),
getNextPageParam: (lastPage, allPages) =>
lastPage.length === row ? allPages.length * row : undefined
});
useEffect(() => {
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
if (scrollTop + clientHeight >= scrollHeight - 10 && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const reviews = reviewsData?.pages.flatMap((page) => page) || [];
return (
<div>
{reviews.map((review) => (
<div key={review.id}>
<h3>{review.user_name}</h3>
<p>{review.content}</p>
</div>
))}
{isFetchingNextPage && <p>Loading more...</p>}
</div>
);
};
export default ReviewPage;
export const QUERY_KEYS = {
guestBook: (invitationId: string) => ['guestbook', invitationId] as const,
invitation: () => ['invitation'] as const,
stickerImages: () => ['stickerImages'],
invitationCard: () => ['invitationCard'],
reviewCarousel: () => ['reviewCarousel'],
authUsers: () => ['authUsers'],
allImageReviews: () => ['allImageReviews'],
reviews: () => ['reviews'],
};
스크롤 이벤트를 사용할 때는 스크롤 위치를 매번 계산해줘야 해서 성능 문제가 발생할 수 있다. 특히 페이지 크기가 크거나 컴포넌트가 복잡해지면 성능 저하가 눈에 띄게 나타난다.
이번 프로젝트에서도 비슷한 문제가 발생해 Intersection Observer API로 리팩토링을 결정했다.
기존에는 useInfiniteQuery와 scroll event를 조합해 작업했는데, 스크롤 이벤트의 성능 저하 문제가 발생하면서 Intersection Observer API를 활용한 리팩토링을 진행하게 되었다.
Intersection Observer API
는 타겟 요소가 뷰포트와 교차하는 순간을 비동기적으로 관찰할 수 있는 API이다. 특정 DOM 요소가 뷰포트 안에 들어왔을 때 콜백 함수를 실행해 필요한 데이터를 로드할 수 있다.
observe(target): 특정 요소 관찰을 시작
unobserve(target): 특정 요소 관찰 중단
disconnect(): 관찰 중인 모든 요소의 관찰 중단
useInfiniteQuery의 구조는 위에서 scrollevent + useInfiniteQuery로 작업했던 것과 동일하다.
const ReviewPage = () => {
const { data: reviewsData, isLoading, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useReviewInfinite();
const observerRef = useRef<IntersectionObserver | null>(null);
const lastReviewRef = useCallback(
(node: HTMLDivElement | null) => {
if (isFetchingNextPage) return;
if (observerRef.current) observerRef.current.disconnect();
observerRef.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasNextPage) {
fetchNextPage();
}
});
if (node) observerRef.current.observe(node);
},
[isFetchingNextPage, fetchNextPage, hasNextPage],
);
if (isLoading) return <div>로딩 중...</div>;
if (error) return <div>에러가 발생했습니다: {error.message}. 다시 시도해 주세요.</div>;
const reviews = reviewsData?.pages.flatMap((page) => page) || [];
return (
<div className='flex flex-col w-full p-4'>
<h1>후기</h1>
<ReviewImage />
<div>
<ReviewCard reviews={reviews} />
{reviews.length > 0 && (
<div
ref={lastReviewRef}
className='h-1'
/>
)}
</div>
{isFetchingNextPage && <div>더 불러오는 중...</div>}
</div>
);
};
export default ReviewPage;