IntersectionObserver - 요소 가시성 감지 API
한 줄 요약
IntersectionObserver는 DOM 요소와 뷰포트(또는 특정 컨테이너)의 교차 상태를 비동기적으로 감지하는 Web API입니다.
무한 스크롤, 이미지 lazy loading, 스크롤 애니메이션 등을 구현할 때, 기존의 scroll 이벤트와 getBoundingClientRect()는 메인 스레드를 차단하고 레이아웃을 강제로 재계산하게 만들어 성능 문제를 일으킵니다. IntersectionObserver는 이러한 계산을 브라우저의 최적화된 내부 로직으로 처리하여, 효율적이고 선언적인 방식으로 요소의 가시성을 추적할 수 있게 해줍니다.
먼저, 기초부터 이해하기
왜 IntersectionObserver가 필요한가?
전통적인 방식의 문제점을 먼저 살펴봅시다.
❌ 전통적인 방식의 문제
// 나쁜 예: scroll 이벤트로 직접 감지
window.addEventListener('scroll', () => {
const element = document.querySelector('.lazy-image');
const rect = element.getBoundingClientRect();
// 메인 스레드에서 매번 계산이 일어남
if (rect.top < window.innerHeight && rect.bottom > 0) {
// 이미지 로드
element.src = element.dataset.src;
}
});
문제점:
- 성능 저하: 스크롤할 때마다 함수가 실행됩니다. 초당 수십~수백 번!
- 레이아웃 계산:
getBoundingClientRect()는 브라우저의 레이아웃 재계산을 유발합니다. - 메인 스레드 차단: 모든 계산이 메인 스레드에서 일어나 UI가 버벅거립니다.
✅ IntersectionObserver를 사용하면
// 좋은 예: IntersectionObserver 사용
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
// 요소가 화면에 보일 때만 실행됨
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
observer.unobserve(img); // 한 번만 로드하면 되므로 관찰 중단
}
});
});
const lazyImages = document.querySelectorAll('.lazy-image');
lazyImages.forEach(img => observer.observe(img));
장점:
- 비동기 처리: 브라우저가 최적의 타이밍에 콜백을 실행합니다.
- 효율적: 스크롤할 때마다가 아니라, 실제로 교차 상태가 변경될 때만 실행됩니다.
- 메인 스레드 보호: 내부 계산이 효율적으로 처리되어 UI가 부드럽습니다.
근본 원리: “교차(Intersection)”란?
IntersectionObserver의 핵심 개념은 교차(Intersection) 입니다. 두 영역이 겹치는 상황을 의미하죠.
┌──────────────────────────────┐
│ 뷰포트 (Viewport) │
│ │
│ ┌──────────────┐ │ ← 교차 중 (Intersecting)
│ │ 요소 │ │
│ │ │ │
└────┴──────────────┴──────────┘
└──────────────┘
┌──────────────────────────────┐
│ 뷰포트 │
│ │
│ │
└───────────────────────────────┘
┌──────────────┐ ← 교차 안 됨 (Not intersecting)
│ 요소 │
└──────────────┘
동작 원리: 브라우저가 어떻게 교차를 감지하는가?
IntersectionObserver는 다음과 같은 방식으로 작동합니다:
-
비동기 관찰: 브라우저의 렌더링 파이프라인과 독립적으로, 최적화된 타이밍에 교차 상태를 체크합니다. 메인 스레드를 차단하지 않습니다.
-
내부 최적화: 브라우저는 합성(composition) 단계에서 이미 각 요소의 위치와 가시성을 알고 있습니다. IntersectionObserver는 이 정보를 재활용하여 추가적인 레이아웃 계산(reflow) 없이 교차를 판단합니다.
-
이벤트 배치 처리: 여러 요소의 교차 상태가 동시에 변경되어도, 브라우저는 이를 한 번에 모아서 콜백을 실행합니다. 이는 리페인트/리플로우를 최소화합니다.
-
threshold 기반 트리거: 사용자가 지정한 threshold 값(예: 0.5 = 50%)을 넘는 순간에만 콜백이 실행됩니다. 이는 불필요한 콜백 호출을 줄입니다.
기존 방식과의 차이:
// ❌ 전통적 방식: 동기적, 레이아웃 강제 계산
window.addEventListener('scroll', () => {
const rect = element.getBoundingClientRect(); // Reflow 유발!
// 스크롤마다 메인 스레드에서 동기 실행
});
// ✅ IntersectionObserver: 비동기적, 내부 최적화
const observer = new IntersectionObserver((entries) => {
// 브라우저가 최적 타이밍에 비동기 실행
// 기존 렌더링 정보 재활용, reflow 없음
});
IntersectionObserver는 다음을 감지합니다:
- 대상 요소(target): 감지하고 싶은 DOM 요소
- 루트(root): 기준이 되는 영역 (기본값: 뷰포트)
- 교차 상태: 두 영역이 겹치는지, 얼마나 겹치는지
- 교차 비율(intersectionRatio): 대상 요소 중 몇 %가 루트 영역과 겹치는지 (0.0 ~ 1.0)
IntersectionObserver의 구조
1. Observer 생성하기
const observer = new IntersectionObserver(callback, options);
세 가지 구성 요소가 있습니다:
callback: 교차 상태가 변경될 때 실행될 함수options: 감지 조건을 설정하는 객체 (선택사항)
2. 콜백 함수 이해하기
콜백 함수는 IntersectionObserverEntry 객체들의 배열을 받습니다.
const callback = (entries, observer) => {
entries.forEach(entry => {
// entry는 IntersectionObserverEntry 객체
console.log('교차 중?', entry.isIntersecting);
console.log('얼마나 보이나?', entry.intersectionRatio);
console.log('어떤 요소?', entry.target);
});
};
Entry 객체의 주요 속성:
{
// 가장 중요한 두 속성
isIntersecting: true, // 현재 교차 중인가?
intersectionRatio: 0.75, // 얼마나 보이는가? (0.0 ~ 1.0)
// 요소 정보
target: <div>, // 관찰 중인 요소
// 영역 정보
boundingClientRect: DOMRect, // 대상 요소의 크기/위치
intersectionRect: DOMRect, // 보이는 부분의 크기/위치
rootBounds: DOMRect, // 루트 영역의 크기/위치
// 시간
time: 3893.92 // 변경이 발생한 시간 (밀리초)
}
isIntersecting vs intersectionRatio: 무엇을 사용해야 할까?
이 두 속성은 비슷해 보이지만 중요한 차이가 있습니다:
isIntersecting (boolean)
- 요소가 루트 영역과 교차하는지 여부만 판단
- threshold를 넘는 순간
true, 넘지 못하면false - 단순한 진입/이탈 감지에 적합
intersectionRatio (number, 0.0 ~ 1.0)
- 요소가 얼마나 교차하는지 정확한 비율
- 요소 전체 영역 대비 보이는 영역의 비율
- 정밀한 애니메이션이나 진행률 표시에 적합
// 예제: 차이점 이해하기
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
console.log('isIntersecting:', entry.isIntersecting);
console.log('intersectionRatio:', entry.intersectionRatio);
// threshold: 0.5일 때
// 49% 보임 → isIntersecting: false, intersectionRatio: 0.49
// 50% 보임 → isIntersecting: true, intersectionRatio: 0.5
// 51% 보임 → isIntersecting: true, intersectionRatio: 0.51
});
}, { threshold: 0.5 });
실전 활용:
// Case 1: 단순 진입 감지는 isIntersecting 사용
const simpleObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
}
});
});
// Case 2: 점진적 효과는 intersectionRatio 사용
const fadeObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
// 요소가 보이는 만큼 투명도 조절
entry.target.style.opacity = entry.intersectionRatio;
});
}, { threshold: Array.from({length: 11}, (_, i) => i * 0.1) });
DOMRect 객체 이해하기
boundingClientRect, intersectionRect, rootBounds는 모두 DOMRect 객체입니다:
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
// boundingClientRect: 대상 요소의 전체 크기와 위치
console.log('요소 위치:', {
top: entry.boundingClientRect.top, // 뷰포트 상단으로부터의 거리
left: entry.boundingClientRect.left, // 뷰포트 좌측으로부터의 거리
bottom: entry.boundingClientRect.bottom, // 뷰포트 상단으로부터 하단까지의 거리
right: entry.boundingClientRect.right, // 뷰포트 좌측으로부터 우측까지의 거리
width: entry.boundingClientRect.width, // 요소의 너비
height: entry.boundingClientRect.height // 요소의 높이
});
// intersectionRect: 실제로 보이는 부분 (교차 영역)
console.log('보이는 영역:', {
width: entry.intersectionRect.width, // 보이는 너비
height: entry.intersectionRect.height // 보이는 높이
});
// rootBounds: 루트 컨테이너의 크기
// 주의: root가 null(뷰포트)이고 iframe이 아니면 null일 수 있음
if (entry.rootBounds) {
console.log('루트 크기:', {
width: entry.rootBounds.width,
height: entry.rootBounds.height
});
}
});
});
rootBounds가 null인 경우:
root가null(뷰포트)로 설정되어 있고- 문서가 iframe 내부에 있지 않을 때
이 경우 뷰포트 크기는 window.innerWidth와 window.innerHeight로 확인할 수 있습니다.
3. Options 깊이 이해하기
const options = {
root: null, // 기준 영역
rootMargin: '0px', // 루트 영역의 마진
threshold: 0 // 콜백 실행 시점
};
root: 기준 영역 설정하기
// 예제 1: 기본값 (뷰포트)
const observer1 = new IntersectionObserver(callback, {
root: null // 뷰포트가 기준
});
// 예제 2: 특정 요소를 기준으로
const scrollContainer = document.querySelector('.scroll-container');
const observer2 = new IntersectionObserver(callback, {
root: scrollContainer // 이 요소 안에서만 감지
});
실전 예제: 스크롤 가능한 컨테이너 내부 감지
// 모달 내부의 무한 스크롤
const modal = document.querySelector('.modal-content');
const loadMoreTrigger = document.querySelector('.load-more');
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadMoreContent();
}
}, {
root: modal // 모달 내부에서만 감지
});
observer.observe(loadMoreTrigger);
rootMargin: 감지 영역 확장/축소
rootMargin은 루트의 경계를 확장하거나 축소합니다. CSS margin과 같은 문법을 사용합니다.
// 예제 1: 요소가 화면에 들어오기 100px 전에 미리 감지
const observer = new IntersectionObserver(callback, {
rootMargin: '100px' // 모든 방향으로 100px 확장
});
// 예제 2: 상하 200px 확장, 좌우는 변경 없음
const observer = new IntersectionObserver(callback, {
rootMargin: '200px 0px'
});
// 예제 3: 개별 설정 (top, right, bottom, left)
const observer = new IntersectionObserver(callback, {
rootMargin: '100px 0px -100px 0px'
});
시각화:
rootMargin: '100px'
┌────────────────────────────────┐
│ 확장된 감지 영역 (100px) │
┌───────┼────────────────────────────────┼───────┐
│ │ │ │
│ │ 실제 뷰포트 │ │
│ │ │ │
└───────┼────────────────────────────────┼───────┘
│ │
└────────────────────────────────┘
← 여기서 이미 감지가 시작됨 (화면 밖 100px)
실전 예제: 이미지 프리로딩
// 이미지가 화면에 보이기 전에 미리 로드
const imageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
imageObserver.unobserve(img);
}
});
}, {
rootMargin: '50px' // 화면에 들어오기 50px 전에 로드 시작
});
document.querySelectorAll('img[data-src]').forEach(img => {
imageObserver.observe(img);
});
threshold: 콜백 실행 시점 정하기
threshold는 요소가 얼마나 보일 때 콜백을 실행할지 결정합니다 (0.0 ~ 1.0).
// 예제 1: 1px만 보여도 실행
const observer1 = new IntersectionObserver(callback, {
threshold: 0 // 기본값
});
// 예제 2: 50% 보일 때 실행
const observer2 = new IntersectionObserver(callback, {
threshold: 0.5
});
// 예제 3: 완전히 다 보일 때만 실행
const observer3 = new IntersectionObserver(callback, {
threshold: 1.0
});
// 예제 4: 여러 시점에 실행 (25%, 50%, 75%, 100%)
const observer4 = new IntersectionObserver(callback, {
threshold: [0, 0.25, 0.5, 0.75, 1]
});
시각화:
threshold: 0 (기본값)
┌──────────────┐
│ 뷰포트 │
│ ┌─┐ │ ← 1px만 보여도 콜백 실행!
└─┴─┴──────────┘
└─┘ 요소
threshold: 0.5
┌──────────────┐
│ 뷰포트 │
│ ┌──────┐ │ ← 50% 보일 때 콜백 실행
│ │ │ │
└─┴──────┴─────┘
└──────┘ 요소
threshold: 1.0
┌──────────────┐
│ ┌──────┐ │ ← 100% 다 보일 때만 콜백 실행
│ │ 요소 │ │
│ └──────┘ │
└──────────────┘
실전 예제: 광고 가시성 측정
// 광고가 50% 이상 보일 때만 "노출"로 카운트
const adObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
// 광고 노출 이벤트 전송
trackAdImpression(entry.target.dataset.adId);
adObserver.unobserve(entry.target);
}
});
}, {
threshold: 0.5
});
document.querySelectorAll('.advertisement').forEach(ad => {
adObserver.observe(ad);
});
실전 활용 패턴
1. 무한 스크롤 구현
// 페이지 하단 근처에 도달하면 다음 페이지 로드
const sentinel = document.querySelector('.infinite-scroll-trigger');
const infiniteScrollObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadNextPage();
}
}, {
rootMargin: '100px' // 하단 100px 전에 미리 로드
});
infiniteScrollObserver.observe(sentinel);
async function loadNextPage() {
const newItems = await fetchNextPage();
renderItems(newItems);
}
2. Lazy Loading 이미지
// HTML
// <img class="lazy" data-src="large-image.jpg" src="placeholder.jpg" alt="...">
const lazyImageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
// 실제 이미지로 교체
img.src = img.dataset.src;
img.classList.remove('lazy');
// 로드 완료 후 관찰 중단
img.onload = () => {
lazyImageObserver.unobserve(img);
};
}
});
}, {
rootMargin: '50px' // 화면 진입 전 미리 로드
});
// 모든 lazy 이미지 관찰
document.querySelectorAll('img.lazy').forEach(img => {
lazyImageObserver.observe(img);
});
3. 스크롤 애니메이션
// 요소가 화면에 보이면 fade-in 애니메이션
const animateObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
// 한 번만 애니메이션하고 관찰 중단
animateObserver.unobserve(entry.target);
}
});
}, {
threshold: 0.1 // 10%만 보여도 시작
});
document.querySelectorAll('.animate-on-scroll').forEach(el => {
animateObserver.observe(el);
});
/* CSS */
.animate-on-scroll {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.6s, transform 0.6s;
}
.animate-on-scroll.visible {
opacity: 1;
transform: translateY(0);
}
4. 뷰포트 진입/이탈 감지
// 요소가 화면에 보이는지 추적
const visibilityObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
console.log('요소가 화면에 진입했습니다');
entry.target.classList.add('in-viewport');
} else {
console.log('요소가 화면을 벗어났습니다');
entry.target.classList.remove('in-viewport');
}
});
});
const trackedElement = document.querySelector('.track-visibility');
visibilityObserver.observe(trackedElement);
5. 비디오 자동 재생/정지
// 화면에 보일 때만 비디오 재생
const videoObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const video = entry.target;
if (entry.isIntersecting) {
video.play();
} else {
video.pause();
}
});
}, {
threshold: 0.5 // 50% 이상 보일 때
});
document.querySelectorAll('video.auto-play').forEach(video => {
videoObserver.observe(video);
});
6. 진입 방향 감지하기
// 위에서 내려오는지, 아래서 올라오는지 감지
const directionObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
// boundingClientRect.top이 음수면 위에서 진입
// boundingClientRect.top이 양수면 아래에서 진입
if (entry.isIntersecting) {
const direction = entry.boundingClientRect.top < 0
? 'from-top'
: 'from-bottom';
console.log(`요소가 ${direction}에서 진입했습니다`);
}
});
});
메서드와 라이프사이클
const observer = new IntersectionObserver(callback, options);
// 1. 요소 관찰 시작
observer.observe(element1);
observer.observe(element2);
// 2. 특정 요소 관찰 중단
observer.unobserve(element1);
// 3. 모든 요소 관찰 중단
observer.disconnect();
// 4. 대기 중인 알림 즉시 가져오기
const pendingEntries = observer.takeRecords();
takeRecords()는 언제 사용하나?
takeRecords()는 콜백이 실행되기 전, 대기 중인(queued) IntersectionObserverEntry 객체들을 즉시 가져옵니다.
사용 시나리오:
// 시나리오 1: Observer 정리 전 마지막 상태 확인
class ComponentManager {
constructor() {
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this)
);
}
destroy() {
// 아직 처리되지 않은 교차 이벤트 가져오기
const pendingEntries = this.observer.takeRecords();
// 정리 작업 전 마지막으로 처리
if (pendingEntries.length > 0) {
this.handleIntersection(pendingEntries);
}
// 이제 안전하게 정리
this.observer.disconnect();
}
handleIntersection(entries) {
entries.forEach(entry => {
// 상태 저장 등...
this.saveState(entry.target, entry.isIntersecting);
});
}
}
// 시나리오 2: 수동으로 즉시 체크
const manualCheckObserver = new IntersectionObserver((entries) => {
console.log('자동 콜백:', entries);
});
manualCheckObserver.observe(element);
// 사용자 액션 발생 시 즉시 확인 (콜백을 기다리지 않음)
button.addEventListener('click', () => {
const currentState = manualCheckObserver.takeRecords();
console.log('수동 체크:', currentState);
// 주의: takeRecords()는 대기 중인 것만 가져옴
// 변경사항이 없으면 빈 배열 반환
});
중요한 특징:
- takeRecords()는 대기 중인 항목만 반환합니다
- 호출 후 대기 큐가 비워지므로 콜백에서 같은 항목을 다시 받지 않습니다
- 교차 상태가 변경되지 않았다면 빈 배열을 반환합니다
라이프사이클 예제:
class LazyLoader {
constructor() {
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
{ rootMargin: '50px' }
);
}
observe(elements) {
elements.forEach(el => this.observer.observe(el));
}
handleIntersection(entries) {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.load(entry.target);
// 로드 후 관찰 중단
this.observer.unobserve(entry.target);
}
});
}
load(element) {
element.src = element.dataset.src;
}
destroy() {
// 모든 관찰 중단
this.observer.disconnect();
}
}
// 사용
const loader = new LazyLoader();
loader.observe(document.querySelectorAll('.lazy'));
// 정리
// loader.destroy();
주의사항과 함정
1. 콜백은 메인 스레드에서 실행됨
❌ 피해야 할 패턴:
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
// 무거운 계산이 메인 스레드를 차단함!
if (entry.isIntersecting) {
for (let i = 0; i < 1000000; i++) {
// 복잡한 계산...
}
}
});
});
✅ 권장 패턴:
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// 무거운 작업은 비동기로 처리
requestIdleCallback(() => {
performHeavyComputation(entry.target);
});
}
});
});
2. threshold는 정확한 픽셀값이 아님
MDN 공식 문서에 따르면:
“이 API는 ‘N% 정도 교차하면 뭔가 해야 할 때’라는 일반적인 사용 사례만 해결합니다.”
정확한 픽셀 단위 교차 정보가 필요하다면 intersectionRect를 사용해야 합니다.
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// 정확한 보이는 높이 (픽셀)
const visibleHeight = entry.intersectionRect.height;
console.log(`${visibleHeight}px만큼 보입니다`);
}
});
});
3. root는 target의 조상이어야 함
❌ 작동하지 않는 코드:
const container1 = document.querySelector('.container1');
const elementInContainer2 = document.querySelector('.container2 .element');
// container1은 element의 조상이 아님!
const observer = new IntersectionObserver(callback, {
root: container1
});
observer.observe(elementInContainer2); // 제대로 작동 안 함
4. 동적으로 추가된 요소는 명시적으로 observe 해야 함
const observer = new IntersectionObserver(callback);
// 초기 요소들 관찰
document.querySelectorAll('.item').forEach(el => observer.observe(el));
// 나중에 추가된 요소도 관찰해야 함
function addNewItem(itemData) {
const newItem = createItemElement(itemData);
container.appendChild(newItem);
// 반드시 명시적으로 observe!
observer.observe(newItem);
}
5. unobserve vs disconnect
// unobserve: 특정 요소만 관찰 중단
observer.unobserve(element1); // element1만 중단
// element2, element3는 계속 관찰 중
// disconnect: 모든 요소 관찰 중단
observer.disconnect(); // 모든 관찰 중단
// 이후 다시 observe()를 호출해야 함
프레임워크에서 사용하기
React에서 사용하기
React에서는 useEffect와 useRef를 사용하여 IntersectionObserver를 구현합니다.
기본 패턴: Custom Hook
import { useEffect, useRef, useState } from 'react';
// 재사용 가능한 커스텀 훅
function useIntersectionObserver(options = {}) {
const [isIntersecting, setIsIntersecting] = useState(false);
const [entry, setEntry] = useState(null);
const targetRef = useRef(null);
useEffect(() => {
const target = targetRef.current;
if (!target) return;
const observer = new IntersectionObserver(([entry]) => {
setIsIntersecting(entry.isIntersecting);
setEntry(entry);
}, options);
observer.observe(target);
// 클린업 함수
return () => {
observer.unobserve(target);
observer.disconnect();
};
}, [options.threshold, options.rootMargin]); // 의존성 배열 주의!
return { targetRef, isIntersecting, entry };
}
// 사용 예제
function LazyImage({ src, alt }) {
const { targetRef, isIntersecting } = useIntersectionObserver({
threshold: 0.1,
rootMargin: '50px'
});
return (
<img
ref={targetRef}
src={isIntersecting ? src : 'placeholder.jpg'}
alt={alt}
className={isIntersecting ? 'loaded' : 'loading'}
/>
);
}
한 번만 실행
function useIntersectionObserverOnce(options = {}) {
const [hasIntersected, setHasIntersected] = useState(false);
const targetRef = useRef(null);
const observerRef = useRef(null);
useEffect(() => {
const target = targetRef.current;
if (!target || hasIntersected) return;
observerRef.current = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
setHasIntersected(true);
// 한 번 실행 후 즉시 정리
observerRef.current?.disconnect();
}
}, options);
observerRef.current.observe(target);
return () => {
observerRef.current?.disconnect();
};
}, [hasIntersected, options.threshold, options.rootMargin]);
return { targetRef, hasIntersected };
}
// 사용: 스크롤 애니메이션
function AnimatedSection({ children }) {
const { targetRef, hasIntersected } = useIntersectionObserverOnce({
threshold: 0.2
});
return (
<div
ref={targetRef}
className={hasIntersected ? 'animate-in' : 'hidden'}
>
{children}
</div>
);
}
무한 스크롤 패턴
function useInfiniteScroll(callback, options = {}) {
const targetRef = useRef(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const target = targetRef.current;
if (!target) return;
const observer = new IntersectionObserver(
async ([entry]) => {
if (entry.isIntersecting && !isLoading) {
setIsLoading(true);
await callback();
setIsLoading(false);
}
},
{ threshold: 1.0, ...options }
);
observer.observe(target);
return () => observer.disconnect();
}, [callback, isLoading]);
return { targetRef, isLoading };
}
// 사용
function InfiniteList() {
const [items, setItems] = useState([]);
const loadMore = async () => {
const newItems = await fetchMoreItems();
setItems(prev => [...prev, ...newItems]);
};
const { targetRef, isLoading } = useInfiniteScroll(loadMore);
return (
<div>
{items.map(item => (
<div key={item.id}>{item.content}</div>
))}
<div ref={targetRef}>
{isLoading ? 'Loading...' : 'Scroll for more'}
</div>
</div>
);
}
Vue에서 사용하기
Vue 3의 Composition API를 활용한 패턴입니다.
기본 패턴: Composable
// useIntersectionObserver.js
import { ref, onMounted, onUnmounted, watch } from 'vue';
export function useIntersectionObserver(options = {}) {
const targetRef = ref(null);
const isIntersecting = ref(false);
const entry = ref(null);
let observer = null;
const observe = () => {
if (!targetRef.value) return;
observer = new IntersectionObserver(([newEntry]) => {
isIntersecting.value = newEntry.isIntersecting;
entry.value = newEntry;
}, options);
observer.observe(targetRef.value);
};
onMounted(() => {
observe();
});
onUnmounted(() => {
if (observer) {
observer.disconnect();
}
});
return { targetRef, isIntersecting, entry };
}
<!-- 사용 예제 -->
<template>
<img
ref="targetRef"
:src="isIntersecting ? props.src : 'placeholder.jpg'"
:class="{ loaded: isIntersecting }"
/>
</template>
<script setup>
import { useIntersectionObserver } from './useIntersectionObserver';
const props = defineProps(['src']);
const { targetRef, isIntersecting } = useIntersectionObserver({
threshold: 0.1,
rootMargin: '50px'
});
</script>
디렉티브 패턴
// v-intersect 디렉티브
export const intersect = {
mounted(el, binding) {
const options = binding.value?.options || {};
const callback = binding.value?.callback || binding.value;
const observer = new IntersectionObserver((entries) => {
const entry = entries[0];
if (entry.isIntersecting) {
callback(entry);
// once 옵션이 true면 한 번만 실행
if (binding.value?.once) {
observer.disconnect();
}
}
}, options);
observer.observe(el);
// 나중에 정리할 수 있도록 저장
el._intersectionObserver = observer;
},
unmounted(el) {
if (el._intersectionObserver) {
el._intersectionObserver.disconnect();
delete el._intersectionObserver;
}
}
};
<!-- 사용 -->
<template>
<div v-intersect="{ callback: onVisible, once: true }">
Content here
</div>
<!-- 간단한 사용 -->
<div v-intersect="handleIntersect">
Content here
</div>
</template>
<script setup>
function onVisible(entry) {
console.log('Element is visible!', entry);
}
function handleIntersect(entry) {
console.log('Intersecting:', entry.isIntersecting);
}
</script>
주의사항: 프레임워크 사용 시
1. 의존성 배열 관리 (React)
// ❌ 무한 루프 발생 가능
useEffect(() => {
const observer = new IntersectionObserver(callback, options);
// ...
}, [options]); // options 객체가 매번 새로 생성됨
// ✅ 개별 속성만 의존성에 추가
useEffect(() => {
const observer = new IntersectionObserver(callback, {
threshold: options.threshold,
rootMargin: options.rootMargin
});
// ...
}, [options.threshold, options.rootMargin]);
2. 반드시 cleanup 함수 작성
// React
useEffect(() => {
const observer = new IntersectionObserver(callback);
observer.observe(element);
// ✅ 필수! 메모리 누수 방지
return () => {
observer.disconnect();
};
}, []);
// Vue
onUnmounted(() => {
// ✅ 필수! 컴포넌트 언마운트 시 정리
observer?.disconnect();
});
브라우저 지원 및 폴리필
IntersectionObserver는 2019년 3월부터 주요 브라우저에서 지원됩니다.
브라우저 지원 확인
if ('IntersectionObserver' in window) {
// IntersectionObserver 사용
const observer = new IntersectionObserver(callback);
observer.observe(element);
} else {
// 폴백: 전통적인 방식 사용
window.addEventListener('scroll', checkVisibility);
}
폴리필 사용
// polyfill.io 사용
// <script src="https://polyfill.io/v3/polyfill.min.js?features=IntersectionObserver"></script>
// 또는 npm 패키지
// npm install intersection-observer
import 'intersection-observer';
성능 최적화 팁
1. 한 번만 실행하면 되는 경우 unobserve 하기
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
doSomething(entry.target);
// 중요: 더 이상 관찰 불필요
observer.unobserve(entry.target);
}
});
});
2. 하나의 Observer로 여러 요소 관찰
❌ 비효율적:
// 요소마다 Observer 생성 (메모리 낭비)
elements.forEach(el => {
const observer = new IntersectionObserver(callback);
observer.observe(el);
});
✅ 효율적:
// 하나의 Observer로 모든 요소 관찰
const observer = new IntersectionObserver(callback);
elements.forEach(el => observer.observe(el));
3. 적절한 threshold 선택
// 너무 많은 threshold는 불필요한 콜백 실행 유발
// ❌ 과도함
const observer = new IntersectionObserver(callback, {
threshold: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
});
// ✅ 필요한 만큼만
const observer = new IntersectionObserver(callback, {
threshold: [0, 0.5, 1.0] // 진입, 절반, 완전 진입만 감지
});
실전 프로젝트 예제
완전한 이미지 Lazy Loading 시스템
class ImageLazyLoader {
constructor(options = {}) {
this.options = {
rootMargin: options.rootMargin || '50px',
threshold: options.threshold || 0.01,
placeholderClass: options.placeholderClass || 'lazy',
loadedClass: options.loadedClass || 'loaded'
};
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
{
rootMargin: this.options.rootMargin,
threshold: this.options.threshold
}
);
}
handleIntersection(entries) {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.loadImage(entry.target);
}
});
}
loadImage(img) {
const src = img.dataset.src;
if (!src) return;
// 실제 이미지 로드
const tempImg = new Image();
tempImg.onload = () => {
img.src = src;
img.classList.remove(this.options.placeholderClass);
img.classList.add(this.options.loadedClass);
this.observer.unobserve(img);
};
tempImg.onerror = () => {
console.error(`Failed to load image: ${src}`);
this.observer.unobserve(img);
};
tempImg.src = src;
}
observe(selector) {
const images = typeof selector === 'string'
? document.querySelectorAll(selector)
: selector;
images.forEach(img => this.observer.observe(img));
}
disconnect() {
this.observer.disconnect();
}
}
// 사용
const lazyLoader = new ImageLazyLoader({
rootMargin: '100px',
threshold: 0.01
});
lazyLoader.observe('img[data-src]');
디버깅과 트러블슈팅
IntersectionObserver를 사용하다가 예상대로 작동하지 않을 때 확인할 사항들입니다.
1. 콜백이 실행되지 않는 경우
원인 1: 요소가 이미 뷰포트에 있음
// 문제 상황: 페이지 로드 시 이미 보이는 요소는 콜백이 실행 안 됨
const observer = new IntersectionObserver((entries) => {
console.log('콜백 실행됨'); // 안 보일 수 있음!
});
observer.observe(alreadyVisibleElement);
해결책: 초기 상태도 감지하기
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
console.log('Initial check:', entry.isIntersecting);
});
}, {
threshold: 0 // threshold 0으로 설정하면 초기에도 트리거됨
});
// 또는 observe 직후 수동 체크
observer.observe(element);
const initialState = observer.takeRecords();
if (initialState.length === 0) {
// 요소가 이미 viewport에 있으면 수동으로 체크
const rect = element.getBoundingClientRect();
const isVisible = rect.top < window.innerHeight && rect.bottom > 0;
if (isVisible) {
// 초기 처리
}
}
원인 2: root 요소가 target의 조상이 아님
// ❌ 작동 안 함
const container1 = document.querySelector('.container1');
const elementInContainer2 = document.querySelector('.container2 .element');
const observer = new IntersectionObserver(callback, {
root: container1 // container1은 element의 부모가 아님!
});
observer.observe(elementInContainer2);
해결책: 올바른 조상 관계 확인
// ✅ root는 target의 조상이어야 함
const container = document.querySelector('.scroll-container');
const elementInContainer = container.querySelector('.item');
const observer = new IntersectionObserver(callback, {
root: container // 올바른 조상
});
observer.observe(elementInContainer);
2. 시각적 디버깅 도구
콜백에서 무슨 일이 일어나는지 시각적으로 확인:
const debugObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const target = entry.target;
// 디버그 정보를 화면에 표시
const debugInfo = document.createElement('div');
debugInfo.style.cssText = `
position: fixed;
top: 10px;
right: 10px;
background: rgba(0,0,0,0.8);
color: white;
padding: 10px;
font-family: monospace;
font-size: 12px;
z-index: 10000;
max-width: 300px;
`;
debugInfo.innerHTML = `
<strong>IntersectionObserver Debug</strong><br>
isIntersecting: ${entry.isIntersecting}<br>
intersectionRatio: ${entry.intersectionRatio.toFixed(2)}<br>
boundingClientRect.top: ${entry.boundingClientRect.top.toFixed(0)}px<br>
boundingClientRect.height: ${entry.boundingClientRect.height.toFixed(0)}px<br>
rootBounds: ${entry.rootBounds ? 'exists' : 'null'}<br>
time: ${entry.time.toFixed(0)}ms
`;
document.body.appendChild(debugInfo);
setTimeout(() => debugInfo.remove(), 2000);
// 요소에 시각적 표시
if (entry.isIntersecting) {
target.style.outline = '3px solid green';
} else {
target.style.outline = '3px solid red';
}
});
}, {
threshold: [0, 0.25, 0.5, 0.75, 1]
});
3. Console 로깅 패턴
const logObserver = new IntersectionObserver((entries, observer) => {
console.group('IntersectionObserver Callback');
console.log('Entries count:', entries.length);
entries.forEach((entry, index) => {
console.group(`Entry ${index + 1}`);
console.table({
'Target': entry.target.className || entry.target.tagName,
'isIntersecting': entry.isIntersecting,
'intersectionRatio': entry.intersectionRatio,
'Rect Top': entry.boundingClientRect.top,
'Rect Height': entry.boundingClientRect.height,
'Time': entry.time
});
console.groupEnd();
});
console.groupEnd();
}, {
threshold: [0, 0.5, 1],
rootMargin: '0px'
});
4. 흔한 실수들
실수 1: Options 객체를 매번 새로 생성
// ❌ React에서 흔한 실수
function Component() {
useEffect(() => {
const observer = new IntersectionObserver(callback, {
threshold: 0.5 // 매 렌더마다 새 객체!
});
// 무한 루프 가능성
}, [{ threshold: 0.5 }]); // 객체 참조가 매번 다름
}
// ✅ 올바른 방법
function Component() {
const threshold = 0.5;
const rootMargin = '0px';
useEffect(() => {
const observer = new IntersectionObserver(callback, {
threshold,
rootMargin
});
// ...
}, [threshold, rootMargin]); // 원시 값 사용
}
실수 2: Cleanup 함수 미작성
// ❌ 메모리 누수
useEffect(() => {
const observer = new IntersectionObserver(callback);
observer.observe(element);
// cleanup 없음!
}, []);
// ✅ 올바른 방법
useEffect(() => {
const observer = new IntersectionObserver(callback);
observer.observe(element);
return () => {
observer.disconnect(); // 필수!
};
}, []);
실수 3: threshold와 isIntersecting 오해
// threshold: 0.5 설정 시
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
// ❌ 오해: "50% 보일 때만 true"
// ✅ 실제: "50% 이상 보이면 true, 미만이면 false"
console.log(entry.isIntersecting);
// 정확한 비율이 필요하면 intersectionRatio 사용
if (entry.intersectionRatio >= 0.5) {
console.log('50% 이상 보임');
}
});
}, { threshold: 0.5 });
5. 성능 프로파일링
Chrome DevTools에서 IntersectionObserver 성능 확인:
// Performance API로 측정
const observer = new IntersectionObserver((entries) => {
const start = performance.now();
entries.forEach(entry => {
// 작업 수행
if (entry.isIntersecting) {
loadImage(entry.target);
}
});
const end = performance.now();
console.log(`콜백 실행 시간: ${(end - start).toFixed(2)}ms`);
// 60fps를 위해서는 16ms 이하여야 함
if (end - start > 16) {
console.warn('⚠️ 콜백이 너무 느립니다! 최적화 필요');
}
});
6. 브라우저 지원 체크
// 기능 감지와 폴백
function observeElement(element, callback) {
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver(callback);
observer.observe(element);
return observer;
} else {
console.warn('IntersectionObserver not supported, using fallback');
// 폴백: scroll 이벤트 사용
const checkVisibility = () => {
const rect = element.getBoundingClientRect();
const isVisible = rect.top < window.innerHeight && rect.bottom > 0;
callback([{
isIntersecting: isVisible,
target: element,
intersectionRatio: isVisible ? 1 : 0
}]);
};
window.addEventListener('scroll', checkVisibility);
checkVisibility(); // 초기 체크
return {
disconnect: () => window.removeEventListener('scroll', checkVisibility)
};
}
}
댓글