요즘은 타입스크립트로 완전히 새로운 라이브러리를 구현하는 프로젝트를 한창 진행중이다. 시기 적절하게도 IE가 종말을 맞이했고, 우리 팀을 비롯 다른 팀들도 "이제 더 이상 IE는 지원하지 않습니다" 라고 공표했다. 그 덕분에 그동안 IE에서 쓸 수 없다는 이유로 외면해왔던 웹 기술이나 API들을 이번 프로젝트에선 적극 활용하고 있다.
그 중에서 오늘은 Intersection Observer API를 소개하려고 한다. (https://developer.mozilla.org/ko/docs/Web/API/Intersection_Observer_API) (자세한 기술적인 설명은 공식문서를 참조)
Intersection Observer API는 웹페이지의 특정 부분이 사용자에게 보여지는지를 탐지해야 할 경우에 유용하다. 예를 들면,
- 사용자가 스크롤을 해서 화면 하단에 위치한 광고를 보았는지.
- 슬라이드쇼가 진행될때 각각의 슬라이드가 사용자에게 보여졌는지.
이런 것을 측정하기 위해 과거엔 스크롤 이벤트와 각 부분의 크기와 위치를 계산해서 매 스크롤 마다 측정하고자 하는 부분이 사용자의 가시권에 들어왔는지를 체크해야만 했다. 스크롤 이벤트를 감지하는 것은 웹브라우저에 많은 부하를 주기 때문에 가급적 사용하지 않는 것이 좋지만, 과거엔 그 방법 밖에 없었다. Intersection Observer API를 사용하면 메인스레드에 부하를 주지 않고 특정 부분의 가시성을 측정할 수 있다.
그럼 Intersection Observer API를 써서 다음의 요구사항들을 구현해 보자.
요구사항 1: data-content-id 속성을 가진 요소가 사용자에게 보여지면 data-content-id의 값을 서버로 전송한다.
요구사항 2: 한번 전송된 data-content-id는 사용자가 같은 페이지에 머무르는 동안 다시 전송되지 않는다.
요구사항 3: 요구사항 1의 변형 - data-content-id 속성을 가진 요소의 50% 이상이 사용자에게 1초 이상 보여지면 data-content-id의 값을 서버로 전송한다.
이 요구사항들을 하나하나 구현해보자. (모든 코드는 타입스크립트로 작성되었다.)
요구사항 1: data-content-id 속성을 가진 요소가 사용자에게 보여지면 data-content-id의 값을 서버로 전송한다.
// 페이지가 로딩되기를 기다렸다가 startup.
// 웹사이트에 따라 startup을 기다렸다가 부를 필요가 없을 수도 있다.
window.addEventListener('load', startup, false);
function startup() {
// IntersectionObserver 인스턴스 생성.
// trackImpression은 콜백 함수.
const observer = new IntersectionObserver(trackImpression);
// data-content-id를 속성을 가지는 모든 요소들을 찾는다.
const elems = document.querySelectorAll('[data-content-id]');
// 그 요소들을 감지하기 위해 observer.observe 사용.
elems.forEach((el) => {
observer.observe(el);
});
}
// IntersectionObserver의 콜백 함수.
// 감지되고 있는 요소들의 상태가 바뀌면 콜백 함수가 작동되고 entries는 그 요소들이다.
function trackImpression(entries: Array<IntersectionObserverEntry>, observer: IntersectionObserver) {
// 각각의 요소를 체크한다.
entries.forEach((entry: IntersectionObserverEntry) => {
const target = entry.target as HTMLElement;
// 요소가 가시화된 상태라면 entry.isIntersecting === true.
// IntersectionObserver는 visibility = hidden인 요소를 가시화된 것으로 분류한다.
// 따라서 두번째 조건을 넣어 visibility = hidden인 요소를 제외해주어야 한다.
if (entry.isIntersecting && window.getComputedStyle(target).visibility !== 'hidden')) {
// 서버로 data-content-id 값 전송.
// sendToServer는 여기서 중요한 내용이 아니니 구현하지 않겠다.
sendToServer(target.dataset.contentId);
}
});
}
꽤 간단히 첫번째 요구사항이 해결되었다.
요구사항 2: 한번 전송된 data-content-id는 사용자가 같은 페이지에 머무르는 동안 다시 전송되지 않는다.
다시 말하면 어떤 요소가 가시화되어 서버로 전송되면 그 요소는 다시 감지되지 않도록 해야한다.
window.addEventListener('load', startup, false);
function startup() {
const observer = new IntersectionObserver(trackImpression);
const elems = document.querySelectorAll('[data-content-id]');
elems.forEach((el) => {
observer.observe(el);
});
}
function trackImpression(entries: Array<IntersectionObserverEntry>, observer: IntersectionObserver) {
entries.forEach((entry: IntersectionObserverEntry) => {
const target = entry.target as HTMLElement;
if (entry.isIntersecting && window.getComputedStyle(target).visibility !== 'hidden')) {
sendToServer(target.dataset.contentId);
// 이 요소는 더 이상 감지되지 않게 한다.
observer.unobserve(target);
}
});
}
#1의 코드에 단 한 줄을 더했다. observer.unobserve(target)는 감지된 요소(target)을 더 이상 감지하지 않게 하는 Intersection Observer API에서 제공되는 함수다. 두번째 요구사항도 쉽게 해결했다.
요구사항 3: 요구사항 1의 변형 - data-content-id 속성을 가진 요소의 50% 이상이 사용자에게 1초 이상 보여지면 data-content-id의 값을 서버로 전송한다.
마지막 요구사항은 광고 트래킹에서 흔히 요구되는 조건이다. 어떤 광고가 사용자에게 의미있게 보여지려면 적어도 면적의 50 퍼센트 이상이 1초 이상 보여져야 한다. #1에선 이런 조건을 따지지 않고 요소가 가시화되면 아이디를 서버로 전송했다. 이 조건에 맞는 요소만을 서버로 전송하려면 어떻게 해야 할까?
우선 Intersection Observer API가 제공하는 기능을 이용해 요소의 50% 이상이 보여질 때를 감지할 수 있다. IntersectionObserver 인스턴스 생성시 다음과 같은 options 객체를 전달할 수 있다.
{
// 감지하고자 하는 요소들의 루트요소. 뷰포트 트래킹을 위해선 제공하지 않아도 된다.
root: null,
// 교차 계산 시 루트 영역의 크기를 키우거나 줄이고자 할 때 사용한다.
// 제공하지 않으면 다음의 기본값이 쓰인다.
rootMargin: '0px 0px 0px 0px',
// 0.0 이상, 1.0 이하의 숫자 단일 값 또는 숫자 배열.
// 요소가 얼만큼 가시화되었을 때 교차로 인정할 것인지를 결정한다.
// 0.0 - 요소가 교차되자마자 가시화됨. 1.0 - 요소의 100%가 교차되어야 가시화됨.
threshold: 0.0,
};
const options = {
threshold: 0.5
};
const observer = new IntersectionObserver(trackImpression, options);
자, 이제 마지막 요구사항이 남았다. 1초 이상 보여지는 요소만 서버로 전송할 것.
아쉽게도 Intersection Observer API는 이 요구사항을 쉽게 구현할 수 있는 방법을 제공하고 있지 않다. 우리가 API를 이용해 알아낼 수 있는 건 각각의 요소들이 상태가 변화할 때 기록되는 타임스탬프다. 이 타임스탬프는 페이지가 생성된 시간부터 요소의 상태가 변화한 순간까지를 밀리초(millisecond)로 나타낸다. 이 타임스탬프를 이용해서 한 요소가 1초 이상 사용자에게 보여졌는지를 측정할 수 있다.
이제 구체적으로 차근차근 구현해 보자.
우리가 감지하고자 하는 요소는 한 페이지에 여러개가 있을 수 있다. 때문에 각 요소의 상태가 변화할 때마다 그 상태와 타임스탬프를 저장하기 위해선 요소들을 식별해줄 아이디가 필요하다. data-content-id가 식별자로 쓰일 수도 있지만 그 값들이 다 다르다고 장담할 순 없다. 그래서 가장 흔하게 사용되는 식별자인 UUID를 사용하겠다. 감지하고자 하는 요소를 찾아 식별자를 값으로 갖는 data-track-id 속성을 각 요소에 더한다.
function startup() {
const observer = new IntersectionObserver(trackImpression, options);
const elems = document.querySelectorAll('[data-content-id]');
elems.forEach((el) => {
// 식별자를 data-track-id 속성에 더하기. uuidv4는 uuid 라이브러리의 함수.
(el as HTMLElement).dataset.trackId = uuidv4();
observer.observe(el);
});
}
이제 각 요소가 감지될 때 마다 그 요소의 상태와 타임스탬프를 Map으로 저장하자.
const trackedElements = new Map();
function trackImpression(entries: Array<IntersectionObserverEntry>, observer: IntersectionObserver) {
entries.forEach((entry: IntersectionObserverEntry) => {
const target = entry.target as HTMLElement;
// 이 어레이는 요소가 감지될 때마다 그 상태와 타임스탬프를 저장한다.
let val = [];
// 요소의 식별자 가져오기.
const id = target.dataset.awsmaTrackId;
// trackedElements 맵에 이미 이 요소가 저장되어 있다면
// 현재 저장된 어레이를 가져온다.
if (trackedElements.has(id)) {
val = trackedElements.get(id);
}
// trackedElements 맵에 이미 이 요소가 저장되어 있거나, 이 요소가 처음으로 가시화되는 상태라면.
if (trackedElements.has(id) || (entry.isIntersecting && window.getComputedStyle(target).visibility !== 'hidden')) {
// 요소의 상태와 타임스탬프를 어레이에 저장
val.push({
isVisible: entry.isIntersecting,
ts: entry.time
});
// 어레이를 맵에 저장.
trackedElements.set(id, val);
sendToServer(target.dataset.contentId);
observer.unobserve(target);
}
});
}
각 요소의 상태와 타임스탬프를 저장했으니 이제 1초 이상 보여진 요소만을 서버로 전송할 일만 남았다. 위의 코드는 아직 가시화된 모든 요소를 서버로 보내고 있다.
맵에 저장된 각 요소의 어레이 값은 다음과 같은 형태다.
[
{isVisible: true, ts: 100},
{isVisible: false, ts: 200},
{isVisible: true, ts: 300},
{isVisible: false, ts: 1400},
{isVisible: true, ts: 1500},
{isVisible: false, ts: 1600},
{isVisible: true, ts: 1700},
]
isVisible이 true에서 false 바뀔 때까지 걸린 시간이 바로 요소가 가시화된 상태인 시간이다. 예를 들면 이 요소는 100 밀리초 동안 가시화 되었다가 다시 비가시화 되었고, 100 밀리초 후에 다시 가시화 되어 1100 밀리초 동안 그 상태를 유지했다고 이해할 수 있다.
그렇다면 이런 형태의 데이터를 입력했을 때 이 요소가 최소 1초 이상 가시화 된 상태를 유지했는지 체크하는 함수를 만들어 보자.
function isVisibleLongerThanMinTime(arr: Array<any>, minTime: number): number {
let timeGap = 0;
let startTime = 0;
let endTime = 0;
for (let i = 0; i < arr.length; i++) {
const a = arr[i];
if (a.isVisible) {
startTime = a.ts;
// 배열의 마지막 항목이 가시화된 상태라면 현재 시간을 비가시화된 시간으로 사용한다.
if (i === arr.length - 1) {
endTime = performance.now();
}
} else {
endTime = a.ts;
}
if (endTime > startTime) {
timeGap = endTime - startTime;
}
if (timeGap >= minTime) {
return true;
}
}
return false;
}
이 함수를 이용해 각 요소가 1초 이상 가시화되었는지를 체크할 수 있다. 그런데 언제 이 함수를 써서 체크해야 할까? 사용자가 다음 페이지로 넘어갈 때까지 기다렸다가 체크하는 방법이 있다. 하지만 그럴 경우 만약 요소의 갯수가 많다면 계산하는데 시간이 걸리고 사용자가 다음 페이지로 넘어가는 것을 지연시킬 수도 있는 위험이 있다. 또 모든 가시화된 요소를 서버로 한꺼번에 전송하게 된다.
그보다는 가급적이면 요소가 1초 이상 가시화 되자마자 서버로 전송하는 편이 안전하다. 그러기 위해 주기적으로 맵에 저장된 각 요소의 상태를 체크하는 방법을 선택했다.
const trackedElements = new Map();
// 1초를 밀리초로.
const impressionTime = 1000;
function trackImpression(entries: Array<IntersectionObserverEntry>, observer: IntersectionObserver) {
entries.forEach((entry: IntersectionObserverEntry) => {
const target = entry.target as HTMLElement;
let val = [];
const id = target.dataset.awsmaTrackId;
if (trackedElements.has(id)) {
val = trackedElements.get(id);
}
if (trackedElements.has(id) || (entry.isIntersecting && window.getComputedStyle(target).visibility !== 'hidden')) {
val.push({
isVisible: entry.isIntersecting,
ts: entry.time
});
trackedElements.set(id, val);
// 1초 후에 요소가 1초 이상 가시화 되었는지를 체크한다.
// 1초보다 오래 기다리면 그 동안 사용자가 다른 페이지로 이동할 수 있으니까.
const timerId = setTimeout(() => {
if (isVisibleLongerThanMinTime(trackedElements.get(id), impressionTime)) {
sendToServer(target.dataset.contentId);
observer.unobserve(target);
}
clearTimeout(timerId);
}, impressionTime);
}
});
}
앞에서 설명했지만 trackImpression 함수는 각 요소의 가시화 상태가 바뀔때마다 실행된다. 그 함수 안에서 1초를 기다렸다가 요소가 1초 이상 가시화되었는지를 체크하면 된다.
이렇게 해서 모든 요구사항을 만족시키는 코드가 완성됐다.
Intersection Observer API를 써보니 생각보다 간단하고, 과거 스크롤 이벤트에 의지해서 구현했던 기능을 더 효율적으로 구현할 수 있는 것 같아 많은 분들께 추천드린다.
'개발자 이야기' 카테고리의 다른 글
프론트 엔드 개발자가 하는 일이 뭔가요? (0) | 2022.01.03 |
---|---|
한국의 조기 코딩 교육 열풍을 보며 드는 생각 (2) | 2021.12.05 |