브라우저의 렌더링 과정

브라우저의 렌더링 과정
렌더링, 그거 어떻게 하는 건데.

브라우저의 구조는 저마다 다르지만 대체로 이런 구성 요소들로 이루어져 있다.

  • UI (당신의 동료 디자이너가 디자인한 것 말고 진짜 브라우저 UI)
  • UI Backend (Backend 개발자 아니다)
  • 브라우저 엔진
  • 렌더링 엔진
  • 네트워킹 인터페이스
  • 자바스크립트 인터프리터
  • 데이터 저장소
Chrome 브라우저의 구성 요소다. 있어 보이라고 가져왔다.

오늘의 주인공은 바로 Rendering Engine이다.
말 그대로 렌더링이 일이라 요청한 문서(document)를 브라우저에 그리는 역할을 한다. (지금의 웹은 앱과 유사하지만, 과거에는 마치 문서에 가깝다고 느껴 document라는 용어를 사용했다고 한다)

이러한 렌더링 엔진은 브라우저마다 다르다. Chrome은 Blink(과거엔 WebKit), Firefox는 Gecko 등. 때문에 크로스 브라우징이란 개념도 생겨났다.

라떼는 IE 6도 맞추고 그랬어.

렌더링 엔진의 할 일은 문서의 내용을 받아 오는 것부터 시작된다. 이는 다음 글에 있으니 생략하겠다.

브라우저의 네비게이션 과정
브라우저의 주소창에 URL을 입력하면 일어나는 일 목차 1. 브라우저의 주소창에 URL을 입력한다. 2. 브라우저가 요청한 URL의 IP 주소를 찾기 위해 캐시를 확인한다. 3. (캐시에 요청한 주소가 없다면) ISP의 DNS 서버가 요청한 URL의 IP 주소를 검색한다. 4. 브라우저가 서버와 TCP 연결을 시작한다. 5. 브라우저가 서버에 HTTP 요청을 보낸다. 6. 서버가 요청을

가져온 HTML은 아래 과정을 거쳐 렌더링되는데:

  1. Parsing (HTML, CSS, JS)
  2. Style calculation
  3. Layout
  4. Paint
  5. Rasterization & Composition

이를 순서도로 풀면 이런 그림이 나온다.

설명이랑 안 맞는 그림이라 ㅈㅅ 언젠가 만들어서 교체하겠음 ㅎ

1. Parsing (HTML, CSS, JavaScript)

뭐가 됐든, 일단 파싱부터 한다. 렌더링 엔진은 HTML을 파싱하기 시작하는데, 그러다 <link /><style />, <script /> 태그 등을 만나면 CSS, JS도 함께 파싱한다.

다만 JS를 만나면 렌더링 엔진은 HTML 파싱을 잠시 멈추는데, JS는 DOM 조작이 가능해 DOM tree에 영향을 주기 때문이다. 그래서 JS를 만나면 일단 JS를 파싱해 죄다 실행한 다음, HTML 파싱을 이어서 한다. HTML 태그 한복판에 <script /> 태그를 넣어 console.log(document)를 찍으면 스크립트 다음의 DOM tree가 아직 비어있을 것이다. (객체 참조라 다시 채워질 테니 debugger를 찍고 보시라)

DOM tree에 영향을 주지 않는 CSS는 HTML과 함께 파싱이 이루어지지만, 이 녀석은 또 렌더링을 잠시 멈춘다. CSS가 렌더링 결과에 영향을 주기 때문. 쉽게 생각해서 HTML이 사람이고 CSS가 옷이다. 옷을 안 입거나 입으면서 밖에 나갈 순 없잖아. (있나? 나는 없다)

이렇게 JS는 Parse blocking resource에 속한다. <script /> 태그를 <body />의 마지막에 넣거나, defer 애트리뷰트와 함께 사용하는 이유. (defer는 DOMContentLoaded 이벤트 발생 직전에 실행되지만, async는 다운로드 완료되는 순으로 즉시 실행된다)

그리고 CSS는 Render blocking resource에 속하는데, 다만 media query 조건에 해당하지 않는 CSS는 Non-render blocking resource에 속한다.

이러한 파싱의 과정으로, DOM tree와 Style rules tree가 만들어진다.


1-1. DOM(Document Object Model) tree

DOM tree 구성은 다음 과정으로 이루어진다:

  1. HTML을 파싱해 토큰화(Tokenizing)
  2. 발행(Emit)된 토큰을 객체(Document Node)로 작성
  3. 객체를 tree 구조로 배치

이 과정을 모두 거치면 비로소 (남아 있는, 또는 새로운 JS가 실행되기 전까지의) DOM tree가 완성된다. 이전 섹션에서도 얘기했지만, DOM tree는 console.log(document)를 찍으면 확인할 수 있는 그것이다.

DOM tree 구조

또한 이 과정에서 DOM tree와 유사한 Accessibility tree라는 것도 구성된다. 이는 Accessibility라는 단어에서 알 수 있다시피 스크린 리더나 점자 정보 단말기 등의 보조 기술로 웹을 탐색하는 사용자를 위한 것으로, 각 객체는 AOM(Accessibility Object Model)으로 구성되어 있다.

과정을 다시 살펴보면 ‘토큰화(Tokenizing)’ 가 무엇인가? 싶을 텐데, 식별자나 값 등 문법에서 이해할 수 있는 최소 단위의 문자열을 토큰이라 하며, 이는 파싱 단계 중 하나인 어휘 분석(Lexical analysis)의 결과물이다. 말로 설명하긴 어렵고, 이는 파서가 어떻게 동작하는지를 알아야 이해할 수 있다.

우리는 평소에 HTML string을 처리할 때 간단히 정규식을 사용하지만, HTML 파서는 순수하게 첫 글자부터 하나하나 읽어 처리한다.

예를 들어 아래의 한 줄의 코드는 다음 과정을 거친다. (동작 원리를 간단히 설명하기 위한 것으로, 실제 state machine과는 다르다):

<h1>Hello, world!</h1>
  1. • 인덱스 위치: <
    • 저장된 스택: [Empty]
    • 발행된 토큰: [Empty]
    • 상태: TAG_OPEN
    • 현재 작업: 스택에 push
    • 다음 작업: 태그 이름 탐색
  2. • 인덱스 위치: h1
    • 저장된 스택: <
    • 발행된 토큰: [Empty]
    • 상태: TAG_NAME
    • 현재 작업: 스택에 push
    • 다음 작업: 태그 닫힘 문자(>) 탐색
  3. • 인덱스 위치: >
    • 저장된 스택: <h1
    • 발행된 토큰: [Empty]
    • 상태: DATA
    • 현재 작업: 스택에 push 후 flush(발행, emit)
    • 다음 작업: 다음 아무 문자열, 또는 태그 열림 문자(<) 탐색
  4. • 인덱스 위치: Hello, world!
    • 저장된 스택: [Empty]
    • 발행된 토큰: [<h1>]
    • 상태: DATA
    • 현재 작업: 스택에 push 후 flush(발행, emit)
    • 다음 작업: 다음 아무 문자열, 또는 태그 열림 문자(<) 탐색
  5. • 인덱스 위치: </
    • 저장된 스택: [Empty]
    • 발행된 토큰: [<h1>, Hello, world!]
    • 상태: END_TAG_OPEN
    • 현재 작업: 스택에 push
    • 다음 작업: 태그 이름 탐색
  6. • 인덱스 위치: h1
    • 저장된 스택: </
    • 발행된 토큰: [<h1>, Hello, world!]
    • 상태: TAG_NAME
    • 현재 작업: 스택에 push
    • 다음 작업: 태그 닫힘 문자(>) 탐색
  7. • 인덱스 위치: >
    • 저장된 스택: <h1
    • 발행된 토큰: [<h1>, Hello, world!]
    • 상태: DATA
    • 현재 작업: 스택에 push 후 flush(발행, emit)
    • 다음 작업: 다음 아무 문자열, 또는 태그 열림 문자(<) 탐색
  8. [<h1>, Hello, world!, </h1>] 완성

DOM tree는 이렇게 발행된 토큰들을 DOM node로 변환해 tree 구조로 재배치함으로써 완성되는 것이다. 예제에서는 토큰을 string array로 구성했지만, 실제로는 발행 과정 내에서 토큰이 DOM node로 변환되어 tree에 추가된다.

이렇게 보니 상태에 따라 현재 작업과 다음 작업이 정해져 있고, 상태 또한 인덱스 위치의 문자열이 결정하는 것을 알 수 있다. 토큰화가 어떤 조건으로 동작해야 하는지는 HTML Living Standard의 명세서에 자세히 정리되어 있다.

HTML Standard
아래는 옛날에 과제로 작성했던 HTML 파서인데 참고가 될까 싶어 첨부한다.
이 코드는 State machine 따위는 보이지 않고, 구문 분석(Syntax analysis) 과정도 없으며, 공식 스펙처럼 다양한 예외 케이스를 대응하지도 못한다. 또한 너그럽다 못해 작성자의 마크업 오류까지 보정해주는 실제 HTML 파서와는 달리, 그런 건 기대할 수 없다. 하지만 모든 건 ChatGPT가 잘 설명해줄 테니 이해하는 데 문제는 없을 것이다.
CodeSpitz 3기 3회차 과제
CodeSpitz 3기 3회차 과제. GitHub Gist: instantly share code, notes, and snippets.
인간 종말의 시대가 도래했다!!!!!

1-2. Style rules tree

Style rules tree도 DOM tree 구성 과정과 크게 다르지 않다.

  1. CSS를 파싱해 토큰화(Tokenizing)
  2. 발행(Emit)된 토큰을 객체(CSSRuleList, CSSStyleRule)로 작성
  3. 객체를 tree 구조로 배치

여기서 DOM Tree 구성 과정과 다른 점이라면 파싱 방법과 tree 구조이다.

먼저 결과물인 tree 구조부터 설명하면, DOM tree는 DOM 구조를 나타내었다면, Style rules tree는 당연히 스타일 시트와 규칙을 담고 있다. console.log(document)처럼 CSS 또한 아무 사이트나 열어서 console.log(document.styleSheets) 로그 찍으면 바로 이해가 될 것이다.

Style rules tree 구조
Medium의 CSSStyleSheet. 사장님, 저도 여기 글 쓰는 처지인데 돈 안 받고 다른 글 좀 읽게 해주시면 안 되겠습니까? 한국인은 수익 정산도 안되는데…

CSS는 문맥 자유 언어(Context-Free Language, 작성 규칙이 명확히 정해져 있는 언어를 말한다)로 수많은 예외를 가진 HTML과는 다르게 파싱 과정에서의 특이점이 없다. 파서의 동작 원리는 충분히 설명된 것 같으니 대신 CSS의 토큰 모델 스펙으로 대체한다.

CSS Syntax Module Level 3

2. Style calculation

CSS, 이름조차 cascade가 들어간다. 각 DOM node는 직접 부여받은 스타일 뿐만 아니라 상속 받은 스타일도 적용되어야 하는데, 렌더링 엔진은 Style rules를 참고해 각 DOM node에 어떤 스타일이 적용되어야 하는지 확정한다. 이 확정된 스타일을 계산된 스타일(Computed style)이라 한다.

좋은 말로 할 때 계산된 스타일 내놔.

렌더링 엔진은 스타일 계산을 위해 DOM tree를 순회하는데, 각 DOM node가 모든 Style rules를 탐색해 조건에 맞는 스타일을 새로운 스타일로 병합하는 비효율적인 방식이 아닌, 조건마다 스타일 객체를 미리 생성해 적절히 재사용(참조)하는 방식으로 이루어진다고 한다. 덕분에 브라우저는 연산 비용과 메모리를 아낄 수 있다. (아쉽게도 정확한 자료는 찾지 못했다)

예를 들어 Webkit은 형제나 사촌 node 간에는 기본적으로 같은 스타일 객체(RenderStyle)를 공유하며, attributes, state(hover, focused) 등이 달라 분기가 갈라졌을 때만 다른 스타일 객체가 적용된다고 한다. 아래 예제에서 자식 클래스를 가진 node들은 기본적으로 모두 같은 스타일 객체를 참조하지만, 나는-남들과는-다르지 클래스를 가진 node는 다른 스타일 객체를 참조한다.

<ul class="부모">
  <li class="자식 나는-남들과는-다르지">Foo</li>
  <li class="자식">Bar</li>
  <li class="자식">Baz</li>
</ul>

계산된 스타일을 DOM tree에 입힌다면 이렇게 표현할 수 있다. (실제 DOM node는 계산된 스타일을 참조만 할 뿐, 가지고 있는 것은 직접 부여받은 스타일뿐이다)

갑자기 배고프다.

여기서 잠깐, 이때 만들어지는 계산된 스타일이 tree 형태로 존재하고, 이를 CSSOM tree라고 주장하는 글이 있다!

Constructing the Object Model
Learn how the browser constructs the DOM and CSSOM trees.
Render-tree Construction, Layout, and Paint
TODO
웹페이지를 표시한다는 것: 브라우저는 어떻게 동작하는가 - 웹 성능 | MDN
사용자는 로드가 빠르고 상호작용이 원활한 컨텐츠로 이루어진 웹 경험을 원합니다. 따라서 개발자는 이 두 가지 목표를 달성하기 위해서 부단히 노력해야합니다.

바로 위의 포스팅들인데, 사실 방금 사용한 이미지의 출처 또한 이곳이다. 이 글에서는 CSSOM tree라는 것이 존재한다. 이는 DOM tree와 유사한 구조를 띠고, 상속 스타일(cascading style)까지 포함되어 있다고 한다.

물론 스타일 계산은 분명히 이루어질 것이고, 계산된 스타일을 반환하는 getComputedStyle() 메서드가 Window의 인터페이스 확장이자 CSSOM의 공식 스펙인 것도 사실이다. 이때 반환된 객체 또한 CSSOM의 스펙에 해당하는 CSSStyleDeclaration 객체다.

다만 이 그림이 CSSOM tree 모델이라거나 이와 비슷한 내용은 W3C 공식 스펙, 그 어디에서도 찾지 못했으며, 같은 주장을 펼치는 글이 꽤 있음에도 내용을 뒷받침할 수 있는 근거가 포함된 글은… 아쉽게도 단 하나도 발견하지 못했다.

허나 렌더링 엔진의 내부 구현은 그럴 수 있겠다 싶어 찾아 보았는데:

  • How browsers work
    Gecko 엔진의 스타일 계산 과정이 유사하다. Resources 링크 대부분이 깨져 있어서 아쉬울 따름.
  • computed_style_property_map.cc
    Blink 엔진인데 그럴 듯 해 보인다 ㅎ 사실 잘 모르겠어요.

결국 내가 낸 결론은 CSSOM이 계산된 스타일을 알고 있는 것은 사실이나, 자료구조나 구현 방식은 엔진마다 다르며, 결국 ‘CSSOM tree’ 라는 것은, 그저 이해를 돕기 위한 용어였거나, 실제로 과거 또는 특정 엔진의 구현체라는 것.

혹시 자세한 내용을 알고 계시다면 이메일로 알려주세요. 제발.

3. Layout

지금부터 Render phase다. 이제 렌더링 엔진은 각 DOM node의 계산된 스타일이 무엇인지 알고 있다. 이를 토대로 렌더링 엔진은 DOM tree를 순회하면서 각 node를, 어디에, 어느 크기로 배치할지 계산하고 이에 따라 화면의 구획을 나눈다. 각 node의 배치는 기본적으로 좌에서 우, 위에서 아래로 배치되며, Box Model이나 Positioning 등에 따라 결정된다.

이때는 실제로 화면에 무엇도 그려지지 않은 상태인데 시각적으로 표현하면 이렇다.

CSS로 죄다 하얗게 만들고, Outline을 그렸다. 그러니 100% 정확하지는 않다.

과정을 영상으로 본다면 이런 느낌.

이렇게 나눈 구획 또한 tree로 구성되며, 이를 Layout tree(또는 Render tree), 일련의 과정을 Layout(또는 Reflow)이라 한다. 이렇게 만들어진 Layout tree는 DOM tree와 굉장히 유사한데, 차이라면 Layout tree는 계산된 스타일을 포함하고 있다는 점, 그리고 배치할 필요가 없는 node는 제외된다는 점이다.

보충 설명을 하자면, <head />, <input type="hidden" />, display: none;은 layout이 생략되어 tree에 포함되지 않지만, visibility: hidden은 영역을 차지하기 때문에 layout까지는 이루어져 tree에 포함된다. 이와 유사한 content-visibility: hidden;은 자신의 layout은 이루어지지만, 자손들의 layout은 생략한다.


4. Paint

모든 투명한 node를 배치했다면 이제는 볼 수 있어야 하겠다. 렌더링 엔진은 그래픽을 표현하기 위해 점 단위로 색을 칠하는데, 이 과정을 Paint라고 한다.

Pixel이 아니라 점(Dot)인 이유는 기기의 물리 해상도와, 렌더링을 위해 정의된 점의 기준이 다르기 때문이다. 맥의 레티나 디스플레이를 생각하면 쉽다. 1dot = 4pixel

렌더링 엔진은 각 점에 무슨 색을 칠할지 결정해야 한다. 마크업을 해보았다면 알 수 있겠지만 node는 겹칠 수 있다. (Z-order) 이게 가장 중요한데, 단순히 Layout tree의 순서대로만 칠한다면 의도적으로 node의 Positioning이나 Z-Order를 제어한 경우 문제가 발생할 것이다.

때문에 무엇을 먼저 칠하는지가 중요한데, 색을 칠하기 전에 먼저 순서를 참조할 수 있도록 Layout tree를 순회하면서 Paint records라는 자료구조를 생성한다. 이후 Paint records를 따라 색을 칠한다. (쉽게 말하면 덧칠 순서를 정하는 셈이다)

마크업이 포토샵이나 피그마의 레이어와 유사하다면, 브라우저의 Paint는 그림판과 유사하다.

5. Rasterization & Composition

칠도 다 했겠다. 이제 끝 아냐? 라는 생각이 들 텐데, 사실 Paint는 실제로 칠을 한 것이 아니다. (으… 제발 그만해) 크롬의 Performance 탭에서 직접 프로파일링을 해본다면, Paint 단계 이후에도 Layerize, Commit 이라는 단계가 더 존재하는 것을 알 수 있다.

다시 말해 지금까지 이루어진 모든 작업은, 사전 작업에 불과하다는 것이다. Layout은 그저 Layout tree를 생성하는 작업이고, Paint 또한 Paint records를 생성하는 작업일 뿐이다.

Paint records를 생성했다면 적절히 가까운 것만 칠하기 위해 레이어를 나누고, Commit phase가 시작되면 그제야 우리가 머릿속에서 떠올리는 Paint, 즉 래스터화가 시작된다.

정보를 화면의 픽셀로 변환하는 작업을 래스터화(Rasterization)라고 하는데, document의 크기가 사용자의 디스플레이 또는 뷰포트(Viewport)보다 크다면 이 정보를 한 번에 표현할 수 없다. 그래서 렌더링 엔진은 브라우저에서 보여줄 수 있는 범위만큼만 래스터화 한다.

아래 이미지는 브라우저에서 스크롤 할 때 벌어지는 일이다. 첫번째 이미지는 과거의 방식으로, 처음부터 모든 걸 렌더링하지 않고 뷰포트가 이동하고 나서야 보이는 부분만 렌더링하는 모습을 볼 수 있다. 하지만 요즘은 두번째 이미지처럼, 합성(Composition)이라는 기술을 함께 사용한다. 일단 문서의 각 부분을 레이어로 나누어 별도로 래스터화한 다음, 이를 조립(?)하는 방식이다.

안-합성. 이걸 보니 배틀그라운드 초기에, 발적화로 건물 렌더링이 안돼서 비행기에서 내리자마자 마라톤만 했던 기억이 난다.
합성. 현실에서도 망원경 쓰고 춤추면 이렇게 보이겠지.

이렇게 생성된 레이어의 목록을 Layer tree(또는 Layer list)라고 한다. 레이어는 엘리먼트에 position이나 translateZ, translate3d 등을 적용해 직접 분리할 수 있다. 아래는 carousel 컴포넌트를 구현할 때, 성능을 위해 의도적으로 레이어를 분리한 모습이다. (적절히 분리하는 것이 왜 성능에 좋은지는 따로 찾아보자)

Bootstrap의 Carousel 컴포넌트

그럼 이쯤에서, 만약 레이어를 하나도 나누지 않았거나 레이어가 너무 크다면 어떻게 될까? 오히려 과거보다 무식하게 그리는 걸까? (요즘 컴퓨터 성능 생각하면...)

이때는 레이어를 타일(tile) 형태로 쪼갠다. 레이어가 한 번에 그리기엔 너무 크다고 판단되면 타일(Tile) 형태로 나누어서 viewport에 가까운 타일만 그리는 것이다. 그리고 이렇게 나누어진 레이어와 타일은 결국 렌더링하려면 조립해야 하는데, 이것을 합성이라고 한다. (합성의 구현에 대해서도 따로 찾아보자)

우리 집 찬장 한편에는 스팸 절단기가 있다.

이로써 브라우저의 렌더링이 비로소 마무리 되었다. 하지만 이 모든 과정은 웹 페이지를 열었을 때, 단 한 번만 이뤄지는 게 아니다. HTML과 CSS가 동적으로 변경되면 필요한 부분에 대해서 일련의 과정들을 다시 거친다. 복잡한 UI를 가졌지만 높은 퍼포먼스를 내야 하는 서비스라면, JS로 DOM을 다루거나 CSS를 작성할 때 더욱 신경 써서 작성해야 할 것이다.

정리

  • 렌더링 엔진은 HTML을 파싱하다 CSS, JS를 발견하면 함께 파싱한다.
    허나 JS는 DOM tree에 영향을 주기 때문에 JS가 모두 파싱되어 실행될 때까지 HTML 파싱을 멈추고, CSS는 렌더링 결과물에 영향을 주기 때문에 렌더링을 멈춘다.
  • DOM tree와 Accessibility tree, Style rules tree 구성은 파싱과 함께 이루어진다.
  • Style rules에 따라 DOM tree에 적용될 스타일을 계산한다.
  • DOM tree와 계산된 스타일들을 조합해 화면에 배치할 tree를 구성한다. 이를 Layout tree 또는 Render tree라고 하며, 배치할 필요가 없는 node는 포함하지 않는다.
  • Layout tree를 화면에 배치하는 작업을 Layout 또는 Reflow라고 하며, 이때 배치된 node는 투명한 상태이다.
  • Layout tree에 그려진 node에 색을 칠하는 작업을 Paint라고 한다. Paint는 점(Dot) 단위로 이루어지며, 겹쳐있는 node들의 Z-Order 때문에 Paint Records라는 자료구조를 만들어 칠하는 순서(덧칠 순서)를 결정한다.
  • 화면 밖의 영역도 반복적인 Paint를 거치면 비용 낭비가 발생하므로, 래스터화(Rasterization) 과정에서 보여지는 영역만 Paint 한다. 또한 크롬의 경우 문서의 각 부분을 레이어로 나누어 별도로 래스터화하고, 이를 합성(Composition)하는 방식으로 렌더링한다.

Reference