[JS] 모던 Javascript Deep Dive 38장 - 브라우저의 렌더링 과정
업데이트:
HTML, CSS, JS로 작성된 텍스트 문서를 브라우저가 어떻게 파싱하여 렌더링하는지 파헤치는 챕터이다. 우선 여기서 말하는 브라우저는 크롬, 사파리와 같은 친구들을 의미한다. 파싱과 렌더링의 의미도 알아보겠다.
- 파싱(parsing) : 텍스트 문서의 문자열을 토큰으로 분해하고, 토큰에 문법적 의미와 구조를 반영하여 트리 구조의 자료구조인 파스 트리를 생성하는 일련의 과정을 말한다. 쉽게 말해 구문을 분석한다는 뜻이라고 이해하면 된다 !
- 렌더링(rendering) : HTML, CSS, JS로 작성된 문서를 파싱(구문 분석)하여 브라우저에 시각적으로 출력하는 것
결국 크롬이 어떻게 텍스트를 분석해서 화면에 보여지게 하는지 과정을 자세히 알아보자는 것이다 !
🔍 브라우저의 렌더링 과정
- 브라우저는 렌더링에 필요한 리소스를 요청하고 서버로부터 응답을 받는다.(HTML, CSS, JS)
- 브라우저의 렌더링 엔진은 서버로부터 응답된 HTML과 CSS를 파싱하여 DOM과 CSDOM을 생성한다. 그리고 이 둘을 결합하여 렌더 트리(Render tree)를 생성한다.
- 브라우저의 자바스크립트 엔진은 서버로부터 응답된 자바스크립트의 구문을 분석(파싱)하여 AST를 생성하고 바이트코드로 변환하여 실행한다. DOM API를 통해 DOM이나 CSSOM을 변경하고 렌더 트리로 결합한다.
- 렌더 트리를 기반으로 HTML 요소의 레이아웃을 계산하고 브라우저 화면에 HTML 요소를 페인팅한다.
[01] 요청과 응답
처음으로 브라우저에 https://poimaweb.com을 검색하면 일어나는 과정은 서버의 요청과 응답이다.
- 서버 : 렌더링에 필요한 리소스가 모두 존재하는 곳
- 요청 / 응답 : 브라우저가 서버에 필요한 리소스를 요청하면 서버로부터 응답을 받는다.
우리는 주로 어떠한 사이트에 접근할 때 브라우저 주소창에 URL을 입력하고 엔터키를 누른다.
엔터키를 누르면 브라우저는 주소창에 적힌 URL을 통해 서버에게 요청을 전송하는데 이때 암묵적으로 index.html이 URL 뒤에 붙어 루트요청된다.
이 루트 요청을 하면 서버의 루트 폴더(가장 최상위 폴더)에 위치하는 index.html을 브라우저로 응답한다.
예를 들어, https://poimaweb.com 를 입력하면 처음에 루트 요청이 poiemaweb.com 서버로 전송된다. 루트 요청은 루트 폴더에 존재하는 index.html을 찾으라는 뜻이었다.
개발자 도구의 Network 탭에서 요청과 응답이 잘 이루어졌는지 확인해볼 수 있다.
신기한건 index.html (poimaweb.com) 뿐만 아니라 요청도 하지 않은 css, js, img 들도 같이 응답이 되었다.
✍ CSS나 자바스크립트, 이미지, 폰트 파일들은 HTML을 파싱할 때 외부 리소스를 로드하는 태그들을 만나 해당 리소스 파일을 서버로 요청하기 때문에 별도로 요청해주지 않아도 알아서 요청/응답된다.
[02] HTTP 1.1과 HTTP 2.0
-
HTTP 1.1 : 커넥션당 하나의 요청과 응답만 처리, 요청할 리소스가 많으면 응답 시간도 늘어난다.
HTPP 1.1은 HTML 문서 내의 CSS 파일을 로드하는 link 태그, 이미지 파일을 로드하는 img 태그, 자바스크립트를 로드하는 script 태그 등에 의한 리소스 요청이 개별적으로 전송되고 응답 또한 개별적으로 전송된다.
-
HTTP 2.0 : 커넥션당 다중 요청과 응답이 가능, HTTP1.1에 비해 페이지 로드 속도가 좀 더 빠르다.
[03] HTML 파싱과 DOM 생성
이제 index.html이 서버로부터 응답되었다고 가정하고 이 html 문서의 텍스트를 어떻게 분석하여 렌더링 하는지 알아보자.
우선, 자바스크립트 렌더링 엔진은 HTML 문서를 이해하기 위해 DOM이라는 자료구조를 생성한다.
책에서 소개한 DOM 생성과정을 다뤄보겠다.
<!DOCTYPE html>
<html>
<head>
<meta charset = "URF-8">
<link rel = "stylesheet" href="style.css">
</head>
<body>
<ul>
<li id="apple">Apple</li>
<li id="banana">Banana</li>
<li id="orange">Orange</li>
</ul>
<script src="app.js"></script>
</body>
</html>
큰 틀로 보면 위 코드에서 DOM은 아래 그림과 같이 생성된다.
바이트(2진수) 형태로 응답을 받고 charset의 UTF-8 방식을 기준으로 문자열로 변환된다.
문자열로 변환된 HTML 문서를 읽어들이고 문자열을 토큰들로 분해한다.
토큰들을 객체로 변환하여 노드를 생성하고 토큰의 내용에 따라 문서 노드, 요소 노드, 어트리뷰트 노드, 텍스트 노드가 생성된다.
이 노드들이 DOM을 구성하는 기본 요소가 된다.
그리고 이 HTML 요소는 중첩 관계를 갖는다. 예를 들어 body 태그 안에 p태그도 있을 수 있고, input 태그도 있을 수 있는데 이 요소들의 부자 관계를 반영하여 모든 노드들을 트리 자료구조로 구성한다.
이렇게 완성된 트리 자료구조를 우리는 DOM이라고 부른다.
[04] CSS 파싱과 CSSOM 생성
이전까지는 HTML 문서를 요청하고 응답하는 과정을 공부했다면, 이제 CSS를 어떻게 파싱하는지 알아본다.
HTML 코드를 한줄한줄 파싱하고 DOM을 생성하다가 CSS를 로드하는 link 태그나 style 태그를 만나면 하던 일을 멈추고 CSS 파일을 서버에 요청한다.
<!DOCTYPE html>
<html>
<head>
<meta charset = "URF-8">
<link rel = "stylesheet" href="style.css"> <!--link 태그를 만나 DOM 생성을 일시중지-->
위에서 다뤘던 예제를 link 태그 부분까지만 다시 가져왔다.
이제 link 태그를 만났으니 css 파일을 파싱하러 가보자
body {
font-size: 18px;
}
ul {
list-style-type: none;
}
서버로부터 CSS 파일이 응답되면 HTML에서 DOM을 생성하듯이 CSSOM이라는 것을 생성한다.
아래는 위 CSS를 파싱하여 만든 CSSOM 구조이다.
font-size 프로퍼티는 body 요소에 적용한 것으로 가장 최상단에 있고 아래 자식들에게 모두 같은 프로퍼티가 적용되는 것을 볼 수 있다.
즉, CSSOM은 CSS의 상속을 반영하여 생성된다.
[05] 렌더 트리 생성
DOM과 CSSOM을 생성했으니 이제 브라우저 렌더링 엔진은 이 둘을 결합하여 렌더링 하기 위해 렌더 트리를 생성한다.
렌더 트리는 렌더링을 위한 자료구조이므로 meta 태그나 script 태그나 css의 display:none 와 같이 화면에 렌더링되지 않는 노드들을 이 렌더트리에 포함되지 않는다.
즉, 렌더 트리는 브라우저 화면에 보여지는 노드만으로 구성된다.
렌더 트리는 각 HTML 요소의 레이아웃을 계산하는데 사용되된다.
그리고 브라우저 화면에 픽셀을 렌더링하는 페인팅 처리에 입력된다.
🔍 리렌더링 일어나는 경우
- 자바스크립트에 의한 노드 추가 / 삭제
- 브라우저 창의 리사이징에 의한 뷰포트 크기 변경
- HTML 요소의 레이아웃에 변경을 발생시키는 width, margin display 등의 스타일 변경
리렌더링은 레이아웃 계산과 페인팅을 다시 실행하는 것을 말한다.
빈번히 일어나면 성능에 악영향이 있으므로 주의할 필요가 있다고 한다.
[06] 자바스크립트 파싱과 실행
렌더링 엔진이 HTML을 한 줄씩 순차적으로 파싱하며 DOM을 생성하다가 자바스크립트 파일을 로드하는 script 태그를 만나면 DOM 생성을 일시 중단한다.
그리고 자바스크립트의 코드를 파싱하기 위해 렌더링 엔진에서 자바스크립트 엔진에게 제어권을 넘긴다.
자바스크립트 엔진은 이제 자바스크립트 코드를 파싱하고 AST(추상적 구문 트리)를 생성한다.
위 그림에서 볼 수 있듯이 토크나이저, 파싱, 바이트 코드 생성기, 인터프리터가 각각의 역할이 다르다.
토크나이징
단순한 문자열인 자바스크립트 소스코드를 어휘 분석하여 문법적 의미를 갖는 코드의 최소 단위인 토큰들로 분해한다.
파싱
토큰들의 집합을 구문 분석하여 AST(추상적 구문 트리)를 생성한다.
어떤식으로 생성하는지는 아래 그림을 통해 보자.
단순히 const ast = "AST";
를 구문 분석했을 뿐인데 AST 안에 많은 정보가 생성된 것을 볼 수 있다.
바이트코드 생성과 실행
파싱의 결과물로서 생성된 AST는 인터프리터가 실행할 수 있는 바이트코드로 변환되고 인터프리터에 의해 실행된다.
[07] 리플로우와 리페인트
자바스크립트 코드에 DOM이나 CSSOM을 변경하는 DOM API가 사용되면 리플로우, 리페인트가 일어난다.
- 리플로우 : 레이아웃 계산을 다시 하는 것
- 리페인트 : 재결합된 렌더 트리를 기반으로 다시 페인트를 하는 것
만약 레이아웃에 영향이 없으면 리플로우 없이 리페인트만 실행된다.
[08] 자바스크립트 파싱에 의한 HTML 파싱 중단
렌더링 엔진과 자바스크립트 엔진은 병렬적으로 파싱이 실행되는 것이 아니라 직렬적으로 파싱이 수행된다.
그래서 script 태그의 위치에 따라 자바스크립트가 실행되지 않을 수 있는 문제가 발생한다.
아래 코드를 통해 어떤 문제가 있는지 보자.
<!DOCTYPE html>
<html>
<head>
<meta charset = "URF-8">
<link rel = "stylesheet" href="style.css">
<script>
const = $apple = document.getElementById('apple');
</script>
</head>
<body>
<ul>
<li id="apple">Apple</li>
<li id="banana">Banana</li>
<li id="orange">Orange</li>
</ul>
<script src="app.js"></script>
</body>
</html>
DOM API는 document.getElementById('apple')
는 DOM 에서 id가 apple 인 요소를 취득해야한다.
그런데 아직 HTML 요소가 다 파싱되지 않고 script 태그를 만나 중단되었기 때문에 apple 요소를 DOM에서 찾을 수 없는 것이다.
그래서 이러한 문제를 피하려면 script 태그를 body요소 아래에 위치시키면 된다.
[09] script 태그의 async/defer 어트리뷰트
위 8번에서 다룬 방법은 사용자가 기본적인 HTML 컨텐츠를 빨리 본다는 장점은 있지만 자바스크립트 의존성이 큰 페이지인 경우 정상적으로 작동하는 웹 페이지를 보지 못한다.
그래서 근본적으로 이 문제를 해결하기 위해 HTML5부터는 script 태그에 async과 defer 어트리뷰트가 추가되었다.
<script async src="extern.js"></script>
<script defer src="extern.js"></script>
<!-- src 어트리뷰트를 통해 외부 js 파일을 로드할 때만 사용 가능-->
async과 defer 어트리뷰트를 사용하면 HTML 파싱과 외부 자바스크립트 파일의 로드가 비동기적으로 동시에 진행된다.
이 둘은 자바스크립트의 실행 시점에 차이가 있다.
async 어트리뷰트
script 태그의 순서와 상관없이 로드가 일단 완료되면 실행하기 때문에 태그의 순서가 보장되지 않는다.
html이 다 파싱되기도 전에 실행이 되기 때문에 querySelector로 DOM 요소를 조작하려 하면 위험할 수 있다.
defer 어트리뷰트
async과 같이 HTML 파싱과 같이 로드되고 실행은 HTML 파싱이 완료된 직후 진행된다.
그러므로 위 방법들보다는 훨씬 안전한 방법이다.
댓글남기기