
요즘 대부분의 웹 서비스 ex) 인스타그램, 유튜브, velog 는 더보기 버튼 대신에 스크롤만 내려도 자동으로 콘텐츠가 추가되는 무한 스크롤 (Infinite Scroll) 방식을 사용한다.

무한 스크롤은 페이지 혹은 특정 영역의 최하단에 도달했을 때, 새로운 콘텐츠를 로드하는 방식으로 동작한다.
콘텐츠 탐색이 간단하고, 사용자가 별도의 추가 동작(ex. 버튼 클릭 등)을 하지 않아도 된다는 점이 장점이다.
이번에는 Intersection Observer API를 활용해 화면의 최하단에 도달하면 자동으로 추가 이미지를 로드해오는 무한 스크롤 기능을 구현해 볼 것이다.
이렇게!
Intersection Observer는 특정 요소가 화면에 들어왔는지 나갔는지를 자동으로 감지하는 웹 API이다.
브라우저 렌더링 엔진 수준에서 동작하기 때문에, scroll 이벤트를 계산하지 않아도 된다.
이 API를 사용하면 스크롤 위치를 일일이 체크하거나, window.scrollY 같은 값을 반복적으로 읽지 않아도 된다.
즉, 훨씬 간결하고 성능이 좋은 구조로 구현이 가능하다.
Intersection Observer의 주요 메서드
observe(target) : 관찰 대상 요소를 관찰 대상 목록에 추가unobserve(target) : 관찰 대상 목록에서 요소를 제거하여 관찰을 중단disconnect() : Intersection Observer를 해제하고 모든 관찰 대상을 중지
new IntersectionObserver()를 사용하여 observer 인스턴스를 생성한다.
생성된 observer는 특정 DOM 요소를 감시하면서, 해당 요소가 지정된 뷰포트 영역에 진입하거나 벗어날 때 callback 함수를 실행한다.
new IntersectionObserver() 생성자는 두 개의 매개변수 (callback 함수, options)를 받는다.
// observer 인스턴스 생성
const observer = new IntersectionObserver(callback, options);
options는 관찰자가 언제 교차했다고 판단할지에 대한 기준을 제공한다.
| 옵션 | 설명 | 무한 스크롤에서의 역할 |
|---|---|---|
| root | 뷰포트(Viewport) 역할을 하는 요소. 기본값은 브라우저 뷰포트(null) | 스크롤 영역을 전체 화면으로 설정 |
| rootMargin | root의 범위를 확장하거나 축소하는 바깥 여백(margin). 기본값은 0 | 0px 0px 100px 0px 설정 시, 뷰포트 최하단에서 100px 남았을 때 교차로 간주하여 로드를 미리 시작할 수 있다 |
| threshold | 타깃 요소가 root 내에서 얼마나 보여야 콜백이 실행될지 결정 (0 ~ 1.0) | 1.0(요소 전체가 보여야 함) 또는 0.1(타깃의 10%만 보여도 됨) 등으로 설정 |
// options 설정
let options = {
root: null, // 기본값 (뷰포트 기준)
rootMargin: '400px 0px', // 하단 400px 전에 미리 감지
threshold: 0, // 요소가 조금만 보여도 콜백 실행
};
callback 함수는 entries(교차 정보 배열)와 observer 객체를 인자로 받으며, 교차 상태가 변할 때마다 호출된다.
즉, 타깃 요소의 관찰이 시작되거나 threshold와 만나면 callback이 실행된다.
entries
현재 교차 상태가 변경된 모든 관찰 대상 요소에 대한 정보를 담고 있는 배열이다.
무한 스크롤에서는 보통 하나의 요소(loading-spinner)만 관찰하기 때문에,entries배열에는 하나의IntersectionObserverEntry객체가 들어있다.
IntersectionObserverEntry 객체에서 무한 스크롤 구현에 중요한 속성은 다음과 같다.
| 속성 | 설명 | 무한 스크롤에서의 역할 |
|---|---|---|
| isIntersecting | 타깃 요소가 root와 교차 중인지 여부를 Boolean 값으로 반환 | true일 때 "스크롤 끝에 도달했다!"고 판단하고 데이터를 로드하는 조건으로 사용 |
| target | 현재 관찰 중인 DOM 요소 (observe()로 등록된 대상) | 관찰을 중단(unobserve)하거나 다시 등록할 때 해당 요소를 직접 참조 |
| intersectionRatio | 타깃 요소가 root와 교차한 비율 (0.0 ~ 1.0) | options의 threshold 값에 따라 교차가 일어났는지 판단하는 데 참고 가능 |
const callback = async (entries, observer) => {
const entry = entries[0];
// 아직 화면에 보이지 않거나 이미 로딩 중이면 종료
if (!entry.isIntersecting || isLoading || !hasMore) return;
// 중복 감지 방지를 위해 잠시 관찰 중단
observer.unobserve(entry.target);
isLoading = true;
// 로딩 대기 (실제 네트워크 요청 대체)
await new Promise((resolve) => setTimeout(resolve, 200));
// 다음 페이지 로드
page++;
loadImages(page);
isLoading = false;
// 데이터가 더 남아있으면 다시 관찰 시작
if (page < totalPages) observer.observe(entry.target);
else {
hasMore = false;
console.log('모든 데이터를 불러왔습니다.');
}
};
생성된 observer 객체에 관찰 대상 요소를 등록하면,
그 요소가 뷰포트 또는 지정된 root 영역에 들어오거나 나갈 때마다 callback이 자동으로 호출된다.
// 관찰 시작
observer.observe(sentinel);
리스트 하단에 sentinel 요소를 추가해 트리거 역할을 하도록 설계한다.
한 번에 여러 요소를 감시할 수도 있으며, 그럴 때는 entries 배열에 여러 개의 IntersectionObserverEntry가 담긴다.
sentinel 등록 : 화면 최하단에 sentinel을 두고 observer.observe()로 관찰을 시작한다.sentinel이 뷰포트에 들어오면 isIntersecting === true가 되어 callback이 호출된다.observe()를 호출해 다음 감시를 이어간다.observer.disconnect()로 관찰을 중단한다.const grid = document.getElementById('imageGrid');
const sentinel = document.getElementById('sentinel');
// 현재 페이지, 로딩 상태, 남은 데이터 여부
let page = 1;
let isLoading = false;
let hasMore = true;
const totalPages = 15;
const defaultImage = './default image.jpeg';
// 초기 렌더링
loadImages(page);
function loadImages(pageNum) {
for (let i = 0; i < 12; i++) {
const img = document.createElement('img');
img.src = defaultImage;
img.alt = `placeholder-${pageNum}-${i}`;
grid.appendChild(img);
}
}