프론트엔드에서 여러 비즈니스 피처를 개발하다 보면, 특정 엘리먼트가 뷰포트에 진입/포함되는 시점을 추적하고, 무언가 작업을 수행하는 식의 기능을 구현하는 경우가 왕왕 있습니다. 이미지를 동적으로 지연 로드한다거나, 광고 노출 여부를 체크한다거나, 무한 스크롤 페이지네이션을 구현하는 등의 경우가 대표적이겠습니다.
과거에는 이런 기능들을 주로 스크롤 이벤트, 타이머 등을 통해 구현했습니다. 하지만 이런 방식은 짧은 시간에 너무 많은 작업이 발생한다는 점에서 성능 문제를 야기하게 될 가능성이 높기 마련입니다.
아무리 디바이스의 성능이 상향 평준화되었다고는 해도, 안일하게(?) 접근하면 프레임 레이트가 불안정해지는 등 버벅임이 눈에 띄어 사용자 경험을 저해할 수 있는 문제가 되기도 하기 때문에, 기본적이지만 중요한 최적화 지점으로 작용합니다.
그래서 throttle
, debounce
같은 테크닉을 적용하여 이슈를 방어하기도 하지만, 상황에 따라서는 본래 의도한 기능과 완벽하게 부합하지 않는 경우가 있을 수도 있어 만능은 아닐 수 있습니다.
여하튼 이러한 문제를 해결하기 위해 Intersection Observer API
라는 것이 등장했고, 훨씬 더 효율적인 방법으로 특정 엘리먼트가 그 상위의 엘리먼트나 뷰포트에 교차(intersect)하는지 여부를 관찰(observe)할 수 있게 되었습니다.
아직
Intersection Observer
가 생소하신 분이 계시다면, MDN 문서를 한 번 살펴보시면 도움이 될 것입니다. 컨셉과 사용법이 복잡하지 않아, 예제 코드를 보면서 어렵지 않게 적용해보실 수 있습니다.
개인적으로는 과거에 개발된 몇몇 페이지들을 리팩토링하는 과정에서 실제로 서비스에 적용해본 적이 있었습니다. 가령 상품의 이미지를 지연 로드하는 Lazy Loading
같은 경우에는 특정한 jQuery
기반 플러그인에 의존하고 있었는데, 레거시였기 때문인지 내부적으로 스크롤 이벤트를 활용해 구현되고 있었으므로 적절한 최적화 대상이었습니다.
몇 줄의 코드로도 수정이 가능해 작업은 오래 걸리지 않았지만, 구체적으로 얼마나 개선 효과가 있을지가 궁금해졌습니다. 다만 다른 변인이 없는 상황에서 순수히 Intersection Observer
의 효과를 파악해야 할 것이므로, 실제 서비스에서가 아닌 별도의 간단한 데모 페이지를 별도로 만들어 비교해보기로 하였습니다.
리스트 형태의 아이템을 100개 배치하고, 50번째 아이템이 뷰포트에 진입하면 그 다음 위치(50번째와 51번째 사이)에 새로운 아이템을 삽입하는 시나리오를 가정해 보았습니다. 가령 AJAX 호출을 통해 어떤 광고 콘텐츠 같은 것을 노출하는 상황이라고 할 수 있겠습니다.
먼저, 스크롤 이벤트로 구현한다면 아래와 같이 이벤트가 발생할 때마다 트리거 지점이 될 타겟 엘리먼트(50번째 아이템)가 뷰포트에 진입했는지를 판단할 수 있어야 합니다.
const productItems = document.querySelectorAll('.product-item');
const isInViewport = (targetElement) => {
const rect = targetElement.getBoundingClientRect();
const windowHeight = (window.innerHeight || document.documentElement.clientHeight);
const windowWidth = (window.innerWidth || document.documentElement.clientWidth);
const isInVertical = (rect.top <= windowHeight) && ((rect.top + rect.height) >= 0);
const isInHorizontal = (rect.left <= windowWidth) && ((rect.left + rect.width) >= 0);
return isInVertical && isInHorizontal;
};
const appendAdItem = (targetItem) => {
const adItem = document.createElement('li');
adItem.classList.add('product-item', 'ad');
adItem.textContent = 'AD';
targetItem.parentNode.insertBefore(adItem, targetItem.nextSibling);
};
document.addEventListener('scroll', () => {
productItems.forEach((productItem) => {
if (
isInViewport(productItem) &&
productItem.dataset.seq % 50 === 0
) {
appendAdItem(productItem);
}
});
});
일단 isInViewport()
함수를 살펴보겠습니다. 이름대로 엘리먼트가 뷰포트에 진입했는지 여부를 반환하는데, getBoundingClientRect()
가 눈에 띕니다. 엘리먼트의 크기와 뷰포트 내에서의 상대적인 위치 정보를 제공하는 DOMRect를 반환하는 메소드인데, 브라우저가 엘리먼트의 크기와 위치를 동기적으로 파악하고 계산하는 과정에서 페이지 전체를 다시 레이아웃하는 reflow
가 발생할 수 있어 주의 깊게 사용해야 합니다.
getBoundingClientRect()
가 그 자체로 무조건reflow
를 발생시키는 것은 아니며, 다른 엘리먼트의 상태나 브라우저의 최적화 로직 등 코드 실행 시점에서의 상황에 따라 다릅니다.
따라서reflow
에만 주목하기보다는, 이러한 계산 작업이 메인 스레드에서 집중되고 있다는 점도 함께 고려하는 편이 바람직합니다.
해당 내용에 대해서는 레이아웃 / 리플로우를 강제하는 요소 및 크고 복잡한 레이아웃 및 레이아웃 스레싱 피하기 문서를 참고해보시면 도움이 됩니다.
게다가 이런 작업을 이벤트가 발생할 때마다, 그것도 모든 .product-item
엘리먼트를 순회하면서 실행하고 있으니 뭔가 문제가 있을 것이라는 느낌이 듭니다. 위 데모 정도의 단순한 페이지에서는 체감이 잘 안 될 수 있지만, 만약 실제 서비스라거나 어떤 무거운 작업을 수행한다면 성능 문제가 체감 가능한 수준으로 발생할 가능성이 높습니다.
const options = {
root: null,
rootMargin: '0px',
threshold: 0
};
const io = new IntersectionObserver((entries, observer) => {
entries.forEach((entry) => {
if (
entry.isIntersecting
entry.target.dataset.seq % 50 === 0 &&
) {
observer.unobserve(entry.target);
appendAdItem(entry.target);
}
});
}, options);
productItems.forEach((productItem) => {
io.observe(productItem);
});
위 예제는 먼저 보여드린 코드와 동일한 동작을 하지만, Intersection Observer
를 사용해 재구현한 것입니다. isInViewport()
함수가 필요하지 않게 되었고, 스크롤 이벤트도 사용하지 않게 되었습니다. 모든 엘리먼트를 매번 전체 순회하지 않게 되었다는 점도 중요합니다.
인스턴스를 생성하면서 제공한 콜백은 타겟 엘리먼트가 루트 엘리먼트와 교차할 때(isIntersecting
) 실행되며, 함께 전달한 options
객체는 실행 조건을 제어합니다.
root
: 타겟 엘리먼트의 가시성을 판단할 때 기준이 되는 요소입니다. 타겟 엘리먼트의 상위(부모) 엘리먼트여야 하며, 값이 없거나 null이라면 기본값인 뷰포트로 설정됩니다.rootMargin
: root의 여백을 설정합니다. CSS margin 속성과 유사하게top, right, bottom, left
으로px
또는%
값을 설정할 수 있습니다. 교차 여부를 판단하기 전에 적용됩니다. 기본값은 0 입니다.threshold
: 타겟 엘리먼트의 가시성 퍼센테이지를 나타냅니다. 50%만큼 보여졌을 때를 판단하려면 0.5를, 25% 단위로 가시성이 변경될 때마다 콜백을 호출하려면 배열로 [0, 0.25, 0.5, 0.75, 1] 과 같이 설정하는 식입니다. 기본값은 0 이므로 1px 라도 교차하는 순간 콜백이 호출됩니다.
그런 이후 각 리스트 아이템(.product-item
)을 순회하면서 해당 요소를 관찰 대상으로 지정하는 식이며 observe()
, 조건을 충족해 엘리먼트를 삽입했다면 옵저버가 관찰을 중지하게 합니다 unobserve()
.
Intersection Observer
도 내부적으로는 getBoundingClientRect()
와 비슷하게 boundingClientRect를 계산하지만, 결정적인 차이는 이것을 비동기적으로 다른 스레드에서 실행한다는 것입니다.
때문에 스크롤 이벤트로 구현했을 때에 비하면 상대적으로 문제가 없게 되는 것이고, 메인 스레드가 훨씬 여유로워지므로 높은 성능을 달성할 수 있게 됩니다.
그러면 이제 간단히 프로파일링을 해 보면서 구체적으로 어떤 차이가 있는지 확인해 보겠습니다. 다만 데모 페이지의 구조가 매우 가볍고 단순하기 때문에, 실제 상황에 비하면 성능의 문제가 극적으로는 나타나지 않을 수도 있겠다는 전제로 접근해 보았습니다.
일단 DevTools
에서 CPU 성능을 최대로 스로틀(6x slowdown)하여 문제 상황을 강조해보고자 하였습니다만, 실제 사용자들의 디바이스의 성능을 정확히 재현, 에뮬레이션하는 것은 아니므로 일정 부분은 개선 효과를 추론/유추해야 할 것입니다.
(섬네일로는 알아보기 어려워 원본 이미지로 크게 보셔야 하는 점 양해 부탁드립니다.)
놀랍게도 데모 페이지이므로 별 차이가 없을지도 모른다는 추측과는 다르게, 스크롤 이벤트로 구현하는 경우에는 문제가 매우 확실하게 나타나고 있었습니다. CPU 스로틀링의 영향으로 더욱 두드러져 보인다고는 하나, 메인 스레드(이미지에서 Main
섹션)에서 스크롤 이벤트(Event: scroll
)와 isInviewport()
, getBoundingClientRect()
함수 호출(Function Call
)에서 엄청난 병목bottleneck이 발생하고 있음을 알 수 있습니다.
특히 주목해야 할 부분은 Frame
섹션입니다. 대체로 200ms 정도가 소요되고 있고, 중간중간 400~500ms가 소요되기도 하는데, 이는 말 그대로 특정 화면(애니메이션 프레임)을 그리는 데에 무려 0.x초 대가 소요되었다는 것입니다.
표준 명세상 일반적으로 브라우저의 최대 프레임 레이트는 현재 모니터의 주사율을 따르도록 되어 있습니다. 따라서 60Hz, 즉 최대 60fps의 환경을 전제하는 경우, 하나의 프레임은 16.7ms 내에 그려져야 합니다(1000ms / 60frames).
그런데 이것도 브라우저의 오버헤드를 고려하지 않았을 때의 이야기이고, 보통은 4~5ms 정도는 더 적게 잡아야 하므로 결국 한 프레임이 12 ~ 13ms 내에 그려져야 한다는 얘기가 됩니다.
이러한 사실을 생각해보면, 위와 같은 케이스는 이미 심각한 프레임 드랍frame dropping이 발생하여 전혀 부드러움을 체감할 수 없게 된 상황이라고 해석할 수 있습니다. 하나의 화면을 채 그리기도 전에 업데이트가 요청되었으니, 띄엄띄엄 화면을 그리게 되면서 뚝뚝 끊겨 보이는 것입니다.
중간에 보이는
Layout Shift(적색 막대)
와 그 아래의Recalculate Style(보라색 막대)
는reflow
작업을 가리킵니다. 새로운 엘리먼트를 삽입하면서 발생한 것으로, 중간에 추가된 리스트 아이템으로 인해 레이아웃이 이동하였으므로Layout Shift
가, 문서 내 엘리먼트의 크기와 위치를 다시 계산해야 하므로Recalculate Style
프로세스가 수행되었음을 알 수 있습니다.
특히 적색 사선으로 표시된 막대는 Dropped Frame
을 나타내는데, 이는 말 그대로 브라우저의 Compositor
가 프레임을 아예 버린 것을 의미하며, 실제 테스트 과정에서 한 번에 많이, 빠르게 스크롤하는 경우 간헐적으로 흰 화면이 나타나면서 발생하는 현상임을 확인할 수 있었습니다.
종합해보면, 메인 스레드에서 수행되는 작업들의 영향으로 화면을 거의 정상적으로 그릴 수 없는 상황이라고 할 수 있겠습니다.
반면 Intersection Observer
로 구현한 경우에는 확연하게 차이가 나타나고 있습니다. 메인 스레드를 보면 화면을 그리는 Composite Layer(녹색 막대)
작업과, 엘리먼트의 교차 여부/가시성을 계산하는 Compute Intersections(보라색 막대)
작업 정도만 수행되고 있으며, 소요된 시간도 약 10 ~ 20ms 대로 짧은 편이어서 이전과 대비해 크게 부하가 되는 수준은 아님을 알 수 있습니다.
Frame
섹션 역시 극적인 차이가 보입니다. 막대 하나하나의 길이가 매우 짧은데, 소요된 시간은 16.7 ~ 33.4ms으로 30 ~ 60fps 수준의 프레임 레이트를 유지했음을 알 수 있습니다. CPU 스로틀링 때문에 60fps를 고정적으로 유지하지는 못했지만, 스크롤 이벤트에 비하면 막대한 성능 차이를 보여주고 있습니다.
Frame Rendering Stats
를 비교해보면 그래프가 상대적으로 매우 안정적임을 알 수 있는데, 프레임의 안정성에서 크게 차이가 나고 있음을 알 수 있습니다.
한정된 자원을 최대한 효율적으로 사용하여 작업을 수행할 수 있도록 하는 것은 모든 개발자들의 공통적인 미션입니다만, 프론트엔드는 특히 사용자와 직접적으로 맞닿는 포지션이라는 점에서 성능 최적화가 민감한 토픽이 되는 경우가 빈번하다고 할 수 있습니다.
이번 글에서 다룬 Intersection Observer
는 비교적 부담 없이 사용해볼 수 있는 기술이니, 이 글을 읽어보시는 분들께서도 업무나 과제에 적용해보시면 최적화에 도움이 되실 거라 생각됩니다.