* 이 글은 How the browser renders a webpage? - DOM, CSSOM and Rendering을 번역하였습니다.
모든 사람이 알고있다시피 Javascript는 ECMAScript 표준을 명세하고 있습니다. 사실 JavaScript가 상표로 등록되었을 때부터, 우리는 ECMAScript로 부르게 되었습니다. 그러므로 모든 JavaScript의 엔진 (V8, Chakra, Spider Monkey 등등) 은 ECMAScript 표준을 따라갑니다.
이러한 표준은 브라우저, Node, Deno 등과 같은 모든 JavaScript 런타임간에 일관된 JavaScript 경험을 제공합니다. 이는 다양한 플랫폼을 웹 어플리케이션으로 지속가능하고 결함이 없는 개발 환경을 구축해줍니다.
그러나 브라우저가 렌더링되는 부분은 이와는 별개의 문제입니다. HTML, CSS, JavaScript 각각은 일부 엔티티 또는 일부 조직에서 표준화되었습니다. 브라우저가 어떻게 이들을 화면에서 함께 렌더링하여 보여주는지에 대한 부분은 표준화되어 있지 않습니다. 구글 크롬 브라우저 엔진의 렌더링은 사파리 브라우저 엔진과는 다르게 동작합니다.
그러므로 특정 브라우저의 렌더링 순서와 기술적인 부분의 동작은 예측하기가 매우 어렵습니다. 이런 문제 때문에 HTML5 사양에서는 브라우저가 어떻게 렌더링이 이론적으로 작동하는 방법을 표준화하였습니다. 비록 브라우저가 이 표준을 준수하는 방법은 각각의 브라우저에게 달려있음에도 불구하고 말이죠.
이런 비 일관성에도 불구하고, 모든 브라우저가 따라가는 공통의 이론은 존재합니다. 브라우저의 생애주기와 화면의 렌더되는 공통의 순서들을 함께 알아보려고 합니다. 이러한 프로세스를 이해하기 위해서 아래의 렌더링 시나리오와 관련된 작은 프로젝트를 준비해 보았습니다.
Parsing and External Resources
파싱은 HTML 컨텐츠를 렌더링하고 DOM 트리를 구성하는 일련의 과정입니다. 그래서 이는 DOM 파싱이고도 불리우기도, DOM 파서라는 프로그램이고도 불리웁니다.
대부분의 브라우저는 HTML 코드로부터 DOM 트리를 구성하는 DOM 파서 웹 API를 제공합니다. DOM 파서 클래스의 인스턴스는 DOM 파서를 나타내고 parserFromString 프로토 타입 메서드를 사용합니다. 이는 한 줄로 되어 있는 HTML 코드를 DOM tree 로 파싱합니다. 아래와 같이 말이죠.
브라우저가 웹 페이지를 요청을 하고 서버로부터 HTML 텍스트(헤더에 Contet-type이 text/html로 적용)를 응답으로 받게 되면, 브라우저는 전체 문서의 몇 자 도는 몇 줄을 사용할 수 있는 즉시 HTML 파싱을 시작합니다. 그다음 브라우저는 하나의 노드에 조금씩 DOM 트리를 구성합니다. 브라우저는 HTML을 중첩 된 트리 구조를 만들어 내야 하기 떄문에 HTML코드를 위에서 아래로 파싱을 합니다.
위에 예시를 보면, incremental.html 파일에 접근하여 네트워크 스피드를 10kbps로 설정하였습니다. 이 파일을 로드하는데 오랜 시간이 걸리기 때문에, 브라우저는 첫 번째 byte로 DOM 트리를 구성하고 화면을 즉시 그리기 시작합니다.
위에 퍼포먼스 차트를 보게 되면, Timing 열을 자세히 살펴보길 바랍니다. 이 이벤트는 퍼포먼스 메트릭스라 불리우는데, 이러한 이벤트가 가능한 서로 가깝게 배치되고 가능한 빨리 발생하면 그만큼 사용자 경험이 향상됩니다.
FP는 First Paint의 줄임말입니다. 이것은 브라우저가 화면에 무언가(단순하게 body 태그에 background color의 하나의 pixel 값) 를 그리기 시작하는 순간을 나타냅니다.
FCP는 First Contentful paint의 줄임말입니다. 이는 브라우저가 Text나 Image와 같은 컨텐츠의 픽셀을 처음으로 렌더링한 순간을 나타냅니다.
LCP는 Largest Contentful Paint의 줄임말인데, 브라우저가 Text나 Image와 같은 컨텐츠의 커다란 부분을 렌더링 한 순간을 나타냅니다.
L은 window 객체에서 발생하는 이벤트 onload의 약자입니다. 비슷하게, DCL은 document 객체에서 발생하고 DOMContentLoaded 이벤트의 약자입니다. DOMContentLoaded는 window 이벤트까지 버블링되기 때문에 window에서도 사용이 가능합니다. 이 두가지 이벤트는 이해하기가 약간 까다로워 짚고 넘어가도록 하겠습니다.
브라우저가 외부 리소스(<script src="url"></script> 요소를 통한 JavaScript 파일, <link rel="stylesheet" href="url"/> 태그를 통한 Stylesheet 파일, <img src="url" /> 태그를 통한 image 파일)를 만나면 브라우저는 백그라운드에서 해당 파일들을 다운로드하기 시작합니다.
기억해야 할 가장 중요한 것은 DOM 파싱이 일반적으로 메인 스레드에서 발생한다는 것입니다. 따라서 메인 스레드에서 JavaScript 실행이 사용 중이라면 스레드가 사용 가능해질 때까지 DOM 파싱이 진행되지 않습니다. 왜 이런 점이 중요할까요? 그 이유는 스크립트 요소는 DOM 파서를 Blocking 하기 때문입니다. 모든 외부파일(image, stylesheet, pdf, video 등등)은 DOM 파서를 Blocking 하지 않지만, 스크립트 파일은 예외입니다.
Parser-Blocking Scripts
Parser-blocking script는 JavaScript 파일 또는 코드입니다. 이는 HTML의 파싱을 멈추게 합니다. 브라우저가 스크립트 요소를 마주하게 되면, 스크립트를 먼저 실행하고 다음 DOM 트리를 구성하기 위한 HTML 파싱을 이어하게 됩니다. 그래서 모든 embeded 스크립트는 파서 blocking이라 불리웁니다.
스크립트 요소가 외부에서 불러오는 스크립트 파일이라면 브라우저는 메인 스레드에서 외부 스크립트 다운로드를 시작하고, 해당 파일이 다운로드 될 때까지 메인 스레드의 실행을 중지합니다. 이 말은 즉슨, 스크립트 파일이 다운로드 될 때까지 DOM 파싱은 실행되지 않는다는 것입니다.
스크립트 파일을 다운로드 하자마자 브라우저는 다운로드된 파일을 메인 스레드에서 실행하고 이후 DOM 파싱을 진행합니다. 브라우저가 또 다른 스크립트 요소를 만나게 되면 같은 연산이 반복될 것입니다. 그러면 왜 브라우저는 스크립트 파일을 다운로드하고 실행하기까지 DOM 파싱을 멈출까요?
브라우저는 DOM API를 JavaScript 런타임에 노출합니다. 이는 JavaScript에서 DOM 요소들을 조작하고 접근할 수 있음을 의미합니다.
이것이 React 나 Angular와 같은 동적 웹 프레임 워크가 작동하는 방식입니다. 브라우저가 DOM 파싱과 스크립트 실행을 병렬적으로 조작하길 원한다면, DOM 파서 스레드와 메인 스레드 사이에 경쟁할 수 있는 조건이 있으므로 DOM 파싱이 메인 스레드에서 발생할 수 있도록 만들어야 합니다.
하지만 스크립트 파일을 백그라운드에서 다운로드 받은 동안 DOM 파싱을 중지하는 것은 매우 불 필요합니다. 그러므로 HTML5는 async 속성을 script 태그에 적용시킬 수 있도록 제공합니다. DOM 파서가 async 속성이 있는 외부 스크립트 요소를 만날 때, 스프립트 파일을 백그라운드에서 다운받는 동안에는 DOM 파서는 파싱을 중지하지 않습니다. 그러나 파일을 다운 받은 즉시, DOM 파싱은 중지되고 스크립트 코드가 실행됩니다.
async 속성과 유사하게 작동하지만, defer 속성은 스크립트 파일이 완전히 다운로드 된 후에도 스크립트 코드가 실행 되지 않습니다. 모든 defer 속성을 지닌 스크립트는 DOM 파서가 모든 HTML을 파싱하고 DOM 트리가 완전히 구성된 이후 스크립트 코드를 실행합니다. async 속성과 달리 모든 defer 속성을 지닌 스크립트는 HTML 문서(또는 DOM 트리)에 나타나는 순서대로 실행됩니다.
* 간략한 비교
- normal: DOM 파싱 중 스크립트 파일을 만나면 DOM 파싱 멈춤 > 스크립트 파일 다운로드 실행 > 스크립트 파일 실행
- async: DOM 파싱과 스크립트 파일 다운로드를 병렬적으로 진행 > 스크립트 파일이 다운 된 후 DOM 파싱 멈춤 > 스크립트 파일 실행
- defer: DOM 파싱과 스크립트 파일 다운로드를 병렬적으로 진행 > 스크립트 파일이 다운 된 후 DOM 파싱은 진행 > DOM 파싱이 끝난 후 스크립트 실행
위에 예시를 보면, parser-blocking.html 파일에는 30개 요소 코드 다음 parser-blocking 스크립트를 가지고 있으므로, 브라우저가 처음 30개 요소를 표시하고 DOM 파싱을 중지한 다음 스크립트 파일을 로드하기 시작하는 걸 볼 수 있습니다.
두번 째 스크립트 파일은 defer 속성을 가지고 있으므로 파싱을 Block하지 않고, DOM 트리가 완벽하게 구성되자마자 스크립트 파일을 실행합니다.
퍼포먼스 패널을 보게되면, FP와 FCP가 브라우저가 HTML 컨텐츠에 접근할 수 있을 때 DOM 트리 구성을 시작하는 순간 발생됩니다.
LCP는 5초 후에 발생하는데 그 이유는 스크립트가 다운로드되고 30글자 요소를 렌더할 때 DOM 파싱을 5초동안 block하고 있기 때문입니다. 그러나 스크립트가 다운로드 되고 실행을 하자마자 DOM 파싱은 재개되고 커다란 컨텐츠는 화면에 렌더 되면서 LCP 이벤트를 발생시킵니다.
Render-Blocking CSS
지금까지 알아본 바로는, 파싱을 블록하는 스크립트 파일을 제외하고 어떤 외부의 리소스 요청들은 DOM 파싱 처리를 block하지 않는다고 했습니다. 그래서 CSS (embedded 포함) DOM 파서를 직접적으로 block하지 않습니다. 직접적으로 하지는 않지만 CSS도 DOM 파싱을 block합니다. 그렇기에 렌더링 순서에 대해 다시 한번 살펴 봅시다.
HTML 컨텐츠를 DOM 트리로 구성하는 브라우저 엔진은 서버로부터 text 문서를 받습니다. 비슷하게 브라우저 엔진은 stylesheet 컨텐츠 (외부 CSS파일 또는 embedded CSS, Inline CSS등) 로부터 CSSOM 트리를 구성합니다.
DOM과 CSSDOM 트리 구성은 메인 스레드에서 이루어지는데 이 트리들은 동시에 구성됩니다. 이 두 트리는 함께 화면을 그려 줄 렌더 트리를 구성하고, 이는 DOM 트리가 구성됨에 따라 점전적으로 빌드됩니다.
DOM 트리 생성이 점진적이라는 것을 살펴 보았듯이, 브라우저가 HTML을 읽을 때 DOM 트리에 점진적으로 DOM 요소를 추가합니다. 그러나 CSSOM 트리는 예외입니다. DOM 트리와는 다르게 CSSOM 트리는 점진적으로 구성되지 않고, 특정 방식에 의해 생성됩니다.
브라우저가 <style/> 블럭을 찾았을 때, HTML 파싱을 멈추고, 모든 embedded CSS 파싱을 할 것이고, CSSOM 트리를 새로운 CSS 규칙으로 CSSOM 트리를 업데이트 할 것입니다. 그 다음 HTML 파싱을 재개 할 것입니다. Inline style도 마찬가지입니다.
그러나 브라우저가 외부 stylesheet 파일을 마주했을 때 완전히 다른 방식으로 운용됩니다. 외부 stylesheet 파일은 parser-blocking 요소가 아니므로 브라우저가 백그라운드에서 자동으로 다운로드 할 수 있으며, 그 동안 DOM 파싱은 계속됩니다.
하지만 HTML 파일과는 다르게 브라우저는 하나의 byte로 stylesheet 파일 컨텐츠를 처리하지 않습니다. 그 이유는 브라우저가 CSS 콘텐츠를 읽을 때 CSSOM 트리를 점진적으로 구성하지 못하기 때문입니다. 그 이유는 파일 끝에 있는 CSS 규칙이 파일 맨 위에 작성된 CSS 규칙을 재 정의할 수 있기 때문입니다.
그러므로 브라우저가 CSS 콘텐츠를 점진적으로 CSSOM으로 구성한다면, Stylesheet 파일에서 추후에 나타나는 스타일 재 정의 규칙으로 인해 동일한 CSSOM 노드가 업데이트 됨에 따라 렌더 트리의 여러 렌더링이 발생합니다. 렌더 트리의 지속적인 변경은 좋지 않는 유저 경험으로 이어질 것입니다. CSS 스타일은 계단식이므로 하나의 규칙 변경이 여러 요소에 영향을 미칠 수 있습니다.
그래서 브라우저는 외부 CSS 파일을 점진적으로 처리하지 않고, CSSOM 트리의 수정은 stylesheet가 처리되는 동안 딱 한번 발생하게 됩니다. CSSOM 트리 수정이 끝나자마자(CSSOM 트리 구성 완료) 렌더 트리는 수정되고, 그 다음 화면에 그려지게 됩니다.
CSS는 render-blocking 리소스입니다. 브라우저가 외부 stylesheet를 가져 오도록 요청하면 렌더 트리 구성이 중지됩니다. 따라서 CRP(critical Rendering Path)도 멈춰 있고 아래와 같이 화면에 아무것도 렌더링 되지 않습니다. 하지만 stylesheet가 백그라운드에서 다운로드되는 동안 DOM 트리 구성은 여전히 진행 됩니다.
브라우저는 Stylesheet가 다운로드가 되고 파싱이 될 때 까지 기다립니다. Stylesheet가 파싱되고 CSSOM이 수정된 후, 렌더 트리는 업데이트 되고 화면에 렌더 트리의 요소를 그리는 CRP는 재개됩니다. 이러한 이유 때문에 모든 외부 stylesheet들은 가능한 head 구역에 위치시키는 것이 좋습니다.
* 즉, DOM 트리가 완성되었다고 해도, CSSOM 트리가 완성되지 않았다면, 렌터 트리 구성이 중지됩니다. 그리고 CSSOM 트리 수정이 끝났을 때, 렌더 트리 구성이 재개 됩니다.
상상을 해봅시다. 브라우저가 HTML을 파싱을 하다가 외부 스타일 시트 파일을 만납니다. 백그라운드에서 해당 파일을 다운로드 하는 동안 CRP를 Blocking 하고, DOM 파싱은 계속 진행이 됩니다. DOM 파싱을 진행하다가 스크립트 파일을 만납니다. 스크립트 파일 다운로드를 하는 동안 DOM 파싱을 Blocking 합니다. 이제 브라우저는 스타일 시트와 스크립트 파일이 완전히 다운로드 될 때까지 기다립니다.
백그라운드에서 스타일시트가 다운로드 있는 동안 외부 스크립트 파일 다운로드가 먼저 끝이 났습니다. 그렇다면 브라우저는 스크립트 파일을 실행할까요? 실행 했을 때 어떤 문제가 있을까요?
우리가 알고 있다시피, CSSOM은 DOM요소의 스타일과 상호 작용할 수 있는 고급 JavaScript API를 제공합니다. 예를 들면 element.style.backgroundColor 속성을 사용하여 DOM 요소의 Background 색상을 수정할 수 있습니다. element와 관련있는 style 객체는 CSSOM API에 노출 되어 있고, 다른 API들도 마찬가지입니다.
* 아래는 CSSOM에 대한 자세한 내용입니다.
스타일시트가 백그라운드에서 다운로드 되는 동안, 메인 스레드가 로드되는 스타일 시트에 blocking 되지 않기 때문에, JavaScript를 계속 실행할 수 있습니다. JavaScript 코드에서 CSSOM API를 통해 DOM 요소의 CSS 속성에 접근하게 되면 CSSOM의 상태에 따라 적절한 값을 얻습니다.
그러나 스타일 시트가 다운로드가 되고 파싱되고, CSSOM 수정으로 이어지면, 새로운 CSSOM 수정으로 인해 해당 DOM요소의 CSS 속성이 변경 될 수 있으므로, JavaScript 요소의 잘못된 CSS 값이 있을 수 있게 됩니다. 이러한 이유 때문에 스타일시트가 다운로드 되고 있는 동안에 JavaScript를 실행하는 것은 안전하지 않습니다.
HTML5 명세에서 정의하는 바로는, 브라우저는 스크립트 파일을 다운로드 할 수는 있지만, 이전의 모든 스타일 시트를 파싱하지 않는 한 실행하지 않습니다. 스타일 시트는 스크립트 실행을 block 할 때, 이러한 액션을 script-blocking stylesheet 또는 script-blocking CSS라고 합니다.
위에 예시를 보면, script-blocking.html은 링크 태그(외부 스타일 시트 용)와 스크립트 태그(외부 JavaScript 용)를 포함합니다. 여기서 스크립트 파일은 어떤 딜레이 없이 빠르게 다운로드 되는 반면, 스타일시트 다운로드는 6초 걸립니다. 스크립트 파일이 다운로드 되었다고 하더라도, 이 파일은 즉시 실행되지 않습니다. 오직 스타일 시트가 다운로드 되고 난 후 로드된 후 스크립트에 의한 "Hello World" 메시지를 볼 수 있을 것입니다.
* 스크립트 요소를 non-parser-blocking 으로 가져오기 위해 defer, async를 사용하는 것과 같이 외부 스타일시트를 같은 방법으로 가지고 올 수 있는 media 속성이 존재합니다. media 속성을 사용하면 브라우저는 스타일 시트를 똑똑하게 로드 할 것입니다.
Document's DOMConentLoaded Event
DOMContentLoaded (DCL) 이벤트는 브라우저가 사용 가능한 모든 HTML에서 완전한 DOM 트리를 구성한 시점을 표시합니다. 하지만 DCL 이벤트가 실행될 때 변경 될 수 있는 많은 요소들이 관련되어 있습니다.
document.addEventListener( 'DOMContentLoaded', function(e) {
console.log( 'DOM is fully parsed!' );
} );
HTML이 어떠한 스크립트를 포함하지 않았을 때, DOM 파싱은 Blocked 되지 않고, DCL은 브라우저가 전체 HTML을 파싱할 수 있을 때 곧장 실행됩니다. 만약 parser-blocking 스크립트가 포함되어 있다면, DCL은 모든 parser-blocking 스크립트가 다운로드 되고 실행 될 때까지 기다립니다.
스타일시트가 포함되게 되면 조금 복잡해집니다. 외부 스크립트를 불러오지 않더라도, DCL은 모든 스타일시트가 로드 될 때까지 기다립니다. DCL은 전체 DOM 트리가 준비된 시점을 표시하기 때문에, CSSOM이 완전히 구성되지 않는 한 DOM은 스타일정보에 대해 안전하게 실행할 수 없습니다. 그러므로 대부분의 브라우저들은 모든 외부 스타일시트가 다운로드 및 파싱이 되기까지 기다립니다.
Script-blocking 인 스타일시트는 DCL 실행을 늦춥니다. 예를 들어보자면, 스크립트가 스타일시트가 다운로드되는 것을 기다려야하기 때문에, DOM 트리도 구성되지 않습니다.
DCL은 웹사이트 퍼포먼스 메트릭스 중 하나입니다. DCL을 가능한 작게 (실행시간) 최적화시켜야 합니다. 모범 사례 중 하나는 스크립트가 백그라운드에서 다운로드되는 동안 브라우저가 다른 작업을 수행할 수 있도록 스크립트 요소에 defer, async 태그를 사용하는 것입니다. 또 하나는 Script-blocking과 Render-blocking 인 스타일시트를 최적화 시키는 것입니다.
Window's load Event
알다시피, JavaScript는 DOM tree가 만들어지는 것을 block 합니다. 하지만 외부 스타일시트나 이미지, 비디오 등 파일 요소들은 DOM tree가 만들어지는 것을 blocking하지 않습니다.
DOMContentLoaded (DCL) 이벤트는 브라우저가 사용 가능한 모든 HTML에서 완전한 DOM 트리를 구성한 시점을 표시하지만, window.onload 이벤트는 위부 스타일시트와 파일(이미지 비디오 등)이 다운로드 되고 웹 어플리케이션이 다운로드를 완료한 시점을 표시합니다.
window.addEventListener( 'load', function(e) {
console.log( 'Page is fully loaded!' );
} )
위 예시를 보면, rendering.html 파일은 head에 외부 스타일시트를 가지고 있고, 5초 정도 다운로드를 합니다. head 섹션에 있기 때문에, 스타일시트가 그 아래에 있는 모든 콘텐츠의 롄더링을 block 하므로 FP와 FCP는 5초 후에 발생합니다.
이후 다운로드하는 데 약 10초가 걸리는 이미지들을 로드하는 img 요소가 있습니다. 브라우저는 백 그라운드에서 이 파일을 계속 다운로드하고 DOM 파싱 및 렌더링을 진행합니다. ( 외부 이미지 리소스는 Script-blocking과 Render-blocking 요소가 아닙니다.)
다음으로 3개의 외부 자바스크립트 파일이 있으며, 각각 다운로드 하는데 3초, 6초, 9초가 소요되며, 여기서 중요한 점은 async 태그가 아닌 스크립트 파일입니다. 즉, 이전 스크립트가 실행되기 전 후속 스크립트가 다운로드를 시작하지 않으므로 총 로드되는 시간이 18초가 걸립니다. 그러나 DCL 이벤트를 살펴보면 브라우저가 예측 전략을 사용하여 스크립트 파일을 다운로드 하였습니다. 총 로드 시간은 9초가 걸렸습니다.
DCL에 영향을 줄 수 있는 마지막 다운로드 파일은 로드 시간이 9초이므로 DCL 이벤트가 약 9.1초에 발생합니다.
또한 이미지 파일인 또 다른 외부 리소스가 있었고 이는 백그라운드에서 계속 로드되었습니다. 다운로드가 완료(10초 소요)되면 웹페이지가 완전히 로드 되었음을 표시하며 10.2초 정도 후에 웹 페이지 창의 이벤트가 시작됩니다.
긴 글 읽으주셔서 감사합니다.
< 참고자료 >
[사이트] #medium
medium.com/jspoint/how-the-browser-renders-a-web-page-dom-cssom-and-rendering-df10531c9969
<JavaScript> 브라우저의 Rendering (3) - Rendering Process in browsers
'Language & Framework & Library > JavaScript' 카테고리의 다른 글
stopPropagation vs stopImmediatePropagation (1) | 2023.11.11 |
---|---|
9가지 확장가능한 JavaScript 코드 (4) | 2021.05.16 |
웹 개발자가 알아야 할 7가지 디자인 패턴 (0) | 2021.04.04 |
브라우저의 Rendering (2) - Operation (0) | 2021.04.03 |
브라우저의 Rendering (1) - Construction (0) | 2021.02.28 |