React·2026. 01. 27
무한 스크롤 (Infinite Scroll)
개요
무한 스크롤은 사용자가 페이지 하단에 도달하면 자동으로 다음 데이터를 로드하는 UI 패턴이다. 페이지네이션의 대안으로, 소셜 미디어 피드나 검색 결과 목록에서 흔히 사용된다.
구현 방식 비교
1. scroll 이벤트
javascript
// 전통적인 방식
window.addEventListener('scroll', () => {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 100) {
loadMore();
}
});
문제점:
- 스크롤할 때마다 이벤트가 발생 (초당 수십~수백 회)
- throttle/debounce를 적용해도 메인 스레드 부하
offsetHeight,scrollY계산이 리플로우를 유발할 수 있음
2. Intersection Observer
javascript
// 현대적인 방식
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadMore();
}
});
observer.observe(sentinelElement);
장점:
- 브라우저가 최적화된 방식으로 교차 감지
- 메인 스레드 블로킹 없음
- 콜백은 교차 상태가 바뀔 때만 호출
Intersection Observer API
기본 개념
Intersection Observer는 타겟 요소가 루트 요소(또는 뷰포트)와 교차하는지 감시한다.
javascript
const observer = new IntersectionObserver(callback, options);
observer.observe(targetElement);
옵션
javascript
const options = {
root: null, // 기준이 되는 요소. null이면 뷰포트
rootMargin: '0px', // 루트의 마진. '100px'이면 100px 전에 감지
threshold: 0, // 얼마나 보여야 교차로 판단할지 (0~1)
};
콜백
javascript
const callback = (entries, observer) => {
entries.forEach(entry => {
console.log(entry.isIntersecting); // 교차 여부
console.log(entry.intersectionRatio); // 교차 비율 (0~1)
console.log(entry.target); // 감시 대상 요소
});
};
React에서 구현
기본 구조
jsx
function InfiniteList() {
const [items, setItems] = useState([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const sentinelRef = useRef(null);
// 데이터 로드
const loadMore = async () => {
const newItems = await fetchItems(page);
if (newItems.length === 0) {
setHasMore(false);
return;
}
setItems(prev => [...prev, ...newItems]);
setPage(prev => prev + 1);
};
// Intersection Observer 설정
useEffect(() => {
if (!hasMore) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMore();
}
},
{ rootMargin: '100px' } // 100px 전에 미리 로드
);
if (sentinelRef.current) {
observer.observe(sentinelRef.current);
}
return () => observer.disconnect();
}, [page, hasMore]);
return (
<div>
{items.map(item => (
<ItemCard key={item.id} item={item} />
))}
{/* 센티널: 이 요소가 보이면 다음 페이지 로드 */}
{hasMore && <div ref={sentinelRef}>로딩 중...</div>}
</div>
);
}
핵심 포인트
- 센티널 요소: 리스트 맨 아래에 빈 요소를 두고, 이 요소가 뷰포트에 들어오면 다음 데이터를 로드
- rootMargin: 양수 값을 주면 실제로 보이기 전에 미리 로드 시작
- cleanup: 컴포넌트 언마운트 시
observer.disconnect()로 정리 - hasMore: 더 로드할 데이터가 없으면 observer 해제
커스텀 훅으로 분리
재사용을 위해 커스텀 훅으로 분리할 수 있다.
jsx
function useInfiniteScroll(onIntersect, options = {}) {
const sentinelRef = useRef(null);
const [isIntersecting, setIsIntersecting] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
setIsIntersecting(entry.isIntersecting);
if (entry.isIntersecting) {
onIntersect();
}
},
{
rootMargin: '100px',
...options,
}
);
const sentinel = sentinelRef.current;
if (sentinel) {
observer.observe(sentinel);
}
return () => {
if (sentinel) {
observer.unobserve(sentinel);
}
};
}, [onIntersect, options]);
return { sentinelRef, isIntersecting };
}
사용 예시
jsx
function PostList() {
const [posts, setPosts] = useState([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const loadMore = useCallback(async () => {
if (isLoading || !hasMore) return;
setIsLoading(true);
const newPosts = await fetchPosts(page);
if (newPosts.length === 0) {
setHasMore(false);
} else {
setPosts(prev => [...prev, ...newPosts]);
setPage(prev => prev + 1);
}
setIsLoading(false);
}, [page, isLoading, hasMore]);
const { sentinelRef } = useInfiniteScroll(loadMore);
return (
<div>
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
{hasMore && (
<div ref={sentinelRef}>
{isLoading ? <Spinner /> : <Skeleton />}
</div>
)}
</div>
);
}
TanStack Query와 함께 사용
TanStack Query(React Query)의 useInfiniteQuery와 조합하면 캐싱, 에러 처리, 로딩 상태 관리가 편해진다.
jsx
import { useInfiniteQuery } from '@tanstack/react-query';
function PostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam),
getNextPageParam: (lastPage, pages) => {
return lastPage.length > 0 ? pages.length + 1 : undefined;
},
});
const { sentinelRef } = useInfiniteScroll(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
});
const posts = data?.pages.flat() ?? [];
return (
<div>
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
{hasNextPage && (
<div ref={sentinelRef}>
{isFetchingNextPage ? <Spinner /> : <Skeleton />}
</div>
)}
</div>
);
}
Skeleton UI와 함께 사용
무한 스크롤 로딩 중에 Skeleton UI를 보여주면 사용자 경험이 좋아진다.
jsx
function PostList() {
// ... useInfiniteQuery 설정
return (
<div>
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
{/* 로딩 중일 때 Skeleton 표시 */}
{isFetchingNextPage && (
<>
<PostCardSkeleton />
<PostCardSkeleton />
<PostCardSkeleton />
</>
)}
{/* 센티널 */}
{hasNextPage && !isFetchingNextPage && (
<div ref={sentinelRef} style={{ height: 1 }} />
)}
</div>
);
}
주의사항
중복 요청 방지
jsx
const loadMore = useCallback(async () => {
if (isLoading) return; // 이미 로딩 중이면 무시
// ...
}, [isLoading]);
메모리 누수 방지
jsx
useEffect(() => {
const observer = new IntersectionObserver(callback);
observer.observe(sentinel);
return () => observer.disconnect(); // 반드시 정리
}, []);
초기 데이터가 뷰포트를 채우지 못할 때
첫 로드 데이터가 적으면 센티널이 바로 보여서 연속 로드될 수 있다.
jsx
// 첫 페이지 로드 후 약간의 지연
useEffect(() => {
const timer = setTimeout(() => {
setCanLoadMore(true);
}, 500);
return () => clearTimeout(timer);
}, []);
요약
| 개념 | 설명 |
|---|---|
| Intersection Observer | 요소가 뷰포트에 들어오는지 감시하는 API |
| 센티널 | 리스트 끝에 두는 감시 대상 요소 |
| rootMargin | 실제 교차 전에 미리 감지하기 위한 마진 |
| useInfiniteQuery | TanStack Query의 무한 스크롤 지원 훅 |
scroll 이벤트 대신 Intersection Observer를 사용하면 성능과 코드 모두 개선된다.