무한 스크롤 구현하기
스크롤 감지부터 React Query까지
Denamu 프로젝트에서 최신 포스트 피드를 만들면서 무한 스크롤을 구현했다. 처음엔 단순하게 생각했는데, 구현하다 보니 고려할 게 꽤 많았다.
필요한 것 알아내기
무한 스크롤은 사용자가 페이지 하단에 도달하면 콘텐츠가 계속 로드되는 UX 패턴이다.
구현에 필요한 두 가지 조건을 정리하면:
- 사용자가 페이지 하단에 도달했는지 감지
- 다음 데이터를 요청하고 기존 데이터에 이어붙이기
이 두 가지를 해결하면 무한 스크롤이 완성된다.
1. 하단 도달 감지하기
Scroll Event로 구현
간단하게 생각하면, scroll 이벤트가 발생할 때마다 현재 스크롤 위치가 하단인지 확인하면 된다.
useEffect(() => {
const handleScroll = () => {
const scrollTop = document.documentElement.scrollTop;
const scrollHeight = document.documentElement.scrollHeight;
const clientHeight = document.documentElement.clientHeight;
if (scrollTop + clientHeight >= scrollHeight - 100) {
fetchNextPage();
}
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
scrollHeight는 문서 전체 높이, clientHeight는 화면에 보이는 높이, scrollTop은 현재 스크롤 위치다. 세 값을 조합하면 하단에 도달했는지 알 수 있다. (자세한 내용은 Scroll Properties 참고)
문제점
이 방식에는 문제가 있다. scroll 이벤트는 스크롤할 때마다 발생한다. 마우스 휠을 한 번 굴려도 수십 번 호출된다.
JavaScript는 싱글 스레드다. 스크롤할 때마다 콜스택에 함수가 쌓이고, 불필요한 연산이 계속된다. 쓰로틀링이나 디바운싱을 걸 수 있지만, 그래도 여전히 "필요 없는 시점"에 함수가 호출된다.
우리가 원하는 건 "하단에 도달했을 때만" 콜백이 실행되는 것이다.
IntersectionObserver로 개선
IntersectionObserver API는 특정 요소가 뷰포트에 들어왔는지를 감지한다. 스크롤 이벤트처럼 매번 호출되지 않고, 요소가 "보이기 시작할 때"만 콜백이 실행된다. (자세한 내용은 IntersectionObserver 참고)
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
fetchNextPage();
}
},
{ threshold: 0.1 }
);
if (targetRef.current) {
observer.observe(targetRef.current);
}
return () => observer.disconnect();
}, []);
리스트 맨 아래에 빈 <div>를 두고, 이 요소가 화면에 보이면 다음 페이지를 로드한다.
<>
<PostCardGrid posts={items} />
<div ref={targetRef} className="h-10" /> {/* 이 요소가 보이면 fetch */}
</>
threshold: 0.1은 요소의 10%만 보여도 콜백을 실행한다는 뜻이다. rootMargin: "100px"을 추가하면 요소가 화면에 들어오기 100px 전에 미리 로드해서 더 부드러운 UX를 만들 수 있다.
2. 데이터 요청과 상태 관리
useState로 구현
하단 감지는 해결됐다. 이제 데이터를 요청하고 관리해야 한다.
const [posts, setPosts] = useState<Post[]>([]);
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const fetchNextPage = async () => {
const response = await api.getPosts({ page, limit: 12 });
setPosts((prev) => [...prev, ...response.data]);
setPage((prev) => prev + 1);
setHasMore(response.hasMore);
};
문제점
이 방식도 문제가 있다. 사용자가 포스트 상세 페이지로 이동했다가 뒤로 가기를 누르면? 컴포넌트가 언마운트되면서 state가 초기화된다. 처음부터 다시 로드해야 한다.
사용자 입장에서 답답하고, 불필요한 API 요청도 늘어난다.
이걸 해결하려면 데이터를 전역 상태로 관리해야 한다. 직접 구현할 수도 있지만, 이미 이 문제를 잘 해결해둔 라이브러리가 있다.
React Query useInfiniteQuery
TanStack Query(React Query)의 useInfiniteQuery는 무한 스크롤에 필요한 것들을 제공한다:
- 데이터 캐싱 (페이지 이동 후 돌아와도 유지)
- 페이지별 데이터 관리
- 다음 페이지 파라미터 자동 처리
- 로딩/에러 상태
import { useInfiniteQuery } from "@tanstack/react-query";
export function useInfiniteScrollQuery<T extends { id: number }>({
queryKey,
fetchFn,
tags,
}: UseInfiniteScrollQueryOptions<T>) {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
useInfiniteQuery({
queryKey: [queryKey, tags],
queryFn: ({ pageParam = 0 }) =>
fetchFn({
limit: 12,
lastId: pageParam,
tags: tags || [],
}),
getNextPageParam: (lastPage) => {
if (!lastPage.hasMore) return undefined;
const lastItem = lastPage.result[lastPage.result.length - 1];
return lastItem.id;
},
initialPageParam: 0,
});
const items = data?.pages.flatMap((page) => page.result) ?? [];
return { items, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage };
}
getNextPageParam에서 다음 요청에 사용할 파라미터를 정의한다. 여기선 cursor 기반 페이지네이션을 사용해서 마지막 아이템의 id를 다음 요청의 lastId로 넘긴다.
최종 구현
IntersectionObserver + useInfiniteQuery를 조합한 최종 코드:
export default function LatestSection() {
const observerTarget = useRef<HTMLDivElement>(null);
const { items, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
useInfiniteScrollQuery<Post>({
queryKey: "latest-posts",
fetchFn: posts.latest,
tags: [],
});
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 0.3, rootMargin: "100px" }
);
if (observerTarget.current) {
observer.observe(observerTarget.current);
}
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
if (isLoading) return <PostGridSkeleton count={8} />;
return (
<>
<PostCardGrid posts={items} />
{isFetchingNextPage && <PostGridSkeleton count={4} />}
<div ref={observerTarget} className="h-10" />
</>
);
}
hasNextPage && !isFetchingNextPage 조건으로 중복 요청을 방지한다. 다음 페이지가 있고, 현재 로딩 중이 아닐 때만 fetch한다.
정리
무한 스크롤 구현에 필요한 두 가지:
| 문제 | 단순한 방법 | 개선된 방법 |
|---|---|---|
| 하단 감지 | scroll event | IntersectionObserver |
| 데이터 관리 | useState | useInfiniteQuery |
scroll event는 불필요한 호출이 많고, useState는 페이지 이동 시 데이터가 날아간다. IntersectionObserver와 React Query를 조합하면 이 문제들을 해결할 수 있다.