안녕하세요. 동쪽별입니다.
매주 진행하는 프론트엔드 스터디에서 한 친구가 브라우저 렌더링에 대해서 발표를 했는데, 너무 유익했어요. 그래서 이에 대해 다시 한번 학습하고 기록하고 싶었기에 글을 쓰게 되었답니다.
그럼 브라우저가 어떤 방식으로 동작하는지, 그리고 렌더링을 최적화하기 위한 방법들은 어떤 것들이 있는지 차근차근 살펴봅시다!
목차
- 브라우저의 기본 구조
- 렌더링 동작 과정
- 렌더링 최적화
브라우저는 웹에서 페이지를 찾아서 보여주고, 사용자가 하이퍼링크를 통해 다른 페이지로 이동할 수 있도록 하는 프로그램입니다. 크롬, 사파리, 파이어폭스, 인터넷 익스플로러 등이 이에 해당돼요.
브라우저는 유저가 선택한 리소스를 서버로부터 받아와서 유저에게 보여줍니다. 리소스는 페이지, 이미지 비디오 등의 콘텐츠를 말해요.
이러한 리소스들을 유저에게 보여주는 것을 바로 '렌더링' 이라 해요. 렌더링에 대해 자세히 알아보기 전에, 먼저 브라우저의 기본 구조에 대해서 살펴봅시다.
브라우저의 기본 구조
User Interface
주소 표시줄, 이전/다음 버튼, 북마크 메뉴 등 요청한 페이지를 보여주는 창을 제외한 나머지 모든 부분을 말해요.
Browser Engine
사용자 인터페이스와 렌더링 엔진 사이의 동작을 제어합니다.
Rendering Engine
요청한 콘텐츠를 표시합니다. 예를 들어, HTML을 요청하면 HTML과 CSS를 파싱 하여 화면에 보여줘요.
Networking
HTTP 요청과 같은 네트워크 호출에 사용됩니다. 플랫폼 독립적인 인터페이스이며 각 플랫폼 하부에서 실행돼요.
UI Backend
콤보 박스와 창 같은 기본적인 장치를 그려요. 플랫폼에서 명시하지 않은 일반적인 인터페이스로서, OS 사용자 인터페이스 체계를 사용합니다.
JavaScript Interpreter
자바스크립트 코드를 해석하고 실행합니다.
Data Persistence
자료를 저장하는 계층입니다. 쿠키를 저장하는 것과 같이 모든 종류의 리소스를 하드 디스크에 저장할 필요가 있어요. 이처럼 HTML5 명세에는 브라우저가 지원하는 '웹 데이터 베이스'가 정의되어 있습니다.
브라우저 별로 조금씩 다르겠지만 브라우저는 UI, 브라우저 엔진, 렌더링 엔진, 네트워킹, 자바스크립트 인터프리터, UI 백엔드, 데이터 스토리지로 구성되어 있습니다.
자, 그럼 본격적으로 렌더링에 대해 자세히 알아봅시다!
렌더링 동작 과정
다음은 렌더링 엔진의 기본적인 동작 과정입니다.
DOM 트리 구축을 위한 HTML 파싱
↓
렌더 트리 구축
↓
렌더 트리 배치(레이아웃)
↓
렌더 트리 그리기(페인트)
브라우저는 웹 페이지에 필요한 리소스를 내려받고 해석한 다음 여러 계산 과정을 거쳐 콘텐츠를 화면에 보여줘요. 이를 브라우저의 로딩 과정이라고 하며 파싱, 스타일, 레이아웃, 페인트, 합성으로 나뉩니다.
그럼 단계마다 어떤 일이 발생하는지 알아봅시다!
파싱(Parsing)
브라우저에서 웹 페이지를 로드하면 가장 먼저 HTML 파일을 다운로드해요. 파싱은 다운로드한 HTML을 해석하여 DOM 트리를 구성하는 단계입니다.
- 파싱 중 <script />, <link />, <img />를 발견하면 각 리소스를 요청하고 다운로드합니다.
- HTML 또는 리소스에 CSS가 포함된 경우에는 CSSOM 트리 구성 작업도 함께 진행합니다.
CSSOM?
CSS Object Model(CSSOM)은 JavaScript에서 CSS를 조작할 수 있는 API 집합으로, HTML 대신 CSS가 대상인 DOM이라고 생각할 수 있으며 사용자가 CSS 스타일을 동적으로 읽고 수정할 수 있는 방법입니다.
DOM 트리 및 CSSOM 트리가 구성되는 방법은 다음과 같습니다.
1. DOM 트리 구성
아래 HTML 코드를 예시로 들겠습니다.
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link href="style.css" rel="stylesheet">
<title>Critical Path</title>
</head>
<body>
<p>Hello <span>web performance</span> students!</p>
<div><img src="awesome-photo.jpg"></div>
</body>
</html>
파싱이 일어나면 HTML을 해석해 DOM을 생성한 후, 각 DOM 객체를 트리 데이터 구조로 연결해 부모-자식 관계를 갖도록 만듭니다.<body>, <p>, <div> 등 각 태그가 DOM 트리의 노드로 생성되고 자식 노드를 참조합니다.
2. CSSOM 트리 구성
/* style.css */
body { font-size: 16px }
p { font-weight: bold }
span { color: red }
p span { display: none }
img { float: right }
위 CSS 파일과 같은 외부 스타일시트 파일이나 내부 스타일시트가 포함되어 있을 경우, CSS를 해석해 CSSOM 트리를 구성합니다. body, p, span 등 선택자가 노드로 생성되고 각 노드는 스타일을 참조합니다.
스타일(Style)
스타일 단계에서는 파싱 단계에서 생성된 DOM, CSSOM 트리를 가지고 스타일을 매칭 시켜주는 과정을 거쳐 렌더 트리를 구성해요. 렌더링 트리에는 페이지를 렌더링하는 데 필요한 노드만 포함됩니다.
아래 이미지는 파싱 단계에서 설명한 DOM 트리와 CSSOM 트리를 조합해 렌더 트리가 구성되는 과정을 보여줍니다.
어라? 그런데 렌더 트리에 span 태그가 사라졌습니다..
이것은 span 태그가 display: none 속성이 설정된 노드이기 때문입니다! display: none 속성이 설정된 노드는 화면에 어떠한 공간도 차지하지 않기 때문에 렌더 트리를 만드는 과정에서 제외됩니다.
visibility: invisible 은 display: none과 비슷하게 동작하지만, 공간은 차지하고 요소가 보이지 않게만 하기 때문에 렌더 트리에 포함됩니다.
레이아웃(Layout)
레이아웃 단계에서는 노드의 정확한 위치와 크기를 계산해요.
노드의 정확한 크기와 위치를 파악하기 위해 루트부터 노드를 순회하면서 계산하고, 레이아웃 결과로 각 노드의 정확한 위치와 크기를 픽셀 값으로 렌더 트리에 반영합니다. 이 과정은 HTML의 루트 오브젝트로부터 재귀적으로 실행이 됩니다.
아래는 레이아웃 전/후 과정을 보여줍니다. 만약 CSS에서 크기 값을 %로 지정하였다면, 레이아웃 단계를 거친 후 % 값은 계산되고 측정 가능한 픽셀 단위로 변환됩니다.
- 레이아웃 전
뷰포트(Viewport)?
그래픽이 표시되는 브라우저의 영역, 크기를 말합니다. 뷰포트는 모바일의 경우 디스플레이의 크기, PC의 경우 브라우저 창의 크기에 따라 달라집니다. 그리고 화면에 그려지는 각 요소들의 크기와 위치는 %, vh, vw와 같이 상대적으로 계산하여 그려지는 경우가 많기 때문에 뷰포트 크기가 달라질 경우 매번 계산을 다시 해야 합니다.
- 레이아웃 후
페인트(Paint)
레이아웃 계산이 완료되면 이제 요소들을 실제 화면을 그리게 돼요. 레이아웃 단계를 통해 화면에 배치된 엘리먼트들에게 색을 입히고 레이어의 위치를 결정하는 단계입니다.
이 단계 역시 루트 오브젝트로부터 재귀적으로 실행이 됩니다.
요소들의 위치와 크기, 스타일 계산이 완료된 렌더 트리를 이용해 실제 픽셀 값을 채워 넣습니다. 이때 픽셀로 변환된 결과는 하나의 레이어가 아니라 여러 개의 레이어로 관리됩니다.
텍스트, 색, 이미지, 그림자 효과 등이 모두 처리되어 그려지는데, 스타일이 복잡할수록 페인트 시간도 늘어납니다. 예를 들어, 단색 배경의 경우 시간과 작업이 적게 필요하지만, 그림자 효과는 시간과 작업이 더 많이 필요합니다..
합성(Composite)
페인트 단계에서 생성된 레이어를 합성하여 스크린을 업데이트해요.
합성 단계가 끝나면 화면에서 웹 페이지를 볼 수 있습니다.
렌더링 최적화
브라우저 로딩 과정 중 스타일 이후의 과정(스타일 -> 레이아웃 -> 페인트 -> 합성)을 렌더링이라고 하는데, 이 렌더링 과정은 상황에 따라 반복하여 발생할 수 있습니다.
리플로우(Reflow = Layout)
스타일 단계에서 구성되는 렌더 트리는 자바스크립트에 의해 DOM 트리, CSSOM 트리가 변경될 때 다시 재구성됩니다. DOM이 추가/삭제되거나 요소에 기하적인 영향(높이, 넓이, 위치)을 주는 CSS 속성 값을 변경하는 경우, 렌더 트리가 다시 재구성되는 것입니다.
const example = document.getElementById('example');
example.style.width = '400px';
즉, 레이아웃부터 이후 과정을 다시 수행하며 이것을 '리플로우' 라고 합니다.
- DOM의 추가/삭제
- CSS 속성 변경을 통해 기하학적(높이/넓이/위치 등)인 변화
- ex) margin, padding, width, height, ...
리페인트(Repaint = Paint)
리플로우는 요소에 기하적인 영향을 주는 CSS 속성 값을 변경할 때 발생한다고 했는데, 반대로 영향을 주지 않는 CSS 속성값을 변경하면 레이아웃 과정을 건너뛰겠죠?
const sample = document.getElementById('example');
example.style.backgroundColor = 'blue';
즉 background-color, visibility와 같이 레이아웃에는 영향을 주지 않는 스타일 속성이 변경되면, 이는 페인트부터 수행하기에 '리페인트' 라고 합니다.
- CSS 속성 변경이 기하학적 변화가 발생하지 않았을 경우
- ex) color, background, transform, box-shadow, ...
여기서 문제점은 리플로우와 리페인트는 시간이 오래 걸리는 작업이라는 것입니다.
그렇기 때문에 브라우저는 렌더링 과정에서 성능을 제일 많이 잡아먹습니다.. (특히 리플로우가 순간적으로 많이 발생할 경우 치명적입니다.)
따라서, DOM을 조작할 때 리플로우와 리페인트가 최소한으로 발생하도록 해야 좋은 성능을 얻을 수 있습니다!
그럼 리플로우와 리페인트를 줄일 수 있는 방법을 포함하여 렌더링을 최적화하는 방법에 대해 알아봅시다.
1. 블록 리소스 최적화하기
- <head /> 에서 CSS 파일 로드
DOM 트리는 파싱 중에 태그를 발견할 때마다 순차적으로 구성할 수 있지만, CSSOM 트리는 CSS를 모두 해석해야 구성할 수 있어요. 즉, CSSOM 트리가 구성되지 않으면 렌더 트리를 만들지 못하고 렌더링이 차단됩니다.
이러한 이유로 CSS는 렌더링 블록 리소스가 되기 때문에, CSS는 항상 HTML 문서 최상단(<head> 아래)에 배치합시다.
- </body> 직전에 자바스크립트 파일 로드
<script> 태그를 만나면 스크립트가 실행되며 그 이전까지 생성된 DOM에만 접근할 수 있습니다. 그리고 스크립트 실행이 완료될 때까지 DOM 트리 생성이 중단되어 버립니다.. 외부에서 가져오는 자바스크립트의 경우 또한, 모든 스크립트가 다운로드되고 실행될 때까지 DOM 트리 생성이 중단됩니다.
이러한 이유로 자바스크립트도 렌더링 차단 리소스가 되기 때문에, 자바스크립트는 HTML 문서 최하단(</body> 직전)에 배치합시다.
2. 사용하지 않는 노드에는 visibilty: invisible 보다 display: none을 사용하기
visibility: invisible 은 레이아웃 공간을 차지하기 때문에 리플로우의 대상이 됩니다. 하지만 display: none 은 레이아웃 공간을 차지하지 않아 렌더 트리에서 아예 제외됩니다.
3. 리플로우와 리페인트가 발생하는 속성 피하기
리플로우가 일어나면 리페인트는 필연적으로 일어나야 하기 때문에 가능하다면 리플로우가 발생하는 속성보다 리페인트만 발생하는 속성을 사용하는 것이 좋습니다.
또한 리플로우, 리페인트가 일어나지 않는 transform, opacitiy 와 같은 속성도 있습니다. 따라서left, right, width, height 보다 transform을, visibility / display 보다 opacitiy를 사용하는 것이 성능 개선에 도움이 됩니다.
4. 가상 노드 조작하기
요소에 무엇인가 변화를 줄 때는 DOM에 달려있는 채로 조작하는 것보다는 DOM에서 떼어낸 채로 조작하는 것이 효과적입니다.
const target = document.querySelector(".target");
const element = target.cloneNode(true);
...
target.replaceWith(element);
위 예시에서 element을 target의 복사본으로 정의했습니다. 이 복사본은 실제 DOM 요소와 똑같지만, DOM 트리와는 전혀 무관해지겠죠? 그렇기에 element를 여기저기 변경해도 실제 DOM에는 변경되는 게 없습니다.
따라서, 변경이 완료된 후에 실제 요소로 교체함으로써 리플로우를 최소화할 수 있습니다.
5. 레이아웃 스레싱(thrashing) 최적화하기
브라우저는 일반적으로 현재 작업이나 프레임이 끝날 때까지 기다린 후 리플로우를 계산하지만, 특정 기하학적인 속성 값을 읽으면 최신 값을 계산하기 위해 리플로우를 동기적으로 발생시킵니다.
이를 '강제 동기 레이아웃' 이라고 합니다.
강제 동기 레이아웃이 발생하면 리플로우 계산을 위해 메인 스레드가 블락되므로 성능에 치명적인 원인이 될 수 있습니다..
const tabBtn = document.getElementById('tab_btn');
tabBtn.style.fontSize = '24px';
console.log(testBlock.offsetTop);
// offsetTop 호출 직전 브라우저 내부에서는 동기 레이아웃이 발생
tabBtn.style.margin = '10px';
위 예시처럼 스타일을 변경한 다음 offsetTop과 같은 계산된 값을 속성으로 읽을 때 강제로 동기 레이아웃을 수행합니다.
계산된 값을 반환하기 전에 변경된 스타일이 계산 결과에 적용되어 있지 않으면 변경 이전 값을 반환하기 때문에 브라우저는 동기로 레이아웃을 해야만 합니다.
최신 브라우저에도 동일하게 발생하는 부분이므로 강제 동기 레이아웃을 발생할 수 있는 코드를 최대한 사용하지 않도록 주의해야 합니다!
function resizeAllParagraphs() {
const box = document.getElementById('box');
const paragraphs = document.querySelectorAll('.paragraph');
for (let i = 0; i < paragraphs.length; i += 1) {
paragraphs[i].style.width = box.offsetWidth + 'px';
}
}
위 예시처럼 반복문 안에서style.width를 설정하고 offsetWidth를 읽어오면 for문이 반복 실행될 때마다 레이아웃이 발생하게 되겠죠.
이와 같은 반복적인 리플로우로 인해 DOM에 반영하는 CPU 이용률이 높아지게 되며, 이를 '레이아웃 스레싱'이라고 합니다.
이때, 아래 예시처럼 반복문 밖에서 엘리먼트의 너비를 읽어오면 레이아웃 스래싱을 막을 수 있어요!
function resizeAllParagraphs() {
const box = document.getElementById('box');
const paragraphs = document.querySelectorAll('.paragraph');
const width = box.offsetWidth;
for (let i = 0; i < paragraphs.length; i += 1) {
paragraphs[i].style.width = width + 'px';
}
}
아래의 애니메이션 최적화 방법으로도 레이아웃 스레싱을 최적화할 수 있습니다.
6. 애니메이션 최적화하기
애니메이션의 경우, 리페인트 과정이 끝나지도 않았는데 다음 좌표로 이동하라고 애니메이션을 수행하게 되면, 애니메이션이 의도한 대로 부드럽게 움직이지 않게 됩니다.
requestAnimationFrame 은 이러한 문제를 해결해줍니다 👍
function animate() {
// 애니메이션 처리 프레임 코드
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
requestAnimationFrame 메서드는 브라우저에게 수행하기를 원하는 애니메이션을 알리고 다음 리페인트가 진행되기 전에 해당 애니메이션을 업데이트하는 함수를 호출하게 합니다. 이 메서드는 리페인트 이전에 실행할 콜백을 인자로 받습니다.
requestAnimationFrame 메서드를 사용하는 이유는 다음과 같습니다.
- 브라우저가 레이아웃을 계산하는 것보다 더 자주 또는 덜 자주 호출 ❌ → 정확한 주기로 호출
- 브라우저가 레이아웃을 계산하기 바로 전에 호출 → 정확한 타이밍에 호출
- 브라우저의 프레임 속도(보통 60fps)에 맞추어 애니메이션을 실행
때문에, DOM을 읽는 로직은 현재 프레임에서 실행하고, DOM을 수정하기 위한 로직은requestAnimationFrame 메서드 와 함께 사용해 다음 프레임에서 함께 실행하도록 예약하여 레이아웃 스레싱을 줄일 수 있습니다.
또한, 현재 페이지가 보이지 않을 때는 콜백 함수가 호출되지 않기 때문에 불필요한 동작을 하지 않습니다. 등록된 콜백들을 리페인트 전에 한 번에 처리하기 때문에 리플로우를 최소화할 수 있게 됩니다.
따라서, 화면에 새로운 애니메이션을 업데이트할 준비가 될 때마다 requestAnimationFrame 메서드를 호출하는 것이 좋습니다!
.
.
.
성능을 최적화하기 위한 또 다른 여러 방법들이 있지만, 여기서 마치도록 할게요 😁
많은 SPA 프레임워크에서 렌더링을 최적화하기 위해 사용하는 'Virtual DOM' 에 대해 알아보는 글로 돌아오겠습니다.
Vanilla JavaScript로 Virtual DOM을 직접 구현까지 해보아요!