Infinite Scroll은 목록의 끝에 도달할 때 자동으로 다음 데이터를 불러오는 패턴입니다. 적절히 구현하면 페이지 전환 없이 매끄러운 탐색을 제공하지만, 성능·접근성·에러 처리까지 신경 써야 합니다. 실무에서 바로 쓸 수 있는 IntersectionObserver 기반 구현, React Query 연동, 최적화 포인트를 정리합니다.
1. 전략 선택: Scroll 이벤트 vs IntersectionObserver
- IntersectionObserver: 브라우저가 뷰포트 교차 여부를 계산해 주어 정확하고 효율적입니다. 대부분 케이스에서 권장합니다.
- Scroll 이벤트: 구형 브라우저나 특수 환경에서 fallback으로 사용합니다. 반드시 throttle/passive 처리로 성능을 챙깁니다.
- 가상화(react-window/virtualized): 데이터가 많을 때 렌더링 비용을 줄입니다. Infinite Scroll과 병행하면 가장 효율적입니다.
2. 기본 구현: IntersectionObserver + Sentinel
목록 끝에 "감시용(div)" 엘리먼트를 두고 뷰포트에 들어올 때 다음 페이지를 요청합니다. 중복 요청 방지, 요청 취소, 마지막 페이지 처리까지 포함합니다.
import React, { useEffect, useRef, useState, useCallback } from 'react';
const PAGE_SIZE = 20;
async function fetchItems({ cursor, signal }) {
const url = new URL('/api/items', window.location.origin);
if (cursor) url.searchParams.set('cursor', cursor);
url.searchParams.set('limit', PAGE_SIZE);
const res = await fetch(url, { signal });
if (!res.ok) throw new Error('데이터 로드 실패');
// 서버 응답 예시: { items: [...], nextCursor: '...', hasMore: true }
return res.json();
}
export default function InfiniteList() {
const [items, setItems] = useState([]);
const [cursor, setCursor] = useState(null);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const sentinelRef = useRef(null);
const abortRef = useRef(null);
const loadMore = useCallback(async () => {
if (loading || !hasMore) return; // 중복/불필요 요청 방지
setLoading(true);
setError(null);
// 이전 요청 취소
if (abortRef.current) abortRef.current.abort();
const controller = new AbortController();
abortRef.current = controller;
try {
const data = await fetchItems({ cursor, signal: controller.signal });
setItems((prev) => [...prev, ...data.items]);
setCursor(data.nextCursor ?? null);
setHasMore(Boolean(data.hasMore));
} catch (e) {
if (e.name !== 'AbortError') setError(e);
} finally {
setLoading(false);
}
}, [cursor, hasMore, loading]);
// 초기 로드
useEffect(() => {
loadMore();
}, []);
// 감시자 등록
useEffect(() => {
const node = sentinelRef.current;
if (!node) return;
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (entry.isIntersecting) {
loadMore();
}
},
{
root: null, // window 스크롤 기준
rootMargin: '200px', // 미리 당겨서 로드
threshold: 0,
}
);
observer.observe(node);
return () => observer.disconnect();
}, [loadMore]);
return (
<div>
<ul>
{items.map((item) => (
<li key={item.id}>{item.title}</li>
))}
</ul>
{error && (
<p role="alert">에러가 발생했습니다. <button onClick={loadMore}>다시 시도</button></p>
)}
{/* 뷰포트에 들어오면 다음 페이지 로드 */}
<div ref={sentinelRef} style={{ height: 1 }} />
{loading && <p>로딩 중입니다...</p>}
{!hasMore && <p>마지막 데이터입니다.</p>}
{/* 접근성/네트워크 문제 대비 수동 버튼 */}
<button
onClick={loadMore}
disabled={loading || !hasMore}
aria-label="더 불러오기"
>
더 불러오기
</button>
</div>
);
}
3. 커스텀 훅으로 감시 로직 분리
감시자 로직을 훅으로 빼면 재사용과 테스트가 쉬워집니다.
import { useEffect, useRef } from 'react';
export function useInfiniteObserver(callback, { enabled = true, root = null, rootMargin = '200px', threshold = 0 } = {}) {
const ref = useRef(null);
useEffect(() => {
if (!enabled) return;
const node = ref.current;
if (!node) return;
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) callback();
}, { root, rootMargin, threshold });
observer.observe(node);
return () => observer.disconnect();
}, [callback, enabled, root, rootMargin, threshold]);
return ref; // 이 ref를 sentinel에 연결합니다.
}
// 사용 예시
// const sentinelRef = useInfiniteObserver(loadMore, { enabled: hasMore });
4. 서버 페이징: 페이지 vs 커서
- 페이지 번호(page): 구현이 단순하지만 특정 항목 삭제/삽입 시 오프셋 불일치 위험이 있습니다.
- 커서(cursor): 마지막 항목의 키를 기준으로 다음 묶음을 요청합니다. 실무에서는 커서 기반을 권장합니다.
- 응답 예시: { items: [], nextCursor: 'abc', hasMore: true } 형태로 맞추면 프론트 처리가 명확합니다.
5. React Query로 더 간단하게
useInfiniteQuery를 쓰면 캐싱·중복요청 방지·에러 자동 처리까지 해결됩니다.
import React, { useEffect, useRef } from 'react';
import { useInfiniteQuery } from '@tanstack/react-query';
const fetchPage = async ({ pageParam = null, signal }) => {
const url = new URL('/api/items', window.location.origin);
if (pageParam) url.searchParams.set('cursor', pageParam);
url.searchParams.set('limit', 20);
const res = await fetch(url, { signal });
if (!res.ok) throw new Error('로드 실패');
return res.json(); // { items, nextCursor, hasMore }
};
export default function InfiniteWithRQ() {
const {
data,
error,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['items'],
queryFn: fetchPage,
getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.nextCursor : undefined),
staleTime: 60_000,
});
const sentinelRef = useRef(null);
useEffect(() => {
const node = sentinelRef.current;
if (!node) return;
const io = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, { rootMargin: '200px' });
io.observe(node);
return () => io.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const items = data?.pages.flatMap((p) => p.items) ?? [];
return (
<div>
{isLoading && <p>초기 로딩 중입니다...</p>}
{error && <p role="alert">에러: {String(error.message)}</p>}
<ul>
{items.map((it) => (
<li key={it.id}>{it.title}</li>
))}
</ul>
<div ref={sentinelRef} style={{ height: 1 }} />
{isFetchingNextPage && <p>더 불러오는 중...</p>}
{!hasNextPage && <p>끝까지 탐색했습니다.</p>}
</div>
);
}
6. 성능 최적화 포인트
- 가상화: react-window를 사용해 DOM 노드 수를 제한합니다.
- 이미지 Lazy-Load: img에 loading="lazy"를 사용하거나 IntersectionObserver로 지연 로딩합니다.
- rootMargin: 150~300px로 미리 당겨 로딩하면 체감이 좋아집니다.
- 중복 요청 방지: loading 플래그 또는 React Query 상태로 제어합니다.
- 스켈레톤: 다음 페이지가 올 때 리스트 하단에 스켈레톤을 표시해 지연 체감을 줄입니다.
import { FixedSizeList as VList } from 'react-window';
function VirtualizedList({ items }) {
return (
<VList height={600} itemCount={items.length} itemSize={80} width="100%">
{({ index, style }) => (
<div style={style}>{items[index].title}</div>
)}
</VList>
);
}
7. Scroll 이벤트 Fallback
컨테이너 스크롤이나 특수 환경에서는 throttled scroll을 사용합니다. passive 옵션으로 메인 스레드 점유를 줄입니다.
import { useEffect } from 'react';
export function useBottomScroll(loadMore, { offset = 200 } = {}) {
useEffect(() => {
let ticking = false;
const onScroll = () => {
if (!ticking) {
window.requestAnimationFrame(() => {
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
const distance = scrollHeight - scrollTop - clientHeight;
if (distance <= offset) loadMore();
ticking = false;
});
ticking = true;
}
};
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, [loadMore, offset]);
}
8. 에러 처리와 접근성
- 에러 메시지와 재시도 버튼을 제공합니다.
- 마지막 페이지 도달 시 명확한 안내 문구를 표시합니다.
- 자동 로드가 실패할 수 있어 "더 불러오기" 수동 버튼을 항상 제공하는 것이 안전합니다.
- 키보드·스크린리더 사용자를 고려해 버튼에 명확한 레이블을 추가합니다.
9. 테스트/디버깅 팁
- IntersectionObserver 목킹으로 트리거를 검증합니다.
- 네트워크 지연을 시뮬레이션해 중복 호출, 취소 로직을 확인합니다.
// Jest 예시: IntersectionObserver 목킹
beforeEach(() => {
global.IntersectionObserver = class {
constructor(cb) { this.cb = cb; }
observe = () => { this.cb([{ isIntersecting: true }]); };
disconnect = () => {};
unobserve = () => {};
};
});
10. 체크리스트
- 커서 기반 페이지네이션 API 준비되어 있나요?
- hasMore/중복 요청/취소 처리를 했나요?
- rootMargin 조정으로 체감 속도를 개선했나요?
- 가상화·이미지 지연 로딩을 적용했나요?
- 에러·접근성·수동 로드 버튼을 제공했나요?
- 특수 스크롤 컨테이너(root)에서 동작 검증했나요?
위 구성으로 구현하면 Infinite Scroll을 안정적으로 운용할 수 있습니다. 먼저 IntersectionObserver로 간단히 시작하고, 데이터 규모가 커지면 가상화를 병행해 성능을 확보합니다.
'React' 카테고리의 다른 글
| React에서 Service Worker로 PWA 기능 추가하기 (0) | 2026.04.21 |
|---|---|
| React와 Three.js를 이용한 3D 객체 렌더링 (1) | 2026.04.20 |
| React 앱 성능 최적화를 위한 메모이제이션 기법 (0) | 2026.04.19 |
| React에서 WebSocket 연결 구현하기 (0) | 2026.04.18 |
| React와 Redux Toolkit으로 상태 관리 구조화하기 (1) | 2026.04.17 |