target 요소와 상위, 또는 document viewport 내의 변화를 비동기적으로 관찰할 수 있는 API이다.
무한 스크롤 또는 lazy loading을 구현하려고 할 때, 기존에는 scroll event를 이용하여 구현하였다.
window.addEventListener('scroll', callback);
그러나 scroll 이벤트가 발생할 때마다 콜백 함수가 실행되어, 많은 성능 저하가 일어났다. scroll을 한번 할 때, scroll이 완전히 멈출 때까지 이벤트가 발생하기 때문에, debouncing이나 requestAnimationFrame를 이용하여 따로 최적화 하는 방식을 사용했었다.
또한, 요소의 크기를 구하기 위하여 getBoundingClientRect() 메서드를 호출 시 reflow가 많이 발생하는 문제가 있다.
그러나 scroll을 이용하여 UI를 구성하는 경우가 적지 않기 때문에, 성능상에 이점을 취할 수 있으면서, 신뢰할 수 있는 api의 필요성이 대두되었는데 그것이 바로 Intersection Observer이다.
IntersectionObserver는 루트 요소와 타겟 요소를 관찰한다. 다음과 같이 작성할 수 있다.
const io = new IntersectionObserver(callback, options)
const options = {
root: document.querySelector('.App'),
rootMargin: '1rem',
threshold: 0.5
}
일반적으로 다음의 형식으로 사용한다.
const io = new IntersectionObserver((entries, observer) => {}, options)
IntersectionObserver는 IntersectionObserverCallback type을 가지며, 해당 콜백함수에 entries, observer 두 인자를 넘겨준다. entries는 IntersectionObserverEntry[]인데, 각 entry에는 관찰 대상이 화면과 교차되고 있는 상태인지 알려주는 boolean 값이므로, 가장 보편적으로 사용되는 값이다.
const io = new IntersectionObserver((entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// do something
}
});
});
일반적으로 사진의 lazy-loading 처럼 렌더링 최적화나, 무한 스크롤을 구현하기 위해 사용된다.
일단 lazy-loading을 IntersectionObserver로 구현해보자.
다음과 같은 App 컴포넌트 안에, 박스 컴포넌트 50개가 있다고 생각해보자.
// Box.js
function Box({ $target }) {
this.element = document.createElement('div');
this.element.className = 'box';
$target.appendChild(this.element);
}
export default Box;
// App.js
import Box from './Box.js';
function App({ $target }) {
// box 100개 등록
const boxes = Array(100).fill(new Box({ $target }));
}
export default App;
* {
box-sizing: border-box;
}
.box {
width: 200px;
height: 200px;
margin: 1rem;
background-color: lime;
}
.lazy {
background-color: royalblue;
}

스크롤을 내릴 때 50%이상 보일 경우 색깔이 변하는 로직을 IntersectionObserver를 이용하여 구현해 보기로 한다. 박스 배열에 존재하는 모든 박스를 관찰하고, 교차할 때 동작을 정의해주면 된다.
import Box from './Box.js';
function App({ $target }) {
// box 100개 등록
for (let i = 0; i < 100; i += 1) {
new Box({ $target });
}
const io = new IntersectionObserver(
(entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// 이미 노출되었기 때문에 더이상 관찰하지 않는다.
// unobserve에 entry.target
observer.unobserve(entry.target);
entry.target.classList.add('lazy');
}
});
},
{ threshold: 0.5 },
);
document.querySelectorAll('.box').forEach((box) => {
io.observe(box);
});
}
export default App;

잘 동작함을 확인할 수 있다.
이와 같은 원리로 서버에서 내려오는 사진의 lazy loading을 구현할 수 있다.
lazy loading을 구현하기 위해서는 placeholder가 필요하다. 또한, 높이 값이 주어져 있어야 한다.
Step
IntersectionObserver로 모든 아이템들을 구독한다.
해당되는 아이템이 viewport와 교차한다면, img 태그에 src를 삽입한다.
삽입 후 placeholder는 보이지 않게 처리한다.
기본 html 마크업을 한다.
<li class="item">
<img data-src=""/>
<div class="placeholder"></div>
</li>
// intersection observer
const io = new IntersectionObserver(
(entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// entry.target을 unobserve에 넣으면 not element error가 발생함
const { target } = entry;
// target 구독 해제
observer.unobserve(target);
const $img = target.querySelector('img');
const { src } = $img.dataset;
if (!src) return;
// img tag에 src 삽입
$img.setAttribute('src', src);
const $placeholder = target.querySelector('.placeholder');
if (!$placeholder) return;
// plarceholder 제거
$placeholder.classList.add('fade-out');
}
});
},
{
threshold: 0.5,
},
);
this.element.querySelectorAll('.item').forEach((item) => io.observe(item));
* {
box-sizing: border-box;
}
.search-result {
display: grid;
grid-template-columns: repeat(4, minmax(250px, 1fr));
gap: 1rem;
list-style: none;
}
.item {
position: relative;
height: 300px;
}
.item > img {
width: 100%;
height: 100%;
object-fit: cover;
}
.item .placeholder {
position: absolute;
width: 100%;
height: 100%;
background-color: #eee;
z-index: 2;
}
.fade-in {
display: block;
}
.fade-out {
display: none;
}
// App.js
import SearchResult from './SearchResult.js';
function App({ $target }) {
this.state = [];
this.element = $target;
this.setState = (nextState) => {
this.state = nextState;
this.render();
};
this.render = () => {
this.element.innerHTML = ``;
new SearchResult({ $target, initialState: this.state });
};
this.setState(
Array(100).fill(
'https://www.sisa-news.com/data/photos/20200936/art_159912317533_32480a.jpg',
),
);
}
export default App;
// SearchResult.js
import Image from './Image.js';
// initialState: string[]
function SearchResult({ $target, initialState }) {
this.element = document.createElement('ul');
this.element.className = 'search-result';
$target.appendChild(this.element);
this.state = initialState;
this.setState = (nextState) => {
this.state = nextState;
this.render();
};
this.render = () => {
// 초기화
this.element.innerHTML = ``;
this.state.map((imgSrc) => {
return new Image({ $target: this.element, initialState: imgSrc });
});
};
this.render();
// intersection observer
const io = new IntersectionObserver(
(entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const { target } = entry;
observer.unobserve(target);
const $img = target.querySelector('img');
const { src } = $img.dataset;
if (!src) return;
$img.setAttribute('src', src);
const $placeholder = target.querySelector('.placeholder');
if (!$placeholder) return;
$placeholder.classList.add('fade-out');
}
});
},
{
threshold: 0.5,
},
);
this.element.querySelectorAll('.item').forEach((item) => io.observe(item));
}
export default SearchResult;
// Image.js
function Image({ $target, initialState }) {
this.element = document.createElement('li');
this.element.className = 'item';
$target.appendChild(this.element);
this.state = initialState;
this.setState = (nextState) => {
this.state = nextState;
this.render();
};
this.render = () => {
this.element.innerHTML = `
<img data-src="${this.state}" />
<div class="placeholder"></div>
`;
};
this.render();
}
export default Image;


요약
무한 스크롤 역시 Intersection Observer로 구현할 수 있으나, 방법은 약간 다르다.
가장 마지막 요소 또는 바닥 요소를 관찰한 뒤 해당 요소가 viewport에 드러나게 될 경우, callback 함수를 실행시켜 주면 된다.
이때, IntersectionObserver로 요소들을 추가해줄 때 중요한 점은, 기존에는 this.setState를 이용하여 데이터가 업데이트 될 때 렌더링을 하는 방식을 사용했는데, 그러다 보니 스크롤을 내리면 위쪽 요소들이 초기화되어 다시 loaded 되기 전으로 돌아가는 문제가 발생했다.
이 문제를 해결하기 위하여 데이터와 완전히 동기화 시키는 렌더링 방식을 포기하고, fetchNextData라는 함수를 만들고, 해당 함수에서 데이터를 바탕으로 element 배열을 만든 후, 해당 배열을 this.element.append(...array) 방식을 이용하여 넣어주는 방식을 사용하였다.
그리고 이 때 lazy loading을 위해 observe를 어떻게 하면 되지 고민했는데, 일단 렌더링 하고 보니 굳이 lazy-loading을 하지 않아도 원하는대로 잘 떠서, 따로 observe 처리는 하지 않았다.
import Image from './Image.js';
// initialState: string[]
function SearchResult({ $target, initialState }) {
this.element = document.createElement('ul');
this.element.className = 'search-result';
$target.appendChild(this.element);
this.state = initialState;
this.setState = (nextState) => {
this.state = nextState;
this.render();
};
this.render = () => {
// 초기화
this.element.innerHTML = ``;
this.state.map((imgSrc) => {
return new Image({ $target: this.element, initialState: imgSrc });
});
};
this.render();
const lazyLoadCallback = (entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const { target } = entry;
observer.unobserve(target);
const $img = target.querySelector('img');
const { src } = $img.dataset;
if (!src) return;
$img.setAttribute('src', src);
const $placeholder = target.querySelector('.placeholder');
if (!$placeholder) return;
$placeholder.classList.add('fade-out');
}
});
};
const fetchNextData = () => {
return Array(25)
.fill(
'https://www.sisa-news.com/data/photos/20200936/art_159912317533_32480a.jpg',
)
.map((url) => {
const item = document.createElement('li');
item.className = 'item';
item.innerHTML = `
<img src="${url}" />
<div class="placeholder" style="display: none;"></div>
`;
return item;
});
};
const infiniteScrollCallback = (entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const { target } = entry;
observer.unobserve(target);
const newElements = fetchNextData();
this.element.append(...newElements);
observer.observe(newElements[newElements.length - 1]);
}
});
};
// intersection observer
const lazyIo = new IntersectionObserver(lazyLoadCallback, {
threshold: 0.5,
});
const infiniteScrollIo = new IntersectionObserver(infiniteScrollCallback);
const $items = this.element.querySelectorAll('.item');
$items.forEach((item) => lazyIo.observe(item));
infiniteScrollIo.observe($items[$items.length - 1]);
}
export default SearchResult;
코드가 잘은 동작하지만, 추후 리팩토링의 여지가 많다. 바닐라 자바스크립트로 앱을 구성하면, 로직과 UI의 분리가 어려운게 참 아쉽다.