[JavaScript] 브라우저 렌더링 과정

HyungJin Han·2023년 2월 13일
0

JavaScript

목록 보기
2/7
post-thumbnail

파싱(Parsing)이란?
프로그래밍 언어로 작성된 파일을 실행시키기 위해 구문 분석(Syntax Analysis)을 하는 단계이다.
파일의 문자열들을 문법적 의미를 갖는 최소 단위인 '토큰'으로 분해하고, 이 토큰들을 문법적인 의미와 구조에 따라 노드라는 요소로 만든다.
이러한 단계에서 노드들은 상하관계를 반영해 트리를 형성하는데, 이 트리를 Parse Tree라고 한다.

렌더링(Rendering)이란?
HTML, CSS, JavaScript 파일을 파싱해, 브라우저에 시각적으로 출력하는 과정이다.


1. 브라우저 렌더링 전체 과정

  1. 브라우저는 HTML, CSS, JS, 이미지, 폰트 등 리소소를 서버에 요청하고, 응답으로 받아온다.

  2. 브라우저 렌더링 엔진은 받아온 HTML, CSS를 파싱해 Dom, CSSOM을 생성하고, 이들을 결합해 Render Tree를 생성한다.

  3. 브라우저 JS 엔진은 받아온 JS를 파싱해 AST를 생성하고, 바이트 코드로 변환해 실행한다.

  4. 렌더트리를 기반으로 HTML 요소의 레이아웃(위치, 크기)을 계산한다.

  5. 화면에 HTML요소를 페인팅한다.

1-1. 요청, 응답

브라우저는 HTML, CSS, JS, 이미지, 폰트 등 리소스를 서버에 요청하고, 응답으로 받아온다.

서버가 가지고 있는 HTML, CSS, JavaScript 파일을 브라우저애서 서버에게 요청하고, 응답으로 받아와야 한다.

브라우저에 있는 주소창에 위와 같은 URL을 입력하고 엔터키를 누르면, URL의 호스트 이름이 DNS(도메인 네임 서비스)를 통해 진짜 주소인 IP 주소로 변환되고, 이 IP 주소를 갖는 서버에게 요청을 보낸다.

서버는 기본적으로 보통 index.html을 응답으로 주도록 설정되어 있다.

예를 들어, https://www.google.com 을 검색하면 사실은 https://www.google.com/index.html을 요청하는 것과 다름 없다.

이 요청에 대해 구글 서버는 클라이언트에 index.html 파일을 전달 해주며, 다른 파일을 요청하고 싶다면 뒤에 다른 파일 경로를 적거나, JavaScript를 통해서 동적으로 요청할 수도 있다.

1-2. 파싱, 생성

1-2-1. HTML 파싱, DOM 생성

브라우저 렌더링 엔진은 받아온 HTML, CSS를 파싱해 DOM, CSSOM을 생성하고, 이들을 결합해 렌더 트리를 생성한다.

응답으로 받아온 HTML 문서는 오직 텍스트로만 이루어져 있다.

이 텍스트들 우리가 알아볼 수 있도록 이 문서를 브라우저가 이해할 수 있는 형태로 바꾸는 작업이 필요한데, 여기서 말하는 형태가 바로 DOM 구조이다.

위의 그림은 서버에서 받아온 파일을 브라우저가 이해하기까지의 과정이다.

  1. 바이트 (Bytes) : 서버는 브라우저에게 2진수 형태의 HTML 문서를 응답으로 준다.

  2. 문자열 (Characters) : 문서는 <meta>charset 속성에 지정된 방식으로 문자열로 인코딩 된다.(ex : UTF-8) 서버는 이 인코딩 방식은 응답 헤더에 담아준다.

  3. 토큰 (Tokens) : 문자열 형태의 HTML문서를 '토큰'단위로 분해한다. (문법적 의미를 갖는 코드의 최소 단위)

  4. 노드 (Nodes) : 각 토큰을 객체로 변환해, 노드를 생성한다. (DOM을 구성하는 기본 요소)

  5. DOM : HTML 문서의 요소들의 중첩관계를 기반으로 노드들을 트리 구조로 구성한다. 이 트리를 DOM이라고 한다.

위와 같은 과정을 거쳐서 HTML 문서가 파싱되고, DOM 이라는 결과물을 생성하게 된다.

위와 같은 복잡한 과정을 거치는 이유는 DOMDocument Object Model의 줄임말로, 우리말로는 문서 객체 모델이라 할 수 있다.

말 그래도 문서를 객체로 바꾼 모델로, 브라우저는 JavaScript 언어만 알아듣는데, JavaScriptHTML의 태그나 속성들을 바로 다룰 수 없기 때문에, 다룰 수 있는 형태인 '객체'로 바꿔주어야 한다.

그래야 브라우저도 HTML 문서를 이해할 수 있게 되기 때문에 이러한 과정을 거친다.

1-2-2. CSS 파싱, CSSOM 생성

앞서 html 파일을 파싱하다가 <link>, <style> 태그를 만나면 파싱을 잠시 멈추고 리소스 파일을 서버로 요청한다.

이 태그들은 CSS 파일을 가져올 때 보통 쓰는데, 이렇게 가져온 CSS 파일도 HTML과 마찬가지로 파싱을 한다. 서버에서 받아온 2진수 파일을 문자열로 인코딩하고, 토큰 단위로 나누고, 노드를 생성하고, 트리를 만들고.. 이렇게 파싱해 만든 트리는 CSSOM 이라고 한다.

위의 그림처럼 CSSOMCSS의 속성이 상속되기 때문에, 이를 반영한다는 점이다.

예를 들어 ul 이 부모 요소이고, li를 자식 요소로 가진다고 생각하면, 아래 코드처럼 ul는 파란색 속성을 가지고 있는데, 이 속성은 자식 요소인 li도 상속받게 된다.

따라서 li는 상속받은 color: blue와 자신이 가지고 있던 font-size 속성 두 가지를 갖게 된다.

1-2-3. 렌더 트리 생성

DOMCSSOM은 굉장히 비슷하게 생겼지만, 서로 다른 속성들을 가진 독립적인 트리들이다.

HTML은 구조를, CSS는 디자인을 담당하기 때문에 둘을 합치는 작업이 필요하다.

렌더 트리는 이름처럼 렌더링을 목적으로 만드는 트리이며, 렌더링은 브라우저가 이제 진짜로 사용자에게 보여주기 위한 화면을 그리는 과정이기 때문에, 보이지 않을 요소들은 이 트리에 포함하지 않는다.

예를 들어, DOM에서는 meta태그같은 정보 전달 목적의 태그나, CSSOM에서는 display: none으로 보이지 않게 해둔 요소(정확히는 노드)들은 렌더 트리에서는 제외된다.

단, visibility: hidden은 레이아웃 트리에 포함되니 주의해야 한다.

위의 그림처럼 DOM, CSSOM에 있던 속성들이 합쳐져 렌더트리를 구성하는 것을 확인할 수 있지만, 렌더 트리는 아직까지도 텍스트로 구성된 객체로 밖에 보이지 않는다.

실제로 우리가 보는 페이지를 만들기 위해서는 '페인팅'이라는 작업을 거쳐야 한다.

페인팅 작업은 렌더트리의 노드들이 가지고 있는 속성들을 바탕으로 이루어지는데, 이 작업은 잠시 후에 다시 자세히 알아보도록 하고 우선은 JavaScript 파일은 어떻게 해석이 되는지부터 잠시 살펴본다.

1-3. JavaScript 파싱

브라우저 JS 엔진은 받아온 JS를 파싱해 AST를 생성하고, 바이트 코드로 변환해 실행한다.

렌더링 엔진은 HTML 파일을 한줄씩 파싱하며 DOM을 생성하다가 JavaScript 코드들 불러오는 <script> 태그를 만날 때도 파싱을 잠시 멈춘다.

그리고 나서 src 속성에 적혀있는 파일을 서버에 요청해 받아오고, 이렇게 받아온 js 파일도 마찬가지로 파싱을 해야하는데, 이 파싱은 브라우저 렌더링 엔진이 직접하지 않고, JavaScript 엔진이 담당하게 된다.

이 때 렌더링 엔진은 JS 엔진에게 제어권을 아예 넘겨주기 때문에, HTML 파싱을 멈췄다가 JS 파싱이 전부 되면 다시 제어권을 돌려받아 파싱을 다시 시작하는 것이다.

JS 엔진은 js 파일의 코드를 파싱해서 컴퓨터가 이해할 수 있는 기계어로 변환하고 실행한다.

좀 더 구체적으로 살펴보면, 먼저 단순한 텍스트 문자열인 코드를 토큰 단위로 분해하며, 이렇게 분해된 토큰에 문법적인 의미와 구조가 더해져, AST(추상 구문 트리) 라는 트리가 완성된다.

구체적인 속성은 다르지만, 이전에 봤던 과정들과 비슷해 보이며, 아래 그림에서 맨 왼쪽의 코드가 바로 다음의 트리 구조로 바뀌는 부분이 여기까지의 내용에 해당한다.

이제 이렇게 코드를 해석해서 만든 AST라는 트리를 실제로 실행할 수 있도록 만들어야 한다.

코드의 실제 실행은 인터프리터가 담당하는데, 인터프리터가 알아들을 수 있도록 하기 위해서는 AST트리를 바이트 코드라는 중간 수준의 코드로 변환해야 한다.

이 변환은 바이트 코드 생성기가 담당해주며, 이제 위의 그림에서 가장 오른쪽에 있는 형태로 바뀌어 받아온 js파일 내용이 실제로 실행된다.

1-4. 레이아웃(리플로우)

렌더 트리를 기반으로 HTML 요소의 레이아웃(위치, 크기)을 계산한다.

레이아웃은 요소의 기하학적인 속성들을 찾는 과정이며, 아까 만들었던 렌더트리가 여기서 사용되니 다시 떠올려야 한다.

렌더 트리에는 요소들의 위치나 크기와 관련된 정보들이 들어있었다.

하지만 이 정보들은 각 요소들에 대한 정보일 뿐, 전체 화면에서 정확히 어디에 위치할 것인지에 대해서는 아직 알지 못하며, 이런 계산을 하는 단계가 레이아웃 단계이다.

브라우저는 각 요소들이 전체 화면에서 어디에, 어떤 크기로 배치되어야 할 지 파악하기 위해 렌더트리의 맨 윗부분부터 아래로 내려가며 계산을 진행하며, 모든 값들은 절대적인 단위인 px값으로 변환된다.

예를들어 우리가 <div>요소 하나만 띄우도록 코드를 작성했고, width를 50%로 지정해두었다면, 이 값은 전체 화면 크기(viewport)의 절반 크기로 계산되고, 절대적인 값인 px 단위로 변환되는 식이다.

1-5. 페인팅

화면에 HTML 요소를 페인팅한다.

이제 위치에 대한 계산도 마쳤으니, 정말로 화면에 보여줄 차례이다.

브라우저 화면은 픽셀이라고 하는 정말 작은 점들로 이루어져 있으며, 각각 정보를 가진 픽셀들이 모여 하나의 이미지, 화면을 구성하는 것이다.

따라서 화면에 색상을 입히고, 어떤 요소를 보여주기 위해서는 이 픽셀에 대한 정보가 있어야 하고, 페인팅은 이러한 픽셀들을 채워나가는 과정이다.

따라서 이 과정을 마지막으로 우리는 단순한 텍스트에 불과했던 파일 내용들을 이미지화된 모습으로 브라우저 화면을 통해 볼 수 있게되는 것이다.


2. Reflow(리플로우), Repaint(리페인트)

리플로우(Reflow) = 레이아웃 계산을 다시하는 것
리페인트(Repaint) = 새로운 렌더트리를 바탕으로 다시 페인트를 하는 것

만약 사용자가 브라우저 화면을 늘리거나 줄이는 등 크기를 조절하거나, 어떤 버튼을 눌러 화면에 요소가 추가되거나 삭제되는 경우가 생기면, 당연히 화면에 있던 요소들의 위치나 크기 등이 바뀌는 일이 생기게 될 것이다.

굉장히 당연하게 여겨지지만, 이렇게 화면에 나타나는 모습을 바꾸기 위해서는 모든 요소들의 위치와 크기를 다시 계산하고, 다시 그려서 보여주어야 한다.

이렇게 어떤 인터랙션으로 인해 앞서 보았던 레이아웃, 페인팅 과정을 반복하는 것을 리플로우, 리페인트라고 한다.

const makeDiv = document.createElement('div');

위의 코드는 JavaScript를 통해 DOM을 조작하는 코드이다.

DOM은 단순히 HTML 파일의 정보만 담고있게 아니라, 이렇게 JavaScript를 통해 요소들을 동적으로 조작할 수 있도록 DOM API라는 것을 제공한다. CSS도 마찬가지로 이런식으로 조작이 가능하다. 이렇게 JavaScript 조작으로 변경이 일어나면, DOM 트리를 다시 구성하는 것으로 시작해 CSSOM와 합쳐져 새 렌더 트리를 생성한다.

그리고 레이아웃과 페인트 과정을 또다시 거쳐서 화면에 보여진다.

DOM 조작은 리플로우, 리페인팅이 일어나는 대표적인 예시라고 할 수 있다.

물론 레이아웃과 페인트는 별개의 작업이기 때문에, 하나 씩만 발생할 수도 있고, 둘 다 발생할 수도 있으며, 위치나 크기에 대한 변경만 있다면 레이아웃 작업만 다시하면 되고, 요소의 색상이나 보이는지 여부 같은 스타일에 대한 사항에 대한 변경만 있다면 페인팅 작업만 다시하면 되기 때문이다.

2-1. Reflow

Reflow는 렌더링 엔진에서 요소를 배치하는 과정을 의미한다.

렌더 트리 구축 단계에서 DOM 트리와 스타일 규칙을 합쳐서 렌더 트리를 만들고, 여기에서 Reflow를 통해 각각의 요소들의 레이아웃을 위치시킨다.

여기서 렌더 트리는 DOM 요소를 기반으로 만들어지지만, 완전히 대응되지는 않는다.

DOM 트리가 문서의 구조를 나타낸다면 렌더 트리는 문서의 시각적 구조를 나타낸다.

예를 들어 스타일에 display: none 속성이 있다면 DOM에는 존재하지만 시각적으로는 없기에 렌더 트리에는 할당되지 않는다.

Reflow가 발생하는 경우는 다음과 같다.

  • DOM 노드의 추가, 제거

  • DOM 노드의 위치 변경

  • DOM 노드의 크기 변경

    margin, padding, border, width, height

  • CSS3 애니메이션과 트랜지션

  • 폰트 변경, 텍스트 내용 변경

  • 이미지 크기 변경

  • offset, scrollTop, scrollLeft와 같은 계산된 스타일 정보 요청

  • 페이지 초기 렌더링

  • 윈도우 리사이징

2-2. Repaint

Repaint는 렌더 트리가 탐색되고 paint 메서드가 호출되어서 UI 기반의 구성 요소를 사용해서 그리는 과정이다.

Repaint가 이루어지기 위해서는 렌더 트리가 있어야 하고 따라서 Reflow 작업이 이루어진 후에 Repaint 작업이 이루어지는 것을 알 수 있다.

화면의 구조가 변경이 될 때는 ReflowRepaint가 모두 발생한다.

다만 Repaint가 발생하기 위해서 항상 Reflow가 발생해야 하는 것은 아니다.

Reflow가 발생하지 않고 Repaint만 발생하는 경우도 있는데, 예를 들면 레이아웃에 영향을 주지 않는 엘리먼트 개별의 변화에 대해서는 Repaint만 발생한다.

color, background-color, visibility 같은 속성의 경우, 곧바로 Repaint 과정만 발생한다.

2-3. Reflow 최적화

Reflow는 비용을 발생시키는 절차이므로 가능한 안 하는 것이 성능 측면에서 유리하며, 아래의 사항을 통해 Reflow 작업을 줄일 수 있다.

2-3-1. 가장 하위 노드의 클래스를 변경을 통한 스타일 변경

DOM 노드의 크기 또는 위치가 변경되면 하위 노드와 상위 노드에도 영향을 미칠 수 있다.

이 때 가장 하위 노드의 스타일을 변경할 경우, 전체 노드가 아닌 일부 노드로 Reflow를 영향을 최소화 할 수 있다.

전체적으로 큰 노드를 뒤흔드는 것보다 그 하위의 변경할 부분만을 변경하여 Reflow의 규모를 줄이는 방식으로 정리할 수 있다.

2-3-2. 인라인 스타일(inline) 사용 줄이기

인라인 스타일은 HTML이 파싱될 때, 레이아웃에 영향을 미쳐 추가 Reflow를 발생시킨다.

또한 관심사 분리가 제대로 이루어지지 않으면 유지 보수가 힘들어 진다.

2-3-3. 애니메이션이 있는 노드는 positionfixed 또는 absolute로 지정

애니메이션 효과는 많은 Reflow 비용을 발생시킨다.

position 속성을 fixed 또는 absolute의 값으로 지정해서 지정된 노드를 전체 노드에서 분리시켜 해당 노드에서만 Reflow가 발생하도록 제한시킬 수 있다.

애니메이션 효과를 줘야 하는 노드에 position 속성이 적용이 되지 않았다면 애니메이션 시작 시 position 속성 값을 fixed 또는 absolute로 변경하였다가 애니메이션 종료 후 다시 원복시켜서 렌더링을 최적화 할 수 있다.

2-3-4. table 레이아웃 사용 줄이기

<table>은 점진적으로 렌더링 되지 않고, 모두 로드되고 테이블 너비가 계산된 후에 화면에 그려진다.

테이블 안의 컨텐츠의 값에 따라 테이블 너비가 계산된다는 의미로, 테이블 컨텐츠의 작은 변경만 있어도 테이블 너비가 다시 계산되고 테이블의 모든 노드들이 Reflow가 발생한다.

이러한 이유로 <table>을 레이아웃 용도로 사용하는 일은 피해야 한다.

2-3-5. CSS 하위 선택자 최소화

CSS 하위 선택자를 최소화 하는 것은 Reflow 횟수를 줄이는 방법이 아니라 렌더 트리 계산을 최소화 하는 방법에 대한 내용이다.

  • 예시 코드
    <div class="reflow_box">
      <ul class="reflow_list">
        <li>
          <button type="button" class="btn">버튼</button>
        <li>
        <li>
          <button type="button" class="btn">버튼</button>
        <li>
      </ul>
    </div>
  • CSS 코드
    /* 잘못된 예 */
    .reflow_box .reflow_list li .btn{
      display:block;
    }
    /* 올바른 예 */
    .reflow_list .btn {
      display:block;
    }

위의 코드처럼 CSS 하위 선택자를 최소화하는 것 또한 렌더링 성능 향상에 도움이 된다.

렌더 트리는 DOMCSSOM이 합쳐져서 만들어지는데, DOMHTML이 파싱되어 만들어진 트리이고, CSSOMCSS가 파싱되어 만들어진 트리이다.

두 트리를 결합하여 렌더 트리를 만드는데, CSS 하위 선택자가 많아지면 CSSOM 트리의 깊이(Depth)가 깊어지게 되고 결국 렌더 트리를 만드는 시간이 더 오래 걸리게 된다.

2-3-6. 숨겨진 노드의 스타일 변경

Reflow 비용을 줄이기 위해서 DOM 노드 사용을 최소화 해야 한다.

한 가지 방법은 DOM Fragment를 사용하여 DOM을 추가할 때 마다 DOM 접근을 최소화 하는 방법이다.

const frag = document.createDocumentFragment();
const ul = frag.appendChild(document.createElement('ul'));

for (let i = 1; i <= 3; i++) {
  li = ul.appendChild(document.createElement('li'));
  li.textContent = `item ${ i }`;
}

document.body.appendChild(frag);

위의 코드처럼 createDocumentFragment를 사용하여 한 번에 DOM을 추가하여 DOM 자체에 직접적인 접근을 최소화할 수 있다.

2-3-7. 캐시 활용

브라우저는 레이아웃 변경을 큐에 저장했다가 한 번에 실행하여 Reflow를 최소화 한다.

하지만 offset, scrollTop과 같은 계산된 스타일 정보를 요청할 때마다 정확한 정보를 제공하기 위해 큐를 비우고 모든 변경 사항을 적용한다.

// 안좋은 예시
for (let i = 0; i < len; i++) {
  el.style.top = `${ el.offsetTop + 10 }px`;
  el.style.left = `${ el.offsetLeft + 10 }px`;
}

// 좋은 예시
let top = el.offsetTop, left = el.offsetLeft, elStyle = el.style;

for (let i = 0; i < len; i++) {
  top += 10;
  left += 10;
  elStyle.top = `${ top }px`;
  elStyle.left = `${ left }px`;
}

이런 낭비를 해결하기 위해 위의 코드와 같이 스타일 정보를 변수에 저장하여 offset, scrollTop 등의 값 요청을 최소화해야 한다.


참고 사이트

joooing - 웹페이지가 사용자에게 보여지기까지 (브라우저 렌더링 과정)
CHANYEONG - 브라우저 렌더링과 최적화
Web Frontend Developer - Reflow와 Repaint에 대하여

profile
토끼보다는 거북이처럼 꾸준하게

0개의 댓글