무한 스크롤 구현하기
왜 만들었나
무한 스크롤을 구현하는 방법은 두 가지다. 하나는 scroll 이벤트를 감지해 scrollTop + clientHeight >= scrollHeight인지 확인하는 방식이고, 다른 하나는 IntersectionObserver를 쓰는 방식이다.
scroll 이벤트는 스크롤할 때마다 수백 번 발화한다. throttle이나 requestAnimationFrame으로 억제해도 코드가 복잡해진다. IntersectionObserver는 브라우저가 교차 여부를 계산해 콜백을 한 번만 준다. 스크롤 핸들러가 없고, 메인 스레드 부담도 없다.
이번 튜토리얼에서 다루는 것:
useInfiniteScroll커스텀 훅 설계- IntersectionObserver로 sentinel 감시
- stale closure 문제 → ref로 해결
loading/hasMore상태로 observer 라이프사이클 제어- Skeleton UI
구조
12-react-infinite-scroll/
└── index.htmlReact 18 CDN + Babel Standalone. 단일 파일.
API 구조
JSONPlaceholder의 /photos 엔드포인트를 사용한다. API 키 불필요, 5,000개 항목을 페이지네이션으로 제공한다.
GET https://jsonplaceholder.typicode.com/photos
?_page=1
&_limit=12이미지는 Picsum Photos로 교체한다. 완전 무료, 저작권 제한 없는 예시 이미지를 제공한다. seed 파라미터에 사진 ID를 넣으면 항상 같은 이미지가 반환된다.
https://picsum.photos/seed/{id}/400/300| JSONPlaceholder | Picsum Photos | |
|---|---|---|
| API 키 | 불필요 | 불필요 |
| 무료 | 완전 무료 | 완전 무료 |
| 용도 | 텍스트 메타데이터 (페이지네이션) | 예시 이미지 (무료, 저작권 없음) |
핵심 구현
useInfiniteScroll 훅
function useInfiniteScroll(onIntersect, loading, hasMore) {
const sentinelRef = useRef(null);
const callbackRef = useRef(onIntersect);
// observer 재생성 없이 최신 콜백 유지
useEffect(() => { callbackRef.current = onIntersect; });
useEffect(() => {
if (loading || !hasMore) return; // 로딩 중·더 없으면 비활성
const observer = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) callbackRef.current(); },
{ rootMargin: '300px' } // 300px 앞에서 미리 로드
);
const el = sentinelRef.current;
if (el) observer.observe(el);
return () => { if (el) observer.unobserve(el); };
}, [loading, hasMore]); // 두 값이 바뀔 때마다 재설정
return sentinelRef;
}
핵심 설계 원칙:
loading=true또는hasMore=false이면 observer를 아예 제거한다. 중복 호출 방지를if가드 하나로 해결한다.- 로딩이 끝나면 (
loading: true → false) effect가 재실행돼 observer가 재생성된다. sentinel이 여전히 뷰포트 안에 있으면 즉시 재발화 — 화면을 채우지 못할 경우 자동으로 다음 페이지를 불러온다.
stale closure — ref로 해결
const pageRef = useRef(1);
const loadMore = useCallback(async () => {
setLoading(true);
try {
const data = await fetchPhotos(pageRef.current);
if (data.length < PER_PAGE) setHasMore(false);
if (data.length > 0) {
setPhotos(prev => [...prev, ...data]);
pageRef.current += 1; // state 아닌 ref로 페이지 증가
}
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
}, []);
loadMore를 useCallback(fn, [])으로 안정화했다. 이 안에서 page state를 직접 읽으면 클로저가 초기값을 캡처해 항상 1페이지만 요청하는 버그가 생긴다. pageRef는 클로저 외부의 가변 참조이므로 항상 최신값을 읽는다.
Skeleton UI
function SkeletonCard() {
return (
<div className="skeleton-card">
<div className="skeleton-img" />
<div className="skeleton-text">
<div className="skeleton-line" style={{ width: '35%' }} />
<div className="skeleton-line" style={{ width: '88%' }} />
<div className="skeleton-line" style={{ width: '65%' }} />
</div>
</div>
);
}
@keyframes shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
.skeleton-img,
.skeleton-line {
background: linear-gradient(
90deg,
rgba(255,255,255,0.04) 25%,
rgba(255,255,255,0.11) 50%,
rgba(255,255,255,0.04) 75%
);
background-size: 200% 100%;
animation: shimmer 1.4s ease-in-out infinite;
}
로딩 중에는 그리드 끝에 12개 스켈레톤 카드를 추가한다. 실제 카드가 들어올 위치를 미리 잡아줘 레이아웃 점프가 없다.
첫 로드 자동 실행
별도의 useEffect(() => { loadMore(); }, []) 없이 첫 로드가 자동으로 실행된다. 초기 렌더에서 sentinel이 뷰포트에 있고, IntersectionObserver가 마운트 시 즉시 발화하기 때문이다.
비교 테이블
| scroll 이벤트 | IntersectionObserver | |
|---|---|---|
| 발화 빈도 | 스크롤마다 수백 회 | 교차 시 1회 |
| throttle 필요 | 필요 | 불필요 |
| 메인 스레드 | 부하 있음 | 거의 없음 |
| 코드 복잡도 | 높음 | 낮음 |
| 브라우저 지원 | 전체 | IE 미지원 (현재 무관) |
삽질 기록
sentinel이 로드 후에도 뷰포트에 남는 경우
첫 로드 12장이 화면을 채우지 못하면 sentinel이 뷰포트에서 나가지 않는다. IntersectionObserver는 교차 상태가 변할 때만 발화하므로 다음 로드가 트리거되지 않는 문제다.
해결: observer를 loading 변경 시마다 재생성한다. loading: true → false로 바뀌면 새 observer가 sentinel을 관찰하고, 여전히 뷰포트 안에 있으면 즉시 콜백을 발화한다. 별도 재시도 로직 없이 해결된다.
페이지 state를 읽으면 항상 1페이지 요청
loadMore 안에서 page state를 직접 사용했더니 클로저가 초기값(1)을 캡처해 매번 1페이지만 요청했다. useCallback(fn, []) + pageRef로 해결. state는 렌더링에만 쓰고 가변 계산 값은 ref로 관리하는 패턴이다.
loading="lazy" 이미지와 grid 높이
이미지에 loading="lazy"를 붙이면 뷰포트 밖 이미지를 지연 로드한다. 그런데 이미지 크기가 정해지지 않으면 레이아웃이 로드 후 점프한다. aspect-ratio: 4/3으로 이미지 영역을 미리 확보해 레이아웃 점프를 없앴다.
마무리
useInfiniteScroll 훅 하나가 IntersectionObserver 라이프사이클을 전부 관리한다. App은 loadMore와 loading, hasMore만 넘기면 된다. stale closure는 ref로, 중복 호출은 observer 제거로 깔끔하게 처리된다. 같은 훅을 댓글 목록, 상품 목록, 뉴스 피드 어디에든 재사용할 수 있다.
기술 스택: React 18 CDN · Babel Standalone · IntersectionObserver API · JSONPlaceholder · Picsum Photos
소스 코드: GitHub
데모: GitHub Pages
'튜토리얼 > 웹' 카테고리의 다른 글
| [Next.js] Vercel AI SDK로 챗봇 만들기 (스트리밍, API 키 불필요) (0) | 2026.03.02 |
|---|---|
| [Next.js] 마크다운 블로그 만들기 (정적 생성) (1) | 2026.03.02 |
| [React] 날씨 앱 만들기 (Open-Meteo API · API 키 불필요) (0) | 2026.03.02 |
| [React] 영화 검색 앱 만들기 (TMDB API) (0) | 2026.03.01 |
| [React] GitHub 프로필 검색기 만들기 (GitHub API) (0) | 2026.03.01 |