IntersectionObserver 활용 현재 페이지 추적 기능 구현하기

안녕하세요. 최근 회사 프로젝트에서 문서 뷰어를 구현하는 과정에서, 사용자가 스크롤을 통해 여러 페이지를 넘나들 때 ‘현재 페이지’가 어디인지 실시간으로 추적하는 기능을 개발할 기회가 있었습니다.

처음에는 IntersectionObserver를 활용해 비교적 빠르게 기능을 구현할 수 있었지만, 중간에 예상치 못한 문제가 발생했고 이를 해결하면서 더 안정적인 방식으로 개선할 수 있었습니다. 이번 글에서는 이 경험을 공유드리고자 합니다.

IntersectionObserver 란?

먼저 IntersectionObserver는 DOM 요소가 뷰포트나 특정 부모 요소와 교차하는지 여부를 비동기적으로 관찰할 수 있는 웹 API입니다. 이를 활용하면 스크롤 이벤트를 직접 추적하는 전통적인 방식보다 훨씬 효율적으로 요소의 가시성 변화를 감지할 수 있습니다.

  • 스크롤할 때마다 발생하는 onscroll 이벤트와 달리, 요소의 교차 상태가 변할 때만 콜백 함수를 실행 → 브라우저의 메인 스레드 부담 감소
  • 뷰포트에 대한 요소의 가시성 비율(Intersection Ratio) 제공 → 요소가 얼마나 보이고 있는지 정밀한 판단 가능
  • 기본적으로 뷰포트를 기준으로 교차 여부를 판단하지만, root 옵션을 사용해 특정 스크롤 영역을 기준으로 설정 가능 → rootMargin을 통해 교차 감지 범위 조절 가능

IntersectionObserver 사용 방법

  1. IntersectionObserver 인스턴스 생성하기
const observer = new IntersectionObserver(callback, options);

IntersectionObserver 생성자에는 두 가지 인자를 전달합니다.

  • callback: 관찰 대상 요소가 지정된 threshold를 통과할 때마다 실행될 함수로, IntersectionObserverEntry 객체 배열과 Observer 자체를 인자로 받습니다.
  • options: 관찰 방법을 설정하는 객체
    • root: 교차를 관찰할 뷰포트 역할을 하는 요소 (기본값: 브라우저 뷰포트)
    • rootMargin: root의 경계에 여백을 추가해 교차 영역을 확장/축소해 교차 범위 조정 (ex. "0px 0px -50% 0px” → 뷰포트의 절반 지점에 도달했을 때 감지)
    • threshold: 콜백이 실행될 교차 비율 (ex. 0.5 설정 시 요소의 절반이 보일 때 콜백 함수를 실행하고, [0, 0.5, 1] 설정 시, 0%, 50%, 100% 일 때마다 콜백 함수를 실행합니다.)
    • trackVisiblity (v2부터): 요소가 실제로 사용자에게 보이는지(다른 컨텐츠에 의해 가려졌거나, transform , opacity 같은 시각 효과 때문에 보이지 않게 되었는지)에 대해 추적할지 유무 (v2부터 도입)
  1. 관찰 대상 등록하기
observer.observe(targetElement);

observe 메서드를 사용해 관찰을 시작할 타겟 요소를 지정합니다. 하나의 Observer 인스턴스는 여러 개의 요소를 동시에 추적할 수 있습니다.

  1. 관찰 중지하기
observer.unobserve(targetElement); // 특정 대상에 대한 관찰 중지
observer.disconnect(); // 모든 대상에 대한 관찰 중지

관찰이 더 이상 필요하지 않을 때는 unobserve()disconnect() 메서드를 사용해 불필요한 리소스 낭비를 막아야 합니다.

사용 예시: 문서 페이지 관찰

const observer = new IntersectionObserver(
  (entries) => {
    console.log(entries);
  },
  { threshold: 0.6 },
);

observer.observe(document.querySelector('#page-1'));
observer.observe(document.querySelector('#page-2'));
observer.observe(document.querySelector('#page-3'));

총 3개의 페이지를 IntersectionObserver의 관찰 대상에 등록합니다. threshold를 0.6으로 설정했으므로, 각 페이지 요소가 뷰포트와 60% 이상 교차할 때마다 콜백이 실행됩니다.

페이지가 모두 관찰 대상으로 등록되면, observer의 콜백 함수가 최초로 한 번 실행됩니다. 이때 entries 배열에는 관찰 대상인 3개의 페이지가 모두 포함됩니다.

이때 각 페이지의 IntersectionObserverEntry 값은 아래와 같습니다.

  • page-1 의 IntersectionObserverEntry
    • intersectionRatio: 1 → 뷰포트에 완전 보이고 있음.
    • isIntersecting: true → 뷰포트와 교차하고 있음.
  • page-2 의 IntersectionObserverEntry
    • intersectionRatio: 0.172 → 뷰포트에 일부만 보이고 있음.
    • isIntersecting: false → 뷰포트와 교차하고 있음.
  • page-3 의 IntersectionObserverEntry
    • intersectionRatio: 0 → 뷰포트 밖에 있음.
    • isIntersecting: true → 뷰포트와 교차하지 않음.

이후 사용자가 스크롤을 내려 두 번째 페이지(page-2)의 60% 영역이 뷰포트에 노출되면, 콜백 함수가 다시 호출됩니다.

이때 entries 배열에는 상태가 변경된 페이지들에 대한 IntersectionObserverEntry만 포함됩니다.

  • page-1IntersectionObserverEntry
    • intersectionRatio: 0.58
    • isIntersecting: false
  • page-2IntersectionObserverEntry
    • intersectionRatio: 0.63
    • isIntersecting: true

이처럼 IntersectionObserverthreshold를 기준으로 교차 상태가 변할 때마다 콜백을 호출하여, 현재 화면에 보이는 페이지를 효율적으로 추적할 수 있게 해줍니다.

실제 적용: 현재 페이지 추적

React 기반 문서 뷰어에서, 각 페이지를 IntersectionObserver로 관찰해 가장 최근에 교차한 페이지를 현재 페이지로 인식하도록 구현했습니다.

// PageObserverProvider.tsx

export const PageObserverProvider = ({ children }) => {
  const pageObserverRef = useRef<IntersectionObserver | null>(null);
  const pagesRef = useRef<Set<HTMLDivElement>>(new Set());
  const setCurrentPageIdx = useSetAtom(currentPageIdxAtom);

  useEffect(() => {
    // IntersectionObserver 생성
    const observer = new IntersectionObserver(
      (entries) => {
        const currentPageEntry = entries.find((entry) => entry.isIntersecting);

        setCurrentPageIdx((prev) => {
          if (currentPageEntry) {
            return Number(currentPageEntry.target.getAttribute('data-page-idx'));
          }
          return prev;
        });
      },
      { threshold: 0.6 }, // 60% 이상 보여야 "현재 페이지"로 판단
    );

    pageObserverRef.current = observer;

    return () => {
      observer.disconnect();
      pagesRef.current.clear();
    };
  }, [setCurrentPageIdx]);

  // 각 페이지에 ref 등록
  const pageCallbackRef = useCallback((node: HTMLDivElement | null) => {
    const observer = pageObserverRef.current;
    if (!observer) return;

    if (node) {
      if (!pagesRef.current.has(node)) {
        pagesRef.current.add(node);
        observer.observe(node);
      }
    } else {
      // ref가 해제된 경우 unobserve
      pagesRef.current.forEach((el) => {
        if (!document.body.contains(el)) {
          observer.unobserve(el);
          pagesRef.current.delete(el);
        }
      });
    }
  }, []);

  return <ObserverContext.Provider value={{ pageCallbackRef }}>{children}</ObserverContext.Provider>;
};

PageObserverProviderIntersectionObserver 인스턴스를 생성하고 pageCallbackRef를 통해 개별 페이지를 관찰 대상으로 등록합니다. threshold: 0.6 옵션은 뷰포트에 60% 이상 보이는 페이지를 현재 페이지로 인식하게 합니다.

// Page.tsx

const Page = ({ idx }: { idx: number }) => {
  const { pageCallbackRef } = useContext(ObserverContext);

  return (
    <div ref={pageCallbackRef} data-page-idx={idx}>
      ...
    </div>
  );
};

Page 컴포넌트는 ObserverContext에서 pageCallbackRef를 가져와 divref 속성에 연결합니다. 이렇게 하면 각 Page 컴포넌트가 렌더링될 때마다 pageCallbackRef 콜백 함수가 실행되어 IntersectionObserver에 해당 요소가 등록됩니다.

문제 발생: 페이지 확대 시 추적 실패

이렇게 구현하니 처음에는 기능이 잘 동작했지만, 페이지 확대/축소 기능을 추가하자 문제가 발생했습니다.

페이지가 크게 확대된 상태에서는 다음 페이지가 뷰포트에 들어와도 60% 이상 보이지 않는 경우가 많아, 현재 페이지 인덱스가 갱신되지 않는 상황이 생겼습니다.

해결: threshold 배열 + 최다 노출 페이지 선택

이 문제를 해결하기 위해 단일 threshold 대신, 0%~100%를 일정 간격으로 나눈 threshold 배열을 사용했습니다.

즉, IntersectionObserver가 5% 단위로 콜백을 호출하게 하여 페이지들의 뷰포트 노출 상태를 더 자주 체크하면서, 현재 뷰포트에 보이는 페이지들 중 가장 많이 보이는 페이지를 현재 페이지로 선택하는 방식으로 변경했습니다.