웹 페이지가 사용자에게 어떻게 표시되는지 이해하는 것은 성능 최적화와 사용자 경험 향상에 필수적이기 때문에 브라우저 렌더링 과정은 웹 개발자에게 매우 중요한 주제입니다.
브라우저 렌더링 과정은 크게 다음과 같습니다.
- 요청과 응답
- HTML, CSS 파싱 및 렌더 트리 생성
- 자바스크립트 실행
- 레이아웃 계산 및 페인팅
각 단계는 웹 페이지의 성능과 사용자 경험에 중요한 영향을 미칩니다. 이제 각 단계에 대해 자세히 알아보도록 합시다.
사용자가 URL을 입력하면 브라우저는 HTML, CSS, 자바스크립트, 이미지, 폰트 파일 등 렌더링에 필요한 리소스를 서버에 요청하고 서버로부터 응답을 받습니다.
브라우저의 주소창에 URL을 입력하면 URL의 호스트 이름이 DNS(Domain Name System)을 통해 IP 주소로 변환되고 이 IP 주소를 갖는 서버에게 요청을 전송합니다.
이 과정에서 중요한 개념은 세 가지 입니다.
브라우저의 렌더링 엔진은 서버로부터 응답된 HTML과 CSS를 파싱하여 DOM과 CSSOM을 생성하고 이들을 결합하여 렌더 트리를 생성한다.
💡 파싱(구문 분석, Syntax analysis)
프로그래밍 언어의 문법에 맞게 작성된 텍스트 문서를 읽어 들여 실행하기 위해 텍스트 문서의 문자열을 토큰으로 분해(어휘 분석: exical analysis)하고, 토큰에 문법적 의미와 구조를 반영하여 트리 구조의 자료구조인 파스 트리(parse tree/syntax tree)를 생성하는 일련의 과정을 말합니다.
일반적으로 파싱이 완료된 이후에는 파스 트리를 기반으로 중간 언어(intermediate code)인 바이트코드를 생성하고 실행합니다.
서버가 응답한 순수한 텍스트인 HTML 문서를 브라우저에 시각적인 픽셀로 렌더링하려면 HTML 문서를 브라우저가 이해할 수 있는 자료구조(객체)로 변환하여 메모리에 저장해야 합니다.
따라서 브라우저의 렌더링 엔진은 HTML 문서를 파싱하여 브라우저가 이해할 수 있는 자료구조인 DOM(Document Object Model)을 생성합니다.
서버에 존재하던 HTML 파일이 브라우저의 요청에 의해 응답됩니다.
이때, 서버는 브라우저가 요청한 HTML 파일을 읽어 메모리에 저장한 다음, 메모리에 저장된 바이트(2진수)를 인터넷을 경유하여 응답합니다.
브라우저는 응답된 바이트 형태의 HTML 문서를 meta 태그의 charset attribute에 의해 지정된 인코딩 방식을 기준으로 문자열로 변환합니다.
참고로, meta 태그의 charset attribute에 선언된 인코딩 방식은 응답 헤더에 담겨 응답되고 브라우저는 이를 확인하여 문자열로 변환합니다.
문자열로 변환된 HTML 문서를 읽어 문법적 의미를 갖는 코드의 최소 단위인 토큰들로 분해합니다.
각 토큰들을 객체로 변환하여 토큰의 내용에 따라 문서 노드, 요소 노드, 어트리뷰트 노드, 텍스트 노드를 생성합니다.
노드는 이후 DOM을 구성하는 기본 요소가 됩니다.
HTML 문서는 HTML 요소들의 집합으로 이루어지며 HTML 요소는 중첩 관계를 갖습니다. HTML 요소 간의 부자 관계를 반영하여 모든 노드들은 트리 자료구조를 형성하고 이를 DOM이라 부릅니다.
이 과정을 통해 HTML 문서를 파싱한 결과물인 DOM을 생성합니다. 이러한 DOM 트리는 HTML 문서의 구조를 나타내며, 각 HTML 요소는 노드로 표현됩니다.
만약, 브라우저가 HTML 파싱 중에 외부 리소스(ex: CSS 파일, 자바스크립트 파일)를 만나면 이를 다운로드하고, 이 리소스가 DOM 트리 생성에 영향을 미칠 수 있기 때문에 파싱을 일시중지합니다.
앞서 언급한 것처럼 렌더링 엔진은 DOM을 생성하다가 CSS를 로드하는 link 태그나 style 태그를 만나면 DOM 생성을 일시 중단합니다.
link 태그의 href attribute에 지정된 CSS 파일을 서버에 요청하여 로드한 CSS 파일이나 style 태그 내의 CSS를 HTML과 동일한 파싱 과정을 거치며 해석하여 CSSOM(CSS Object Model)을 생성합니다.
CSSOM은 CSS의 상속을 반영하여 생성됩니다. 따라서 예시 그림처럼 body 요소에 적용한 스타일과 ul 요소에 적용한 스타일이 하위에 있는 모든 li 요소에 상속됨을 볼 수 있습니다.
이후 CSS 파싱을 완료하면 HTML 파싱이 중단된 시점부터 다시 HTML을 파싱하여 DOM 생성을 재개합니다.
생성된 DOM과 CSSOM은 렌더링을 위해 렌더 트리(render tree)로 결합됩니다.
렌더 트리는 렌더링을 위한 트리 구조의 자료구조이기 때문에 브라우저 화면에 렌더링되는 노드만으로 구성됩니다. 따라서 브라우저 화면에 렌더링 되지 않는 노드(예: meta 태그, script 태그 등)와 CSS에 의해 비표시(예: display: none)되는 노드들은 포함되지 않습니다.
이후 완성된 렌더 트리는 각 HTML 요소의 레이아웃(위치와 크기)을 계산하는 데 사용되며 브라우저 화면에 픽셀을 렌더링하는 페인팅 처리에 입력됩니다.
지금까지의 브라우저 렌더링 과정은 한 번만 실행되는 것이 아니라 다음과 같은 경우에 반복해서 실행될 수 있습니다.
- 자바스크립트에 의한 노드 추가 또는 삭제
- 브라우저 창의 리사이징에 의한 뷰포트 크기 변경
- HTML 요소의 레이아웃(위치, 크기)에 변경을 발생 시키는 width/height, margin, padding, border, display, position, top/right/bottom/left 등의 스타일 변경
레이아웃 계산과 페인팅을 다시 실행하는 리렌더링은 성능에 악영향을 주는 작업이기 때문에 가급적 빈번하게 발생하지 않도록 주의할 필요가 있습니다.
브라우저의 자바스크립트 엔진은 서버로부터 응답된 자바스크립트를 파싱하여 추상적 구문 트리(AST: Abstract Syntax Tree)를 생성하고 바이트코드로 변환하여 실행합니다.
이때 자바스크립트는 DOM API를 통해 DOM이나 CSSOM을 변경할 수 있고, 변경된 DOM과 CSSOM은 다시 렌더 트리로 결합됩니다.
DOM은 HTML 문서의 구조와 정보뿐만 아니라 HTML 요소와 스타일 등을 변경할 수 있는 DOM API를 제공합니다. 즉, 자바스크립트 코드에서 DOM API를 사용하여 이미 생성된 DOM을 동적으로 조작할 수 있습니다.
렌더링 엔진은 DOM을 생성하다가 자바스크립트 파일을 로드하거나 자바스크립트 코드를 담은 script 태그를 만나면 DOM 생성을 일시 중단합니다.
HTML과 CSS 파싱을 렌더링 엔진이 담당한다면 자바스크립트 코드 파싱과 실행은 자바스크립트 엔진이 담당합니다.
따라서 서버에 요청하여 로드한 자바스크립트 코드나 script 태그 내의 자바스크립트 코드를 파싱하기 위해 자바스크립트 엔진에 제어권을 넘깁니다. 그리고 이를 블로킹(blocking이 일어났다고도 말합니다.)
렌더링 엔진이 DOM과 CSSOM을 생성하듯이 자바스크립트 엔진은 자바스크립트를 해석하여 AST를 생성하고, 이를 기반으로 인터프리터가 실행할 수 있는 바이트코드를 생성하여 실행합니다.
이후 자바스크립트 파싱과 실행이 종료되면 렌더링 엔진에게 다시 제어권을 넘겨 중단된 시점부터 DOM 생성을 재개합니다.
DOM API에 의해 변경된 DOM이나 CSSOM은 다시 렌더 트리로 결합되고 이를 기반으로 레이아웃과 페인트 과정을 거쳐 브라우저의 화면에 다시 렌더링하는데, 이를 리플로우(reflow), 리페인트(repaint)라고 합니다.
리플로우 | 리페인트 |
---|---|
레이아웃 계산을 다시 하는 것 | 재결합된 렌더 트리를 기반으로 다시 페인트를 하는 것 |
노드 추가/삭제, 요소의 크기/위치 변경, 윈도우 리사이징 등 레이아웃에 영향을 주는 변경이 발생한 경우에 한하여 실행 |
이 두 가지가 반드시 순차적으로 동시에 실행되는 것은 아니며 레이아웃에 영향이 없는 변경이 발생한 경우, 리펜인트만 실행됩니다.
렌더링 엔진과 자바스크립트 엔진은 아래와 같이 직렬적으로 파싱을 수행합니다.
이처럼 브라우저는 동기적으로 HTML, CSS, 자바스크립트를 파싱하고 실행합니다.
이는 script 태그의 위치에 따라 HTML 파싱이 블로킹 되어 DOM 생성이 지연될 수 있다는 것을 의미하기 때문에 script 태그의 위치는 매우 중요한 의미를 갖습니다.
자바스크립트 코드에서 DOM이나 CSSOM을 변경하는 DOM API를 사용할 경우 DOM이나 CSSOM이 이미 생성되어 있어야 합니다.
DOM이 완성되기 전 DOM API를 호출하는 예시를 살펴봅시다.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-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>
</body>
</html>
코드를 보면 DOM API인 document.getElementById('apple')은 DOM에서 id가 'apple'인 HTML 요소를 취득합니다.
하지만 document.getElementById('apple')을 실행하는 시점엔 아직 id가 'apple'인 HTML 요소를 파싱하지 않았기 때문에 DOM에 id가 'apple'인 HTML 요소가 포함되어 있지 않은 상태입니다.
따라서 위 예제는 정상적으로 동작하지 않습니다.
이러한 문제를 회피하기 위해 body 요소 가장 아래에 자바스크립트를 위치시키는 것이 좋습니다.
자바스크립트를 body 요소의 가장 아래, 즉 닫는 body 태그 바로 위에 위치시켜야 하는 이유는 다음과 같습니다.
- DOM이 완성되지 않은 상태에서 자바스크립트가 DOM을 조작하면 에러가 발생할 수 있습니다.
- 자바스크립트 로딩/파싱/실행으로 인해 HTML 요소들의 렌더링에 지장받는 일이 발생하지 않아 페이지 로딩 시간이 단축됩니다.
예시와 함께 살펴봅시다.
<!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>
<li id="orange">Orange</li>
</ul>
<script>
const $apple = document.getElementById('apple')
</script>
</body>
</html>
코드를 보면 자바스크립트가 실행될 시점엔 이미 렌더링 엔진이 DOM 생성을 완료한 이후입니다. 따라서 DOM이 완성되지 않아 생기는 문제가 발생할 우려도 없고, 자바스크립트 실행 전에 렌더링되므로 페이지 로딩 시간이 단축됩니다.
<script async src='extern.js'></script>
<script defer src='extern.js'></script>
자바스크립트 파싱에 의한 DOM 생성 중단 문제를 해결하기 위해 HTML5부터 script 태그에 외부 자바스크립트 파일을 로드하는 경우에만 사용할 수 있는 async/defer 어트리뷰트가 추가되었습니다.
이를 사용하면 HTML 파싱과 외부 자바스크립트 파일의 로드가 비동기적으로 실행됩니다. 두 어트리뷰트의 차이점은 자바스크립트의 실행 시점에 있습니다.
async 어트리뷰트를 사용하면 자바스크립트의 파싱과 실행은 자바스크립트 파일의 로드가 완료된 직후 진행되며, 이때 HTML 파싱이 중단됩니다.
여러 개의 script 태그에 async 어트리뷰트를 지정하면 script 태그의 순서와 상관없이 로드가 완료된 자바스크립트부터 먼저 실행되므로 순서가 보장되지 않습니다.
-> 따라서 순서 보장이 필요한 script 태그에는 async 어트리뷰트를 지정하지 않아야 합니다.
defer 어트리뷰트를 사용하면 자바스트의 파싱과 실행은 HTML 파싱이 완료된 직후, 즉 DOM 생성이 완료된 직후(DOMContentLoaded 이벤트가 발생하는 시점) 진행됩니다.
-> DOM 생성이 완료된 이후 실행되어야 할 자바스크립트에 유용합니다.
여러 개의 script 태그에 defer 어트리뷰트를 지정하면 선언 순서대로 실행되므로 순서를 보장합니다.
웹 페이지를 불러오는 데 걸리는 시간은 사용자 경험에 큰 영향을 미칩니다. 따라서, 브라우저 렌더링 성능을 개선하기 위해 다음과 같은 방법을 고려해볼 수 있습니다.
CSS 와 Javascript는 웹 페이지의 디자인과 동작을 결정하는 데 중요한 역할을 합니다. 하지만 코드가 지나치게 복잡하거나 파일 크기가 크면 렌더링 성능을 저하시킬 수 있습니다. 이를 해결하기 위해서 다음과 같은 방법을 고려해볼 수 있습니다.
CSS 코드를 압축하고 중복된 스타일 코드 제거하기
리플로우가 발생하는 속성보다는 리페인트만 발생하는 속성 사용하기
리플로우를 발생시키는 속성
position
width
, height
top
, bottom
, left
, right
margin
, padding
, border
, border-width
clear
, display
, float
, overflow
font-family
, fontsize
, font-weight
line-height
, min-height
text-align
, vertical-align
리페인트를 발생시키는 속성
background
, background-image
, background-pisition
, background-repeat
, background-size
border-radius
, border-style
, box-shadow
color
, outline-color
line-style
, outline
clear
, display
, float
, visibility
font-family
, fontsize
, font-weight
opacity
속성은 리페인트가 일어나지 않으므로,visibilty
,display
속성을 사용하는 것보다 성능 개선에 도움이 됩니다.
Javascript 코드를 압축하고 불필요한 코드 제거하기
Javascript 코드를 지연 로딩하거나 비동기 로딩을 이용하여 페이지 로딩 시간 최적화하기
웹 페이지에서 사용하는 이미지는 렌더링 성능에 큰 영향을 미칩니다. 따라서 이미지 최적화를 하여 불필요한 로딩 시간을 줄이는 것이 중요합니다. 이를 위해서 다음과 같은 방법을 고려해볼 수 있습니다.
이미지 포맷을 최적화하여 파일 크기 줄이기
(JPEG는 사진에, PNG는 아이콘과 같은 단순 그래픽에 적합)
불필요한 이미지를 제거하거나 CSS 스프라이트 기술을 이용하여 여러 이미지를 하나의 이미지 파일로 통합
Lazy Loading 기술을 이용하여 페이지 스크롤링에 따라 이미지를 로딩
```
<img loading="lazy" src="..." />
// 화면에 실제로 이미지가 보여져야 할 때, 이미지 다운로드
// CSS, JS를 더 앞서 다운로드하게 함
```
레이아웃은 브라우저에서 웹 페이지의 요소를 배치하는 과정으로, 렌더링 성능에 큰 영향을 미칩니다. 렌더링을 최적화를 위해 다음과 같은 방법을 고려해볼 수 있습니다.
CSS Flexbox 또는 CSS Grid를 이용하여 요소 배치
요소의 크기와 위치를 계산할 때, 불필요한 계산을 줄이는 최적화 기술 사용
불필요한 CSS 요소를 제거하거나 CSS 속성 줄이기
CSS 속성 중 레이아웃을 발생시키는 속성 피하기
(이 속성들이 매번 렌더 트리를 만들고, 레이아웃이 발생하여 페인팅을 발생시키기 때문)
Javascript와 CSS를 조합한 애니메이션이 많거나, 레이아웃 변화가 많은 요소의 경우, position
속성을 absolute
또는 fixed
로 사용해주면 영향을 받는 주변 노드를 줄일 수 있습니다.
fixed
는 영향을 받는 노드가 전혀 없는 경우, 리플로우없이 리페인트만 진행합니다.
모던 자바스크립트 DeepDive
브라우저 렌더링 과정 이해하기
웹 브라우저의 리소스 호출, 그리고 CDN
브라우저 렌더링 원리와 순서(+성능 최적화 고려사항)
브라우저 렌더링 과정 & 렌더링 최적화
브라우저 렌더링 과정과 최적화