Intersection Observer는 요소가 뷰포트에 들어오거나 나갈 때 콜백을 실행하는 API입니다. 스크롤 이벤트를 직접 다루는 것보다 성능이 훨씬 좋고, 무한 스크롤이나 Lazy Loading 구현에 필수적입니다.

기본 사용법

JS
const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        console.log("보임:", entry.target);
      } else {
        console.log("안 보임:", entry.target);
      }
    });
  },
  {
    root: null,        // 뷰포트 기준 (null = 브라우저 뷰포트)
    rootMargin: "0px", // 마진 (미리 감지)
    threshold: 0,      // 교차 비율 (0 = 1px만 보여도)
  }
);

// 감시 시작
observer.observe(document.querySelector(".target"));

// 감시 중지
observer.unobserve(element);

// 전체 중지
observer.disconnect();

entry 객체

JS
const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    entry.isIntersecting;      // 뷰포트에 보이는가?
    entry.intersectionRatio;   // 보이는 비율 (0~1)
    entry.target;              // 감시 대상 요소
    entry.boundingClientRect;  // 요소의 위치/크기
    entry.intersectionRect;    // 교차 영역
    entry.rootBounds;          // root의 위치/크기
    entry.time;                // 이벤트 발생 시간
  });
});

무한 스크롤

JS
class InfiniteScroll {
  constructor(container, loadMore) {
    this.page = 1;
    this.isLoading = false;
    this.loadMore = loadMore;

    // 센티널 요소 생성
    this.sentinel = document.createElement("div");
    this.sentinel.className = "scroll-sentinel";
    container.appendChild(this.sentinel);

    // Observer 설정
    this.observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && !this.isLoading) {
          this.loadNextPage();
        }
      },
      { rootMargin: "200px" } // 200px 미리 로딩
    );

    this.observer.observe(this.sentinel);
  }

  async loadNextPage() {
    this.isLoading = true;
    try {
      const items = await this.loadMore(this.page);
      if (items.length === 0) {
        this.observer.disconnect(); // 더 이상 데이터 없음
        return;
      }
      this.page++;
    } finally {
      this.isLoading = false;
    }
  }
}

// 사용
new InfiniteScroll(document.querySelector(".list"), async (page) => {
  const res = await fetch(`/api/items?page=${page}`);
  const items = await res.json();
  renderItems(items);
  return items;
});

이미지 Lazy Loading

JS
// HTML
// <img data-src="image.jpg" class="lazy" alt="설명">

const lazyObserver = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src;
        img.classList.remove("lazy");
        lazyObserver.unobserve(img); // 로드 후 감시 해제
      }
    });
  },
  { rootMargin: "100px" } // 100px 미리 로드
);

document.querySelectorAll("img.lazy").forEach((img) => {
  lazyObserver.observe(img);
});

참고로 모던 브라우저는 <img loading="lazy">를 지원합니다. 하지만 세밀한 제어가 필요하면 Intersection Observer를 사용합니다.

스크롤 애니메이션

JS
const animateObserver = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        entry.target.classList.add("animate-in");
      }
    });
  },
  { threshold: 0.2 } // 20% 이상 보일 때
);

document.querySelectorAll(".animate-on-scroll").forEach((el) => {
  animateObserver.observe(el);
});
CSS
.animate-on-scroll {
  opacity: 0;
  transform: translateY(30px);
  transition: opacity 0.6s, transform 0.6s;
}

.animate-in {
  opacity: 1;
  transform: translateY(0);
}

목차 하이라이트 (Active Section)

JS
function setupTableOfContents() {
  const sections = document.querySelectorAll("section[id]");
  const navLinks = document.querySelectorAll(".toc a");

  const observer = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          navLinks.forEach((link) => link.classList.remove("active"));
          const activeLink = document.querySelector(
            `.toc a[href="#${entry.target.id}"]`
          );
          activeLink?.classList.add("active");
        }
      });
    },
    {
      rootMargin: "-20% 0px -60% 0px", // 상단 20% ~ 하단 40% 영역
    }
  );

  sections.forEach((section) => observer.observe(section));
}

threshold 활용

JS
// 여러 threshold로 진행률 추적
const progressObserver = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      const progress = Math.round(entry.intersectionRatio * 100);
      entry.target.style.setProperty("--progress", `${progress}%`);
    });
  },
  {
    threshold: Array.from({ length: 101 }, (_, i) => i / 100),
    // [0, 0.01, 0.02, ..., 0.99, 1]
  }
);

광고 뷰어빌리티 추적

JS
function trackAdVisibility(adElement) {
  let viewStartTime = null;

  const observer = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        if (entry.intersectionRatio >= 0.5) {
          // 50% 이상 보임
          viewStartTime = Date.now();
        } else if (viewStartTime) {
          const duration = Date.now() - viewStartTime;
          if (duration >= 1000) {
            // 1초 이상 노출 → 유효 노출로 기록
            trackImpression(adElement.dataset.adId, duration);
          }
          viewStartTime = null;
        }
      });
    },
    { threshold: [0, 0.5, 1] }
  );

  observer.observe(adElement);
}

스크롤 이벤트 vs Intersection Observer

항목스크롤 이벤트Intersection Observer
성능매 프레임 실행 (무거움)비동기, 브라우저 최적화
쓰로틀링직접 구현 필요불필요
정확도getBoundingClientRect브라우저 내부 계산
코드량많음적음
여러 요소각각 계산하나의 observer

**기억하기 **: Intersection Observer는 스크롤 이벤트의 상위 호환입니다. 무한 스크롤에는 센티널 요소를, Lazy Loading에는 rootMargin을 활용합니다. 성능 이슈 없이 요소의 가시성을 추적할 수 있는 가장 좋은 방법입니다.

댓글 로딩 중...