Intersection Observer — 무한 스크롤, Lazy Loading, 가시성 추적
Intersection Observer는 요소가 뷰포트에 들어오거나 나갈 때 콜백을 실행하는 API입니다. 스크롤 이벤트를 직접 다루는 것보다 성능이 훨씬 좋고, 무한 스크롤이나 Lazy Loading 구현에 필수적입니다.
기본 사용법
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 객체
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; // 이벤트 발생 시간
});
});
무한 스크롤
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
// 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를 사용합니다.
스크롤 애니메이션
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);
});
.animate-on-scroll {
opacity: 0;
transform: translateY(30px);
transition: opacity 0.6s, transform 0.6s;
}
.animate-in {
opacity: 1;
transform: translateY(0);
}
목차 하이라이트 (Active Section)
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 활용
// 여러 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]
}
);
광고 뷰어빌리티 추적
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을 활용합니다. 성능 이슈 없이 요소의 가시성을 추적할 수 있는 가장 좋은 방법입니다.
댓글 로딩 중...