웹 서비스에서 데이터를 연속으로 불러오는 무한 스크롤(Infinite Scroll)이나 이미지 지연 로딩(Lazy Loading)을 구현할 때, 스크롤 이벤트를 등록했다가 브라우저가 버벅거리는 현상을 경험해봤을 것이다.
다만 스크롤 이벤트가 발생할 때마다 실행되는 콜백 함수가 브라우저 메인 스레드를 얼마나 점유하는지, 그리고 왜 이 문제가 발생하는지 정확한 원인과 해결책을 모르는 경우가 많다.
이번에는 IntersectionObserver API가 정확히 뭔지, 왜 필요한지, 실무에서 어떻게 활용하는지 완벽하게 정리해서 소개하겠다.
대부분의 개발자들은 스크롤 하단 도달을 감지할 때 window 객체에 scroll 이벤트를 걸고 getBoundingClientRect() 메서드로 위치를 계산한다. 그러나 이 방식은 브라우저 렌더링 성능에 치명적인 영향을 미친다.
스크롤을 1px 내릴 때마다 이벤트 콜백 함수가 실행되고, 그때마다 엘리먼트의 정확한 위치를 계산하기 위해 브라우저가 동기적으로 레이아웃(Reflow)을 재계산하기 때문이다.
throttle이나 debounce 함수를 써서 이벤트 실행 횟수를 강제로 줄일 수는 있지만, 여전히 메인 스레드에서 연산이 이루어진다.
반면 IntersectionObserver API는 관찰 대상 요소가 지정한 상위 요소(또는 뷰포트)와 교차하는지 여부를 브라우저 내부의 백그라운드 스레드에서 비동기적으로 감지한다. 메인 스레드 점유 없이 교차 상태가 변경될 때만 연산하므로 극적인 성능 개선이 가능하다.
| 비교 항목 | scroll 이벤트 + getBoundingClientRect | IntersectionObserver API |
|---|---|---|
| 연산 주체 | 메인 스레드 (JavaScript 실행) | 브라우저 내부 렌더링 엔진 (비동기) |
| 성능 영향 | 스크롤 시 반복적인 Reflow(레이아웃 재계산) 발생 | Reflow 발생 없음 (이벤트 기반 실행) |
| 추가 최적화 | throttle / debounce 라이브러리 필수 | 기능 자체에 최적화 내장 |
| 사용 목적 | 실시간 스크롤 위치 감지 (상단 이동 버튼 등) | 뷰포트 진입 감지 (무한 스크롤, 이미지 Lazy Load) |
IntersectionObserver 인스턴스를 생성할 때는 콜백 함수와 옵션 객체를 전달한다. 옵션의 의미를 정확히 알고 설정해야 의도한 시점에 데이터를 정확히 불러올 수 있다.
1. root: 관찰 대상(target)을 감싸는 부모 요소를 지정한다. 기본값은 null이며, 이 경우 브라우저 최상단 뷰포트(Viewport)가 기준이 된다.
2. rootMargin: root 영역의 마진을 설정한다. CSS 마진 표기법과 동일하게 "100px 0px 200px 0px" 형태로 작성한다. 유저가 스크롤을 하단 타깃에 도착하기 전에 미리 200px 밑에서 데이터를 가져오고 싶다면 "0px 0px 200px 0px"로 설정하면 된다.
3. threshold: 관찰 대상이 root 영역에 얼마나 노출되었을 때 콜백을 실행할지 0.0 ~ 1.0 사이의 비율로 설정한다. 0.0은 1px이라도 보일 때 실행되고, 1.0은 요소 전체가 완전히 보일 때 실행된다.
스크롤이 발생할 때마다 동기적으로 위치를 계산하여 비동기 요청을 보낸다. 스크롤 동작이 조금만 빨라져도 중복 API 요청이 터지고 화면 프레임이 떨어진다.
// ✗ 잘못된 예시: 스크롤 이벤트 등록으로 인한 프레임 드랍 발생
window.addEventListener('scroll', () => {
const target = document.querySelector('#scroll-sentinel');
if (!target) return;
const rect = target.getBoundingClientRect();
// 스크롤 시마다 레이아웃 재계산(Reflow) 강제 발생
if (rect.top <= window.innerHeight) {
fetchNextPageData(); // 스크롤 시 순간적으로 수십 번 중복 호출될 위험 있음
}
});
타깃 요소가 뷰포트 하단 200px 지점에 접근하면 비동기로 다음 데이터를 가져온다. 데이터를 받아오는 중에는 관찰을 중단하고 처리 완료 후 다시 관찰을 재개한다.
// ✓ 올바른 예시: IntersectionObserver API 활용
let page = 1;
let isLoading = false;
const fetchNextPage = async () => {
isLoading = true;
console.log(`페이지 ${page} 데이터 요청 중...`);
// 가상의 API 통신 (1초 소요)
await new Promise(resolve => setTimeout(resolve, 1000));
page++;
isLoading = false;
console.log('데이터 로딩 완료');
};
const handleIntersection = (entries, observer) => {
entries.forEach(async (entry) => {
// 타깃이 화면(rootMargin 포함)에 진입했고 로딩 중이 아닌 경우
if (entry.isIntersecting && !isLoading) {
// 기존 관찰 대상에서 감지 중단 (중복 방지)
observer.unobserve(entry.target);
await fetchNextPage();
// 새로운 데이터 로딩 후 하단 감지 타깃 재등록
const newSentinel = document.querySelector('#scroll-sentinel');
if (newSentinel) {
observer.observe(newSentinel);
}
}
});
};
const observerOptions = {
root: null, // 브라우저 뷰포트 기준
rootMargin: '0px 0px 200px 0px', // 하단 200px 전 미리 로딩 시작
threshold: 0
};
const observer = new IntersectionObserver(handleIntersection, observerOptions);
// 문서 하단의 센티널(감지 타깃) 엘리먼트 관찰 시작
const sentinel = document.querySelector('#scroll-sentinel');
if (sentinel) {
observer.observe(sentinel);
}
페이지를 아래로 내리면 스크롤 이벤트가 수백 번 발생하는 동안에도 메인 스레드 연산은 전혀 멈추지 않는다. 타깃 요소가 하단 200px 경계선에 걸치는 순간 정확히 1회의 비동기 로딩 로그가 출력된다.
IntersectionObserver를 사용할 때 가장 흔하게 범하는 실수는 관찰(observe)의 해제 시점을 놓쳐 메모리 누수를 발생시키는 것이다.
✗ 비동기 요청을 보낸 뒤 observer.unobserve(target)를 호출하지 않으면, 유저가 스크롤을 위아래로 미세하게 움직일 때마다 entry.isIntersecting 조건이 다시 트러거되어 동일한 API를 수십 번 중복 호출하게 된다.
✓ 교차(isIntersecting)가 확인된 즉시 unobserve()로 관찰을 끄거나 flags(isLoading) 변수를 활용해 비동기 작업 중 추가 진입을 차단해야 한다.
또한 싱글 페이지 애플리케이션(React, Vue 등)에서는 컴포넌트가 언마운트될 때 반드시 observer.disconnect()를 실행하여 모든 관찰 대상을 완전히 제거해야 메모리 누수가 생기지 않는다.
IntersectionObserver API는 웹 애플리케이션의 스크롤 성능을 극대화하고 유저 경험을 높이는 필수 도구다. 비효율적인 스크롤 이벤트 리스너를 교체하는 작은 습관이 모여서 서비스 전체의 프레임 드랍을 막고 쾌적한 브라우징 환경을 만든다는 점을 잊지 말자. 이 글의 실전 코드를 참고해 무한 스크롤이나 이미지 로딩 로직을 개선해보면, 브라우저 메모리와 CPU 사용량을 대폭 절감하는 최종 결과를 얻을 수 있을 것이다.