브라우저의 렌더링 과정(Critical Rendering Path)

브라우저의 주소창에 URL을 입력하고 엔터 키를 눌렀을 때 발생하는 일련의 과정을 알아보자.

1. Request & Response

브라우저 렌더링은 유저가 서버에 요청(request)하는 것으로부터 출발한다. velog를 이용하고 싶은 유저가 웹 브라우저 주소창에 velog.io를 입력해서 요청을 보내면, velog 서버는 요청에 대한 응답(response)으로써 리소스 파일(HTML, CSS, JS, 이미지, 폰트 등)을 제공한다. 브라우저는 이 리소스를 파싱하여 렌더링하는 것이다.

velog.io와 같은 루트 요청1은 일반적으로 서버 내 루트 폴더에 존재하는 index.html을 요청하는식으로 작동한다. velog.io라는 URL 뒤에 /assets/data/data.json과 같은 식으로 부언을하여 특정 파일을 요청하는 방법도 있다.

위 그림은 velog.io 루트 요청에 대한 응답 결과다. [개발자 도구] - [네트워크] 패널에서 위와 같이 확인해볼 수 있다. 그런데 루트 요청의 응답 결과로 추정되는 velog.io(index.html) 파일 외에도 무수히 많은 CSS, JS, 이미지 파일 또한 응답된 것을 볼 수 있다. 요청도 하지 않은 이 리소스들은 왜 응답되었을까?

이는 브라우저의 렌더링 엔진이 velog.io(index.html) 파일을 파싱하는 도중에 <link>, <img>, <script>와 같은 태그를 만나서 외부 리소스를 로드해왔기 때문이다. 자세한 내용은 차차 알아갈 것이다.

1. https와 같은 Scheme(Protocol), www.velog.io와 같은 Host(domain)만으로 구성된 요청을 뜻한다.

2. HTML 파싱과 DOM 생성

브라우저 요청에대한 응답으로써 서버가 내놓은 HTML 문서는 영문자 나열(텍스트)에 불과하다. 텍스트인 HTML 문서를 브라우저에 시각적으로 렌더링하기 위해서는 텍스트를 브라우저가 이해할 수 있는 자료구조(객체)로 변환해야한다. 이 변환 과정이 파싱parsing이고, HTML 파싱의 결과로 브라우저가 이해할 수 있는 자료구조인 DOM2이 생성된다.

파싱 과정은 아래와 같다.

  1. velog.io 요청에 대한 응답으로 서버는 서버 내 HTML 파일을 반환한다. 이때 HTML 파일은 바이트(2진수)형태로 인터넷을 경유하여 사용자 브라우저에 도달한다.
  2. 응답된 바이트 형태의 HTML 문서는 meta 태그의 charset 어트리뷰트에 의해 지정된 인코딩 방식(UTF-8 등)을 기준으로하여 문자열로 변환된다.
  3. 문자열 형식(개발자가 작성한 형식)의 HTML 문서를 읽어 들여 문법적 의미를 갖는 코드의 최소 단위인 토큰token들로 분해한다.
  4. 각 토큰들을 객체로 변환하여 노드node들을 생성한다. 토큰에 따라 문서 노드, 요소 노드 등으로 변환된다. 노드는 DOM을 구성하는 기본 요소가 된다.
  5. HTML 요소 간의 부자 관계를 반영하여 모든 노드들을 트리 자료구조로 구성한다. 이 노드들로 구성된 트리 자료구조를 DOM(Document Object Model)이라 부른다.
<!DOCTYPE html>
<html>
	<head>
    	<meta charset="UTF-8">
        <link rel="stylesheet" href="style.css">
    </head>
    <body>
    	<ul>
        	<li id="apple">Apple</li>
            <li id="banana">Banana</li>
        </ul>
    </body>
</html>

위 HTML 파일의 파싱 결과로 아래와 같은 DOM이 생성된다.

2. DOM은 HTML 문서의 계층적 구조와 정보를 표현하며 이를 제어할 수 있는 API, 즉 프로퍼티와 메서드를 제공하는 트리 자료구조다.

3. CSS 파싱과 CSSOM 생성

HTML 파일은 렌더링 엔진에 의해 한 줄씩 순차적으로 파싱된다. 렌더링 엔진은 이처럼 DOM을 생성해 나가다가, CSS를 로드하는 link 태그나 style 태그를 만나면 DOM 생성을 일시 중단하고 서버에 CSS 파일을 요청한다. 로드한 CSS파일 역시 HTML과 동일한 파싱 과정(바이트 -> 문자 -> 토큰 -> 노드 -> CSSOM)을 거쳐 해석하고, CSSOM을 만들어낸다. CSS 파싱이 끝나면 렌더링 엔진은 HTML 파싱이 중단된 지점부터 DOM 생성을 재개한다.

CSSOM은 CSS의 상속을 반영하여 생성된다.

body{
	font-size: 18px; 
}
ul {
	list-style-type: none;
}

4. 렌더 트리 생성

렌더링 엔진이 HTML/CSS 파일을 파싱하여 생성된 DOM과 CSSOM은 렌더링을 위해서 렌더 트리render tree로 결합된다.

렌더 트리는 렌더링을 위한 트리 형태의 자료구조이므로, 브라우저 화면에 렌더링되지 않는 노드(meta, script 태그 등)와 CSS에 의해 비표시(display:none)되는 노드들은 렌더 트리에 포함되지 않는다.

완성된 렌더 트리는 각 요소의 레이아웃(위치와 크기)을 계산하는 데 사용되며 브라우저 화면에 픽셀을 렌더링하는 페인팅painting 처리에 입력된다.

레이아웃 계산과 페인팅 작업을 다시 수행하는 것을 리렌더링이라 한다. 리렌더링은 성능에 악영향을 주므로 빈번하게 발생하지 않도록 주의할 필요가 있다. 리렌더링은 다음과 같은 상황에서 발생한다.

  • 자바스크립트에 의한 노드 추가 또는 삭제
  • 브라우저 창의 리사이징에 의한 뷰포트 크기 변경
  • HTML 요소의 레이아웃(위치, 크기)에 변경을 발생시키는 width/height, margin, padding, border, display, position, top/right/bottom/left 등의 스타일 변경

5. 자바스크립트 파싱과 실행

DOM은 HTML 문서의 구조와 정보뿐만 아니라 HTML 요소와 스타일 등을 변경할 수 있는 프로그래밍 인터페이스로서 DOM API를 제공한다. 즉, 자바스크립트 코드에서 DOM API를 사용하면 이미 생성된 DOM을 동적으로 조작할 수 있다.

CSS 파싱과 마찬가지로, 자바스크립트 파싱은 렌더링 엔진이 HTML 파일을 파싱하며 DOM을 생성해나가다가 <script>태그를 만나서 DOM 생성을 일시 중단하고 자바스크립트 엔진에 제어권이 넘어간 시점에 시작된다.

자바스크립트 코드의 파싱과 실행은 렌더링 엔진이 아닌 자바스크립트 엔진이 처리한다. 자바스크립트 엔진은 자바스크립트 코드를 파싱하여 CPU가 이해할 수 있는 저수준 언어로 변환하고 실행하는 역할을 한다.

자바스크립트 엔진은 자바스크립트 코드를 해석하여 AST(Abstract Syntax Tree; 추상적 구문 트리)를 생성한다. 그리고 AST를 기반으로 인터프리터가 실행할 수 있는 중간 코드intermediate code인 바이트코드3를 생성하여 실행한다.

3. 바이너리 코드가 0과 1로만 이루어진 이진코드여서 CPU가 이해할 수 있는 수준의 언어라면, 바이트 코드는 Virutal Machine이 이해할 수 있는 코드를 말한다.

6. 리플로우와 리페인트

만약 자바스크립트 코드에서 DOM API가 사용되어 DOM이나 CSSOM이 변경된 경우, 변경된 DOM과 CSSOM을 기반으로 렌더 트리를 재생성한다. 이후 새로운 렌더 트리로 레이아웃과 페인트 과정을 거쳐 브라우저의 화면에 다시 렌더링하는데, 이를 리플로우reflow, 리페인트repaint라 한다.

리플로우는 레이아웃 계산을 다시 하는 작업을 말한다. 노드 추가/삭제, 요소의 크기/위치 변경, 윈도우 리사이징 등 레이아웃에 영향을 주는 변경이 발생한 경우에 한하여 실행된다.

리페인트는 재결합된 렌더 트리를 기반으로 다시 페인팅하는 것을 말한다.

리플로우와 리페인트는 각각 layout과 paint 단계를 다르게 일컫는 명칭이다. firefox에서는 reflow, repaint라고 표현하지만 chrome, safari 등에서는 layout, paint로 표현한다.

리플로우와 리페인트가 반드시 순차적으로 실행되는것은 아니다. 레이아웃에 영향이 없는 변경은 리플로우 없이 리페인트만 실행된다. 리플로우는 소모값이 큰 작업이므로 가능한 리페인트, 또는 composite 만으로 렌더링하는 게 좋다.

7. 자바스크립트 파싱에 의한 HTML 파싱 중단

HTML파일 내에서 자바스크립트 파일을 로드하거나, 자바스크립트 코드를 수행하는 <script> 태그의 위치는 중요한 의미를 갖는다. 자바스크립트 코드 내에서 DOM API를 이용해서 DOM을 조작하려 하는데 DOM이 온전히 생성되지 않은 상황이라면 문제가 발생할 수 있다. 그리고 이 문제는 브라우저가 동기적synchronous으로 위에서 아래 방향으로 순차적으로 파싱하기 때문에 발생할 수 있는 여지가 충분하다.

따라서, <body> 태그에서 HTML 코드를 모두 수행하여 DOM 생성이 완료된 뒤에 자바스크립트 코드를 수행하는 편이 좋다. <body>태그 아래에 <script>코드를 위치시키는 것이다. 이는 HTML 요소들의 렌더링이 자바스크립트 로딩/파싱/실행으로 인해 지연되는 상황을 방지하여 페이지 로딩 시간 단축의 효과도 가져온다.

async / defer를 이용한 해결법

HTML5부터 <script> 태그에 async, defer 어트리뷰트를 사용할 수 있게 되었다. 이는 앞서 살펴본 자바스크립트 파싱에 의한 DOM 생성이 중단blocking되는 문제를 해결해준다.

async와 defer 어트리뷰트를 사용하면 HTML 파싱과 외부 자바스크립트 파일의 로드가 비동기적asynchronous으로 동시에 진행된다. 하지만 자바스크립트의 실행 시점에 차이가 있다.

async 어트리뷰트

HTML/자바스크립트 파일의 로드가 비동기적으로 동시에 진행된다. 자바스크립트의 파싱과 실행은 자바스크립트 파일의 로드가 완료된 직후에 진행된다. 따라서 자바스크립트 파일의 로드가 완료되기만 하면 HTML 파싱 도중이더라도 언제든지 자바스크립트의 파싱과 실행이 진행될 수 있다.

defer 어트리뷰트

HTML/자바스크립트 파일의 로드가 비동기적으로 동시에 진행된다. 그러나 자바스크립트의 파싱과 실행은 HTML 파싱이 다 끝나서 DOMContentLoaded 이벤트가 발생한 직후에 수행된다. 따라서 defer 어트리뷰트를 사용하면 DOM 생성이 완료된 이후에 자바스크립트를 파싱하고 실행하게끔 지정할 수 있다.


Reference

<모던 자바스크립트 Deep Dive> (이웅모)

좋은 웹페이지 즐겨찾기