들어가기 전에

브라우저 공부 1편 - 브라우저는 다중 프로세스에 이어 브라우저에 대해 본격적으로 알아보고자 한다. 1편에서는 브라우저 내부 프로세스들의 종류와 역할에 대해 알아보았다면, 이번 글은 Renderer Process 가 담당하는 렌더링 과정에 자세히 알아보고자 한다. 참고로 이 글 에서 브라우저Chrome 을 의미한다.

브라우저의 렌더링 과정

렌더링 과정에 대해서 자세하게 알아보기 전에 먼저 전체적으로 얕게만 훑어보자. 위 그림은 지금까지 많이 봐온 그림일텐데, 간략하게만 설명하자면 이렇다. 우선 HTML, CSS 를 파싱하여 각각 DOM Tree, CSSOM Tree 를 생성한다. 만약 파싱 도중에 script 태그를 만나면, 파싱은 잠시 중단되고 JavaScript 엔진이 JS 를 파싱하고 실행하게 된다. 파싱 과정이 모두 종료되면, DOM 트리와 CSSOM 트리를 결합하여 Render 트리를 생성한다. 그리고 Render 트리를 바탕으로 각 노드의 실제 좌표와 크기 등을 계산하게 되는데 이를 Layout 과정이라고 한다. 마지막으로 Paint 과정을 통해 Render 트리의 노드를 하나하나 화면에 그리게 된다.

하지만, 최신 브라우저는 위 과정에서 몇 가지가 더 추가되었다. Layer Tree 와 Composite 이라는 단계이다. 그러면 지금부터 각 단계에 대해 자세히 알아보자.

파싱 (HTML, CSS, JS)

 HTML 은 그저 문서일 뿐이다. 이 문서를 브라우저가 이해하기 위해서는 파싱 과정을 통해 DOM 트리로 변경해주는 작업을 거쳐야 한다. (이는 CSS 도 동일하다.) 파싱 과정은 두 단계로 이루어져 있다. 1) 토큰화와 2)트리 구축 이다.

토큰화

토큰화는 HTML 문서를 읽어들이면서 작은 단위(토큰) 으로 나누는 작업이다. 브라우저는 상태 기계(State Machine)를 사용하여 HTML 의 각 문자를 읽고, 상태를 전환하면서 필요한 정보를 생성한다. 말이 조금 어려운데 직접 과정을 따라가 보면 그렇게 어렵지 않다.

<html>
  <body>
          Hello world    
  </body>
</html>

위와 같은 HTML 문서가 주어졌다고 하면 다음과 같은 과정으로 토큰이 발행된다.

html 토큰 발행하기

  1. 초기 상태는 자료 상태 이다.
  2. < 문자를 만나면 태그 열림 상태로 전환한다.
  3. h 문자를 만나면 시작 태그 토큰을 생성하고, 태그 이름 상태 로 전환된다.
  4. > 문자를 만나면 html 토큰 이 발행되고, 다시 자료 상태 로 전환된다.

위와 유사한 방식으로 body 토큰, 문자 토큰, 종료 태그 토큰 이 생성된다. 결과적으로 브라우저는 토큰화 과정을 통해 HTML 문서를 다음과 같은 토큰 목록을 반환한다.

• <html> → 시작 태그 토큰
• <body> → 시작 태그 토큰
• Hello world → 문자 토큰
• </body> → 종료 태그 토큰
• </html> → 종료 태그 토큰
트리 구축

앞서 파싱 단계에서 생성된 토큰을 바탕으로 트리를 구축하게 된다. 마찬가지로 상태 기계를 사용하여 토큰이 입력될 때마다 상태를 전환하면서 트리를 생성한다.

<html>
  <body>
    Hello world
  </body>
</html>

트리 구축 과정

  1. <html> 토큰 처리
  • 브라우저는 HTMLHtmlElement를 생성하고, 문서의 최상단에 추가한다.
  • 상태는 head 이전 모드로 전환됩니다.
  1. <body> 토큰 처리
  • 브라우저는 HTMLBodyElement를 생성하고, <html> 요소 아래에 추가합니다.
  • 상태는 body 안쪽 모드로 전환됩니다.
  1. Hello world 토큰 처리
  •  텍스트 노드를 생성하여 <body> 요소 아래에 추가합니다.
  1. </body> 토큰 처리
  • 브라우저는 현재 열린 <body> 태그를 닫습니다.
  • 상태는 “body 다음” 모드로 전환됩니다.
  1. </html> 토큰 처리
  • 상태는 body 다음 다음 모드로 전환
예외처리
<html>    
	<mytag></mytag>    
	<div>      
		<p>    
	</div>    
		Really lousy HTML    
	</p>
</html>

DOM 트리를 만들 때 중요한 부분은 예외처리이다. 우리가 HTML 을 잘못 작성했다고 해도 브라우저에서 에러를 발생시킨 적은 못봤을 것이다. 그 이유는 위의 예시와 같이 잘못된 형식의 HTML 이 주어져도 HTML5 가 명시한 예외 처리 방식에 따라 DOM 트리가 결정되기 때문이다.

DOM / CSSOM

앞서 파싱 과정을 통해 HTML 은 DOM 트리로, CSS 는 CSSOM 트리로 변환된다. 그렇다면, DOM Tree 는 무엇일까. DOM 은 브라우저가 내부적으로 웹 페이지를 표현하는 방법일 뿐만 아니라 웹 개발자가 JavaScript를 통해 상호작용을 할 수 있는 데이터 구조이자 API이다. CSSOM 또한, JavaScript 를 통해 상호작용할 수 있도록 API 를 제공한다.

Render Tree

렌더 트리란 DOM Tree 와 CSSOM 트리의 정보를 결합해서 만든 트리다. 그리고 렌더 트리라는 이름 그대로 화면에 렌더링을 하기 위한 정보를 가지고 있다.

위 그림에서 왼쪽은 DOM Tree, 오른쪽은 Render Tree 로, 둘의 관계를 나타내고 있다. DOM 트리와 렌더 트리는 1:1 대응 관계가 아니다. 예를 들어, head 의 경우 DOM Tree 에는 존재하지만, 렌더링에는 필요 없는 태그이기에 Render Tree 에는 존재하지 않는다. 뿐만 아니라, display: none 속성을 가진 노드 또한 렌더링에 포함될 필요가 없기 때문에 Render Tree 에서 제거된다.

Q. CSS 를 항상 HTML 문서 최상단에 위치해야하는 이유는?

Render Tree 가 생성되기 위해서는 DOM 트리와 CSSOM 트리가 모두 필요하다. 다만 Render Tree 가 만들어질 때 DOM 트리는 100% 완성되어있지 않아도 된다. 즉, HTML 문서가 100% 파싱되지 않아도 일단 렌더 트리로 만들고, 이후에 토큰이 파싱되면서 점진적으로 완성해 나가도 문제가 없다. 하지만, CSSOM 트리는 다르다. CSSOM 트리는 CSS 전체 파일이 파싱이 되고 나서야 만들 수 있다. 그러니 최대한 빠르게 파싱을 시작해야한다. 만약 css 를 import 하는 부분이 head 가 아니라 body 등에 위치에 있어 뒤늦게 파싱을 시작하면, 그만큼 Render 트리가 만들어지는 것도 지연되게 된다. 이러한 이유 때문에 CSS 는 렌더링 차단(Blocking) 리소스라고 부르고, 렌더링이 차단되지 않도록 CSS 는 항상 HTML 문서의 최상단에 위치시킨다.

Q. Script 태그를 HTML 하단에 두는 이유는?

<script> 태그를 만나면 HTML 파싱은 즉각 중단되며, 이후에는 JavaScript 엔진이 JavaScript 를 실행하게 된다. 이때 JavaScript 는 이전까지 생성된 DOM 에만 접근이 가능하다. 이러한 이유로 자바스크립트도 렌더링 차단 리소스라고 하며, HTML 문서의 최하단에 위치시킨다.

Layout

레이아웃이란 렌더트리를 바탕으로 각 노드의 정확한 위치와 크기를 계산하는 과정이다. 즉, 레이아웃 트리의 각 노드를 순회하면서 계산하고, 정확한 pixel 값을 레이아웃 트리의 노드에 기록한다. 아래 그림은 레이아웃 전에는 % 로만 지정되어있던 노드가 레이아웃 후에는 정확한 px 값으로 변환된 것을 볼 수 있다.

레이아웃 전

레이아웃 후

Global Layout 과 Incremental Layout

레이아웃 과정은 전체 렌더트리를 순회해서 계산해야하는 작업이기 때문에 비싼 작업이다. 따라서, 소소한 변경으로 인해 전체를 다시 레이아웃 과정을 거치지 않기 위해 더티 비트 체제를 사용한다. 즉, 다시 배치할 필요가 있는 요소만 “더티” 로 표시하고, 해당 부분만 재계산한다. 이렇게 부분적으로 레이아웃을 수행하는 것을 Incremental Layout 이라고 한다. 반면, Global Layout 은 전체 렌더트리에 대해 Layout 을 수행하는 것을 의미한다. Global Layout 은 2가지 조건에 의해 발생한다.

  1. 윈도우 사이즈를 변경하는 것
  2. 폰트를 변경하는 것

나머지는 모두 Incremental Layout 이 발생한다. Global Layout 이 계산해야할 노드 수가 월등히 많기 때문에 당연히 Incremental Layout 보다 성능적으로 불리하다. 하지만, 불리한 이유는 또 존재한다.

Incremental Layout 은 비동기로 실행된다. 즉, 재배치 명령을 받았다고 즉시 Layout 을 수행하는 것이 아니라, 이를 일종의 버퍼에 쌓아 놓고 스케줄러가 이 명령을 한번에 실행한다. 하지만, Global Layout 은 보통 동기적으로 실행한다.

const tabBtn = document.getElementById("tab_btn")
 
tabBtn.style.fontSize = "24px"
console.log(testBlock.offsetTop) // offsetTop 호출 직전 브라우저 내부에서는 동기 레이아웃이 발생한다.
tabBtn.style.margin = "10px"

위의 예시를 보자. 우선, tabBtnfontSize 를 수정했다. 이는 위에서 언급한 Global Layout 조건 2번 (폰트를 변경)을 충족하므로, Global Layout 이 다음 틱에서 발생할 것이다. 여기까지는 그럴 수 있다. 하지만, 스타일을 변경한 후에 offsetTop 속성을 접근하고 있는데, 브라우저는 최신 레이아웃 상태를 보장해야 하므로 강제 동기 레이아웃이 발생한다. (만약 동기적으로 실행하지 않으면 fontSize 가 변경되기 이전의 offsetTop 값을 가져올 것이다.)

이처럼 offsetTop과 같은 레이아웃 관련 속성에 접근하면 브라우저는 강제 동기 레이아웃이 발생하며, 이는 JavaScript 실행시간과 Main 스레드의 작업량을 증가시킬 수 있다. 만약 이러한 작업이 과도하게 실행되거나 Main 스레드의 작업량이 많아 VSync 주기(60FPS 기준 약 16.6ms)를 초과한다면, 화면이 매끄럽지 않고 버벅거림(Jank)이 발생할 가능성이 있다. VSync 관련 내용은 추후에 따로 작성할 예정이다.

Update Layer Tree

레이어는 렌더링될 요소들을 형태로 나눈 것을 말한다. (포토샵의 Layer 개념을 생각하면 좀 더 이해하기 쉽다.) 그런데 왜 굳이 레이어로 나누어서 렌더링를 할까?

위의 이미지에서 왼쪽 화면을 렌더링해야 한다고 생각하자. 만약 하나의 레이어에서 모든 렌더링을 하는 경우 나뭇잎 위치가 조금이라도 변경된다면, 배경 이미지부터 텍스트까지 모든 것을 다시 계산해야할 것이다. 하지만, 레이어로 나누어 렌더링을 관리를 하면, 나뭇잎 위치가 변경되었을 때 다른 레이어는 건드리지 않고 딱 나뭇잎과 관련된 레이어만 수정하면 된다.

그러면 어떤 기준으로 레이어를 나눌까? 위 이미지에 나와 있지만, 몇 가지를 적어보자면 대표적으로 transform 속성이 있다. 우리가 애니메이션을 적용할 때 transform 속성을 사용하면 좋다고들 하는데, 그 이유는 transform 을 위한 별도의 레이어가 존재한다. 따라서, 애니메이션에 의해 요소가 움직여도 딱 해당 레이어만 다시 페인팅하면 된다. 한가지 재미있었던 점은 스크롤바도 별도의 레이어에서 처리된다고 한다. 그 외에도 브라우저의 내부적인 구현에 의해 다양한 경우에 레이어가 생성된다고 한다.

참고로 레이어 또한 Layer Tree 즉, 트리 형태로 관리가 된다. (위 이미지 참고)

Paint

각 Layer 별로 스타일링을 입히는 과정이다.

Tiled Backing Store

각 레이어별로 페인팅을 할 때 최적화 기법이 들어간다. 이 기법은 한 번에 전체 레이어를 렌더링하는 것이 아니라, 레이어를 타일 단위로 나누어 페인팅을 진행한다. (위 그림에서 노란색 격자에 주목하자.) 그리고 완성된 결과물은 Tiled Backing Store라는 메모리 공간에 타일 단위로 저장된다. 이를 통해 repaint 발생시 변화가 필요한 타일들만 수정함으로써 성능을 최적화할 수 있다.

위 그림을 자세히 보면 Raster thread 라는 낯선 용어가 나온다. Raster Thread 는 타일을 픽셀로 변환하는 작업을 담당하는 스레드이다. Raster thread 는 결과적으로 main 스레드의 일을 덜어주는 역할을 하는데, 자세한 내용은 후속편에서 다룬다.

Composite Layers

마지막으로 Composite 과정은 레이어를 합성해서 하나의 비트맵으로 만드는 과정을 말한다.

Composite 과정은 Compositor thread 라는 또 다른 스레드에서 진행한다. 즉, main 스레드와 별개로 작동한다는 점만 알아두자.

참고 자료

https://d2.naver.com/helloworld/5237120 https://d2.naver.com/helloworld/59361 https://www.slideshare.net/slideshow/125-119068291/119068291 https://ui.toast.com/fe-guide/ko_PERFORMANCE