IntersectionObserver
무한 스크롤이나 이미지 lazy loading을 구현하려면 특정 요소가 화면에 보이는지 감지해야 한다. 가장 직관적인 방법은 scroll 이벤트를 사용하는 것이다.
window.addEventListener("scroll", () => {
// 스크롤할 때마다 호출됨
});
그런데 이 방식에는 심각한 성능 문제가 있다. 스크롤할 때마다 콜백이 실행되면서 성능이 저하되고, 특히 콜백 안에서 getBoundingClientRect()를 호출하면 매번 reflow가 발생해서 더 느려진다. 쓰로틀링이나 디바운싱으로 호출 빈도를 줄일 수는 있지만, "필요 없는 시점"에 호출되는 문제는 해결되지 않는다.
IntersectionObserver는 이 문제를 근본적으로 해결한다. 요소가 실제로 보이기 시작할 때만 콜백이 실행되기 때문에 불필요한 호출이 없다. 브라우저가 내부적으로 최적화된 방식으로 감지하기 때문에 메인 스레드를 블로킹하지도 않는다.
기본 사용법
IntersectionObserver는 생성, 관찰 등록, 관찰 중지 세 단계로 사용한다.
// 1. Observer 생성
const observer = new IntersectionObserver(callback, options);
// 2. 관찰할 요소 등록
observer.observe(targetElement);
// 3. 관찰 중지
observer.unobserve(targetElement);
observer.disconnect(); // 모든 요소 관찰 중지
observe()로 여러 요소를 등록할 수 있다. 특정 요소만 관찰을 중지하려면 unobserve()를, 모든 요소의 관찰을 한 번에 중지하려면 disconnect()를 사용한다.
콜백 함수
관찰 중인 요소가 뷰포트에 들어오거나 나갈 때 콜백이 실행된다. 콜백은 entries 배열을 인자로 받는데, 각 entry에서 해당 요소의 가시성 정보를 확인할 수 있다.
const callback = (entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
console.log("요소가 화면에 들어왔다");
}
});
};
가장 많이 사용하는 속성은 isIntersecting이다. 요소가 현재 보이는 상태인지 boolean으로 알려준다. intersectionRatio는 요소가 얼마나 보이는지를 0~1 사이 값으로 나타내고, target은 관찰 중인 DOM 요소 자체를 참조한다.
| 속성 | 설명 |
|---|---|
isIntersecting | 요소가 보이는지 여부 |
intersectionRatio | 보이는 비율 (0~1) |
target | 관찰 중인 DOM 요소 |
boundingClientRect | 요소의 위치/크기 |
rootBounds | root 요소의 위치/크기 |
time | 교차 발생 시간 |
옵션
Observer를 생성할 때 세 가지 옵션을 설정할 수 있다.
const options = {
root: null,
rootMargin: "0px",
threshold: 0
};
root
교차를 감지할 기준 요소다. null이면 브라우저 뷰포트가 기준이 된다. 모달이나 스크롤 컨테이너 안에서 감지해야 한다면 해당 요소를 지정하면 된다.
root: document.querySelector(".scroll-container")
rootMargin
root 요소의 마진을 조정한다. 이 옵션이 유용한 이유는 요소가 실제로 보이기 전에 미리 감지할 수 있기 때문이다. 이미지 lazy loading에서 이미지가 뷰포트에 들어오기 100px 전에 미리 로드를 시작하면 사용자가 스크롤할 때 이미지가 이미 로드되어 있어서 더 부드러운 경험을 제공할 수 있다.
rootMargin: "100px" // 화면에 들어오기 100px 전에 감지
rootMargin: "0px 0px -50% 0px" // 하단 50% 지점에서 감지
threshold
콜백이 실행될 가시성 비율이다. 기본값은 0으로, 요소가 1px이라도 보이면 콜백이 실행된다. 0.5로 설정하면 요소의 50%가 보일 때, 1로 설정하면 요소 전체가 보일 때 콜백이 실행된다.
threshold: 0 // 1px이라도 보이면 (기본값)
threshold: 0.5 // 50% 보이면
threshold: 1 // 100% 보이면
threshold: [0, 0.5, 1] // 0%, 50%, 100%에서 각각 콜백
배열로 여러 값을 지정하면 각 비율에 도달할 때마다 콜백이 실행된다. 스크롤 진행률을 표시하는 기능에서 유용하게 쓸 수 있다.
활용 예시
무한 스크롤
리스트 맨 아래에 빈 요소를 하나 두고, 이 요소가 화면에 보이면 다음 페이지를 로드하는 방식이다.
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMoreContent();
}
},
{ threshold: 0.1, rootMargin: "100px" }
);
observer.observe(document.querySelector("#load-trigger"));
rootMargin: "100px"을 설정해서 트리거 요소가 화면에 보이기 100px 전에 미리 로드를 시작한다. 이렇게 하면 사용자가 스크롤하는 동안 콘텐츠가 이미 로드되어 있어서 끊김 없는 경험을 제공할 수 있다.
이미지 Lazy Loading
처음에는 placeholder만 보여주다가 이미지가 뷰포트에 들어오면 실제 이미지를 로드한다. 초기 페이지 로드 시간을 줄이고 불필요한 네트워크 요청을 방지할 수 있다.
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
observer.unobserve(img);
}
});
});
document.querySelectorAll("img[data-src]").forEach((img) => {
observer.observe(img);
});
이미지 로드가 완료되면 unobserve()로 해당 이미지의 관찰을 중지한다. 이미 로드된 이미지를 계속 관찰할 필요가 없기 때문이다.
스크롤 애니메이션
요소가 화면에 일정 비율 이상 보이면 애니메이션을 적용하는 패턴이다.
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("fade-in");
}
});
},
{ threshold: 0.3 }
);
document.querySelectorAll(".animate-on-scroll").forEach((el) => {
observer.observe(el);
});
threshold: 0.3으로 설정해서 요소의 30%가 보일 때 애니메이션이 시작된다. 이 값을 조절해서 애니메이션 시작 시점을 제어할 수 있다.
React에서 사용
React에서는 커스텀 훅으로 만들어서 사용하면 편하다.
function useInView(options?: IntersectionObserverInit) {
const ref = useRef<HTMLDivElement>(null);
const [isInView, setIsInView] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => setIsInView(entry.isIntersecting),
options
);
if (ref.current) {
observer.observe(ref.current);
}
return () => observer.disconnect();
}, [options]);
return { ref, isInView };
}
이 훅이 반환하는 ref를 요소에 연결하면 isInView로 해당 요소가 현재 화면에 보이는지 확인할 수 있다. cleanup 함수에서 disconnect()를 호출해서 컴포넌트가 언마운트될 때 메모리 누수를 방지한다.
브라우저 지원
모든 최신 브라우저에서 지원한다. IE에서 사용해야 한다면 폴리필이 필요하다.
- Chrome 51+
- Firefox 55+
- Safari 12.1+
- Edge 15+
관련 문서
- Scroll Properties - scrollHeight, scrollTop, clientHeight