Browser Rendering & Animation Performance Optimization

브라우저 렌더링과 애니메이션 최적화 방법

# browser# javascript
/ Contents

들어가며

브라우저에서 동작하는 애니메이션을 최적화하기 위해 할 수 있는 기법들과 이를 이해하기 위해 필요한 브라우저의 렌더링 과정에 대해서 살펴보겠습니다.

글의 목차는 '브라우저 렌더링' - '애니메이션 최적화' 순으로 이루어져 있으며 전체 흐름은 해당 글의 흐름과 같습니다.

위의 링크에서는 Webkit 엔진을 기준으로 설명하였으나 현재 크롬에서 쓰이는 엔진은 Webkit에서 파생된 Blink엔진을 사용하고 있고, RenderingNG라는 리팩토링 프로젝트를 통해 기존의 렌더링 파이프라인에서 변경된 부분들이 존재합니다.

하지만 이러한 변경은 크롬 내부적인 최적화를 수행한 것이기에 뒤에 설명할 애니메이션 최적화에 적용되거나 내용이 변경되는 부분은 없습니다.

Rendering

렌더링 과정을 이해하기 전에 머릿속에 새겨두면 더 이해하기 편리한 점을 알려드리겠습니다.

첫번째로 렌더링이 목적입니다. 구글의 Life of a pixel 강의에서 말하는 렌더링의 목적은 "컨텐츠를 픽셀로 바꾸는 것"과 "효율적인 렌더링을 위해 자료구조를 구축하는 것"이라고 합니다.

두번째로 프로세스와 스레드의 구조입니다. 렌더링을 담당하는 프로세스는 Renderer Process입니다. Renderer Process는 내부에 Main Thread와 Compositor Thread가 존재합니다. [1]

세번째로 렌더링 파이프라인 내에서 각 스테이지가 만들어내는 자료구조에 주목하면서 이해하면 좋습니다.

Render Tree

Render Forest

익숙한 DOM Tree부터 시작하여 Render Object , Render Layer , Graphics Layer까지 초기 DOM 노드는 렌더링 과정을 거치며 다양한 형태로 변하게 됩니다.

RenderObject

RenderObject는 익숙하게 알고 있는 Render Tree의 노드입니다. RenderObject는 DOM 노드가 화면에 그려질 때 필요한 정보들을 가지고 있습니다.[2] 위의 그림만 본다면 DOM 노드와 RenderObject가 1 대 1 대응처럼 보이지만 그렇지 않습니다.

RenderObject 위의 그림에서처럼 트리 해석의 용이성을 위해 익명의 Block 요소를 생성할 수도 있습니다.

페이지에 노드를 그리기 위해 렌더링 노드에 대한 정보 이외에도 각 노드의 렌더링 레벨을 알아야 합니다. 이를 정의하기 위해 RenderLayer를 만듭니다.

RenderLayer

Render Layer는 Render Object들을 바탕으로 생성되며, 주로 요소들이 겹칠 때 어떤 요소가 더 앞쪽에 보일지(z축 깊이)를 결정하는 '쌓임 차례(Stacking Context)'를 관리하기 위해 사용됩니다.

RenderObject와 비슷하게 RenderObject와 RenderLayer는 1 대 1 대응이 아닙니다. 대부분의 일반적인 Render Object들은 가장 가까운 부모 요소의 Render Layer에 속하게 됩니다

하지만 RenderObject가 특정 조건을 성립하면 독립적인 Render Layer가 생성됩니다. 성립 주요 조건은 다음과 같습니다

  • 문서의 최상위 루트 노드인 경우
  • 명시적인 CSS 위치 지정 속성이 있는 경우 (relative, absolute, transform 등)
  • 투명도 속성이 있는 경우 (Transparent / opacity 등)
  • overflow, CSS mask, 또는 반사(reflection) 속성이 사용된 경우
  • CSS filter 속성이 적용된 노드
  • 3D 컨텍스트 또는 하드웨어 가속 2D 컨텍스트를 사용하는 <canvas> 요소
  • <video> 요소
CPU (소프트웨어 렌더링) vs. GPU (하드웨어 가속 렌더링)

초기 브라우저나 단순한 렌더링은 CPU가 Layer 위에 존재하는 Object들을 직접 그렸습니다. 소프트웨어 렌더링은 별도의 화면을 분리하고 합칠 필요가 없습니다. 하지만 이런 방식은 단점이 분명합니다.

  • 3D 요소 처리 불가: CPU는 3D Canvas나 CSS의 3D 변환(transform: translate3d 등) 같은 3D 그래픽을 처리할 수 없습니다.
  • 애니메이션 버벅거림: 애니메이션으로 인해 요소의 크기나 위치가 바뀔 때마다 CPU는 RenderLayer 트리를 처음부터 다시 만들고 레이아웃을 다시 계산해야 합니다. 이로 인해 연산량이 너무 많아져 프레임 속도가 떨어지고 화면이 버벅거리는 현상이 발생합니다.

이러한 한계를 극복하고자 현대 브라우저는 GPU 하드웨어 가속을 도입했습니다.

하드웨어 가속 렌더링은 화면의 요소들을 여러 개의 독립적인 '층(Layer)'으로 분리하여 각자 별도의 백엔드 메모리 공간에 할당합니다. 그런 다음 GPU의 병렬 처리 능력을 이용해 층별로 렌더링을 수행하고, 마지막에 이를 하나의 최종 화면 이미지로 병합(합성, Compositing)합니다

Graphics Layer

GPU 리소스를 효율적으로 쓰기 위해 모든 RenderLayer를 각각 따로 그리지 않습니다. 대신 조건을 만족하는 레이어만을 승격하여 독립적인 그리기 공간(Backing surface)을 갖도록 합니다. 이렇게 독립적인 공간을 가진 레이어를 GraphicsLayer라고 부릅니다.

만약 GraphicsLayer로 승격되지 못한 요소는 부모의 레이어로 편입됩니다. (해당 경우를 아래)

GraphicsLayer로 승격되기 위한 조건은 아래와 같습니다.

  • 3D 변환(transform: translate3d, perspective 등) 속성이 적용된 경우
  • 하드웨어 가속을 사용하는 <video> 요소 및 <canvas>(WebGL 또는 가속 2D) 요소
  • opacitytransform 속성을 사용하여 애니메이션이 실행 중인 경우
  • 하드웨어 가속 CSS filter 기술을 사용하는 경우
  • 자식 요소가 합성 레이어(Composite Layer)를 포함하고 있는 경우
  • Overlap: 자신보다 낮은 Z-index를 가진 형제 노드가 합성 레이어이며, 해당 레이어와 겹치는 경우
Layer Explosion and Layer Squashing

위의 승격 조건 중 마지막 조건은 Overlap 입니다. 이는 승격된 요소 위에 존재하는 노드 또한 승격시킵니다. 하지만 이 과정에서 문제가 발생할 수 있습니다.

바로 승격된 요소 위에 수백개의 노드들이 존재한다면 각 노드들을 모두 독립적인 레이어로 승격시킬 겨우 많은 양의 메모리와 CPU 자원을 소모하게 되고 이는 버벅거리거나 멈추는 레이어 폭발 현상(Layer Explosion)을 일으킵니다.

이를 방지하기 위해 같은 조건을 가진 여러 RenderLayer들을 하나로 묶어 압축된 레이어를 생성합니다. 이를 Layer Squshing이라고 부릅니다.

Webkit Rendering Pipeline

Overview

anatomy-of-a-frame

Rendering Pipeline 12 Steps

  1. Frame Start (프레임 시작)[3]: 브라우저가 새로운 프레임의 시작을 알리는 수직 동기화 신호(Vsync)를 보냅니다.

  2. Input event handlers (입력 이벤트 처리): 컴포지터 스레드가 스크롤, 클릭 등의 사용자 입력 이벤트를 메인 스레드로 전달하여 콜백을 처리합니다.

  3. requestAnimationFrame (rAF): 자바스크립트 애니메이션을 제어하기 위해 등록된 requestAnimationFrame 함수들이 실행됩니다.

  4. Parse HTML (HTML 파싱): 이전 작업으로 인해 DOM 노드에 변화(예: 노드 추가)가 생겼다면 HTML을 다시 파싱합니다.

  5. Recalc Styles (스타일 재계산): CSS 스타일이 수정되었다면 변경된 DOM 노드와 그 하위 노드의 스타일을 브라우저가 다시 계산합니다.

  6. Layout (레이아웃): 보이는 화면 요소들이 차지할 정확한 크기와 위치 등 기하학적 정보를 계산합니다.

  7. Update Layer Tree (렌더 트리 업데이트): 앞선 DOM 노드 변경과 CSS 스타일 수정을 반영하여 최종적인 렌더 트리를 업데이트합니다.

  8. Paint (페인트)[4]: 화면에 실제 픽셀을 바로 색칠하는 것이 아니라, 요소들을 렌더링하기 위한 그리기 명령어 목록(Paint commands/SkPicture)을 생성하는 단계입니다.

  9. Composite (합성 데이터 동기화): 메인 스레드가 변경된 렌더 트리와 그리기 명령어 목록을 컴포지터 스레드로 전송하여 두 스레드의 상태를 동기화합니다. 즉, 실제 합성이 일어나는 것이 아니라 합성에 필요한 데이터를 넘겨주는 메인 스레드의 마지막 단계입니다.

  10. Rasterize (래스터화): 컴포지터 스레드가 넘겨받은 명령어 정보를 실제 픽셀 값(비트맵)으로 변환합니다. 이때 전체 페이지를 통째로 변환하지 않고 작은 크기의 타일(Tile)로 나눈 뒤, 뷰포트에 가까운 타일들부터 래스터라이저 스레드 풀을 통해 우선적으로 래스터화합니다.

  11. Frame End (프레임 종료): 타일 래스터화가 완료되면 컴포지터 스레드가 타일들의 메모리 위치와 그려질 정보(Draw quads)를 수집하여 하나의 컴포지터 프레임(Compositor frame)으로 만들고, 이를 GPU 프로세스로 넘깁니다.

  12. Image display (이미지 표시): GPU 프로세스가 3D API를 사용하여 텍스처들을 하나의 비트맵으로 병합하고 최종 이미지를 모니터 화면에 그려냅니다

결국 애니메이션 최적화의 핵심은 화면이 변할 때마다 이 전체 12단계를 매번 실행하는 것이 아니라, transform이나 opacity 같은 속성을 활용하여 무거운 6단계(Layout)와 8단계(Paint)를 건너뛰고 GPU에서 레이어만 변형시켜 렌더링 효율을 높이는 데 있습니다.

Current Chrome Rendering Pipeline

current-chrome-rendering-pipeline

위의 그림은 간단한 HTML 파일을 렌더링했을 때 실행되는 렌더링 파이프라인 중 일부이다. Layout, Paint 같이 익숙한 단계도 보이지만 Pre-paint, Commit등 새로운 단계들도 보입니다. 실제 크롬은 Webkit을 포크한 Blink엔진을 사용하고 있고 RenderingNG라는 리팩토링을 하면서 이전 렌더링 파이프라인과 달라진 점이 존재합니다.

하지만 이러한 변경은 크롬 내부적인 최적화를 수행한 것이기에 뒤에 설명할 애니메이션 최적화에 적용되거나 내용이 변경되는 부분은 없습니다.

Animation Performance Optimization

Handle page scrolling reasonably

브라우저는 화면을 그릴 때, 페이지의 어느 영역에 '자바스크립트 이벤트 리스너(특히 touch나 wheel)'가 걸려 있는지 파악합니다.

  • Fast Scrollable Region: 이벤트 리스너가 없는 영역입니다. 브라우저의 합성 스레드(Compositor Thread)가 메인 스레드에 물어볼 필요 없이 즉시 화면을 드래그하여 스크롤할 수 있어 매우 빠릅니다.

  • Non-Fast Scrollable Region: 이벤트 리스너가 등록된 영역입니다. 여기서는 스크롤이 발생할 때마다 합성 스레드가 메인 스레드에게 "야, 이 스크롤 이벤트 자바스크립트가 가로채서 preventDefault() 쓸 거야? 아니면 그냥 스크롤할까?"라고 물어보고 대답을 기다려야 합니다.

preventDefault()의 실행 여부를 물어봐야 하는지 살펴보면, preventDefault는 이벤트의 기능(Action)을 막는 역할을 합니다. 예를 들어 formsubmit버튼을 누르면 페이지가 새로고침하는 Action이 발생합니다.

마찬가지로 스크롤 이벤트 리스너 내부에서 preventDefault를 사용하게 되면 스크롤(Action)이 동작하지 않게 됩니다. 스크롤 이벤트 핸들러가 등록되어 있으면, 브라우저의 합성(Compositor) 스레드는 자바스크립트에서 preventDefault()를 호출하여 스크롤을 취소할지 모른다고 판단하여 메인 스레드의 처리를 기다립니다. 이로 인해 스크롤 애니메이션이 지연될 수 있습니다

1. Passive Event Listeners

스크롤이나 터치 이벤트 리스너를 등록할 때 { passive: true } 옵션을 사용하는 것이 좋습니다. 이 옵션은 브라우저에게 해당 핸들러가 preventDefault()를 호출하지 않을 것임을 미리 알려줍니다. 덕분에 컴포지터 스레드는 자바스크립트 실행을 기다리지 않고 즉시 부드러운 스크롤을 수행할 수 있습니다.

2. Avoid Global Event Delegation for Scroll/Touch

documentbody 전체에 스크롤/터치 이벤트를 위임하는 것은 피해야 합니다. 페이지 전체가 'Non-fast Scrollable Region' 으로 지정되어, 모든 입력 이벤트마다 컴포지터가 메인 스레드의 응답을 기다려야 하는 병목 현상이 발생할 수 있습니다.

React의 이벤트 위임 방식: React v17부터는 이벤트를 document가 아닌 리액트 트리가 렌더링되는 Root 컨테이너(div#root)에 위임합니다. 따라서 리액트의 onScroll을 통해 스크롤 이벤트를 부착하게 된다면 결국 Root 컨테이너 전체가 Non-fast Scrollable Region으로 지정될 수 있습니다.

따라서 고성능 스크롤 인터랙션(예: 패럴랙스 효과 등)이 필요하다면[5], 리액트의 onScroll 대신 useRef를 통해 DOM 노드에 직접 addEventListener를 부착하고 passive: true를 설정하는 것이 브라우저의 Compositor Thread를 가장 잘 활용하는 방법입니다.

3. Intersection Observer API

스크롤 위치를 계산하기 위해 Element.getBoundingClientRect()를 반복 호출하는 대신 Intersection Observer를 사용하세요. 이 API는 요소의 가시성 변화를 비동기적으로 감지하므로 메인 스레드 부하를 줄이고 강제 리플로우를 방지할 수 있습니다.

Javascript optimization

1. rAF over setTimeout/setInterval

애니메이션 구현에는 setTimeout이나 setInterval[6] 대신 requestAnimationFrame(rAF)을 사용하는 것이 좋습니다. rAF는 브라우저의 렌더링 파이프라인(스타일 계산 전)과 동기화되어 실행되므로, 디스플레이 주사율에 맞춘 부드러운 애니메이션을 보장합니다.

2. Move heavy tasks to Web Workers

DOM 조작이 필요 없는 순수 계산 작업은 Web Worker로 옮겨서 작업하는 것이 좋습니다. Web Worker는 DOM에 접근할 권한은 없지만 무거운 연산을 메인 스레드와 분리하여 처리할 수 있어서 메인 스레드를 렌더링 전용으로 비워두어 사용자 인터렉션에 즉각 반응하게 만듭니다.

3. Task Splitting (requestIdleCallback)

메인 스레드에서 실행해야만 하는 큰 작업은 여러 프레임에 걸쳐 쪼개서 실행하는 것이 좋습니다. requestIdleCallback을 활용하여 브라우저가 휴식 중일 때 조금씩 처리하거나, rAF 내에서 작업을 청킹(Chunking)하여 프레임 드랍을 방지합니다.

4. Reduce forced reflow (Layout Thrashing)

자바스크립트에서 DOM의 시각적 속성을 '수정(Write)'하는 작업과 '읽는(Read)' 작업의 순서를 엄격히 분리하는 것이 좋습니다.

  • Bad: 스타일 수정 후 바로 레이아웃 속성(offsetWidth, clientHeight 등) 읽기 -> 브라우저는 최신 값을 주기 위해 즉시 레이아웃을 다시 계산해야 함 (Forced Synchronous Layout).
  • Good: 모든 읽기 작업을 먼저 수행하고, 수정 작업을 나중에 배치하세요.
// Bad
function logBoxHeight() {
  box.classList.add('super-big'); // Write
  console.log(box.offsetHeight); // Read
}

// 개선된 코드: Read 먼저, Write 나중에
function logBoxHeight() {
  console.log(box.offsetHeight); // Read
  box.classList.add('super-big'); // Write
}

Reduce Layout and Paint

1. Synthetic layer boost (Compositing)

애니메이션이 일어나는 요소를 독립적인 **합성 레이어(GraphicsLayer)**로 승격시키는 것이 좋습니다. GPU가 해당 레이어만 별도로 관리하게 되어, 다른 요소의 재그리기(Repaint) 범위를 줄일 수 있습니다.

(위에서 언급한 layer explosion을 경계해야 합니다, 즉 너무 많은 레이어를 생성하지 않도록 주의해야 합니다.)

transform과 opacity 활용

transformopacity'Compositor-only' 속성입니다. GPU가 기존 레이어의 텍스처를 변형하거나 투명도만 조절하므로, 애니메이션 중 Layout과 Paint 단계를 완전히 건너뛸 수 있습니다.

#cube {
  transform: translateX(0);
  transition: transform 3s linear;
}
#cube.move {
  transform: translateX(100px);
}

will-change

will-change: transform, opacity; 속성을 통해 브라우저에게 해당 요소가 곧 변할 것임을 미리 알려주어 레이어 승격을 미리 준비하게 할 수 있습니다.[7] 주의: 애니메이션이 끝나면 메모리 절약을 위해 해당 속성을 제거하는 것이 좋습니다.

2. CSS Containment (contain)

contain: layout, paint, 또는 strict 속성을 사용하여 DOM 트리에서 서브트리로 격리할 수 있습니다. 해당 요소 내부의 변화가 외부의 레이아웃이나 페인트 계산에 영향을 주지 않도록 보장하여 연산 범위를 제한합니다.

  • contain: strict 또는 layout의 역할: 브라우저에게 "이 요소 내부의 변화는 외부 요소의 크기나 위치에 절대 영향을 주지 않는다"라고 선언하는 것입니다.
Confusing Point

contain 속성의 역할을 들었을 때 리액트의 vDom을 통한 렌더링 최적화와 역할이 비슷하다고 느꼈습니다. 그래서 리액트를 사용하는 경우 변경이 된 DOM 노드와 그 하위 노드만이 리렌더링되기 때문에 contain 속성의 적용이 필요한가? 라는 의문이 있었습니다.

하지만 리액트의 vDomcontain이 최적화하는 단계는 아예 다르기 때문에 서로 영향이 없습니다. 리액트는 변경할 노드만을 특정하는 것이고 브라우저는 해당 변경으로 인해 주위 요소의 레이아웃 변화가 있는지 계산해야 합니다.

그래서 vDom을 통해서 변경이 필요한 노드만 변경했다고 하더라도 변경으로 인한 레이아웃 계산은 별개의 것입니다. 최악의 경우 레이아웃 변경으로 인해서 페이지 전체 레이아웃에 영향을 주는지 확인하기 위해 모든 노드를 순회해야할 수도 있습니다.

이러한 상황을 막기 위해 contain 속성을 통해 외부에 영향이 없다는 것을 미리 알려줄 수 있습니다.

3. content-visibility: auto

화면 밖(Off-screen) 요소의 렌더링 작업을 지연시킬 수 있습니다. contain 속성과는 다르게 auto 값이 존재해서 auto 값을 주면 뷰포트에 접근할 때까지 레이아웃과 페인트를 건너뛰어 초기 로딩과 스크롤 성능을 개선할 수 있습니다.

하지만 크기가 큰 요소가 갑자기 등장하는 경우 스크롤바가 튀는 현상이 발생할 수 있습니다. 이를 방지하기 위해 contain-intrinsic-size를 병행 사용하여 요소가 렌더링되기 전의 가상 크기를 지정하여 갑자기 튀는 현상을 예방할 수 있습니다.

.card-section {
  content-visibility: auto;
  contain-intrinsic-size: 500px;
}

references

https://segmentfault.com/a/1190000041295744/en#item-2-2

https://docs.google.com/presentation/d/1boPxbgNrTU0ddsc144rcXayGA_WF53k96imRH8Mp34Y/edit?slide=id.ga884fe665f_64_80#slide=id.ga884fe665f_64_80

https://www.youtube.com/watch?v=sUbJPHYKZkU

https://www.chromium.org/developers/design-documents/gpu-accelerated-compositing-in-chrome/

https://blogs.igalia.com/mrego/2019/01/11/an-introduction-to-css-containment/?ref=heydesigner

https://so-so.dev/web/browser-rendering-process/

https://developer.chrome.com/docs/chromium/renderingng-architecture?hl=ko

https://web.dev/articles/content-visibility?hl=ko


Footnotes

  1. Renderer Process는 여러 개 존재합니다.

  2. Webkit 엔진에서는 RenderObject 정보를 변경할 수 있습니다. 현재 크롬 엔진에서는 Fragment Tree로 불리고 Immutable한 객체로 취급됩니다.

  3. 16.6ms 마다 실행된다고 생각하면 됩니다.

  4. 크롬 엔진에서는 앞 단계로 pre-paint 단계가 추가로 있습니다.

  5. 라이브러리를 쓴다면 라이브러리 내부에서 이미 최적화가 되어있을 가능성이 높습니다.

  6. 그리고 setTimeoutsetInterval의 실행 타이밍은 콜스택에 의해 영향을 받기 때문에 명확한 타이밍을 보장할 수 없습니다.

  7. youtube에서 레이어 분리를 will-change로 하고 있습니다.