Skip to content
Ethan Sup's log
Github

스크롤 이벤트 제대로 다루기(w. 브라우저 렌더링 원리)

Performance, Web3 min read

들어가며

스크롤 이벤트를 다루는 기술을 떠올려보면 크게 세가지가 떠오른다.

애플 제품 소개 페이지

Scroll Interaction(Scroll Tracking)

리디북스 홈페이지

Lazy loading

Pinterest

Infinite Scroll

이러한 스크롤 이벤트를 다루는 페이지들에서는 fps drop을 중요시 여긴다. 많은 디바이스는 60fps로 작동하는 것을 인지해야 한다. 그리고 한 프레임이 1000 / 60 ms, 16ms 안에 렌더링을 완료 해야한다. 그렇지 않으면 서비스 이탈로 이어질 가능성이 높아진다.

한 화면이 16ms 안에 렌더링 되어야 하고, 총 10ms 내에 JavaScript 실행을 마쳐야 한다.

https://web.dev/rendering-performance/

그렇다면 스크롤 이벤트 최적화가 필요할텐데, 어떻게 스크롤 이벤트 최적화를 해야할까?

Throttling with requestAnimationFrame...?

Throttling 개념을 모른다면 https://css-tricks.com/debouncing-throttling-explained-examples/ 을 참고하세요!

throttle.html
1<!DOCTYPE html>
2<html>
3<head>
4</head>
5<body style="height: 500vh;">
6 <script>
7 let ticking = false;
8 function onScroll() {
9 if (!ticking) {
10 requestAnimationFrame(() => {
11 console.log("tick") // Changing some DOM.
12 ticking = false;
13 })
14 }
15 ticking = true;
16 }
17 window.addEventListener('scroll', onScroll);
18 </script>
19</body>
20</html>

리페인트 전에 호출되는 requestAnimationFrame을 이용해 frame이 생성되는 delay 이내에 들어온 이벤트들을 무시한다는 이론과 함께 만들어진 패턴이다. 밑의 그림을 보면 정말 throttling이 제대로 작동하는 것처럼 보인다.

Result Throttling

하지만 위의 경우와 반대로 Throttling이 있을 때 더 로깅되는 때도 있다.

...? 뭔가 이상하다. 몇 번 Throttle되었는지 확인해보자.

throttle-2.html
1<!DOCTYPE html>
2<html>
3<head>
4</head>
5<body style="height: 500vh;">
6 <script>
7 let ticking = false;
8 function onScroll() {
9 if (!ticking) {
10 requestAnimationFrame(() => {
11 console.log("tick") // Changing some DOM.
12 ticking = false;
13 })
14 } else {
15 console.log("Throttled") // log when Throttled
16 }
17 ticking = true;
18 }
19 window.addEventListener('scroll', onScroll);
20 </script>
21</body>
22</html>

위 코드는 몇 번 Throttle되었는지 확인하는 코드이다. 과연 이 코드에서는 몇번의 Throttled를 로깅할까?

Result Throttling 2 throttle-2.html 결과

정답은 0번이다. 왜 Throttling이 적용되지 않을까? 우리는 이를 알기 위해 브라우저 렌더링이 어떻게 흘러가는지 알아보아야 한다.

브라우저 렌더링 원리

브라우저 렌더링 원리를 찾아보면 많이 나오는 그림은 다음과 같다.

Browser Rendering Basic https://www.html5rocks.com/en/tutorials/internals/howbrowserswork/

유명한 그림이지만 2011년에 나온 그림이다. 꽤 오래된 그림이므로 새로운 그림으로 바꿔보았다.

Browser Rendering Advanced https://web.dev/rendering-performance/

  • JavaScript: 처음 렌더링 시 HTML 파싱 및 DOM 트리 구성, JavaScript Engine(ex.V8, SpiderMonkey...)의 JS 실행이 여기 포함된다. event handler에 의해 실행되는 JavaScript도 여기에 포함된다.
  • Style(Recalculate Style): CSS 파싱 후 CSSOM 생성과 함께 한 요소의 Computed Style을 생성한다. 그 뒤에는 DOM Tree와 합쳐서 Render Tree를 만든다. 아래 그림은 웹 개발자 도구에서 볼 수 있는 Computed Style이다. Computed Style console
  • Layout(Reflow): 이제 Render Tree를 순회하면서 해당 노드의 위치와 크기를 측정하여 Layout Tree를 만든다. 웹 페이지에 보이는 요소로만 만드므로 display: none이 담긴 요소는 Layout Tree에 포함되지 않고, ::before같은 pesudo class의 콘텐츠는 Layout Tree에 포함된다.
  • Update Layer: Layout Tree에서 일정한 기준(ex. transform 속성 등)에 따라 Layer를 생성하고, Layer Tree를 만든다.
  • Paint: Layer Tree를 바탕으로 Layer의 모든 시각적 요소를 그리는 순서(Paint Record)를 제작한다. 아래 그림은 BCSD Lab에서 만든 코인 어플리케이션의 Layer를 보여준다. KOIN Layer Console
  • Composite: Compositor 스레드에서 레이어를 타일로 나눈 뒤, 나눈 타일을 Raster 스레드로 넘긴다. 그 이후 타일을 픽셀화 해서 GPU에 저장한다. 이후 타일을 합쳐서 프레임을 만들고 GPU에 렌더링 요청을 한다. How Composite works Composite 과정

더 자세한 렌더링 과정은 다음을 참고하세요!

DEVIEW 웹 성능 최적화에 필요한 브라우저의 모든 것

렌더러 프로세스의 내부 동작

위와 같은 과정을 Vsync 신호가 나올 때마다 반복한다. 이 때 requestAnimationFrame의 콜백은 어디서 호출되는가를 알아보자면 Style(Recalculating Style)을 하기 전에 실행된다.

life of frame

Event loop와 requestAnimationFrame

requestAnimationFrame에 보낸 callback이 어디에서 저장되는지 살펴보면 아래와 같다.

Event Loop with callstack

Timer나 event handler를 저장하는 (Macro)Task Queue, Promise handler와 MutationObserver callback를 저장하는 MicroTask Queue, 그리고 requestAnimationFrame의 callback을 저장하는 AnimationFrame이 있다. 즉 Style을 계산하기 전에 해당 프레임에 AnimationFrame에 저장된 callback들을 모두 실행한다. 새로고침 아이콘이 의미하는 Event loop가 어떻게 Queue에 있는 Task들을 Call stack에 두는지 자세하게 보면 다음과 같다.

Event Loop

여기서 Layout Shift가 있는 고리가 무엇인지 궁금할 것이다. Layout Shift는 JS에서 일정 변수를 불러오거나 함수를 호출하면 Layout 계산을 하는 것으로 현재 실행되는 JS Stack 위에 CallStack을 쌓는 방식으로 계산한다. 도중 Layout Shift가 발생하면 rAF, Style, Layout을 차례대로 수행한 뒤 다시 JS를 수행한다.

이제 다시 코드로 돌아가보자.

throttle-2.html
1<!DOCTYPE html>
2<html>
3<head>
4</head>
5<body style="height: 500vh;">
6 <script>
7 let ticking = false;
8 function onScroll() { // 1. Trigger Scroll
9 if (!ticking) {
10 requestAnimationFrame(() => { // 2. rAF
11 console.log("tick") // 4. Changing some DOM.
12 ticking = false; // 5. unblock
13 })
14 } else {
15 console.log("Throttled") // log when Throttled
16 }
17 ticking = true; // 3. block
18 } // 6. render something
19 window.addEventListener('scroll', onScroll);
20 </script>
21</body>
22</html>

이 코드를 분석해보면, 다음과 같다.

  1. 사용자가 스크롤해서 onScroll이 실행된다.
  2. AnimationFrame에 callback을 저장한다.
  3. tickingtrue로 변경해 로직에 접근할 수 없게 막는다.
  4. AnimationFrame에 있는 callback을 실행해서 로직을 실행한다(여기서는 console.log)
  5. tickingfalse로 변경해 로직에 접근할 수 있다.
  6. 렌더링을 실행한다.

즉, 이 코드는 의미가 없다고 볼 수 있다. 그렇다면 스크롤 이벤트 최적화를 위해 어떤 작업을 거쳐야 할까?

passive event

passive-event.html
1<!DOCTYPE html>
2<html>
3<head>
4</head>
5<body style="height: 500vh;">
6 <script>
7 let ticking = false;
8 function onScroll() {
9 console.log("tick");
10 }
11 window.addEventListener('scroll', onScroll, {passive: true});
12 </script>
13</body>
14</html>

기본적으로 element에 event handler가 추가되면 컴포지터 스레드에서 해당 element를 고속 스크롤 불가 영역으로 두고, 컴포지터 스레드에서 메인 스레드로 보내서 event handler를 처리한 다음 컴포지터 스레드에게 넘겨서 렌더링을 한다. event handler가 오래걸리면 그동안 프레임이 만들어지지 않아 fps drop이 발생한다.

when event handler take long time

하지만 event handler에 passive 옵션을 달아주면 컴포지터 스레드에서 메인 스레드를 기다리지 않고 바로 새 프레임을 만들어 스크롤을 부드럽게 할 수 있다.

when event has passive option https://web.dev/debounce-your-input-handlers/#avoid-long-running-input-handlers

IntersectionObserver

두번째 방법은 scroll event대신 IntersectionObserver를 사용하는 것이다. IntersectionObserver는 해당 요소의 일정 부분이 viewport에 들어오게 되면 등록한 callback을 실행한다. IntersectionObserver의 callback들은 Style 계산 전에 실행된다.

life of frame

자세한 사용방법을 원한다면 이 링크를 참조하자.

추가로 볼 자료들

© 2023 by Ethan Sup's log. All rights reserved.
Theme by LekoArts