이 글에서는 페이지 콘텐츠 사이사이에 여러 광고가 흩어져 있는 모의 블로그를 만든 다음, Intersection Observer API를 사용하여 각 광고가 사용자에게 얼마나 오래 보이는지 추적할 거예요. 광고가 1분 이상의 가시 시간을 초과하면, 새 광고로 교체될 거예요.
이 예제의 많은 부분이 실제 사용과 일치하지 않지만 (특히, 모든 기사가 같은 텍스트를 가지고 있고 데이터베이스에서 로드되지 않으며, 배열에서 선택되는 간단한 텍스트 전용 광고 몇 개만 있어요), 이것은 Intersection Observer API를 여러분의 사이트에 어떻게 적용하는지 빠르게 배울 수 있도록 API에 대한 충분한 이해를 제공할 거예요.
이 예제에서 광고의 가시성 추적 개념이 사용되는 데에는 좋은 이유가 있어요. 사실 웹 광고에서 Flash나 다른 스크립트의 가장 일반적인 용도 중 하나는 청구 및 수익 지불을 목적으로 각 광고가 얼마나 오래 보이는지 기록하는 거예요. Intersection Observer API 없이는, 이것이 각각의 개별 광고에 대해 인터벌과 타임아웃을 사용하거나, 페이지를 느리게 만드는 경향이 있는 다른 기술들을 사용하여 수행되곤 했어요. 이 API를 사용하면 브라우저가 모든 것을 간소화하여 성능에 미치는 영향을 상당히 줄일 수 있어요.
안녕하세요! 이전 시간에 Intersection Observer API의 개념과 이론을 꽉 잡으셨군요. 이제 그 지식을 바탕으로 '요소의 가시성 타이밍 측정하기(Timing element visibility)' 튜토리얼의 실전 예제 구축 파트에 들어오셨습니다.
실무에서 구글 애널리틱스(GA)나 광고 플랫폼에 "이 광고가 사용자의 화면에 정확히 몇 초 동안 노출되었는가?"를 보고해야 할 때가 있습니다. (단순히 화면에 보였다가 아니라, '머무른 시간'을 재야 하죠!) 이 문서의 전체 튜토리얼은 바로 그 기능을 구현하는 예제이며, 지금 가져오신 부분은 그 앱을 만들기 위한 기초 뼈대(HTML/CSS)를 세우는 작업입니다.
CSS Grid로 레이아웃을 잡는 깔끔한 예제이니, 편안하게 따라와 주세요!
(튜토리얼을 진행하기 위한 가짜 블로그 사이트를 만들어 봅시다.)
이 사이트의 구조는 전혀 복잡하지 않습니다. 우리는 사이트의 스타일링과 레이아웃을 잡기 위해 CSS Grid(그리드 레이아웃)을 사용할 것이므로, HTML은 아주 직관적이고 단순하게 유지할 수 있습니다.
<div class="wrapper">
<header>
<h1>가짜 블로그 (A Fake Blog)</h1>
<h2>Intersection Observer가 실제로 작동하는 모습 보기!</h2>
</header>
<aside>
<nav>
<ul>
<li><a href="#link1">링크 하나</a></li>
<li><a href="#link2">또 다른 링크</a></li>
<li><a href="#link3">마지막 링크</a></li>
</ul>
</nav>
</aside>
<main>…</main>
</div>
이것이 전체 사이트의 뼈대(프레임워크)입니다.
맨 윗부분에는 <header> 블록으로 감싸진 사이트의 헤더 영역이 있습니다. 그 아래에는, 사이트의 사이드바를 정의하는 <aside> 블록이 있으며, 그 안에는 링크 목록이 들어있습니다. (이 튜토리얼 예제에서는 이 링크들이 실제로 작동하지는 않지만, 일반적인 블로그 같은 시각적 경험을 제공하기 위해 만들어 두었습니다.)
마지막으로 메인 본문(body)이 등장합니다. 여기서는 일단 비어있는 <main> 요소로 시작합니다. 이 상자 안의 내용물(기사 본문과 광고들)은 나중에 자바스크립트를 사용해서 동적으로 꽉 채워 넣을 예정입니다.
💡 강사의 실무 팁:
시맨틱(Semantic) 태그인<header>,<aside>,<main>을 아주 잘 활용한 훌륭한 마크업입니다! 리액트(React)로 개발하실 때도 무작정<div>로 떡칠하기보다는, 이렇게 시맨틱 태그를 사용해야 스크린 리더와 SEO(검색엔진 최적화) 측면에서 이득을 볼 수 있습니다.
사이트의 구조(HTML)가 정의되었으니, 이제 사이트를 예쁘게 꾸밀 스타일링(CSS)으로 넘어가 보겠습니다. 페이지를 구성하는 각 컴포넌트의 스타일을 하나씩 뜯어보죠.
사이트의 배경색과 전체 레이아웃의 그리드(Grid)를 정의하기 위해 <body>와 가장 바깥쪽 래퍼(wrapper) 요소에 스타일을 제공합니다.
body {
font-family: "Open Sans", "Helvetica", "Arial", sans-serif;
background-color: aliceblue;
}
.wrapper {
display: grid;
grid-template-columns: auto minmax(min-content, 1fr);
grid-template-rows: auto minmax(min-content, 1fr);
max-width: 700px;
margin: 0 auto;
background-color: aliceblue;
}
사이트의 <body>에는 흔히 쓰이는 산세리프(sans-serif) 글꼴 중 하나를 사용하도록 설정했고, 배경색으로는 "aliceblue"를 주었습니다.
그다음은 wrapper 클래스의 정의입니다. 이 클래스는 헤더, 사이드바, 그리고 본문 콘텐츠(기사와 광고들)를 모두 포함하여 블로그 전체를 감싸는 역할을 합니다.
이 래퍼는 두 개의 열(columns)과 두 개의 행(rows)으로 이루어진 CSS Grid를 생성합니다.
auto)은 내부 콘텐츠(사이드바)의 크기에 맞춰 자동으로 조절되며, 두 번째 열(minmax(...))은 최소한 콘텐츠의 너비만큼을 확보하되, 남는 공간이 있다면 그 공간(1fr)을 모두 채우도록 설정되었습니다. 이 두 번째 열은 메인 본문 콘텐츠가 들어갈 자리입니다.auto)은 사이트의 헤더를 위해 특별히 사용됩니다. 행의 크기 역시 열과 같은 방식으로 설정되어 있습니다. 첫 번째 행은 내용물에 맞춰 자동으로 크기가 결정되고, 두 번째 행은 남은 공간을 모두 차지하되, 그 안에 들어갈 모든 요소들이 잘리지 않고 표시될 수 있는 최소한의 공간은 확보하도록 했습니다.래퍼의 최대 너비(max-width)는 700px로 고정해 두었습니다. 이렇게 하면 MDN 문서 내부에 작은 예제 화면(인라인)으로 띄워졌을 때도 레이아웃이 깨지지 않고 주어진 공간에 잘 맞게 표시됩니다.
💡 강사의 핵심 팁:
minmax(min-content, 1fr)은 Grid 레이아웃의 꽃입니다! 화면이 줄어들 때는 콘텐츠가 찌그러지지 않도록 최소 크기(min-content)를 방어해주고, 화면이 넓어질 때는 남는 여백을 시원하게 다 차지(1fr)하게 만들어주는 아주 강력한 반응형(Responsive) CSS 기법이에요.
헤더는 꽤 단순합니다. 이번 예제에서는 그저 텍스트 몇 줄만 담고 있으니까요. 헤더의 스타일은 다음과 같습니다:
header {
grid-column: 1 / -1;
grid-row: 1;
background-color: aliceblue;
}
여기서 가장 눈여겨봐야 할 부분은 grid-column이 1 / -1로 설정되었다는 점입니다. 이는 그리드의 첫 번째 열(1)에서 시작해서 마지막 그리드 라인(-1)에서 끝나야 함을 의미합니다. 다시 말해, 헤더가 그리드 내의 '모든 열'을 가로질러 쫙 펼쳐진다는 뜻이죠. 우리가 원하는 헤더의 모습에 완벽히 부합합니다. 그리고 grid-row는 1로 설정되어 사이트 그리드의 가장 첫 번째(맨 위) 줄에 배치됩니다.
사이드바는 사이트 내의 다른 페이지로 이동하는 링크들을 보여주기 위해 사용됩니다. (물론 이 예제에서는 링크를 눌러도 아무 데도 가지 않지만, 블로그 같은 느낌을 연출하기 위해 존재합니다.) 사이드바는 <aside> 요소로 표현되며, 다음과 같이 스타일링 됩니다:
aside {
grid-column: 1;
grid-row: 2;
background-color: cornsilk;
padding: 5px 10px;
}
aside ul {
padding-left: 0;
}
aside ul li {
list-style: none; /* 리스트 앞의 점(bullet) 제거 */
}
aside ul li a {
text-decoration: none; /* 링크의 밑줄 제거 */
}
여기서 가장 중요한 특징은 그리드 위치 설정입니다. grid-column을 1로 설정하여 사이드바를 화면의 왼쪽(첫 번째 열)에 배치했습니다. 만약 이 값을 -1(맨 끝 열)로 바꾼다면 사이드바는 오른쪽으로 이동할 것입니다. (다만 그렇게 되면 다른 요소들의 여백(margin)을 조금씩 수정해 주어야 간격이 예쁘게 맞겠죠.) grid-row는 2로 설정되어, 헤더 바로 아래쪽이자 메인 본문과 나란히 위치하게 됩니다.
사이트의 본문 얘기가 나온 김에 살펴보죠. 사이트의 메인 콘텐츠는 <main> 요소 안에 보관되며, 아래와 같은 스타일이 적용됩니다:
main {
grid-column: 2;
grid-row: 2;
margin: 0;
margin-left: 16px;
font-size: 16px;
}
여기서 핵심은 그리드 위치를 column 2, row 2로 설정하여 사이드바의 오른쪽이자 헤더의 아래쪽에 본문 콘텐츠가 들어가도록 자리를 잡아준 것입니다.
본문 안에 들어갈 각각의 기사(글)는 <article> 요소에 담기며, 다음과 같이 스타일링 됩니다:
article {
background-color: white;
padding: 6px;
}
/* 컨테이너의 마지막 기사가 아닌 모든 기사들에 적용 */
article:not(:last-child) {
margin-bottom: 8px;
}
article h2 {
margin-top: 0;
}
이 CSS는 파란색 배경 위에 떠 있는 흰색 배경의 기사 박스들을 만들어내며, 기사 안쪽으로 약간의 패딩(여백)을 줍니다. 그리고 여러 기사들을 간격 있게 떨어뜨려 놓기 위해, 목록의 맨 마지막 기사가 아닌 다른 모든 기사들(:not(:last-child))의 아래쪽에 8px의 하단 마진(margin-bottom)을 부여합니다.
💡 강사의 실무 팁:
:not(:last-child) { margin-bottom: 8px; }기법은 실무에서 리스트 아이템 사이의 간격을 띄울 때 정말 자주 쓰는 베스트 프랙티스(Best Practice)입니다! 맨 마지막 아이템에는 불필요한 여백이 들어가지 않게 막아주어 레이아웃이 깔끔하게 떨어지거든요. (물론 최근엔 부모 요소에gap속성을 주는 방식도 많이 씁니다.)
마지막으로, 광고(ad) 블록들은 다음과 같은 초기 스타일을 가집니다. 뒤에서 보게 되겠지만 개별 광고마다 인라인 스타일 등을 통해 이 기본 스타일을 조금씩 커스터마이징할 수 있습니다.
.ad {
height: 96px;
padding: 6px;
border-color: #555555;
border-style: solid;
border-width: 1px;
}
.ad:not(:last-child) {
margin-bottom: 8px;
}
.ad h2 {
margin-top: 0;
}
.ad div {
position: relative;
float: right;
padding: 0 4px;
height: 20px;
width: 120px;
font-size: 14px;
bottom: 30px;
border: 1px solid black;
background-color: rgb(255 255 255 / 50%); /* 반투명한 흰색 배경 */
}
여기에 특별한 마법은 없습니다. 꽤나 기본적인 평범한 CSS입니다. (광고 상자의 높이와 테두리를 정하고, 그 안에 타이머 숫자를 띄워줄 작은 반투명 상자(.ad div)를 우측 하단 쪽에 플로팅(float: right) 시켜둔 구조입니다.)
자, 이렇게 Intersection Observer API 튜토리얼을 위한 테스트 앱의 무대가 모두 세팅되었습니다! 아주 모던하고 깔끔한 CSS Grid 구조를 가지고 있네요.
다음 챕터부터는 이 비어있는 <main> 태그 안에 자바스크립트로 가짜 기사들과 가짜 광고 박스들을 밀어 넣은 다음, 사용자가 스크롤을 내려서 광고 박스가 화면에 나타날 때 타이머를 작동시키는 자바스크립트 로직을 본격적으로 작성하게 될 겁니다.
지금까지 배운 개념들을 활용해서, 스크롤을 내리며 기사를 읽을 때 중간중간 삽입된 광고들이 "사용자에게 실제로 몇 초 동안 노출되었는가"를 정확히 측정하는 데모를 만들어 보겠습니다. 모든 기적이 일어나는 자바스크립트 코드를 파헤쳐 봅시다!
먼저 우리가 사용할 전역 변수(global variables)들부터 선언하고 시작하겠습니다.
const contentBox = document.querySelector("main");
let nextArticleID = 1;
let visibleAds = new Set();
let previouslyVisibleAds = null;
이 변수들의 역할은 다음과 같습니다.
contentBox
DOM 안의 <main> 요소에 대한 참조입니다. 우리는 이곳에 계속해서 기사(articles)들과 광고(ads)들을 동적으로 끼워 넣을 예정입니다.
nextArticleID
우리가 생성할 각 기사(article)에 부여할 고유한 ID 숫자입니다. 1부터 시작해서 기사를 하나씩 만들 때마다 증가시킬 것입니다.
visibleAds
현재 화면(뷰포트)에 조금이라도 노출되어 있는 광고 요소들을 모아두는 Set(집합)입니다.
previouslyVisibleAds
사용자가 현재 탭을 벗어나서 다른 창이나 탭을 보고 있을 때(문서가 보이지 않게 될 때), 기존에 화면에 노출 중이던 광고 목록을 잠시 저장(임시대피)해 두기 위한 변수입니다.
모든 준비를 마치기 위해, 페이지가 최초로 로드될 때 다음과 같은 초기화 코드를 실행합니다.
// 사용자가 탭을 전환하는 등 페이지 가시성이 변할 때를 감지하는 리스너를 답니다.
document.addEventListener("visibilitychange", handleVisibilityChange);
const observerOptions = {
root: null, // 브라우저 창(뷰포트)을 기준으로 관찰합니다.
rootMargin: "0px",
threshold: [0.0, 0.75], // 0%(완전 가려짐/막 나타남)와 75%(충분히 보임) 두 순간을 캐치합니다.
};
// Intersection Observer를 탄생시킵니다!
const adObserver = new IntersectionObserver(
intersectionCallback,
observerOptions,
);
// 1초(1000ms)마다 타이머를 갱신하고 화면을 다시 그릴 인터벌을 돌립니다.
const refreshIntervalID = setInterval(handleRefreshInterval, 1000);
const loremIpsum =
"<p>Lorem ipsum dolor sit amet, consectetur adipiscing" +
" elit. Cras at sem diam. Vestibulum venenatis massa in tincidunt" +
" egestas. Morbi eu lorem vel est sodales auctor hendrerit placerat" +
" risus. Etiam rutrum faucibus sem, vitae mattis ipsum ullamcorper" +
" eu. Donec nec imperdiet nibh, nec vehicula libero. Phasellus vel" +
" malesuada nulla. Aliquam sed magna aliquam, vestibulum nisi at," +
" cursus nunc.</p>";
// 페이지에 기사와 광고 내용물들을 쭈욱 채워 넣습니다.
buildContents();
가장 먼저 우리는 visibilitychange 이벤트를 듣는 리스너를 하나 설정했습니다. 이 이벤트는 사용자가 브라우저에서 다른 탭으로 넘어가는 등 문서 자체가 아예 숨겨지거나 다시 나타날 때 발생합니다.
왜 이 이벤트가 필요할까요? Intersection Observer API는 탭이 백그라운드로 넘어가서 숨겨지는 현상을 '교차점 변화(intersection)'로 인식하지 않기 때문입니다. (화면 안에서 요소의 위치가 바뀐 게 아니니까요!) 따라서 사용자가 딴짓을 하러 탭을 나갔을 때 광고 노출 시간(타이머)이 계속 올라가는 꼼수를 막기 위해, 이 이벤트를 사용해 타이머를 강제로 일시 정지시켜야 합니다.
다음으로는 타겟 요소(우리의 광고들)를 관찰할 IntersectionObserver의 options를 세팅합니다. 뷰포트를 기준으로 관찰하기 위해 root는 null로, 마진은 딱 맞게 "0px"로 두었습니다.
여기서 가장 중요한 건 threshold 배열에 0.0과 0.75를 넣었다는 점입니다. 이렇게 하면 콜백 함수는 대상 광고가 1. 완전히 화면 밖으로 나가거나 화면 끝자락에 살짝 걸치기 시작할 때(0.0), 2. 광고 면적의 75% 이상이 화면에 드러나거나 다시 75% 밑으로 가려질 때(0.75)마다 실행됩니다.
loremIpsum 변수는 기사의 본문으로 쓰일 더미 텍스트입니다. 실제 현업에서는 데이터베이스나 API에서 텍스트를 긁어오겠지만, 지금은 예제니까 이걸로 퉁치도록 하겠습니다!
그다음 buildContents()를 호출해서 문서 안에 우리가 볼 기사와 광고 덩어리들을 실제로 만들어 끼워 넣습니다.
마지막으로 1초에 한 번씩 handleRefreshInterval 함수를 호출하는 setInterval을 세팅합니다. 이건 화면에 보이는 광고 타이머(숫자)를 1초마다 시각적으로 업데이트해서 보여주기 위해 둔 것입니다. 만약 화면에 시간을 렌더링할 필요 없이 뒤에서 데이터만 모으는 용도라면 굳이 이런 인터벌을 둘 필요가 없겠죠.
자, 그럼 사용자가 탭을 전환했을 때 불리는 visibilitychange 이벤트 핸들러를 뜯어볼까요?
앞서 말했듯 Intersection Observer는 탭의 가시성 상태(이 탭이 현재 활성화되어 모니터에 떠 있는지 여부)까지는 신경 써주지 않습니다. 그래서 우리는 Page Visibility API를 활용해서 탭이 숨겨진 동안에는 광고 타이머가 올라가는 것을 '스톱!' 시켜야 합니다.
function handleVisibilityChange() {
if (document.hidden) {
// 탭이 백그라운드로 넘어가서 화면이 숨겨졌을 때!
if (!previouslyVisibleAds) {
previouslyVisibleAds = visibleAds; // 현재 보고 있던 광고 목록을 임시 대피소에 저장합니다.
visibleAds = new Set(); // 화면에 보이는 광고 목록을 싹 비워버립니다.
previouslyVisibleAds.forEach((adBox) => {
updateAdTimer(adBox); // 지금까지 누적된 시간을 정산(저장)합니다.
adBox.dataset.lastViewStarted = 0; // 타이머가 멈췄다는 의미로 시작 시간을 0으로 리셋합니다.
});
}
} else {
// 사용자가 다시 이 탭으로 돌아왔을 때!
previouslyVisibleAds.forEach((adBox) => {
adBox.dataset.lastViewStarted = performance.now(); // 타이머의 '시작 시간'을 지금 이 순간으로 다시 맞춰줍니다.
});
visibleAds = previouslyVisibleAds; // 대피소에 있던 목록을 다시 복구시킵니다.
previouslyVisibleAds = null;
}
}
이벤트 자체는 탭이 '보이게 된 건지' '숨겨진 건지'를 직접 알려주지 않기 때문에, 우리는 document.hidden 속성을 검사해서 현재 상태를 알아냅니다.
만약 탭이 숨겨졌다면(타이머를 일시정지 해야 한다면), 현재 visibleAds에 있던 요소들을 previouslyVisibleAds 변수로 옮겨 담아 백업해 둡니다. 그리고 visibleAds 셋(Set)을 아예 텅 비워버립니다. 그러고 나서 대피소에 있는 각 광고들에 대해 updateAdTimer() 함수를 불러서 "지금까지 노출된 시간"을 확실하게 정산해서 저장해 둔 뒤, dataset.lastViewStarted를 0으로 만들어 타이머가 멈췄음을 표시합니다.
반대로 사용자가 딴짓을 마치고 다시 이 탭으로 돌아왔다면(문서가 다시 보이게 됐다면), 백업해 두었던 previouslyVisibleAds 목록을 돌면서 각 광고의 dataset.lastViewStarted 값을 지금 이 순간의 시간(performance.now())으로 재설정합니다. 이렇게 해야 과거에 탭이 숨겨져 있던 시간들을 노출 시간에 포함시키는 오류를 막을 수 있으니까요! 마지막으로 이 목록을 다시 visibleAds로 복구시키면 타이머가 다시 정상적으로 째깍째깍 흘러가게 됩니다.
브라우저의 이벤트 루프(event loop)가 한 바퀴 돌 때마다, IntersectionObserver는 자신이 감시하는 요소들 중 임계값을 돌파한 녀석들이 있는지 체크합니다. 돌파한 녀석들이 있다면, 그 요소들의 정보가 담긴 IntersectionObserverEntry 객체 배열(entries)을 만들어서 우리의 콜백 함수로 쏴줍니다.
우리가 만든 intersectionCallback() 콜백은 이렇게 생겼습니다:
function intersectionCallback(entries) {
entries.forEach((entry) => {
const adBox = entry.target;
if (entry.isIntersecting) {
// 요소가 화면에 진입하는 중!
if (entry.intersectionRatio >= 0.75) {
// 광고 면적의 75% 이상이 보이기 시작했다면, 비로소 "노출됨"으로 인정하고 타이머를 켭니다!
adBox.dataset.lastViewStarted = entry.time;
visibleAds.add(adBox);
}
} else {
// 요소가 화면에서 사라지는 중!
visibleAds.delete(adBox); // 일단 보이는 목록에서 뺍니다 (타이머 정지).
if (
entry.intersectionRatio === 0.0 &&
adBox.dataset.totalViewTime >= 60000
) {
// 광고가 화면에서 '완전히(0.0)' 사라졌는데,
// 누적 노출 시간이 1분(60000ms)을 넘었다면?! 새로운 광고로 바꿔치기합니다!
replaceAd(adBox);
}
}
});
}
💡 강사의 팁: 여기서 정말 대단한 비즈니스 로직이 들어갑니다. 보통 광고주들은 "광고 면적의 최소 50%나 75% 이상이 화면에 일정 시간 이상 노출되어야" 돈(광고비)을 줍니다. 그래서
entry.intersectionRatio >= 0.75일 때만 타이머를 작동하게 둔 것이죠.
그리고 광고가 화면 밖으로 완전히 사라졌을 때(entry.intersectionRatio === 0.0), 이 광고가 화면에 노출되었던 누적 시간이 무려 1분(60000ms)을 넘었다면 replaceAd()라는 함수를 불러서 새로운 광고로 갈아 끼웁니다. 이렇게 하면 사용자가 눈치채지 못하게 화면 밖으로 나간 헌 광고만 조용히 새 광고로 교체하여 아주 매끄러운(smooth) 사용자 경험을 제공할 수 있죠!
우리가 설정한 setInterval에 의해 약 1초마다 실행되는 handleRefreshInterval() 함수입니다. 이 녀석의 주요 임무는 매초 타이머 데이터를 업데이트하고, 광고판 안에 표시될 시간 숫자를 다시 그려주는 것입니다.
function handleRefreshInterval() {
const redrawList = [];
visibleAds.forEach((adBox) => {
const previousTime = adBox.dataset.totalViewTime;
updateAdTimer(adBox); // 누적 노출 시간을 현재 시간 기준으로 최신화합니다.
// 시간이 진짜로 올랐다면 다시 그릴 목록(redrawList)에 추가합니다.
if (previousTime !== adBox.dataset.totalViewTime) {
redrawList.push(adBox);
}
});
if (redrawList.length) {
// requestAnimationFrame을 써서 브라우저가 화면을 갱신하는 찰나의 타이밍에
// 텍스트(시간)를 부드럽게 업데이트해 줍니다.
window.requestAnimationFrame((time) => {
redrawList.forEach((adBox) => {
drawAdTimer(adBox);
});
});
}
}
시스템 상황이나 인터벌 오차 때문에 시간이 안 오르고 그대로일 수도 있으니, 이전 시간과 바뀐 녀석들만 redrawList에 담습니다. 그러고 나서 만약 그릴 게 있다면, requestAnimationFrame()을 써서 다음 애니메이션 프레임 때 화면의 숫자를 갱신하도록 예약합니다.
우리가 계속해서 누적 시간을 정산할 때 부르던 updateAdTimer() 함수의 내부입니다.
function updateAdTimer(adBox) {
const lastStarted = adBox.dataset.lastViewStarted;
const currentTime = performance.now();
if (lastStarted) { // 타이머가 돌고 있던 중이라면
const diff = currentTime - lastStarted; // 경과 시간(차이)을 구합니다.
adBox.dataset.totalViewTime =
parseFloat(adBox.dataset.totalViewTime) + diff; // 누적 시간에 더해줍니다!
}
adBox.dataset.lastViewStarted = currentTime; // 시작 시간을 지금으로 다시 리셋!
}
광고 요소 안에는 데이터 속성(data attributes)으로 2가지 상태가 숨어있습니다.
lastViewStarted: 이 광고가 (마지막으로) 가시 영역에 들어와서 타이머가 돌기 시작한 그 순간의 타임스탬프입니다. (안 보일 땐 0입니다.)totalViewTime: 이 광고가 사용자 눈에 보였던 총 누적 시간(밀리초)입니다.이 속성들은 자바스크립트의 HTMLElement.dataset 객체를 통해 접근할 수 있죠. 조심할 점은 dataset 안에 들어있는 값들은 전부 '문자열(String)' 취급을 받는다는 겁니다. 그래서 더하기를 할 때 숫자가 아니라 문자열이 이어 붙여지는 참사("10" + 5 = "105" 가 됨)를 막기 위해, 위 코드에서는 parseFloat()를 사용해 명시적으로 숫자로 변환해 주었습니다.
drawAdTimer() 함수는 밀리초(ms) 단위인 누적 시간을 분:초 형식의 텍스트로 예쁘게 가공해서 광고 영역 안에 그려주는 녀석입니다.
function drawAdTimer(adBox) {
const timerBox = adBox.querySelector(".timer");
const totalSeconds = adBox.dataset.totalViewTime / 1000;
const sec = Math.floor(totalSeconds % 60);
const min = Math.floor(totalSeconds / 60);
// padStart를 써서 '5초'를 '05초'로 2자리로 예쁘게 맞춰줍니다.
timerBox.innerText = `${min}:${sec.toString().padStart(2, "0")}`;
}
맨 처음 페이지가 열릴 때, 기사와 광고를 번갈아가며 생성해 문서에 쑤셔 넣는 buildContents() 함수입니다.
function buildContents() {
for (let i = 0; i < 5; i++) {
// 1. 기사를 하나 만들어서 메인 영역에 붙입니다.
contentBox.appendChild(createArticle(loremIpsum));
// 2. 인덱스가 짝수(0, 2, 4)일 때마다 광고도 하나 만들어서 끼워 넣습니다!
if (!(i % 2)) {
loadRandomAd();
}
}
}
createArticle() 함수는 <article> 태그와 그 안에 들어갈 제목(<h2>), 그리고 본문 텍스트를 조립해서 DOM 요소를 반환하는 평범한 함수입니다. (코드는 원문 참고)
그리고 loadRandomAd() 함수가 바로 광고를 만들어서 집어넣는 역할을 하는데요, 이 녀석이 꽤 다재다능합니다. 처음에는 광고를 새로 '생성(Create)'해서 페이지에 붙이지만, 나중에는 1분 넘게 노출된 기존 광고를 아예 '교체(Replace)'해 버리는 역할까지 수행하거든요!
function loadRandomAd(replaceBox) {
// 실제로는 서버에서 가져올 광고 데이터들입니다.
const ads = [
{ bgcolor: "#cceecc", title: "Eat Green Beans", body: "Make your mother proud..." },
// ... 생략 ...
];
let adBox, title, body, timerElem;
// 무작위로 광고 하나를 뽑습니다.
const ad = ads[Math.floor(Math.random() * ads.length)];
if (replaceBox) {
// 1. 교체 모드일 때: 기존 광고 박스(DOM)를 재활용합니다!
adObserver.unobserve(replaceBox); // 일단 기존 광고의 감시를 중단시킵니다.
adBox = replaceBox;
title = replaceBox.querySelector(".title");
body = replaceBox.querySelector(".body");
timerElem = replaceBox.querySelector(".timer");
} else {
// 2. 신규 생성 모드일 때: 새 DOM 요소를 팝니다.
adBox = document.createElement("div");
adBox.className = "ad";
title = document.createElement("h2");
body = document.createElement("p");
timerElem = document.createElement("div");
adBox.appendChild(title);
adBox.appendChild(body);
adBox.appendChild(timerElem);
}
// 이제 뽑아둔 새 광고 데이터(ad)를 DOM에 덮어씌웁니다.
adBox.style.backgroundColor = ad.bgcolor;
title.className = "title";
body.className = "body";
title.innerText = ad.title;
body.innerHTML = ad.body;
// 새 광고니까 누적 시간 데이터도 0으로 깔끔하게 리셋!
adBox.dataset.totalViewTime = 0;
adBox.dataset.lastViewStarted = 0;
timerElem.className = "timer";
timerElem.innerText = "0:00";
// 신규 생성이었다면 DOM 트리에 추가합니다. (교체 모드였다면 이미 DOM 안에 있으니 패스)
if (!replaceBox) {
contentBox.appendChild(adBox);
}
// 새로워진 광고 상자를 다시 Observer에게 감시하라고 던져줍니다!
adObserver.observe(adBox);
}
// 1분이 지나 수명이 다한 광고를 교체하라고 호출하는 함수입니다.
function replaceAd(adBox) {
updateAdTimer(adBox); // 교체 전, 마지막으로 시간 정산을 확실히 해둡니다.
const visibleTime = adBox.dataset.totalViewTime;
console.log(`Replacing ad: ${adBox.querySelector("h2").innerText} - visible for ${visibleTime}`);
// loadRandomAd 함수에 타겟 박스를 넘겨주어 교체(Replace) 모드로 작동하게 만듭니다.
loadRandomAd(adBox);
}
💡 강사의 팁:
loadRandomAd(replaceBox)패턴을 잘 보세요! 만약 광고를 교체할 때 기존<div>를 삭제하고 아예 새로운<div>를 만들어 끼워 넣었다면, 브라우저가 화면 레이아웃을 다시 계산(Reflow)하느라 엄청난 비용을 치렀을 겁니다. 하지만 위 코드처럼 기존에 있던 박스 껍데기는 그대로 두고 안의 텍스트와 색상만 쏙 바꿔치기하면, 성능상 아주 막강한 최적화를 이룰 수 있습니다!
이 모든 것이 조합된 결과물을 아래에서 직접 확인해 보세요!
스크롤을 오르락내리락 하면서,
1. 광고가 화면 면적의 75% 이상 보일 때만 타이머가 올라가는지,
2. 브라우저 탭을 다른 곳으로 잠시 이동했다가 돌아왔을 때 타이머가 멈춰있었는지,
3. 1분(1:00) 이상 노출된 광고가 화면 밖으로 완전히 밀려났다가 다시 들어올 때 새로운 광고로 '샥' 바뀌는지 체크해 보세요!
(스크롤에 따라 타이머가 동작하는 텍스트/광고 혼합 데모 화면)
수고하셨습니다! 프론트엔드의 성능과 트래킹 퀄리티를 폭발적으로 높여줄 무기, Intersection Observer를 완벽하게 마스터하셨네요! 실무에서 무한 스크롤이나 애널리틱스 이벤트 수집 등을 만드실 때 이 지식을 적극 활용해 보세요! 🚀