·webintersection-observer

왜 페이지 추적에 IntersectionObserver가 필요할까?


문서 뷰어를 구현하고 있다고 가정해보자. 사용자가 현재 보고 있는 페이지를 실시간으로 추적하고 보여줘야 한다면 어떻게 구현할 수 있을까?

바로 생각나는 방법은 스크롤 이벤트를 추적하며 스크롤 위치를 사용해 페이지 위치값을 계산하는 방법이다. 하지만 횟수가 엄청난 스크롤 이벤트가 발생할 때마다 연산 로직을 실행하는 것은 성능적으로 좋지 않았다.

브라우저 API 중 하나인 IntersectionObserver API를 활용하면 이 기능을 효율적으로 구현할 수 있다. 다만 동작 원리나 정확한 사용 방법을 잘 알지 못한 채 구현하면 예기치 못한 문제들을 마주할 수 있다. 이번 글에서는 IntersectionObserver가 무엇인지와 개발 과정에서 겪었던 문제를 공유해보려 한다.

IntersectionObserver 란?

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

  • 스크롤할 때마다 발생하는 onscroll 이벤트와 달리, 요소의 교차 상태가 변할 때만 콜백 함수를 실행하여 브라우저의 메인 스레드 부담을 줄여준다.

  • 뷰포트에 대한 요소의 가시성 비율인 Intersection Ratio를 제공하여 요소가 얼마나 보이고 있는지 정밀하게 판단할 수 있다.

  • 기본적으로 뷰포트를 기준으로 교차 여부를 판단하지만, root 옵션을 사용해 특정 스크롤 영역을 기준으로 설정할 수 있고 rootMargin을 통해 감지 범위를 조절할 수 있다.

IntersectionObserver 사용 방법

1. 인스턴스 생성하기

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: 요소가 실제로 사용자에게 보이는지(다른 컨텐츠에 의해 가려졌거나, transform , opacity 같은 시각 효과 때문에 보이지 않게 되었는지)에 대해 추적할지 유무 (v2부터 지원)

2. 관찰 대상 등록하기

observer.observe(targetElement);

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

3. 관찰 중지하기

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'));

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

  1. 페이지가 모두 관찰 대상으로 등록되면, 콜백 함수가 최초로 실행된다. 이때 entries 배열(IntersectionObserverEntry[])은 아래와 같다.
[
  { intersectionRatio: 1, isIntersecting: true, ... },       // page 1 
  { intersectionRatio: 0.172, isIntersecting: true, ... },   // page 2 
]

IntersectionObserverEntry 객체는 해당 노드가 뷰포트에 얼마나 보이고 있는지 (intersectionRatio), 뷰포트와 교차하고 있는지(isIntersecting) 등 정보를 담고 있다.

  1. 이후 사용자가 스크롤을 내리다가 두번째 페이지의 60% 이상이 뷰포트에 노출되는 순간, 콜백 함수가 다시 호출된다.
[
  { intersectionRatio: 0.5909, isIntersecting: false, ... },       // page 1 
  { intersectionRatio: 0.6482, isIntersecting: true, ... },       // page 2
]

entries 배열에 교차 상태가 변경된 페이지들에 대한 IntersectionObserverEntry만 포함된 것을 볼 수 있다.

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

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

이제 '현재 페이지 추적' 코드를 작성해보자.

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

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% 이상 보이는 페이지를 현재 페이지로 인식하게 했다.

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에 해당 노드가 등록된다.

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

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

IntersectionObserver의 intersectionRatio는 타겟 요소의 전체 면적 대비 교차되는 면적의 비율이다. 사용자가 페이지를 크게 확대하면 한 페이지의 전체 높이가 뷰포트 높이보다 훨씬 길어진다.

예를 들어 페이지 높이가 뷰포트 높이의 2배가 되었다고 가정해보자. 이 경우 페이지가 화면을 가득 채우고 있어도 intersectionRatio는 최대 0.5까지만 도달할 수 있다. 하지만 기존 코드의 threshold는 0.6으로 설정되어 있었기 때문에, 콜백 함수가 실행되지 않거나 조건문을 통과하지 못해 페이지 인덱스가 갱신되지 않는 현상이 발생했다.

즉, 사용자는 분명 다음 페이지를 보고 있는데 뷰포트 대비 해당 페이지의 전체 크기가 너무 커서 60% 기준을 충족하지 못하는 상황이 벌어진 것이다.

해결 방법: 가시성 판단 로직 개선

단순히 특정 threshold를 넘었는지 확인하는 대신, 모든 페이지의 가시성 비율을 추적하고 그중 가장 많이 보이는 페이지를 현재 페이지로 결정하는 방식을 선택했다.

// 0.0부터 1.0까지 0.05 단위로 threshold 배열 생성
const thresholds = Array.from({ length: 21 }, (_, i) => i * 0.05);

export const PageObserverProvider = ({ children }) => {
  const pageObserverRef = useRef(null);
  const setCurrentPageIdx = useSetAtom(currentPageIdxAtom);
  
  // 각 페이지의 현재 가시성 비율을 저장할 Map
  const visibilityMap = useRef(new Map());

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        // 1. 상태가 변한 요소들의 비율을 Map에 업데이트했다.
        entries.forEach((entry) => {
          const pageIdx = Number(entry.target.getAttribute('data-page-idx'));
          if (entry.isIntersecting) {
            visibilityMap.current.set(pageIdx, entry.intersectionRatio);
          } else {
            // 완전히 화면에서 벗어나면 Map에서 제거하거나 0으로 설정했다.
            visibilityMap.current.delete(pageIdx);
          }
        });

        // 2. 현재 Map에 저장된 페이지 중 가장 비율이 높은 페이지를 찾았다.
        let maxRatio = 0;
        let dominantPageIdx = -1;

        visibilityMap.current.forEach((ratio, idx) => {
          if (ratio > maxRatio) {
            maxRatio = ratio;
            dominantPageIdx = idx;
          }
        });

        // 3. 가장 많이 보이는 페이지로 인덱스를 업데이트했다.
        if (dominantPageIdx !== -1) {
          setCurrentPageIdx(dominantPageIdx);
        }
      },
      {
        threshold: thresholds,
        // 필요에 따라 root나 rootMargin을 설정할 수 있다.
      }
    );

    pageObserverRef.current = observer;

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

  // ... 생략 (pageCallbackRef 로직)
};

이렇게 수정하면 다음과 같은 이점이 있다.

  • 페이지가 뷰포트보다 훨씬 큰 확대 상황에서도 대응이 가능하다. 특정 기준인 0.6을 넘지 못하더라도, 화면에 걸쳐 있는 여러 페이지 중 상대적으로 가장 많이 노출된 페이지를 찾아낼 수 있기 때문이다.

  • threshold를 0.05 단위로 촘촘하게 설정했기 때문에 사용자의 스크롤에 따라 페이지 번호가 더 매끄럽게 변경되는 효과를 얻을 수 있었다.

  • useRef를 사용해 visibilityMap을 관리함으로써 불필요한 리렌더링을 방지하면서도 각 페이지의 가시성 상태를 안정적으로 유지할 수 있었다.

왜 IntersectionObserver가 더 효율적일까?

앞부분에서 IntersectionObserver를 사용하면 onScroll와 비교했을때 더 빠르고 효율적이라고 말했다. 그런데 문득 IntersectionObserver를 사용해도 스크롤이 움직일 때마다 교차 상태를 확인해야 하는 것은 동일한데, 왜 IntersectionObserver를 쓰는 것이 성능상 유리할까 에 대해 더 궁금해졌다.

1. 실행 스레드의 차이

onScroll 이벤트는 브라우저의 메인 스레드에서 실행된다. 사용자가 스크롤을 할 때마다 자바스크립트 엔진은 이벤트를 감지하고 우리가 등록한 콜백 함수를 실행한다. 만약 메인 스레드에서 무거운 연산이나 UI 렌더링이 진행 중이라면 스크롤 이벤트 처리가 뒤로 밀리게 되고, 화면이 뚝뚝 끊기는 쟁크(Jank) 현상이 발생한다.

반면 IntersectionObserver는 브라우저 엔진 내부에서 관리된다. 구체적으로는 메인 스레드가 아닌 컴포지터 스레드나 렌더링 파이프라인의 특정 단계에서 교차 여부를 계산한다. 즉, 자바스크립트 실행 흐름을 방해하지 않으면서 별도로 계산을 수행하므로 메인 스레드의 부하를 크게 줄일 수 있다.

2. 동기적 레이아웃 탈조(Layout Thrashing) 방지

기존의 onScroll 방식에서 현재 요소의 위치를 알려면 getBoundingClientRect() 혹은 offsetTop 같은 속성을 조회해야 한다. 문제는 브라우저가 이 값을 정확하게 내놓기 위해 현재까지의 모든 레이아웃 변경 사항을 동기적으로 다시 계산해야 한다는 점이다. 이를 레이아웃 탈조(Layout Thrashing)라고 부른다. 스크롤 한 번에 수십 번씩 레이아웃을 다시 계산하게 되면 성능에 치명적이다.

IntersectionObserver는 비동기적으로 동작한다. 브라우저가 레이아웃과 페인트 과정을 모두 마친 뒤 여유가 있을 때 교차 상태를 체크하고, 그 결과를 콜백 큐에 넣어준다. 개발자가 직접 레이아웃 계산을 강제하지 않아도 브라우저가 최적의 타이밍에 알려주는 구조다.

IntersectionObserver가 비동기적으로 동작한다는건, 지연이 발생할 수도 있다는 건가?

IntersectionObserver의 콜백은 메인 스레드가 비어 있을 때 실행되는 태스크 큐에 쌓이게 된다. 만약 다른 UI 로직이나 복잡한 연산 때문에 메인 스레드가 0.5초 동안 꽉 잡혀 있다면, 사용자가 그사이 여러 페이지를 지나쳤어도 콜백은 0.5초 뒤에 한꺼번에 실행된다. 이 경우 페이지 번호가 부드럽게 변하지 않고 갑자기 점프하는 것처럼 보일 수 있다.

콜백 함수가 늦게 실행될 수는 있지만, 그 안에 담긴 데이터(IntersectionObserverEntry)가 가리키는 교차 상태는 실제 교차가 일어났던 시점의 스냅샷이다. 각 Entry 객체에는 time이라는 타임스탬프 속성이 포함되어 있다. 이는 교차가 실제로 감지된 정확한 시간을 밀리초 단위로 알려준다. 즉, 콜백 실행은 늦어질 수 있어도 브라우저 내부에서 해당 교차가 언제 일어났는지에 대한 기록은 정확하게 관리된다.

IntersectionObserver는 실시간성을 아주 살짝 양보하는 대신, 브라우저가 전체 렌더링 최적화를 유지할 수 있는 여유를 준다. 결과적으로 사용자 입장에서는 페이지 번호가 0.01초 늦게 바뀌는 것보다 화면이 뚝뚝 끊기지 않는 것을 더 쾌적하게 느낀다.

3. 브라우저 최적화 엔진 활용

브라우저 엔진은 화면에 요소를 그리기 위해 이미 모든 요소의 위치와 크기 정보를 가지고 있다. IntersectionObserver는 우리가 자바스크립트로 좌표를 일일이 계산하는 것이 아니라, 브라우저가 이미 수행하고 있는 렌더링 루틴의 정보를 재활용하는 방식이다. C++로 작성된 브라우저 엔진의 최적화된 알고리즘을 그대로 사용하기 때문에 자바스크립트 연산보다 훨씬 빠를 수밖에 없다.

결론적으로 IntersectionObserver를 사용한다고 해서 연산 자체가 사라지는 것은 아니다. 하지만 연산의 주체를 자바스크립트에서 브라우저 엔진으로 옮기고 실행 시점을 비동기로 제어함으로써 메인 스레드를 자유롭게 해주는 것이 핵심이다.