useIntersectionObserver: 무한 스크롤과 레이지 로딩의 핵심

크리스가 쇼핑몰의 상품 목록 페이지를 만들고 있다.
상품이 1,000개가 넘기 때문에 한 번에 다 보여줄 수는 없다. 페이스북처럼 스크롤이 바닥에 닿으면 새로운 상품을 불러오는 무한 스크롤(Infinite Scroll)을 구현해야 한다.
크리스는 익숙한 방식인 스크롤 이벤트를 사용했다.
// ❌ 크리스의 성능 파괴자
useEffect(() => {
const handleScroll = () => {
// 매번 스크롤 위치를 수학적으로 계산한다.
const { scrollTop, clientHeight, scrollHeight } = document.documentElement;
// 바닥에 닿았는지 1px 단위로 체크
if (scrollTop + clientHeight >= scrollHeight - 10) {
loadMoreItems();
}
};
// 스크롤 할 때마다 수백 번씩 실행됨 (메인 스레드 점유)
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);기능은 동작하지만 팬(Fan) 소리가 요란해진다. 스크롤 이벤트는 1초에 수십 번 발생하고, 그때마다 scrollTop 같은 레이아웃 수치를 계산(Reflow)하느라 브라우저가 비명을 지르기 때문이다.
Throttle을 써도 계산 비용 자체는 줄어들지 않는다.
이 문제를 해결하기 위해 브라우저가 제공하는 우아한 해결책, IntersectionObserver API를 훅으로 감싸보자.
1. IntersectionObserver: "마중 나가기"
이 API의 개념은 "감시자(Observer)"를 심어두는 것이다.
우리는 "바닥(Bottom)"이라는 투명한 요소를 리스트 맨 아래에 둔다. 그리고 감시자에게 이렇게 명령한다.
"저 '바닥' 요소가 화면(Viewport)에 10% 정도 보이면 나한테 무전 쳐."
스크롤 이벤트를 계속 감시할 필요가 없다. 브라우저 내부적으로 최적화된 로직을 통해, 요소가 화면에 교차(Intersection)하는 순간 딱 한 번만 콜백을 실행해 준다.
2. useIntersectionObserver 구현
이 API는 사용법이 약간 복잡하다(new, observe, disconnect 등). 이를 리액트스럽게 포장해 보자.
// useIntersectionObserver.ts
import { useEffect, useState, RefObject } from 'react';
// 옵션 타입 (감도 조절 등)
interface Args extends IntersectionObserverInit {
freezeOnceVisible?: boolean; // 한 번 보이면 감시 중단할지 여부
}
export function useIntersectionObserver(
elementRef: RefObject<Element>,
{
threshold = 0.1, // 10% 보이면 감지
root = null, // null이면 브라우저 뷰포트 기준
rootMargin = '0%', // 마진 (미리 감지하고 싶을 때 사용)
freezeOnceVisible = false,
}: Args = {}
): IntersectionObserverEntry | undefined {
const [entry, setEntry] = useState<IntersectionObserverEntry>();
const [frozen, setFrozen] = useState(false);
useEffect(() => {
const node = elementRef?.current; // 감시할 DOM 요소
const hasIOSupport = !!window.IntersectionObserver;
// 지원하지 않거나 이미 한 번 보여서 멈춘 상태라면 패스
if (!hasIOSupport || frozen || !node) return;
const observerParams = { threshold, root, rootMargin };
const observer = new IntersectionObserver(([entry]) => {
// 교차 상태 업데이트
setEntry(entry);
// 한 번만 감지하고 싶다면 감시 중단 (예: 레이지 로딩)
if (entry.isIntersecting && freezeOnceVisible) {
setFrozen(true);
}
}, observerParams);
observer.observe(node); // 감시 시작
return () => observer.disconnect(); // 클린업: 감시 종료
}, [elementRef, JSON.stringify(threshold), root, rootMargin, frozen]);
return entry;
}3. 실전 적용 1: 무한 스크롤
이제 크리스는 바닥에 보이지 않는 div 하나만 깔아두면 된다.
// ProductList.tsx
function ProductList() {
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery(...);
// 1. 바닥 감지용 Ref 생성
const bottomRef = useRef<HTMLDivElement>(null);
// 2. 훅 연결
const entry = useIntersectionObserver(bottomRef, {});
const isIntersecting = !!entry?.isIntersecting;
// 3. 바닥이 보이면 다음 페이지 불러오기
useEffect(() => {
if (isIntersecting && hasNextPage) {
fetchNextPage();
}
}, [isIntersecting]);
return (
<div>
{data.map(item => <ProductItem key={item.id} {...item} />)}
{/* 이 투명한 박스가 화면에 들어오면 추가 로딩 */}
<div ref={bottomRef} style={{ height: 20 }} />
</div>
);
}복잡한 수학 계산 없이, "바닥이 보이면 불러온다"는 직관적인 코드가 완성되었다.
4. 실전 적용 2: 이미지 레이지 로딩 (Lazy Image)
무한 스크롤뿐만 아니라, 이미지 최적화에도 쓸 수 있다. 화면에 들어오기 전까지는 이미지를 로드하지 않다가, 들어오는 순간 로드하는 것이다.
function LazyImage({ src, alt }: { src: string; alt: string }) {
const ref = useRef<HTMLImageElement>(null);
// 한 번 보이면 감시를 중단한다 (freezeOnceVisible: true)
const entry = useIntersectionObserver(ref, { freezeOnceVisible: true });
const isVisible = !!entry?.isIntersecting;
return (
<div ref={ref} className="image-placeholder">
{/* 화면에 들어왔을 때만 src를 할당 */}
{isVisible && <img src={src} alt={alt} />}
</div>
);
}이렇게 하면 사용자가 스크롤을 내리지 않은 아래쪽 영역의 이미지는 네트워크 요청을 보내지 않으므로, 초기 로딩 속도가 획기적으로 빨라진다.
핵심 정리
우리는 useState의 기초부터 시작해, React 19의 최신 기능, 아키텍처, 성능 최적화, 그리고 나만의 커스텀 훅을 만드는 단계까지 올라왔다.
이제 여러분은 단순히 "화면을 그리는" 코더가 아니다.
여러분의 프로젝트는 이제 시작이다. 여기서 배운 "원리 중심의 사고방식"을 무기로, 더 복잡하고 흥미로운 문제들에 도전해 보기를 바란다.