실무에서 느낀 점을 곁들인 Intersection Observer API 정리

천재민(Karl)·2021년 9월 30일
166
post-thumbnail
post-custom-banner

요즘 회사에서 컨텐츠의 가시성을 기반으로 작업해야 할 일이 많습니다. 예를 들어, 광고 요금을 청구하기 위해선 광고 노출 통계를 쌓고 이를 기반으로 청구해야 합니다. 이런 경우 scroll 이벤트를 통해 콜백을 호출하여 가시성을 확인하는 방법도 있지만 Intersection Observer API 를 사용하여 작업하기도 합니다.

이 포스팅에선 먼저 Intersection Observer에 대해 소개하고 이 후 실무에서 API를 사용하면서 느낀 한계점에 대해서 얘기해보고자 합니다.


Intersection Observer API란?

Intersection Observer API 는 2016년 4월에 구글 개발자 페이지 를 통해 소개되었습니다. 새롭게 API가 만들어진 이유는 기존의 scroll 이벤트와 가시성 관찰에 사용되는 getBoundingClientRect 의 문제점 때문입니다.

scroll 이벤트는 성능에 악영향을 줄 수 있는데 스크롤시 짧은 시간 내에 수 백, 수 천의 이벤트가 동기적으로 실행될 수 있습니다. 그리고 페이지 내에 각 요소가 각기의 목적(광고, 레이지 로딩, 무한 스크롤 등)의 이유로 scroll 이벤트를 리스닝하기 때문에 이에 상응하는 콜백이 무수히 실행될 수 있습니다. 이는 메인 스레드에 큰 부하를 줄 수 있습니다.

그리고 getBoundingClientRectreflow를 발생시킬 수 있습니다. 본래 브라우저는 최적화를 위해 필요한 여러 작업을 묶어 큐에 쌓아 대기하다가 한 번의 reflow 로 처리하고자 합니다. 그러나 getBoundingClientRect 호출시 값(top, right 등)을 정확히 읽어들이기 위해 큐를 flush하고 스타일을 적용함으로써 다 수의 reflow 를 발생시킬 수 있습니다.

사실, 스크롤 관련 루틴이 실무에서 매우 많이 쓰이고 있으므로 신뢰도있는 공식 API의 필요성도 있었습니다.

Intersection Observer API 는 루트 요소와 타겟 요소의 교차점을 관찰합니다. 그리고 타겟 요소가 루트 요소와 교차하는지 아닌지를 구별하는 기능을 제공하고 있습니다. scroll 이벤트와 다르게 교차 시 비동기적으로 실행되며 가시성 구분 시 reflow 를 발생시키지 않습니다. 여러모로 성능 상 유리합니다.


교차성(가시성)을 계산하는 방법

좀 더 자세히 알아보기 전에 Intersection Observer 가 교차성(가시성)을 계산하는 방법에 대해서 알아봅시다. 앞으로 나올 사용법에서 계산된 교차성(가시성)에 대해 계속 언급이 되니 말입니다.

Intersection Observer 는 모든 영역을 사각형(rectangle)로 취급합니다. 요소가 사각형이 아니거나, 이외 이상하고 불규칙한 모습으로 렌더링되었다고 하더라도, 요소의 모든 부분을 감싸는 가장 작은 사각형으로 가정하고 교차성(가시성)을 계산합니다.

이를 명심하고 글을 계속 읽어주세요.


사용법 및 스펙

일단 Intersection Observer 인스턴스를 생성해봅시다.

let options = {
	root: document.querySelector('#scrollArea'),
	rootMargin: '0px',
  threshold: 1.0
}

// options에 따라 인스턴스 생성
let observer = new IntersectionObserver(callback, options);

// 타겟 요소 관찰 시작
let target = document.querySelector('#listItem');
observer.observe(target);

new 키워드를 통해 인스턴스를 생성합니다. callback , options 2개의 파라미터를 받습니다. callback 은 가시성의 변화가 생겼을 때 호출되는 콜백 로직입니다. options 는 만들어질 인스턴스에서 콜백이 호출되는 상황을 정의합니다.


Options

우선 options 부터 살펴보도록 하겠습니다.

root

타겟 요소의 가시성을 확인할 때 사용되는 루트 요소입니다. 이것은 타겟 요소보다 상위 요소, 즉 요소의 조상 요소이어야 합니다. 설정하지 않거나 root 값을 null 로 주었을 때 기본 값으로 브라우저 뷰포트가 설정됩니다.

rootMargin

margin 을 주어 루트 요소의 범위를 확장할 수 있습니다. 즉 확장된 영역 안에 타겟 요소가 들어가면 가시성에 변화가 생깁니다. CSSmargin 과 유사하게 top, right, bottom, leftmargin 정도롤 각각 설정할 수 있습니다. 기본 값은 0이며 따로 설정 시 단위를 꼭 입력해야합니다.

threshold

콜백이 실행될 타겟 요소의 가시성 퍼센티지를 나타내는 단일 숫자 및 숫자 배열이 들어갈 수 있습니다. 즉, 요소의 top, bottom 이 노출된 순간만 콜백을 실행할 수 있는 것이 아니라 어느정도 타겟 요소가 보여졌는 지에 따라서도 콜백을 호출할 수 있습니다. 예를 들어 요소가 50%만큼 보여졌을 때 탐지하고 싶다면 단일 숫자 값 0.5 를 설정하면 됩니다. 혹은 25% 단위로 가시성이 변경될 때마다 콜백이 실행되게 하고 싶다면 [0, 0.25, 0.5, 0.75, 1] 을 설정하면 됩니다.

// 타겟 요소가 50% 가시성이 확인되었을 때
let observer1 = new IntersectionObserver(callback, {
	threshold: 0.5
});

// 타겟 요소가 25% 단위로 가시성이 확인되었을 때
let observer1 = new IntersectionObserver(callback, {
	threshold: [0, 0.25, 0.5, 0.75, 1]
});

Callback

타겟 요소의 관찰이 시작되거나, 가시성에 변화가 감지되면(threshold 와 만나면) 등록된 callback 이 실행됩니다.

let callback = (entries, observer) => {
  entries.forEach(entry => {
    // 각 entry는 가시성 변화가 감지될 때마다 발생하고 그 context를 나타냅니다.
    // target element:
    //   entry.boundingClientRect
    //   entry.intersectionRatio
    //   entry.intersectionRect
    //   entry.isIntersecting
    //   entry.rootBounds
    //   entry.target
    //   entry.time
  });
};

이 콜백은 메인스레드에서 처리되고 파라미터로 entriesobserver 를 받게 됩니다.

Entries

entriesIntersectionObserverEntry 인스턴스를 담은 배열입니다. 일반적으로 callback 에 파라미터로 전달이 되고 후술할 Intersection Observer.takeRecords() 를 통해 반환받을 수도 있습니다.

IntersectionObserverEntry 는 루트요소와 타겟요소의 교차(threshold 와 만났을 때)의 상황을 묘사합니다. 포함된 프로퍼티들은 모두 읽기전용(read only) 입니다.


  • IntersectionObserverEntry.boundingClientRect : 타겟 요소의 사각형 정보(DOMRectReadOnly)를 반환합니다. getBoundingClientRect() 호출과는 다르게 reflow 를 발생시키진 않습니다.

  • IntersectionObserverEntry.intersectionRect : 타겟 요소의 가시성이 감지된 부분의 정보(DOMRectReadOnly)를 반환합니다.

  • IntersectionObserverEntry.intersectionRatio : 타겟 요소의 intersectionRectboundingClientRect 와 어느정도로 교차(겹치는 지) 비율(0.0 ~ 1.0)을 반환합니다. 바꿔 말하면 타겟 요소가 루트 요소와 얼마나 교차하는지의 정도와 같습니다.


앞서, 타겟 요소의 관찰이 시작되면 콜백 또한 바로 호출된다고 말씀드렸습니다. 타겟 요소와 루트 요소가 전혀 교차하지 않았음에도 불구하고 말입니다. 이는 Intersection Observer 의 기본동작입니다. 이를 예외처리 하기 위해서 intersectionRatio 가 사용됩니다.


let callback = (entries, observer) => {
  entries.forEach(entry => {
	// 타겟 요소가 루트 요소와 교차하는 점이 없으면 콜백을 호출했으되, 조기에 탈출한다.
	if (entry.intersectionRatio <= 0) return

	// 혹은 isIntersecting을 사용할 수 있습니다.
	if (!entry.isIntersecting) return

	// ... 콜백 로직
  });
};

  • IntersectionObserverEntry.isIntersecting : 해당 entry 에 타겟 요소가 루트 요소와 교차하는 지 여부를 Boolean 값으로 반환합니다.
  • IntersectionObserverEntry.rootBounds : 루트 요소의 사각형 정보(DOMRectReadOnly)를 반환합니다. 이 정보는 rootMargin 옵션 설정에 영향을 받습니다.
  • IntersectionObserverEntry.target : 타겟 요소를 반환합니다.
  • IntersectionObserverEntry.time : 문서(Document)가 만들어진 표준 시간(time origin)을 기준으로 타겟 요소와 루트 요소의 교차가 발생한 시간(DOMHighResTimeStamp)을 반환합니다.

Methods


  • IntersectionObserver.observe(targetElement) : 타겟 요소에 대한 관찰을 시작합니다.

  • IntersectionObserver.unobserve(targetElement) : 타겟 요소에 대한 관찰을 중지합니다. 관찰의 목적이 이루어져 굳이 계속 관찰을 할 필요가 없는 경우 사용합니다.

  • IntersectionObserver.disconnect() : 인스턴스의 타겟 요소들에 대한 모든 관찰을 중지합니다.

  • IntersectionObserver.takerecords(targetElement) : IntersectionObserverEntry 인스턴스들의 배열을 리턴합니다.


용례와 한계


MDN Intersection Observer API 페이지에서는 대표적인 용례를 4개 정도 말하고 있습니다.


  • 페이지가 스크롤 되는 도중에 발생하는 이미지나 다른 컨텐츠의 레이지 로딩
  • 스크롤 시에, 더 많은 컨텐츠가 로드 및 렌더링되어 사용자가 페이지를 이동하지 않아도 되게 하는 무한스크롤을 구현
  • 광고 수익을 계산하기 위한 용도로 광고의 가시성 보고
  • 사용자에게 결과가 표시되는 여부에 따라 작업이나 애니메이션을 수행할 지 여부를 결정

이 중 제가 적용한 지연 로딩과 무한스크롤의 예제를 작성해보도록 하겠습니다. 그리고 실무에서 광고의 가시성 보고를 위해 Intersection Observer API를 적용하면서 느낀 한계점에 대해서 얘기해보도록 하겠습니다.


지연 로딩(Lazy Loading)



요소의 dataset 에 저장돼있는 이미지의 url을 타겟 요소와 루트 요소가 교차했을 때(entry.isIntersectingtrue 일 때) 타겟 요소의 src 속성의 값을 dataset 의 이미지 url 로 교체해줍니다.


무한 스크롤(Infinite Scroll)



리스트의 끝을 나타내는 타겟 요소(list-end 클래스를 가진 p 요소)를 관찰하고 루트 요소와 타겟 요소가 교차할 때마다(entry.isIntersectingtrue 일 때) 새로운 item 들을 추가해줍니다.


한계

실무에선 의미없는 광고 노출(광고의 가시성 보고)을 가려내야할 때가 있습니다. 예컨대, 사용자가 화면을 빠르게 스크롤링할 때의 광고 노출은 사용자가 광고를 인식할 수 없으므로 사용자에게, 서비스 제공자에게도 상업적으로 의미없는 노출입니다. 광고의 실효성, 합리적인 광고 요금 청구를 위해선 이러한 광고 노출을 가려내야합니다.

현재 작업하고 있는 줌 쇼핑 모바일입니다. 이렇게 빠르게 넘기는데, 인식할 수 없겠죠.

또한, 한 요청에 노출 보고를 모아서 보내는 것이 유리합니다. 서버와의 네트워크 통신은 생각보다 코스트가 큰 작업이기 때문입니다. 예를 들어, 3개의 광고가 노출되었다고해서 3개의 요청을 보내는 것보단, 묶어서 1개의 요청으로 끝낼 수 있으면 좋겠죠.

사실, 의미없는 광고 노출을 가려내고, 노출 보고를 모아서 보내기 위해선 유저가 스크롤링을 멈췄을 때 노출을 감지하고 보고하는 것이 가장 좋습니다. 스크롤링이 멈췄을 때, getBoundingClientRect 를 이용해 뷰포트 내 요소의 노출을 감지할 수 있습니다. 이렇게 한다면 고속 스크롤링으로 인한 노출을 방지할 수 있고, 노출 보고를 모아서 보낼 수 있습니다.

하지만, 루트 요소와 타겟 요소의 교차로 가시성을 판단하는 Intersection Observer API 로는 이러한 구현 어렵습니다. 일반적인 스크롤링이든 고속스크롤링이든 교차가 이루어지기 때문에 이 둘을 구별하기 어렵습니다.

IntersectionObserverEntry.time 으로 시간차를 계산하더라도, 어느정도의 시간차를 기준으로 스크롤링 종류를 구별할 지도 정하기 어렵습니다. 또한, 교차를 기준으로 콜백을 실행하기 때문에 이미 threshold를 지나쳤으나 타겟요소가 뷰포트 내에 있다고 감지되었을 때, 타겟요소의 콜백을 실행시킬 방법이 없어 노출 보고를 모아서 보내기 어렵습니다. 결국, 이벤트 기반으로 로직으로 회귀할 수 밖에 없습니다.

공식적으로 스크롤을 멈췄을 때 발생하는 이벤트는 제공하지 않습니다. 그래서 scroll-stop 확장 이벤트를 만들어 사용합니다. scroll 이벤트가 발생하는 동안 timer를 계속 초기화하고, 마지막 scroll 이벤트가 종료된 이후 일정시간 동안 scroll 이벤트가 발생하지 않는다면 scroll-stop 커스텀 이벤트를 dispatch 합니다.

// event stop 이벤트
let timer = null;

window.addEventListener("scroll", function() {
  // 기존에 timer가 동작하고 있었다면, clear해준다.
  if (timer !== null) {
    this.clearTimeout(timer);
  }
  
  // timer가 tick되면 scroll-stop event를 dispatch 한다.
  timer = setTimeout(function() {
    const event = new Event("scroll-stop");
    window.dispatchEvent(event);
  }, 150);
});

scroll-stop 이벤트를 활용하면, 해당 이벤트가 발생했을 시 스크롤이 멈췄다는 것을 보장할 수 있고, 그 순간 뷰포트 내 요소의 노출을 감지하여 노출 보고를 모아보낼 수 있습니다. 그리고 사실상 debounce 를 적용한 것이나 마찬가지여서 성능상의 걱정도 덜 수 있습니다.

// scroll-stop 이벤트 핸들러 정의
const scrollStopHandler = function() {
  const { top, right, bottom } = el.getBoundingClientRect();

  if (
    ((top <= window.innerHeight && top >= 0) ||
      (bottom <= window.innerHeight && bottom >= 0)) &&
    right <= window.innerWidth &&
    right > 0
  ) {
    // 이미 노출된 컨텐츠라면
    if (isExposed) return;

    // 컨텐츠가 노출되면 노출 통계를 보낸다.
    // 또한 모아 보고하는 모드(data.isCollect)라면 모아보낸다.
    if (data.isCollect) sendExposedStatForCollect(el);
    else sendExposedStat(el);
    isExposed = true;

    // 사용한 이벤트 핸들러는 지워주기
    el.removeEventListener("scroll-stop", scrollStopHandler);
  }
};

// 스크롤 stop 시 이벤트 감지할 핸들러 등록
window.addEventListener("scroll-stop", scrollStopHandler);

결국, 광고 요금 청구를 위한 통계를 적절히 쌓기 위해선 한계가 있는 Intersection Observer를 사용하기 보다는 전통적인 scroll 이벤트 로직이 적합하다는 것이 제 생각입니다.

의견이 있으시면 댓글로 남겨주세요.

감사합니다.


참고문헌


https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver
https://developers.google.com/web/updates/2016/04/intersectionobserver
https://heropy.blog/2019/10/27/intersection-observer/

profile
나에게 나무를 자를 여섯 시간을 준다면, 나는 먼저 네 시간을 도끼를 날카롭게 하는 데에 쓰겠다.
post-custom-banner

12개의 댓글

comment-user-thumbnail
2021년 10월 6일

잘 읽고 갑니다!

1개의 답글
comment-user-thumbnail
2021년 10월 8일

스크롤 이벤트가 마냥 나쁜 것은 아니군요..역시 상황에 따라 적용하는 것이 베스트인가 봅니다. 글 공유 감사합니다 :)

1개의 답글
comment-user-thumbnail
2021년 10월 14일

근래에 본 도큐먼트 중에 최고입니다. 정리가 너무 깔끔하네요 :)

1개의 답글
comment-user-thumbnail
2022년 4월 19일

감사합니다.

답글 달기
comment-user-thumbnail
2023년 3월 25일

광고에서도 가시성과 가시성에서 벗어났을때의 콜백을 이용한다면
오히려 스크롤 이벤트를 사용하지 않고도 편하게 구현이 가능하지 않을까요 ?

답글 달기
comment-user-thumbnail
2023년 8월 6일

와 대박 ㅋㅋ이해 잘됩니다 ㄷㄷ

답글 달기
comment-user-thumbnail
2023년 8월 28일

좋은글 감사합니다. 비슷한 이슈로 기능을 개발중인데 많은 도움 되었습니다.
서버로 노출 아이템을 모아보내기를 할때, 일단 localStoreage에 저장후 setTimeout를 지정하여 전송하였습니다.
서버단에서는 redis 의 자료구조와 함수를 활용하여, 사용자를 기준으로 노출 아이템이 중복카운팅 되지 않도록 하였습니다.

답글 달기
comment-user-thumbnail
2023년 10월 14일

잘 정리된 글 감사합니다 :)
개인 프로젝트중 Intersection Observer API를 사용하고 있는데
이해하기 쉽게 정리해주셔서 도움이 많이 됐습니다.

답글 달기
comment-user-thumbnail
2024년 7월 10일

와 진짜 완벽한 글입니다

답글 달기