TIL

Intersection Observer 톺아보기

물론입니다. 작성하신 내용에 이어서 전체 글을 완성해 드리겠습니다. Reference MDN - Intersection Observer API YouTube - Learn Intersection Observer In 15 Minutes 1. Overview

2025년 8월 14일8min read

1. Overview ✍️

랜딩 페이지에 react-intersection-observer 라이브러리의 useInView 훅을 통해 애니메이션 효과를 간단하게나마 적용했습니다. useInView의 소스 코드를 읽어보니, Intersection Observer라는 WEB API를 기반으로 코드가 동작하고 있음을 확인할 수 있었습니다.

Intersection은 화면(뷰포트)에 특정 요소가 나타나거나 사라지는 상태를 의미합니다. Observer는 관찰자죠. Intersection Observer API는 화면에 나타나거나 사라지는 요소의 상태를 관찰하는 역할을 수행하겠네요.

과거에는 교차 감지를 구현하기 위해 이벤트 핸들러Element.getBoundingClientRect() 메서드를 사용했습니다. 사용자가 스크롤할 때마다 scroll 이벤트 내부에서 getBoundingClientRect() 메서드를 계속해서 호출해서 요소의 visibility를 확인한 것이죠.


그런데 스크롤은 아주 짧은 순간에도 수십 번씩 발생하는 이벤트입니다. getBoundingClientRect() 메서드가 짧은 순간에도 수십 번씩 실행되어, 브라우저에게 특정 요소의 현재 위치와 크기에 대한 계산을 지속적으로 요청하게 됩니다.

Intersection Observer는 우리가 설정한 조건(예: '50% 이상 보이면')을 만족할 때, 등록한 콜백 함수가 실행되도록 합니다.

getBoundingClientRect() 방식은, 목적지에 도착할 때까지 1초마다 운전자에게 "아직 멀었어?"라고 계속 묻는 것과 같습니다. 운전자를 방해해서 운전에 집중할 수 없게 만듭니다. Intersection Observer 방식은, 운전자에게 "목적지에 도착하면 알려줘"라고 한 번만 말해두는 것과 같습니다. 운전자는 운전에만 집중하다가, 목적지에 도착했을 때 딱 한 번 알려줍니다.

2. Concepts and usage ✍️

2-1. Creating an intersection observer 🎯

2-1-1. Intersection observer options 🟣

js
const options = {
  root: document.querySelector("#scrollArea"),
  rootMargin: "0px",
  threshold: 1.0,
};

const observer = new IntersectionObserver(callback, options);

new IntersectionObserver(callback, options)와 같이 생성자를 호출하여 옵저버를 생성합니다. 생성자에는 실행할 callback 함수와 동작을 제어할 options 객체를 전달합니다. callback은 설정한 threshold를 넘나들 때마다 실행됩니다. 위 코드에서는 threshold가 1로 설정되어 있기에, root로 지정된 요소 내에서 100% 전부 보일 때 콜백이 호출됩니다. 즉, options 객체를 통해 콜백이 호출되는 조건을 세밀하게 제어할 수 있습니다.

1. root: 교차를 감지할 기준이 되는 viewport 요소입니다. 대상(target) 요소의 상위 요소여야 합니다. 2. rootMargin: root의 교차 감지 범위를 확장하거나 축소합니다. 3. threshold: 대상 요소가 얼마큼 보여야 콜백을 실행할지 지정하는 값입니다.

2-1-2. Intersection change callbacks 🟣

IntersectionObserver() 생성자에 전달하는 콜백 함수는, IntersectionObserverEntry 객체 목록과 해당 옵저버(observer)를 인자로 받습니다.

js
const callback = (entries, observer) => {
  entries.forEach((entry) => {
    // Each entry describes an intersection change for one observed
    // target element:
    //   entry.boundingClientRect
    //   entry.intersectionRatio
    //   entry.intersectionRect
    //   entry.isIntersecting
    //   entry.rootBounds
    //   entry.target
    //   entry.time
  });
};

콜백이 받는 entries에는 threshold를 넘는 이벤트 하나당 IntersectionObserverEntry 객체가 하나씩 포함됩니다. 한마디로 엔트리는 교차 이벤트에 대한 상세 보고서에 해당합니다. 옵저버가 감시하던 요소의 화면 노출 상태가 변할 때마다, 그 순간의 모든 정보가 담긴 보고서가 콜백 함수로 전달되는 것입니다.

아래의 예제 코드는 상세 보고서를 하나씩 살펴보며 화면에 교차 중인 요소가 있고, 동시에 75% 이상 보이는 요소가 있으면 카운터 숫자를 올리는 동작을 수행합니다.

js
const intersectionCallback = (entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      let elem = entry.target;

      if (entry.intersectionRatio >= 0.75) {
        intersectionCounter++;
      }
    }
  });
};

2-1-3. Targeting an element to be observed 🟣

옵저버를 생성했으니, 이제 감시할 대상(target) 요소를 지정해야 합니다.

js
const target = document.querySelector("#listItem");
observer.observe(target);

// the callback we set up for the observer will be executed now for the first time
// it waits until we assign a target to our observer (even if the target is currently not visible)

target이 IntersectionObserver에 지정된 threshold를 만족할 때마다 콜백이 호출됩니다. root 옵션을 지정했다면 target은 반드시 root의 하위 요소여야 한다는 점을 유의해야 합니다.

2-2. How intersection is calculated 🎯

Intersection Observer API가 고려하는 모든 영역은 직사각형입니다. 모양이 불규칙한 요소의 경우, 그 요소의 모든 부분을 감싸는 가장 작은 직사각형이 해당 요소의 영역으로 간주됩니다.

2-2-1. The intersection root and root margin 🟣

앞서 options 객체에서 살펴본 교차 루트(root)는, 특정 요소와의 교차를 감지할 기준이 되는 컨테이너 역할을 합니다. 이 컨테이너는 감시할 대상의 상위 요소이거나, null로 지정하여 브라우저의 뷰포트 전체를 기준으로 삼을 수도 있습니다.

브라우저는 이 root를 기준으로 루트 교차 영역(root intersection rectangle)이라는 사각형을 만들어 교차 여부를 확인하는데, 이 영역은 다음과 같이 결정됩니다.

- root가 뷰포트일 경우: 루트 교차 영역은 뷰포트의 사각형입니다. - root에 overflow 속성이 있을 경우: 루트 교차 영역은 루트 요소의 콘텐츠 영역입니다. - 그 외의 경우: 루트 교차 영역은 getBoundingClientRect()로 얻을 수 있는 루트의 경계 사각형(바운딩 박스)입니다.

이 루트 교차 영역은 rootMargin 옵션을 통해 실제 크기보다 더 크거나 작게 조정할 수 있습니다. rootMargin 값은 CSS의 margin 속성처럼 root의 바운딩 박스 각 변에 추가되는 간격을 정의하며, 양수 값은 교차 영역을 확장하고 음수 값은 축소하는 효과를 줍니다.

rootMargin을 이용해 교차 영역을 확장하면, 이미지가 실제로 화면에 보이기 직전에 교차 상태로 만들 수 있습니다. 사용자가 이미지를 마주하기 전에 미리 로딩을 시작하여 더 나은 사용자 경험을 제공하는 지연 로딩(Lazy Loading) 같은 기능을 구현할 수 있습니다.

2-2-2. The intersection root and scroll margin 🟣

root 내부에 좌우로 스크롤되는 이미지 캐러셀처럼, 스크롤이 가능한 컨테이너가 중첩된 경우를 생각해봅시다. 기본적으로 옵저버는 캐러셀이 화면(root)에 보이고, 동시에 캐러셀 안의 이미지가 스크롤되어 보여야 교차 상태로 판단합니다.

스크롤 마진(scrollMargin)은 바로 이 두 번째 조건, 즉 중첩된 컨테이너 내부의 스크롤에 대한 교차 감지 시점을 조절하는 옵션입니다.

이 옵션을 사용하면 root 내부에 있는 모든 중첩 스크롤 컨테이너의 클리핑 영역(clipping region)을 확장(양수 값)하거나 축소(음수 값)하여, 대상이 컨테이너 안으로 들어오기 전후로 교차를 감지하게 할 수 있습니다.

rootMargin을 이용해 각 스크롤 컨테이너마다 옵저버를 따로 만드는 복잡한 방법 대신, scrollMargin을 사용하면 단 하나의 옵저버로 모든 중첩된 요소들을 효율적으로 관리할 수 있어 편리합니다. 예를 들어, 이미지 캐러셀에 scrollMargin으로 양수 값을 주면 다음 이미지가 화면에 보이기 직전에 미리 감지하여 로딩할 수 있고, 음수 값을 주면 이미지가 완전히 화면에 들어온 후에 감지하도록 동작을 지연시킬 수도 있습니다.

2-2-3. Thresholds 🟣

threshold 옵션은 대상 요소가 root와 어느 정도의 비율로 교차했을 때 콜백을 실행할지 결정합니다. 하나의 숫자나 여러 숫자로 이루어진 배열을 값으로 설정할 수 있습니다.

- threshold: 0.5 : 대상 요소가 50% 보였을 때 콜백이 실행됩니다. - threshold: [0, 0.25, 0.5, 1] : 대상이 보이기 시작할 때(0), 25% 보일 때, 50% 보일 때, 100% 전부 보일 때마다 콜백이 각각 실행됩니다.

이 옵션 덕분에 "요소가 화면에 나타나기 시작하면 데이터를 불러오고, 완전히 사라지면 동영상을 멈춰줘" 와 같이 정교한 상호작용을 구현할 수 있습니다. 기본값은 0으로, 1픽셀이라도 보이면 즉시 콜백이 실행됩니다.

2-2-4. Tracking visibility and delay 🟣

이 옵션들은 실험적인 기능에 해당합니다. trackVisibility는 대상 요소가 다른 요소에 의해 완전히 가려지거나, opacity, filter 등의 스타일로 인해 시각적으로 보이지 않는 상태까지 추적할지 결정하는 불리언 값입니다. 계산 비용이 매우 높기 때문에 기본값은 false이며, true로 설정할 경우 delay 옵션을 함께 사용하여 콜백 호출 빈도를 조절하는 것이 권장됩니다.

2-2-5. Clipping and the intersection rectangle 🟣

브라우저는 교차 상태를 계산할 때 '클리핑(clipping)' 개념을 사용합니다. 이는 overflow: hidden 스타일처럼, 부모 요소의 경계를 벗어나는 자식 요소의 부분을 잘라내 보이지 않게 만드는 것입니다. Intersection Observer는 이렇게 잘린 부분을 제외하고, 실제로 root 영역과 겹치는 가시적인 부분만을 기준으로 교차 여부와 비율을 계산합니다. 이 실제 교차 영역의 정보는 콜백으로 전달되는 IntersectionObserverEntry 객체의 intersectionRect 속성을 통해 확인할 수 있습니다.

3. Interfaces ✍️

Intersection Observer API는 두 가지 주요 인터페이스로 구성됩니다.

3-1. IntersectionObserver 🎯

옵저버 인스턴스 그 자체입니다. new IntersectionObserver() 생성자를 통해 만들어지며, 다음과 같은 주요 메서드를 가집니다.

- observe(target): 특정 대상 요소에 대한 관찰을 시작합니다. - unobserve(target): 특정 대상 요소에 대한 관찰을 중지합니다. - disconnect(): 옵저버가 관찰하는 모든 요소에 대한 관찰을 중지합니다. - takeRecords(): 마지막 콜백 실행 이후 발생한 모든 교차 정보를 담은 IntersectionObserverEntry 배열을 반환합니다.

3-2. IntersectionObserverEntry 🎯

콜백 함수에 전달되는 '교차 이벤트 상세 보고서'입니다. 감시 중인 대상과 root 사이의 교차 상태가 변할 때마다 생성되며, 읽기 전용의 다양한 속성을 통해 유용한 정보를 제공합니다.

- isIntersecting: 대상이 현재 root와 교차 중인지 여부를 나타내는 불리언 값입니다. - intersectionRatio: 대상 요소에서 현재 보이는 영역이 차지하는 비율을 0.0에서 1.0 사이의 값으로 나타냅니다. - target: 교차 상태가 변경된 대상 요소를 가리킵니다. - boundingClientRect: 대상 요소의 경계 사각형 정보를 반환합니다. (Element.getBoundingClientRect()와 유사) - intersectionRect: 대상 요소와 root가 실제로 교차하는 사각형 영역의 정보를 반환합니다. - rootBounds: root 요소의 경계 사각형 정보를 반환합니다.

4. Example ✍️

4-1. index.html 🎯

html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="stylesheet" href="styles.css" />
    <script src="script.js" defer></script>
  </head>
  <body>
    <div class="card-container">
      <div class="card">This is the first card</div>
      <div class="card">This is the a card</div>
      <div class="card">This is the a card</div>
      <div class="card">This is the a card</div>
      <div class="card">This is the a card</div>
      <div class="card">This is the a card</div>
      <div class="card">This is the a card</div>
      <div class="card">This is the a card</div>
      <div class="card">This is the a card</div>
      <div class="card">This is the a card</div>
      <div class="card">This is the a card</div>
      <div class="card">This is the a card</div>
      <div class="card">This is the a card</div>
      <div class="card">This is the a card</div>
      <div class="card">This is the a card</div>
      <div class="card">This is the a card</div>
      <div class="card">This is the a card</div>
      <div class="card">This is the last card</div>
    </div>
  </body>
</html>

4-2. script.js 🎯

js
const cards = document.querySelectorAll(".card");

const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      entry.target.classList.toggle("show", entry.isIntersecting);
      if (entry.isIntersecting) observer.unobserve(entry.target);
    });
  },
  { threshold: 1 }
);

const lastCardObserver = new IntersectionObserver(
  (entries) => {
    const lastCard = entries[0];
    if (!lastCard.isIntersecting) return;
    loadNewCards();
    lastCardObserver.unobserve(lastCard.target);
    lastCardObserver.observe(document.querySelector(".card:last-child"));
  },
  { rootMargin: "100px" }
);

lastCardObserver.observe(document.querySelector(".card:last-child"));

cards.forEach((card) => {
  observer.observe(card);
});

const cardContainer = document.querySelector(".card-container");

function loadNewCards() {
  for (let i = 0; i < 10; i++) {
    const card = document.createElement("div");
    card.textContent = "New Card";
    card.classList.add("card");
    observer.observe(card);
    cardContainer.append(card);
  }
}

4-3. styles.css 🎯

css
.card-container {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  align-items: flex-start;
}

.card {
  background: white;
  border: 1px solid black;
  border-radius: 0.25rem;
  padding: 0.5rem;
  transform: translateX(100px);
  opacity: 0;
  transform: 700ms;
}

.card.show {
  transform: translateX(0);
  opacity: 1;
}

Reference

- MDN - Intersection Observer API - YouTube - Learn Intersection Observer In 15 Minutes