무한 스크롤
규칙
- 커서 기반 페이지네이션: 마지막 아이템의 ID 또는 타임스탬프를 커서로 사용 — 삽입/삭제 시 문제가 발생하는 오프셋 사용 금지
- IntersectionObserver: 리스트 하단의 감시 요소(sentinel)에 연결 — 가시화될 때 fetch 트리거
- 가상화:
@tanstack/react-virtual을 1,000개 이상 아이템 리스트에 사용 — DOM에 보이는 행만 렌더링
- 로딩 스켈레톤: 다음 페이지를 가져오는 동안 하단에 플레이스홀더 아이템 표시 — 스피너 사용 금지
- "더 보기" 버튼: 수동 제어를 선호하거나 느린 연결 상태의 사용자를 위한 대체 수단 제공
- 스크롤 위치 복원: 탐색 전 스크롤 위치 저장, 뒤로 가기 시 복원 —
sessionStorage 또는 라우터 상태 사용
- 빈 상태: 리스트에 아이템이 없을 때 메시지 표시 — 빈 페이지 금지
- 리스트 끝:
hasNextPage가 false일 때 "끝까지 도달했습니다" 표시 — observer 중단
패턴
import { useInfiniteQuery } from "@tanstack/react-query";
import { useRef, useEffect } from "react";
function InfiniteList() {
const sentinelRef = useRef<HTMLDivElement>(null);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ["items"],
queryFn: ({ pageParam }) => fetchItems({ cursor: pageParam, limit: 20 }),
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
initialPageParam: undefined as string | undefined,
});
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => { if (entries[0].isIntersecting && hasNextPage) fetchNextPage(); },
{ rootMargin: "200px" }
);
if (sentinelRef.current) observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [hasNextPage, fetchNextPage]);
const allItems = data?.pages.flatMap((page) => page.items) ?? [];
return (
<div>
{allItems.map((item) => <ItemCard key={item.id} item={item} />)}
{isFetchingNextPage && <LoadingSkeleton count={3} />}
{!hasNextPage && allItems.length > 0 && <p>끝까지 도달했습니다</p>}
{allItems.length === 0 && !isFetchingNextPage && <EmptyState />}
<div ref={sentinelRef} />
</div>
);
}
import { useVirtualizer } from "@tanstack/react-virtual";
function VirtualList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 64,
overscan: 5,
});
return (
<div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div key={virtualRow.key} style={{
position: "absolute", top: 0, left: 0, width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}>
<ItemCard item={items[virtualRow.index]} />
</div>
))}
</div>
</div>
);
}
피해야 할 것
- 오프셋 기반 페이지네이션 — 삽입/삭제로 인해 모든 오프셋이 이동하여 중복 또는 누락된 아이템 발생
- 모든 아이템을 로드한 후 클라이언트에서 필터링 — 서버에서 현재 페이지만 가져오기
- 10,000개 이상의 DOM 노드 렌더링 —
@tanstack/react-virtual을 사용하여 보이는 행만 렌더링
rootMargin 없는 IntersectionObserver — 100-200px 여백을 추가하여 사용자가 하단에 도달하기 전에 미리 가져오기
- 리스트 끝 표시 누락 — 사용자는 더 이상 로드할 항목이 없음을 알아야 함