React|Custom Hooks

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

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

크리스가 쇼핑몰의 상품 목록 페이지를 만들고 있다.

상품이 1,000개가 넘기 때문에 한 번에 다 보여줄 수는 없다. 페이스북처럼 스크롤이 바닥에 닿으면 새로운 상품을 불러오는 무한 스크롤(Infinite Scroll)을 구현해야 한다.

크리스는 익숙한 방식인 스크롤 이벤트를 사용했다.

typescript
// ❌ 크리스의 성능 파괴자
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 등). 이를 리액트스럽게 포장해 보자.

typescript
// 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 하나만 깔아두면 된다.

typescript
// 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)

무한 스크롤뿐만 아니라, 이미지 최적화에도 쓸 수 있다. 화면에 들어오기 전까지는 이미지를 로드하지 않다가, 들어오는 순간 로드하는 것이다.

typescript
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>
  );
}

이렇게 하면 사용자가 스크롤을 내리지 않은 아래쪽 영역의 이미지는 네트워크 요청을 보내지 않으므로, 초기 로딩 속도가 획기적으로 빨라진다.

핵심 정리

  • 문제: scroll 이벤트를 사용하여 특정 요소의 가시성을 판단하면, 잦은 이벤트 발생과 레이아웃 재계산(Reflow)으로 인해 성능이 저하된다.
  • 해결: IntersectionObserver API는 브라우저 레벨에서 최적화된 방식으로 요소의 교차 여부를 비동기적으로 감시한다.
  • useIntersectionObserver: 복잡한 옵저버 설정과 해제 로직을 훅으로 캡슐화하여, ref만 연결하면 쉽게 가시성을 판단할 수 있게 만든다.
  • 활용: 무한 스크롤, 이미지 레이지 로딩, 스크롤에 따른 애니메이션 트리거 등 모던 웹에서 필수적인 기능들을 고성능으로 구현할 수 있다.

  • 우리는 useState의 기초부터 시작해, React 19의 최신 기능, 아키텍처, 성능 최적화, 그리고 나만의 커스텀 훅을 만드는 단계까지 올라왔다.

    이제 여러분은 단순히 "화면을 그리는" 코더가 아니다.

  • Why: 왜 렌더링이 발생하는지 이해하고 제어할 수 있다.
  • How: 복잡한 비동기 상태와 폼을 우아하게 다룰 수 있다.
  • Structure: 유지보수하기 좋은 폴더 구조와 컴포넌트를 설계할 수 있다.
  • 여러분의 프로젝트는 이제 시작이다. 여기서 배운 "원리 중심의 사고방식"을 무기로, 더 복잡하고 흥미로운 문제들에 도전해 보기를 바란다.


    🔗 참고 링크

  • MDN - Intersection Observer API
  • usehooks-ts: useIntersectionObserver
  • 댓글 (0)

    0/1000
    댓글을 불러오는 중...