본문 바로가기
카테캠/2단계

[7/21] Intersection Observer

by 쪼꼬에몽 2025. 7. 22.

무한 스크롤

Intersection Observer

- 특정 DOM 요소가 View Port에 진입했는지 또는 벗어났는지를 비동기적으로 관찰할 수 있게 해주는 브라우저 내장 API

- 스크롤 이벤트 없이도 효율적으로 요소의 가시 상태 추적 가능

작동 흐름

1. 로딩 트리더용 div를 맨 아래에 렌더링, IntersectionObserver로 감시

2. 해당 요소가 화면에 보이면(isIntersecting === true), 다음 페이지 데이터 요청

3. 데이터를 받아오면 기존 리스트에 추가하고, 페이지 증가

4. 데이터가 없으면 옵저버 등록 해제(observer.unobserve)로 불필요한 호출 막기

function InfiniteScrollList() {
	const [items, setItems] = useState([]); // 아이템 목록
    const [page, setPage] = useState(1); // 현재 페이지
    const [hasMore, setHasMore] = useState(true); // 더 가져올 데이터 있는지 확인
    const loader = useRef(null); // 옵저버 대상 요소
    
    useEffect(() => {
    	const observer = new IntersectionObserver(
        	([entry]) => {
            	if (entry.isIntersecting && hasMore) {
                	loadMore(); // 다음 페이지 요청
                }
            },
            { threshold: 1.0 }
        );
        
        const el = loader.current;
        if (el) observer.observe(el);
        
        return () => {
        	if (el) observer.unobserve(el); // 클린업
        };
    }, [loader, hasMore]);
    
    const loadMore = async () => {
    	const res = await fetch(url);
        const data = await res.json();
        
        setItems(prev => [...prev, ...data.items]);
        setPage(prev => prev + 1);
        if (data.items.length === 0) setHasMore(false); // 더 이상 아이템 없으면 종료
    };
    
    return (
    	<div>
        	{items.map((item, idx) => (
            	<div key={idx}>{item.title}</div>
            ))}
            <div ref={loader} /> {/*  관찰 대상 */}
        </div>
    )
}
const [items, setItems] = useState([]);

- 불러온 아이템 목록 상태

- API에서 받은 데이터를 누적해서 렌더링

const [page, setPage] = useState(1);

- 현재 페이지 번호 상태

- 서버에 요청할 때 쿼리 파라미터로 사용 (?page=1, ?page=2)

const [hasMore, setHasMore] = useState(true);

- 더 불러올 데이터가 있는지 여부

- 더 이상 데이터가 없으면 로딩 중단 (옵저버 작동 중지)

const loader = useRef(null);

- 로딩 트리거용 요소를 참조

- <div ref={loader} 요소가 뷰포트에 보일 때 옵저버가 감지함

const observer = new IntersectionObserver(
	([entry]) => {
    	if (entry.isIntersecting && hasMore) {
        	loadMore(); // 뷰포트에 들어오면 다음 페이지 요청
        }
    },
    { threshold: 1.0 }
);

- 옵저버 인스턴스 생성

- entry.isIntersecting : 요소가 화면에 보이면 true

- threshold: 1.0 : 요소가 100% 보여질 때 감지

- loadMore() : 새로운 데이터 요청 

const el = loader.current;
if (el) observer.observe(el);

- 옵저버에 로딩 요소 등록

- 이 요소가 뷰포트에 들어오면 loadMore 호출 

return () => {
	if (el) observer.unobserve(el);
};

- 정리 함수 (unmount 또는 deps 변경 시)

- 옵저버 해제 

const loadMore = async () => {

- 데이터 요청 함수

- 뷰포트에 요소가 감지되었을 때 실행

setItems(prev => [...prev, ...data.items]);

- 기존 목록에 새 데이터 추가 (누적)

setPage(prev => prev + 1);

- 다음 페이지 번호로 증가 (다음 요청 대비)

if (data.items.length === 0) setHasMore(false);

- 불러온 데이터가 없으면 hasMore를 false로 설정해 더 이상 요청하지 않도록 막음 

페이지네이션 API 설계 시 고려사항

limit 페이지당 데이터 수 LIMIT 10
offset 건너뛸 데이터 수 OFFSET 20 (3페이지 -> (3-1)*10)
totalCount 전체 데이터 수 SELECT COUNT(*) FROM articles;
currentPage 현재 요청한 페이지 번호 /api/articles?page=3
totalPages 전체 페이지 수 Math.ceil(totalCount / limit);
hasNextPage 다음 페이지 유무 boolean

 

{
  "items": [/* ... */],
  "pagination": {
    "currentPage": 3,
    "totalPages": 5,
    "limit": 10,
    "totalCount": 45,
    "hasNextPage": true
  }
}

 

- 무한스크롤에서는 cursor 기반 페이징이 더 안정적 

 

 

 

 

 

 

 

 

 

 

'카테캠 > 2단계' 카테고리의 다른 글

[7/22] React Query  (1) 2025.07.22
[7/22] 무한 스크롤 옵저버  (0) 2025.07.22
[7/18] Fetch API Wrapping  (0) 2025.07.18
[7/17] local storage  (0) 2025.07.18
[7/17] 로그인 axios post  (3) 2025.07.18