node.js 의 등장으로 JS 는 웹 브라우저를 벗어나 서버 사이드 앱 개발도 가능한 범용 개발 언어가 되었습니다. 하지만 역시 JS 가 가장 많이 사용되는 분야는 웹 브라우저 환경에서 동작하는 웹페이지나 애플레키이션의 클라이언트 사이드입니다.
웹 애플리케이션의 클라이언트 사이드 JS 는 브라우저에서 HTML/CSS 와 함께 동작합니다. 즉, 브라우저 환경을 고려한다면 더 효율적인 자바스크립트 프로그래밍이 가능합니다. 이를 위해 브라우저는 아래와 같이 HTML/CSS/JS 로 작성된 문서를 파싱하여 브라우저에 렌더링합니다.
1. 브라우저는 HTML/CSS/JS, 이미지, 폰트 파일등 렌더링에 필요한 리소스를 요청하고 서버로부터 응답받습니다.
2. 브라우저의 렌더링 엔진은 서버로부터 응답받은 HTML/CSS 를 파싱하여 DOM/CSSOM 을 생성하고 결합하여 렌더 트리를 생성합니다.
3. 브라우저의 JS 엔진은 서버로부터 응답받은 JS 를 파싱하여 AST(Abstract Syntax Tree) 를 생성해 바이트코드로 변환해 실행합니다. 이때 JS 는 DOM API 를 통해서 DOM/CSSOM 을 변경하는 것이 가능합니다. DOM/CSSOM 이 변경되면 다시 렌더 트리로 결합됩니다.
4. 렌더 트리를 기반으로 HTML 요소의 레이아웃을 계산하고 브라우저의 화면에 HTML 요소를 페인팅합니다.
요청과 응답
렌더링에 필요한 리소스는 모두 서버에 존재합니다. 브라우저는 필요한 리소스를 서버에 요청하고 서버로부터 응답을 받아 브라우저에 시각적으로 렌더링합니다. 서버에 요청을 전송하기 위해서 브라우저는 주소창을 제공합니다. URL 을 입력하고 엔터 키를 누르면 URL 의 호스트 이름이 DNS 를 통해 IP 주소로 변환되고 IP 주소를 가지는 서버에게 요청을 전송하게 됩니다.
브라우저에 주소를 입력하고 엔터 키를 누르면 루트 요청(스킴과 호스트만으로 구성된 URL)이 서버로 전송됩니다. 루트 요청은 https://주소/index.html 과 같은 요청으로 암묵적으로 index.html 을 응답하도록 기본적으로 설정되어 있습니다.
만약 index.html 이 아닌 다른 파일을 서버에 요청하려면 정적 파일의 경로와 파일 이름을 URI의 호스트 뒤의 path 에 기술하여 서버에 요청하면 됩니다.
특이한 점은 index.html 을 요청했을때 네트워크탭에서 확인해본 결과 index.html 뿐만 아니라 CSS, JS, 이미지, 폰트 파일들도 똑같이 응답된 것을 확인할 수 있습니다. 분명 요청한것은 index.html 뿐인데 왜 이 리소스들이 응답됬을까요?
이는 브라우저의 렌더링 엔진이 HTML 을 파싱하는 도중에 외부 리소스를 로드하는 태그를 만나면 HTML 의 파싱을 일시 중단하고 해당 리소스 파일을 서버로 요청하기 때문에 일어나는 현상입니다.
HTTP 1.1 vs HTTP 2.0
HTTP/1.1 은 기본적으로 커넥션당 한 개의 요청과 응답만을 처리합니다. 즉, 여러 요청과 여러 응답이 한 번에 전송되는것이 불가능합니다. HTTP/1.1 은 리소스의 동시 전송이 불가능하므로 요청할 리소스 개수가 많아질수록 응답 시간이 증가하는 단점이 있습니다.
반면 HTML/2 는 커넥션당 여러 개의 요청과 응답(다중 요청/응답)이 가능하다는 장점이 있습니다. HTTP/1.1에 비해서 페이지 로드 속도가 무려 50% 정도나 빠르다고 알려져있습니다.
HTML 파싱으로 DOM 생성하기
브라우저가 요청해서 서버가 응답한 HTML 문서는 문자열로 이루어진 순수 텍스트에 불과합니다. 이 텍스트를 브라우저에서 시각적으로 렌더링하기 위해서는 HTML 문서를 브라우저가 이해할 수 있어야 합니다.
브라우저의 렌더링 엔진은 HTML 문서를 파싱하여 브라우저가 이해할 수 있도록 DOM(Document Object Model) 자료구조를 생성합니다. 과정은 아래와 같이 진행됩니다.
1. 브라우저가 서버에 존재하는 HTML 파일을 요청해 응답받습니다. 서버는 브라우저가 요청한 HTML 파일을 읽어 메모리에 저장하고 메모리에 저장된 바이트(2진수)를 인터넷을 경유해서 응답해줍니다.
2. 브라우저는 서버가 응답한 HTML 문서를 바이트 형태로 응답받습니다. HTML 문서의 meta 태그의 charset 어트리뷰트에 의해서 지정된 인코딩 방식으로 바이트 형태의 HTML 문서를 문자열로 변환합니다.
3. 문자열로 변환된 HTML 문서를 읽어 들여 토큰들로 분해합니다.
4. 토큰들을 객체로 변환해 노드들을 생성합니다. 노드는 DOM 을 구성하는 기본요소가 됩니다.
5. HTML 문서는 HTML 요소들의 집합으로 이루어지고 HTML 요소는 중첩 관계를 가지게 됩니다. HTML 요소 간의 중첩 관계에 의해 부자 관계가 형성됩니다. 이런 부자 관계를 효율적으로 반영하기 위해 노드들을 통해 트리 자료구조를 구성합니다. 이 자료구조를 DOM 이라고 부릅니다.
즉, DOM 은 HTML 문서를 파싱한 결과물이라고 볼 수 있습니다.
CSS 파싱과 CSSOM 생성
렌더링 엔진은 일반적인 JS 의 실행 흐름과 같이 처음부터 한 줄씩 순차적으로 파싱하며 DOM 을 생성해 나갑니다. DOM 을 생성해 나가다가 CSS 를 로드하는 태그를 만나면 DOM 생성을 일시 중단합니다.
이후에 CSS 파일을 서버에 요청해 HTML 과 동일한 파싱과정을 거치고 해석해 CSSOM 을 생성합니다. CSS 파싱이 완료되면 HTML 파싱이 중단된 지점부터 다시 HTML을 파싱하며 DOM 생성을 재개합니다.
렌더 트리 생성
렌더링 엔진은 HTML/CSS 를 파싱해 DOM/CSSOM 을 생성합니다. DOM/CSSOM 은 렌더링을 하기 위해 렌더 트리로 결합됩니다.
렌더 트리는 렌더링을 위한 트리 구조의 자료구조입니다. 따라서 브라우저 화면에 렌더링되지 않는 노드들이나 CSS에 의해 비표시되는 노드들은 포함하지 않습니다. 즉, 렌더 트리는 브라우저의 화면에 렌더링 되는 노드들로만 구성됩니다.
렌더 트리가 완성되면 HTML 요소의 레이아웃을 계산하는데 사용하고 브라우저 화면에 픽셀을 렌더링 하는 페인팅 처리에 입력되게 됩니다.
주의해야 할 점은 렌더링 과정은 반복 실행이 가능하다는 점입니다. 대표적으로 아래와 같은 경우에 레이아웃 계산과 페인팅이 재차 실행되게 됩니다.
1. JS 를 통한 노드 추가/삭제
2. 브라우저 창의 리사이징에 의한 뷰포트 크기 변경
3. HTML 요소의 레이아웃에 변경을 발생시키는 스타일 변경
레이아웃의 재계산과 리페인팅과 같은 리렌더링은 비용이 많은 드는 작업입니다. 이는 성능에 악영향을 줄 수 있기 때문에 가급적이면 리렌더링이 빈번하게 일어나지 않도록 주의해야 합니다.
JS 파싱과 실행
DOM 은 HTML 문서의 구조와 정보 뿐만 아닌 HTML 요소와 스타일을 변경할 수 있는 DOM API 를 제공합니다. JS 에서 DOM API 를 사용해 이미 생성된 DOM 을 동적으로 조작할 수 있다는 것을 이야기합니다.
CSS 파싱과 마찬가지로 렌더링 엔진은 HTML 을 순차적으로 파싱하며 DOM 을 생성해 나가다가 JS 파일을 로드하는 태그를 만나면 DOM 생성을 일시 중단합니다.
이후에 JS 파일을 서버에 요청하고 로드한 JS 파일이나 script 태그 내의 JS 코드를 파싱하기 위해 JS 엔진에 제어권을 넘기게 됩니다. JS 파싱과 실행이 종료되면 렌더링 엔진으로 다시 제어권을 넘기고 HTMl 파싱이 중단된 지점부터 다시 HTML 파싱을 시작해 DOM 생성을 재개합니다.
JS 파싱과 실행은 브라우저의 렌더링 엔진이 아니라 JS 엔진이 처리합니다. JS 엔진은 JS 코드를 파싱해 CPU 가 이해할 수 있는 저수준 언어로 변환하고 실행하는 역할을 합니다. JS 엔진은 구글 크롬과 Node.js 의 V8, 파이어폭스의 SpiderMonkey, 사파리의 JavaScriptCore 등 다양한 종류가 있으며 ECMAScript 사양을 준수합니다.
JS 엔진이 렌더링 엔진으로부터 제어권을 넘겨받으면 코드를 파싱하기 시작합니다. 렌더링 엔진이 HTML/CSS 파싱하여 DOM/CSSOM 을 생성하듯이 JS 엔진은 JS 를 해석해 AST 를 생성합니다. 그리고 AST 를 기반으로 인터프리터가 실행할 수 있는 중간 코드인 바이트코드를 생성해 실행합니다.
리플로우와 리페인트
만약 JS 코드에 DOM API 를 사용해 DOM/CSSOM 을 변경하게 되면 DOM/CSSOM 은 다시 렌더 트리로 결합되고 변경된 렌더 트리를 기반으로 레이아웃과 페인트 과정을 거쳐 다시 렌더링하게 됩니다. 이를 리플로우, 리페인트라고 합니다.
리플로우는 레이아웃을 다시 계산하는 것을 말합니다. 노드 추가/삭제, 요소의 크기/위치 변경, 윈도우 리사이징 등 레이아웃에 영향을 주면 리플로우가 일어나게 됩니다.
리페인트는 렌더 트리가 재결합된 경우에 재결합된 렌더 트리를 기반으로 다시 페인트를 하는 것을 말합니다. 이 둘을 반드시 순차적으로 실행되지 않으며 만약 레이아웃에 영향이 없는 변경이 일어나는 경우에는 리페인트만 실행되게 됩니다.
JS 파싱에 의한 HTML 파싱 중단
지금까지 살펴보았듯이 렌더링 엔진과 JS 엔진은 직렬적으로 파싱을 실행합니다. 즉 브라우저는 위에서 아래로 순차적으로 HTML/CSS/JS 를 파싱하고 실행합니다. 이는 script 태그의 위치에 따라서 HTML 파싱이 블로킹 되어 DOM 생성이 지연될 수 있다는 점을 의미합니다. 따라서 script 태그의 위치는 중요한 의미를 가질 수 있습니다.
script 태그의 올바른 위치는 body 요소의 가장 아래에 위치하는 것이 좋다고 볼 수 있습니다. 만약 DOM 이 완성되지 않은 상태에서의 JS가 DOM 을 조작하는 경우에 에러가 발생할 수 있고, JS 의 로딩/파싱/실행으로 HTML 요소들의 렌더링에 지장 받는 일이 발생하지 않아 페이지 로딩 시간이 단축될 수 있기 때문입니다.
즉 JS 를 body 요소의 가장 아래에 위치하도록 하면 DOM 이 완성되지 않은 상태에서의 DOM 조작 에러가 발생하지 않고 DOM 생성이 JS 실행 이전에 완성되므로 페이지 로딩 시간 또한 단축될 수 있는 이점이 있습니다.
async/defer 어트리뷰트
JS 파싱에 의해서 DOM 생성이 중단되는 문제를 해결하기 위해 HTML5 부터는 script 태그에 async 와 defer 어트리뷰트를 사용할 수 있습니다. async 와 defer 어트리뷰트는 src 어트리뷰트를 통해 외부 JS 파일 로드시에만 사용이 가능합니다.
<script async src="extern.js"></script>
<script defer src="extern.js"></script>
이 둘은 HTML 파싱과 외부 JS 파일 로드가 비동기적으로 동시에 진행된다는 공통점이 있지만 아래와 같은 차이가 있습니다.
1. async
HTML 파싱과 외부 JS 파일 로드가 비동기적으로 동시에 진행됩니다. 단, JS 파싱과 실행은 JS 파일 로드가 완료된 직후에 진행됩니다. 즉 이때 HTML 파싱이 중단될 수 있습니다.
만약 여러개의 script 태그에 async 어트리뷰트를 지정하면 script 의 순서와는 별개로 로드가 완료된 JS 부터 먼저 실행되어 순서를 보장하지 않습니다. 즉, 순서 보장이 필요하다면 async 를 사용하지 않는 것이 좋습니다.
2. defer
async 와 마찬가지로 HTML 파싱과 외부 JS 파일 로드가 비동기적으로 동시에 실행됩니다. 하지만 JS 파싱과 실행은 HTML 파싱이 완료된 직후, 즉 DOM 생성이 완료된 직후에 진행됩니다. (DOMContentLoaded 이벤트 발생시) 즉, DOM 이 완전히 생성된 이후에 실행될 JS 에 유용합니다.
마무리
이번에는 브라우저의 렌더링 과정에 대해서 알아봤습니다. 가장 중요하게 봐야할 점은 성능과 관련된 리페인트/리플로우 부분인 것 같습니다. 리플로우와 리페인트가 빈번하게 일어나지 않게 하기 위해서는 브라우저의 렌더링 과정을 자세히 이해해야 할 것입니다.
보다 더 자세한 내용은 아래 링크에서 확인 가능하고, 저 또한 내용들을 공부하며 정리하기 위한 목적으로 글을 남기는 것을 알려드립니다. 읽어주셔서 감사합니다!