프론트엔드 기술 질문 딥 다이브를 시작해보려고 합니다. 그냥 자주 나오는 면접 질문만 달달 외우는게 편하지 않을까 싶기도 하지만 그러면 너무 재미없잖아요? 물론 시간적인 여유가 없다면, 혹은 당장 이번 주가 면접이라면 면접 질문과 답변을 빠르게 보는게 맞겠죠. 하지만 전 시간이 많습니다. 왜냐고요? 백수거든요. 😂
먼저 시작은 정말 이건 필수다 싶은 질문부터 시작해보려고 합니다.
"브라우저의 렌더링 과정에 대해 말씀해보시겠어요?" 혹은 "브라우저에 www.naver.com이라고 입력했을때 어떤 일이 발생하게 될까요?" 라는 질문을 받을 수 있습니다. 저 또한 둘 다 받아본 경험이 있습니다. 그만큼 정말 자주 물어보는 질문 중 하나입니다. 그렇다면 이런 뻔한 질문을 왜 물어볼까요? 뻔한 질문이라고 하더라도 제각각인 답변이 나오기 때문입니다. 그러면서 면접관은 면접자가 어느 수준인지 파악할 수 있을 것입니다.
브라우저 렌더링은 브라우저(크롬, 사파리 등)가 HTML, CSS, JavaScript로 작성된 텍스트 문서에 대해 파싱(해석)하고 이를 화면에 그리는 일련의 과정을 말합니다. 중요 렌더링 경로(Critical Rendering Path, CRP)라고도 하며 이를 최적화 하는 것은 렌더링 성능을 향상시킵니다.
전체적인 flow는 아래와 같습니다.
이제부터 차근차근 브라우저 렌더링 과정에 대해 알아보도록 하겠습니다.
파싱은 렌더링 엔진 내에서 매우 중요한 프로세스이기 때문에, 먼저 파싱에 정확한 소개가 필요해보입니다. 파싱은 문서를 코드가 사용할 수 있는 구조로 문서를 변환하는 것을 의미합니다. 파싱의 결과는 일반적으로 문서의 구조를 나타내는 노드의 트리입니다. 이를 parse tree 또는 syntax tree 라고 합니다.
예를 들어 식 2 + 3 - 1을 구문 분석하면 이 트리가 반환될 수 있습니다:
구문 분석은 문서가 준수하는 구문 규칙, 즉 문서가 작성된 언어 또는 형식을 기반으로 합니다.
렌더링 엔진은 HTML 문서의 구문 분석을 시작하고 요소를 "콘텐츠 트리"라고 불리는 트리의 DOM 노드로 변환합니다. 구체적으로는 토큰화(Tokeniser)와 트리 생성(Tree Construction) 작업을 포함합니다.
예를 들어, 다음과 같은 간단한 HTML이 있습니다.
<html>
<body>
<p>
Hello World
</p>
<div> <img src="example.png"/></div>
</body>
</html>
DOM 트리를 만들기 위해 먼저 해야할 작업은 토큰화입니다. HTML 특성 상 '<', '>'로 된 태그가 있고, 시작 및 종료 태그로 구성되어있습니다. 그리고 태그 안에는 속성 이름 및 값이 포함되어 있기도 합니다. 이런 규칙을 통해 토큰화를 진행합니다.
그리고 나서 트리 형식을 만드는데, html 요소가 DOM 트리의 루트 노드가 됩니다. 그 밑에는 HEAD와 BODY가 있고, 계층 구조로 되어있습니다. 트리는 다른 태그간의 관계와 계층을 반영합니다.
참고 : https://developer.chrome.com/docs/lighthouse/performance/render-blocking-resources/
구문 분석 작업을 하다가 블로킹되는 경우가 있습니다. 대표적으로 defer나 async 속성이 없는 script 태그, <link rel="stylesheet">
이 해당됩니다. 이를 통칭해서 블로킹 리소스(blocking resources)라고 합니다. 이런 녀석들은 렌더링을 막고, HTML 분석을 중지시키기 때문에 웹 성능에 있어서 좋지 못한 결과를 불러올 수 있습니다.
참고 : https://developer.mozilla.org/ko/docs/Web/Performance/How_browsers_work#프리로드_스캐너preload_scanner
브라우저가 DOM 트리를 만드는 프로세스는 메인 스레드를 차지합니다. 근데 블로킹 리소스 때문에 차단이 된다면 다른 중요한 리소스를 찾는 과정을 지연이 됩니다. 이러한 문제를 완화 시키기 위한 것이 바로 프리로드 스캐너라고 하는 보조 HTML 파서가 존재합니다. 프리로드 스캐너는 메인 스레드가 블로킹 되는 것과 별개로 사용 가능한 컨텐츠를 분석하고 CSS나 Javascript, 웹 폰트, 이미지 같은 자원을 찾아서 요청해줍니다.
HTML을 구문 분석을 통해 DOM을 만든것과 동일하게 CSS도 구문 분석을 통해 CSSOM 트리를 만듭니다. CSS 객체 모델은 DOM과 비슷합니다.
CSSOM을 만드는 것은 매우 매우 빠르고 현재 개발자 도구에서 고유한 색으로 표시되지 않습니다. CSSOM을 만드는데 드는 시간은 일반적으로 한 번의 DNS 조회를 하는 시간보다 짧기 때문에 웹 성능 최적화의 관점에서 CSSOM는 성능 향상에 큰 기여를 할 수 있는 영역은 아닙니다. (오... 그렇군요?)
CSSOM과 DOM 트리는 구문 분석되는 과정에서 생성되고 렌더 트리로 합성됩니다. 계산된 스타일 트리(다른 말로 렌더 트리)는 DOM 트리의 루트부터 시작하여 눈에 보이는 노드를 순회하며 만들어집니다.
다음은 디스플레이 속성에 따라 DOM 노드에 대해 생성할 렌더러 유형을 결정하기 위한 웹Kit 코드입니다:
RenderObject* RenderObject::createObject(Node* node, RenderStyle* style)
{
Document* doc = node->document();
RenderArena* arena = doc->renderArena();
...
RenderObject* o = 0;
switch (style->display()) {
case NONE:
break;
case INLINE:
o = new (arena) RenderInline(node);
break;
case BLOCK:
o = new (arena) RenderBlock(node);
break;
case INLINE_BLOCK:
o = new (arena) RenderBlock(node);
break;
case LIST_ITEM:
o = new (arena) RenderListItem(node);
break;
...
}
return o;
}
Non-visual DOM elements는 렌더 트리에 삽입되지 않습니다. 예를 들어 "head" 요소가 있습니다. 또한 display 값이 "none"으로 할당된 요소는 트리에 나타나지 않습니다. visibility: hidden 속성을 가진 요소는 자리를 차지하기 때문에 렌더 트리에 포함됩니다.
렌더러를 만들고 트리에 추가할 때 렌더러의 위치와 크기는 없습니다. 레이아웃은 렌더 트리에 있는 모든 노드의 너비, 높이, 위치를 결정하는 프로세스입니다. 추가로 페이지에서 각 객체의 크기와 위치를 계산합니다. 리플로우는 레이아웃 이후에 있는 페이지의 일부분이나 전체 문서에 대한 크기나 위치에 대한 결정입니다.
각 객체의 정확한 크기와 위치를 결정하기 위해서, 브라우저는 렌더 트리의 루트부터 시작하여 순회합니다. 그리고 이것을 Layout Tree라는 것으로 관리합니다.
페이지의 레이아웃을 결정하는 것은 어려운 작업입니다. 위에서 아래로 block 흐름과 같은 가장 간단한 페이지 레이아웃도 글꼴의 크기와 모양에 영향을 미치기 때문에 글꼴의 크기와 줄 바꿈 위치를 고려해야 합니다. 그러면 다음 단락이 필요한 위치에 영향을 미치기 때문이다.
그니까 한 요소의 스타일이 바뀐다고 해도, 이에 대해 다른 요소들도 영향을 받을 수 있다는 것이겠죠.
페인팅 혹은 레지스터화 단계에서, 브라우저는 레이아웃 단계에서 계산된 각 박스를 실제 화면의 픽셀로 변환합니다. 페인팅에서 텍스트, 색깔, 경계, 그림자 및 버튼이나 이미지 같은 대체 요소를 포함하여 모든 요소의 시각적인 부분을 화면에 그리는 작업이 포함됩니다.
첫 페인팅보다 다시 페인팅하는 것이 더 빠르게 마무리되기 위해서, 화면에 그리는 작업은 일반적으로 몇 개의 레이어로 구분됩니다. 이것이 일어나면 합성이 필요합니다. 페인팅은 레이아웃 트리의 요소를 레이어로 분리할 수 있습니다. 컨텐츠를 CPU의 메인 쓰레드에서 GPU 레이어로 격상하는 것은 페인트 및 리페인트 성능을 높입니다.
레이어를 가동시키는 구체적인 속성과 요소가 있습니다.
레이어는 성능을 향상시킵니다. 하지만 메모리 관리 측면에서 봤을 때는 비싼 작업입니다. 따라서 웹 성능 최적화 전략으로 과도하게 쓰이지는 않아야 합니다.
문서의 각 섹션이 다른 레이어에서 그려질 때, 섹션을 겹쳐놓으면서 그것들이 올바른 순서로 화면에 그려지는 것과 정확한 렌더링을 보장하기 위해 합성이 필요합니다.
합성(Compositing)은 페이지의 일부를 레이어(층)으로 분리하여 별도로 래스터화(rasterize)하고, compositor 스레드라는 별도의 스레드에서 페이지로 합성하는 기술입니다. 애니메이션은 레이어를 이동하고 새로운 프레임을 합성함으로써 동일한 방식으로 달성될 수 있습니다.
이러한 합성기 프레임은 GPU로 전송되어 화면에 표시됩니다. 합성의 이점은 메인 스레드를 포함하지 않고 수행된다는 것입니다. compositor 스레드는 스타일 계산이나 자바스크립트 실행을 기다릴 필요가 없습니다. 원활한 성능을 위해서는 애니메이션만을 구성하는 것이 최고로 꼽히는 이유입니다.
면접관이 브라우저 렌더링에서 좀 더 물어본다면 아마 Reflow 와 Repaint 가 될 것입니다.
Reflow 및 Repaint 모두 고가의 작업입니다.
Reflow는 성능 측면에서 매우 고가이며, 특히 낮은 디바이스에서 느린 DOM 스크립트의 주요 원인 중 하나입니다. 많은 경우 전체 페이지를 다시 배치하는 것과 같습니다.
여러가지 경우를 가정해봅시다.
var bodyStyle = document.body.style; // cache
bodyStyle.padding = "20px"; // reflow, repaint
bodyStyle.border = "10px solid red"; // reflow, repaint
bodyStyle.color = "blue"; // repaint only, no dimensions changed
bstyle.backgroundColor = "#cc0000"; // repaint
bodyStyle.fontSize = "2em"; // reflow, repaint
// new DOM element - reflow, repaint
document.body.appendChild(document.createTextNode('Hello!'));
여기서 핵심은 리플로우와 리페인트가 반드시 순차적으로 동시에 실행되는 것은 아니라는 것입니다. 레이아웃에 영향이 없는 변경은 리플로우 없이 리페인트만 실행됩니다. 혹은 리플로우와 리페인트 둘 다 생략하고 합성(Compositing)으로만 처리되도록 할 수도 있습니다. 이를 잘 알아두면 성능 최적화를 할 수 있습니다.
최적화 예시로 position과 transform의 차이를 보도록 하겠습니다. 요소를 왼쪽으로 이동시키려고 한다면 position을 relative나 absolute로 설정한다음 left로 몇 px 이동하겠다고 지정할 것입니다. 이는 reflow와 repaint를 유발합니다. 따라서 별로 좋지 못한 방법입니다. 직접 width, height를 변경하는 것도 마찬가지로 좋지 못합니다.
transform으로도 요소를 왼쪽으로 이동시킬 수 있습니다. transform은 별도의 레이어를 생성한 후 GPU가 해당 부분을 수행해주기 때문에 reflow와 repaint 없이 합성만으로 처리가 가능합니다.
성능 비교 : https://web.dev/animations-guide/
그 외 최적화 참고 : https://beomy.github.io/tech/browser/reflow-repaint/, https://developers.google.com/speed/docs/insights/browser-reflow?hl=ko
막상 블로그로 작성하려고 하니까 꽤나 오래걸리네요. 그리고 막상보니까 별로 딥 다이브하진 않네요... 너무 딥하게 가면 본질(핵심)을 찾기 어렵고 난잡해질 거 같아서 최대한 핵심을 포함해서 정리하려고 노력했습니다.
그래도 이번 기회에 공부 잘 한거 같습니다. 막상 해보니까 보람도 있고 재미도 있네요.