들어가며..
스크롤 이벤트를 이용한 성능 최적화를 위한 방법이 무엇이 있을지 알아보고자 한다.
보통 addEventListener()의 scroll 이벤트를 이용하여 특정 위치에 도달했을 때 어떤 액션을 취하도록 한다.
document에 스크롤 이벤트를 등록하고, 특정 위치를 관찰하며 엘리먼트가 위치에 도달했을 때 실행할 콜백 함수를 등록하는 것이다.
document.addEventListener("scroll", function() {
console.log(`scroll`);
});
하지만 scroll 이벤트는 사용자가 스크롤 할 때마다 이벤트를 발생시키면 단시간에 수백, 수천번 호출될 수 있어 서버와 클라이언트 모두 매우 큰 부하가 발생한다. 또한 동기적으로 실행되기 때문에 메인 스레드에 영향을 준다.
위와 같이 스크롤 할때마다 console.log가 어마어마하게 찍히는 것을 볼 수 있다.
스크롤 이벤트에 조금이라도 무거운 코드를 작성하게 된다면 reflow, repaint 같은 비용이 많이 드는 렌더링이
스크롤 시마다 계속 일어나 최악의 상황에는 브라우저가 뻗어버릴 수 있다.
throttle, debounce 함수 이용
throttle과 debouce 모두 특정 함수의 호출 횟수를 줄여, 웹 성능이 저하되는 것을 방지하기 위해 사용한다.
스크롤 이벤트를 이용하게 되면 throttle 함수를 이용하여 이벤트가 트리거 되는 텀을 조정하고,
리사이즈 이벤트를 이용하게 되면 debounce 함수를 이용하여 이벤트가 완료되고 나면 실행되도록 최적화한다.
간단하게 정의하면,
- throttle: 일정 시간마다 함수를 호출한다.
- debounce: 일정 시간 이후에 함수를 호출한다.
function onScroll() {
// do something
}
document.addEventListener('scroll', throttle(onScroll, 300));
function onResize() {
// do something
}
document.addEventListener('resize', debounce(onResize, 300));
문제점
throttle, debounce 함수의 치명적인 단점이 있다.
throttle 함수는 debounce를 기반으로 동작하며, debounce는 setTimeout 기반으로 동작한다.
즉, 둘 다 setTimeout이라는 Web API에 의해 실행되는데 setTimeout, setInterval은 정확한 지연 시간을 보장해주지 않는다.
싱글 스레드로 동작하는 JavaScript 엔진은 setTimeout의 비동기 API 태스크들을 Task Queue에 넣어둔 후 순차적으로 처리한다. Queue에 저장된 비동기 태스크를 처리하는 시점은 Call Stack이 비어져 있을 경우이다. 이 시점이 setTimeout 또는 setInterval에 할당해준 delay와 맞지 않는다면 등록해둔 callback(setTimeout 또는 setInterval의 비동기 API)은 trigger 되지 않을 수 있다.
따라서 setTimeout을 기반으로 한 것으로 기대한 결과대로 동작하지 않을 수 있다.
때문에, 정교한 작업의 개발이 필요하다면 다른 방법을 찾아보아야 한다.
requestAnimationFrame (rAF) 함수 이용
위 방식으로는 브라우저가 렌더링 할 수 있는 능력보다 함수가 실행되는 횟수가 더 많다는 문제가 있다.
즉, 일부러 300ms 마다 trigger 하려 하지 않아도 브라우저가 렌더링 할 수 있는 '능력'에 맞추어 이벤트를 trigger 해줄 수 있다.
브라우저는 60fps(초당 60회)로 화면을 렌더링 한다.
이 렌더링에 최적화하기 위해 requestAnimationFrame API를 이용해서 해결할 수 있다.
✨ 다시 정리 !!
requestAnimationFrame은 브라우저가 렌더링 할 수 있는 능력에 따라 이벤트를 트리거해주는 함수이다.
setTimeout, setInterval은 화면에 해당 요소가 보이든 말든 상관없이 무조건 콜백 함수를 실행하지만, rAF는 화면에 요소가 보이지 않을 시 콜백 함수가 호출되지 않는다.
rAF API도 setTimeout처럼 callback으로 넘겨지는 함수를 비동기 task로 분류하여 비동기적으로 처리한다.
하지만 setTimeout은 Task Queue에 쌓여 실행되지만, requestAnimationFrame은 Animation frames라는 queue에 쌓여 처리되며,
setTimeout 두번째 파라미터로 전달되는 delay 값이 브라우저 렌더링에 최적화되어 있다는 차이가 있다.
function onScroll() {
let scheduledAnimationFrame = false;
let lastScrollY = window.scrollY;
if (scheduledAnimationFrame) {
return;
}
scheduledAnimationFrame = true;
requestAnimationFrame(function() {
console.log(`scroll: ${lastScrollY}`);
// do something
scheduledAnimationFrame = false;
});
}
document.addEventListener("scroll", onScroll, {
passive: true, // preventDefault 호출하지 않음
});
스크롤을 발생시키면 어떤 일이 벌어지는지 순차적으로 살펴보자.
- 스크롤 이벤트가 발생한다.
- onScroll 함수가 호출된다.
- scheduledAnimationFrame 변수가 false 인 경우에만 다음 단계를 진행한다.
- scheduledAnimationFrame 변수를 true로 설정하여 추가 스크롤 이벤트 예약을 막는다.
- rAF의 콜백으로 넘겨지는 함수가 호출되어 animation frames에 들어간다. 이 콜백 함수는 브라우저의 다음 리페인트 전에 실행된다.
- 콜백 함수가 event loop에 의해 실행되고, 콜백 함수 내부에서 원하는 작업을 수행한다.
- 콜백 함수 실행이 끝나면, 다시 scheduledAnimationFrame 변수를 false로 설정하여 새로운 스크롤 이벤트 예약을 허용한다.
- 다음 스크롤 이벤트가 발생하면 위 단계를 반복한다.
이처럼 requestAnimationFrame()에 콜백을 넘겨주면 브라우저가 화면을 다시 그리기 전에 해당 함수를 호출한다.
실제로 호출되어야 하는 콜백 함수가 rAF에 의해 비동기로 처리되고, scheduledAnimationFrame에 의해 브라우저 렌더링 범위 내에서 animation frame에 들어가게 되므로 스크롤 이벤트를 최적화할 수 있다.
위와 같이 함으로써 스크롤 이벤트가 너무 자주 발생하는 것을 방지하고, 브라우저가 렌더링 할 수 있는 만큼의 스크롤 이벤트 구현이 완성되어 부드럽고 최적화된 스크롤 처리를 할 수 있게 된다.
Passive Listener
브라우저는 기본적으로 preventDefault를 호출하는지 호출하지 않는지를 감시하게 된다.
스크롤 이벤트를 호출할 경우에는 event 객체의 preventDefault 를 호출하지 않기 때문에 이 비용을 절감할 수 있다.
passive 속성을 true로 지정해줄 경우, preventDefailt API를 호출하지 않음을 명시하여 event.preventDefault 가 호출되는지에 대한 감시 비용을 줄일 수 있다. 즉, 이벤트 리스너 등록 시 passive 옵션에 true를 설정하면 컴포지터는 이 리스너에서 preventDefault()를 호출하지 않을 것이라고 판단해 스크롤 시 바로 화면을 합성하고 따라서 부드러운 스크롤이 동작하게 된다.
따라서 만약 스크롤되는 화면에서 터치나 휠 같은 이벤트를 사용한다면 {passive: true} 옵션을 사용해서 이벤트 리스너를 등록하는 것을 고려해 볼 필요가 있다.
passive가 true인 이벤트 리스너는 preventDefault()를 호출할 수 없다.
preventDefault()를 호출할 일이 없으니, 브라우저의 메인 스레드까지 악영향을 끼쳐 사용자가 페이지 전체가 버벅거린다 느낄 수 있는 참사를 막을 수 있다.
아래와 같이 DOM Element에 이벤트 등록할 때 사용하는 API인 addEventListener의 세 번째 인자로 이 속성을 전달하면 된다.
document.addEventListener("scroll", onScroll, { passive: true });
passive 옵션은 모든 브라우저와 버전에서 지원하지 않기 때문에 가능한 경우에만 사용해야 하고, 크롬(55+)이나 파이어폭스(61+)와 같은 브라우저에서는 기본값이 true로 설정되어 있어 반대의 경우에 false로 설정할 필요가 있다.
따라서 먼저, 브라우저에서 passive 속성을 지원하는지에 대한 판단이 필요하다. 아래 코드를 추가할 것을 권장한다.
// if they are supported, setup the optional params
// IMPORTANT: FALSE doubles as the default CAPTURE value!
const passiveEvent = passiveEventSupported
? { capture: false, passive: true }
: false
document.addEventListener("scroll", onScroll, passiveEvent);
참고
https://developer.mozilla.org/ko/docs/Web/API/Window/requestAnimationFrame
https://jbee.io/web/optimize-scroll-event/
https://fe-developers.kakaoent.com/2022/220120-ux-and-perf-in-kakaowebtoon/
'Devlog > JavaScript' 카테고리의 다른 글
[JavaScript] Image Lazy Loading과 Intersection Observer API (0) | 2022.08.10 |
---|---|
[JavaScript] $(document).ready() / $(function(){}) / window.onload 차이 (0) | 2022.08.04 |
[JavaScript] script의 속성 async와 defer (0) | 2022.07.12 |
[JavaScript] Promise 알아보기 (0) | 2022.06.29 |
[JavaScript] Slick Slider 모바일 스크롤 시 autoplay 멈춤 현상 (0) | 2022.06.09 |