Intersection Observer API로 무한 스크롤 구현하기
무한 스크롤이란?
무한 스크롤(Infinite Scroll)은 사용자가 페이지의 끝에 가까워졌을 때 새로운 콘텐츠를 이어서 불러오는 방식입니다. 페이지네이션(Pagination)과 마찬가지로 많은 콘텐츠를 탐색하게 만드는 방법이며, 어느 한쪽이 더 낫다기보다는 애플리케이션의 성격과 사용 목적에 맞게 선택하면 됩니다.
이번에는 무한 스크롤을 만들어 보는 시간이니, 무한 스크롤에 대한 장단점만 살펴보겠습니다.
무한 스크롤의 장점
- 콘텐츠 탐색이 간단하며, 별도의 추가 동작(페이지 버튼 클릭 등)이 필요 없습니다.
- 모바일 환경(터치 스크린) 또는 세로로 긴 화면을 보유한 디바이스에서 강점을 갖습니다.
무한 스크롤의 단점
- 콘텐츠가 계속 DOM에 추가되므로 메모리 사용량과 렌더링 비용이 커질 수 있습니다.
- 많은 콘텐츠를 탐색한 뒤에는 눈여겨봤던 위치로 다시 돌아가기 어렵습니다.
- 사이트 하단(Footer)을 찾기 어려워집니다.
페이지네이션의 장단점은 추후 페이지네이션을 구현해 보는 내용을 다룰 때 살펴보도록 하겠습니다!

오늘은 개인적으로 좋아하는 걸그룹 에스파의 윈터로 예제를 준비했습니다.
무한 스크롤 구현하기
Intersection Observer API
여기서는 Web API 중 하나인 Intersection Observer를 사용해 무한 스크롤을 구현해 볼 생각입니다. Intersection Observer는 기본적으로 관찰 중인 요소(Element)가 뷰포트(Viewport)와 교차하고 있는지를 감지하는 API인데, 좀 더 쉽게 설명하자면 관찰 중인 요소가 사용자가 보는 화면 영역 내에 들어왔는지를 알려주는 API입니다.

사용 방법은 간단합니다. 먼저 new IntersectionObserver(callback, options)로 관찰자 객체를 생성한 후, 관찰 대상을 지정하여 관찰을 시작하면 됩니다. 관찰자 객체를 생성할 때 옵션은 필수가 아니지만, 다음 세 가지 옵션을 설정 가능합니다.
- root: 관찰 기준이 되는 영역입니다. 기본값은
null이며 브라우저의 뷰포트를 의미합니다. 특정 스크롤 컨테이너를 기준으로 삼으려면 해당 요소를 지정합니다. - rootMargin: 교차 여부를 계산할 때
root의 범위를 확장하거나 축소합니다. 기본값은"0px 0px 0px 0px"이며 CSS의margin처럼 문자열과 단위를 함께 작성합니다. - threshold: 관찰 대상이 어느 정도 보였을 때 콜백을 호출할지 결정합니다. 기본값은
0이며1까지 지정할 수 있습니다. 예를 들어0.6은 대상이 60% 이상 보일 때를 의미합니다.
관찰 대상이 화면 내에 70% 이상 들어 왔을 때 콜백 함수를 호출하고 싶다면 이렇게 작성하면 됩니다.
const observer = new IntersectionObserver(callback, { threshold: 0.7 });observer.observe(target);옵션과 함께 전달하는 콜백 함수는 entries와 observer를 매개변수로 받습니다. entries에는 교차 상태가 변한 대상의 정보가 담기며, 각 항목은 IntersectionObserverEntry 객체입니다. 두 번째 매개변수는 현재 관찰 중인 IntersectionObserver 객체입니다.
const callback = (entries, observer) => { entries.forEach((entry) => { // 화면 안에 요소가 들어왔는지 체크 if (entry.isIntersecting) { // 기존 관찰하던 요소는 더 이상 관찰하지 않음 observer.unobserve(entry.target); // TODO: 새로운 콘텐츠 추가 // TODO: 새로운 콘텐츠의 마지막 요소 관찰 시작 } });};entry 객체는 관찰 중인 요소 각각에 대한 관찰 정보를 담고 있으며, 해당 요소가 화면 내에 감지되었는지, 그렇다면 어느 좌표에 위치하고 있는지 등을 확인할 수 있습니다. 이 외에도 entry 객체는 많은 정보를 갖고 있으니 직접 출력해 보면서 값을 확인해 보기 바랍니다.
본문은 무한 스크롤 구현을 소개하는 글이므로
Intersection Observer에 대해서는 자세히 다루지 않습니다. MDN 문서를 포함해 정리가 잘 되어 있는 블로그가 많기 때문에 궁금한 분들은 따로 확인해 보시면 좋을 것 같습니다.
Scroll 이벤트로도 처리할 수 있지 않을까?
대답은 Yes입니다! Intersection Observer가 나오기 이전에는 window 객체에 스크롤 이벤트를 연결하여 특정 지점까지 스크롤되었을 때, 다음 콘텐츠를 불러오는 식으로 무한 스크롤을 구현했습니다. 하지만 스크롤을 진행하는 순간마다 이벤트가 호출되어 메인 스레드 성능에 좋지 않았고, 쓰로틀(Throttle)과 같은 최적화 작업이 늘 동반되어야 했습니다.

최적화를 하지 않았다면 위 그림처럼 스크롤할 때마다 이벤트가 계속해서 발생했을 겁니다. Intersection Observer를 사용하면 매 스크롤마다 요소의 위치를 직접 계산할 필요가 없고, 교차 상태 계산은 브라우저가 최적화합니다. 다만 등록한 콜백 자체는 메인 스레드에서 실행되므로 콜백 안에서는 무거운 작업을 피하는 것이 좋습니다.
Skeleton UI
유튜브에 접속해 보면 로딩 중임을 알려주는 회색조의 애니메이션 요소를 본 적이 있을 거예요. 이를 스켈레톤 UI라 부릅니다. 실제로 표시될 콘텐츠와 유사한 모양을 먼저 보여 주기 때문에 사용자는 어떤 정보가 들어올지 예측할 수 있고, 체감 대기 시간도 줄어듭니다.
그렇다고 로딩 스피너 대신 항상 스켈레톤을 사용해야 하는 것은 아닙니다. 화면과 비슷한 형태를 만들고 유지하는 비용이 들며, 애니메이션이 지나치면 오히려 산만해질 수 있으므로 상황에 맞게 선택해야 합니다.
예제로 준비한 HTML/CSS 코드
반응형은 크게 고려하지 않고, 한 줄에 4개의 카드가 나타나도록 간단하게 디자인했습니다.

HTML 코드
<!doctype html><html lang="ko"> <head> <meta charset="UTF-8" /> <title>Infinite Scroll</title> <!-- Styles --> <link rel="stylesheet" href="style.css" /> </head> <body> <ul class="card-list"> <li class="card"> <img class="card__image" src="images/winter-1.jpeg" alt="에스파 윈터" /> <p class="card__text">Aespa :: Winter 1</p> </li> <li class="card"> <img class="card__image" src="images/winter-2.jpeg" alt="에스파 윈터" /> <p class="card__text">Aespa :: Winter 2</p> </li> <li class="card"> <img class="card__image" src="images/winter-3.jpeg" alt="에스파 윈터" /> <p class="card__text">Aespa :: Winter 3</p> </li> <li class="card"> <img class="card__image" src="images/winter-4.jpeg" alt="에스파 윈터" /> <p class="card__text">Aespa :: Winter 4</p> </li> <li class="card"> <img class="card__image" src="images/winter-5.jpeg" alt="에스파 윈터" /> <p class="card__text">Aespa :: Winter 5</p> </li> <li class="card"> <img class="card__image" src="images/winter-6.jpeg" alt="에스파 윈터" /> <p class="card__text">Aespa :: Winter 6</p> </li> <li class="card"> <img class="card__image" src="images/winter-7.jpeg" alt="에스파 윈터" /> <p class="card__text">Aespa :: Winter 7</p> </li> <li class="card"> <img class="card__image" src="images/winter-8.jpeg" alt="에스파 윈터" /> <p class="card__text">Aespa :: Winter 8</p> </li> </ul> <!-- Scripts --> <script src="app.js"></script> </body></html>CSS 코드
* { margin: 0; padding: 0; box-sizing: border-box;}
.card-list { width: 100%; height: 500px; padding: 0.5rem; overflow-y: scroll; background: bisque;}
.card,.skeleton { width: 25%; padding: 0.5rem; float: left; list-style: none;}
.card__image { display: block; width: 100%; height: 300px; object-fit: cover;}
.card__text,.skeleton__text { height: 40px; text-align: center; line-height: 40px; background: #fff; font-size: 0.9rem;}
.skeleton__image { width: 100%; height: 300px; animation: skeleton-gradient 3s linear infinite;}
@keyframes skeleton-gradient { 0% { background: rgb(212, 212, 212); } 50% { background: rgb(128, 128, 128); } 100% { background: rgb(212, 212, 212); }}
.spinner { margin: 0 auto; display: block; width: 150px;}자바스크립트 로직 작성
필요한 변수 선언과 함수 정의
우리는 스크롤이 사이트 하단에 도달했을 때 스켈레톤 UI와 로딩 스피너를 띄워야 하기 때문에, 미리 생성해서 변수에 담아 둡니다. 무한 스크롤을 통해 매 순간 8장의 신규 이미지를 로드할 것이기에, 그 자리를 잠시 동안 대신할 수 있는 8개의 스켈레톤 요소를 생성합니다. 저는 조금이라도 재사용할 만한 부분이 있으면 함수로 만드는 편이라, 스켈레톤 요소와 스피너 요소를 생성하는 함수를 추가로 구현했습니다.
const makeSpinner = () => { const spinner = document.createElement('div'); const spinnerImage = document.createElement('img'); spinner.classList.add('loading'); spinnerImage.setAttribute('src', 'images/spinner.gif'); spinnerImage.setAttribute('alt', ''); spinnerImage.classList.add('spinner'); spinner.appendChild(spinnerImage); return spinner;};
const makeSkeleton = () => { const skeleton = document.createElement('li'); const skeletonImage = document.createElement('div'); const skeletonText = document.createElement('p'); skeleton.classList.add('skeleton'); skeletonImage.classList.add('skeleton__image'); skeletonText.classList.add('skeleton__text'); skeletonText.textContent = ' '; skeleton.appendChild(skeletonImage); skeleton.appendChild(skeletonText); return skeleton;};
const list = document.querySelector('.card-list');const items = document.querySelectorAll('.card');const skeletonItems = Array.from({ length: items.length }, () => makeSkeleton());const spinner = makeSpinner();4개의 변수는 각각 다음의 요소들을 담고 있습니다.
- list: 이후 새로운 요소를 추가할 때 사용될 부모 요소입니다.
- items: 현재 화면에 출력되고 있는 카드 아이템을 담은
NodeList입니다. (최초 8개의 아이템) - skeletonItems: 8개의 스켈레톤 요소를 담고 있는 배열입니다.
- spinner: 신규 콘텐츠 로드 중 스켈레톤 UI와 함께 출력할 스피너 요소입니다.
로딩 관련 기능 구현
새로운 콘텐츠를 불러오는 로딩 작업이 시작되면 스켈레톤 UI와 스피너를 화면에 보이고, 로딩이 종료되면 해당 요소들을 화면에서 제거합니다. 이렇게 로딩과 관련된 기능들도 각각 함수로 구현했습니다. 그리고 로딩이 종료되면서 추가될 새로운 콘텐츠는 addNewContent 함수에서 처리하는데, 데이터를 별도로 받아 오는 API가 없기 때문에 최초에 사용되는 8장의 이미지를 복사해서 재사용했습니다.
const addSkeleton = () => { skeletonItems.forEach((item) => list.appendChild(item));};
const removeSkeleton = () => { skeletonItems.forEach((item) => list.removeChild(item));};
const loadingStart = () => { addSkeleton(); list.appendChild(spinner);};
const loadingFinish = () => { removeSkeleton(); list.removeChild(spinner);};
const addNewContent = () => { items.forEach((item) => list.appendChild(item.cloneNode(true)));};만약 백엔드와의 통신을 통해 데이터를 받아올 수 있다면, 비동기 문법을 사용해서 데이터를 받은 후 띄우는 식으로 처리하면 됩니다.
Intersection Observer 연결
Intersection Observer 객체를 만든 후에는 관찰(observe)할 요소를 결정해야 하는데, 어차피 사이트 최하단에는 마지막 아이템 요소가 있을 것이기에, 이에 대해서만 관찰하도록 처리했습니다. 그리고 신규 콘텐츠가 로드된 이후에는 기존에 관찰하던 요소를 더 이상 관찰하지 않도록 취소(unobserve)하고, 새로 추가된 마지막 요소를 관찰 시작해야 합니다.
이 과정은 계속해서 반복되기 때문에 observeLastItem이라는 함수를 추가로 구현했습니다.
const ioCallback = (entries, io) => { entries.forEach((entry) => { if (entry.isIntersecting) { io.unobserve(entry.target); loadingStart(); setTimeout(() => { addNewContent(); loadingFinish(); observeLastItem(io, document.querySelectorAll('.card')); }, 2000); } });};
const observeLastItem = (io, items) => { const lastItem = items[items.length - 1]; io.observe(lastItem);};
const io = new IntersectionObserver(ioCallback, { root: list, rootMargin: '0px 0px 120px', threshold: 0,});observeLastItem(io, items);root에는 실제 스크롤 영역인 list를 지정했습니다. 아래쪽 rootMargin을 넓혀 마지막 카드가 완전히 보이기 전에 다음 콘텐츠를 준비하도록 했습니다. 콜백 함수 뒷부분의 setTimeout은 비동기 통신을 흉내 내기 위해 사용했습니다.
구현 결과
이렇게 길지 않은 코드로 간단하게 스켈레톤 UI를 적용한 무한 스크롤을 구현해 봤습니다.
