치춘짱베리굿나이스
DOM과 웹 렌더링 본문
DOM과 웹 렌더링
개요
Next.js 의 첫 챕터에 DOM과 바닐라 자바스크립트, 리액트 관련 내용이 있길래 정리하다가 아예 DOM, 브라우저 렌더링 관련 내용을 분리하는 게 나을 것 같아서 쓰는 포스팅
여기에 대강 DOM에 관해 정리해놓긴 했지만 뭔가 크진 않다
사전 지식
DOM
Document Object Model
HTML (또는 XML) 요소들을 객체로 표현한 모델이다
DOM은 트리와 같은 구조를 띄고, HTML (또는 XML) 에서의 각 요소들이 노드로 표현되며 서로 부모 - 자식 간의 관계로 연결되어 있다
프로그래밍 언어 (JavaScript 등의 스크립팅 언어) 와 유저 인터페이스 (UI ⇒ 웹 페이지) 간의 연결다리 역할을 하며, DOM 덕분에 프로그래머는 요소에 접근하여 수정하거나, 새로운 요소를 추가, 기존 요소를 삭제 등의 작업을 수행할 수 있다
MDN에는 ‘DOM은 웹 페이지의 객체지향 표현이다’ 라고 적혀있는데, 요소들을 객체화하여 표현한 트리니까 그렇게 볼 수도 있겠다
const body = document.querySelector('body');
const div = document.createElement('div');
body.appendChild(div);
DOM은 JavaScript 로 조작 가능하며, 특정 노드를 삭제하거나 형제 / 자식을 추가, 어트리뷰트를 조작할 수도 있다할 수 있다
DOM 자체는 프로그래밍 언어들과 독립적으로 디자인되었기 때문에, 언어상에서 지원만 한다면 다른 언어들로도 충분히 조작이 가능하다
CSSOM
CSS Object Model
DOM 이 HTML을 파싱해서 만든 객체 모델이라면, CSSOM은 CSS 스타일시트를 파싱하여 만든 객체 모델이다
DOM이 프로그래밍 언어가 UI를 다룰 수 있도록 돕듯이, CSSOM은 스타일을 다룰 수 있게 해주는 중간다리 역할을 한다
CSSOM을 각 언어 (특히 JavaScript) 에서 조작하면서 스타일시트 접근, 스타일 변경, 어떤 요소에 어떤 스타일이 적용될지에 대한 디버깅이 가능하다
토크나이저, 렉서, 파서
- 토크나이저는 구문 (여기서는 HTML, XML) 들을 작은 토큰으로 쪼갠다
- 렉서는 토크나이저를 통해 자른 토큰에 의미를 부여한다
- 파서는 토크나이저 + 렉서 (= Lexical Analyze) 과정을 거친 토큰들을 구조적으로 표현한다
- 이때 데이터가 올바른지, 오류는 없는지 검증하기도 한다
https://blog.chichoon.com/753
여기에 어느정도 정리해 두었다 (근데 자세하진 않음 😕)
UI 렌더링 과정
간단하게 브라우저가 코드를 읽어들여 UI (User Interface) 를 생성하는 과정을 알아보자
브라우저 렌더링은 중간 과정을 다시 수행하게 되면 하위에 있는 과정도 전부 다시 수행해야 한다는 점을 명심하자
(예를 들면, DOM에 변화가 생겨 3번 과정인 DOM 과 CSSOM 구성을 한번 더 수행할 경우, 4, 5, 6번 과정도 다시 수행해야 한다)
0. 서버에 연결하기
- 브라우저는 제일 먼저 주소창에 입력된 URL을 파싱한다
- 이때 필요한 요소들 (도메인명, 프로토콜 (http인지 https인지), 도메인에서의 경로) 을 추출한다
- 가까운 DNS에 도메인 주소와 함께 요청을 보내고, 응답으로 도메인 주소에 연결된 IP 주소를 받아온다
- 해당 IP 주소에 TCP/IP 연결을 수행하고, 정상적으로 연결되었다면 해당 IP 주소로 HTTP 요청을 보낸다
- RESTful API라면 대개 GET 메서드의 요청을 보낸다
- 서버에서는 HTTP 요청을 받고, HTML 을 보내기 전에 서버에서 수행할 수 있는 작업 (데이터베이스 접근, 서버사이드 렌더링 등) 을 수행한다
- 만들어진 HTML을 가지고 서버에서는 HTTP 응답을 생성, 전송한다
- 이 응답은 헤더 - 페이로드 (바디) 구조를 띄고, 상태 코드와 요청 헤더, 응답 헤더 등을 포함한다
- 브라우저가 HTTP 응답을 돌려받는다
여기까지가 서버와 브라우저가 최초로 연결 시에 수행하는 과정이며, 받은 HTML 처리부터는 아래로 넘어간다…
1. 서버로부터 HTML 받아오기
브라우저에서 응답을 받았다면, 헤더에서 Content-type
을 읽어들인 후, 그에 맞는 방식으로 파싱을 수행한다
주소에 해당하는 서버로부터 HTML 파일을 응답받았다면, Content-type
이 text/html
이므로 HTML 파일 파싱을 수행하는 것
HTML 파일은 다음과 같이 생겼을 것이다…
<!DOCTYPE html>
<html lang="en">
<head>
<title>React App</title>
</head>
<body>
<h1>Title</h1>
<div id="root">
<img src="image.png" />
<span>hello world!</span>
</div>
</body>
</html>
2. HTML 읽어들이기
- HTML로부터 가장 첫 줄의 DOCTYPE을 읽어들이고, HTML 버전을 파악한다
- 해당 버전의 HTML 을 이용하여 위에서부터 아래로 해석을 시도한다
- HTML 기준,
<> </>
단위 (마크업 단위) 로 토크나이징, 렉싱, 파싱을 수행한다- 이 과정에서 아래의 DOM 트리가 생성된다
- 특수한 요소들이나 단위를 만났을 때, 추가적인 일을 수행한다
- 여기서 “특수한 요소들” 이라 함은,
link
,style
,script
,img
,video
,audio
등의 태그들이다 - 이 요소들 중 몇몇은 “외부 요소” 를 문서 내부로 끌어다가 사용하며, 이러한 요소를 Embedded Content Model 이라고 부른다
link
: 대개 CSS와 HTML을 연결할 때 사용하는 요소로, CSS 문서 import를 만났을 경우 서버에 다시 한번 요청을 보내 (이 시점에서 아직 TCP/IP 연결 해제되지 않음) CSS 문서를 받아온다script
: 대개 JavaScript와 HTML을 연결할 때 사용하는 요소로, 이 태그를 만나면 렌더링 엔진의 처리 작업 (HTML 읽어들이는 작업) 을 중단하고 자바스크립트를 해석하기 시작한다 (따라서script
태그는 대개 HTML 파일의 아래쪽에 배치하는 것이 좋다)img
,video
,audio
: 미디어를 HTML과 연결할 때 사용하는 요소로, link와 마찬가지로 미디어를 받아오기 위한 HTTP 요청을 보낸다
- 여기서 “특수한 요소들” 이라 함은,
3. DOM 트리와 CSSOM 트리 생성하기
브라우저는 위와 같은 과정으로 HTML 파일을 파싱하여 DOM 트리를 생성한다
또한 병렬로 (응답으로 받아온) CSS 파일 또한 파싱하여 CSSOM 트리를 생성한다
DOM 트리의 최상위 요소는 window
이고, 그 다음 요소는 document
임을 명심하자
Object {
EventTarget {
Node {
Element {
HTMLElement {
HTMLDivElement { }
}
}
}
}
}
이렇게 생성된 DOM을 JavaScript 내에서 열어보면 위와 같은 프로토타입 체인이 걸려 있다
프로토타입 체인이 걸리면 상위 객체 (프로토타입) 의 프로퍼티를 하위 객체에서도 상속받아 호출할 수 있기 때문에 호출에 드는 비용이 적고 메모리를 절약할 수 있다
Selector {
display: flex;
flex-direction: row;
...
}
CSSOM은 JavaScript 코드로 열어보기 힘들고, 사실상 볼 수 있는 방법이 없다고 한다,,
위와 같은 구성의 요소들이 트리 구조로 연결되어 있으며, 최종 계산된 스타일시트 구성은 브라우저의 레이아웃 탭에서 볼 수 있다
CSSOM을 생성할 때 display
등 필수적이라 할 수 있는 속성이 CSS에 정의되어 있지 않더라도 W3C 기준에 따라 정의된 기본 속성이 기본적으로 적용되며, 자식에게 필요하지만 CSS 시트에 정의되지 않은 속성은 부모에서 상속받아 가져 오기도 한다
이처럼 스타일이 계층적인 트리 관계로 구성되어 스타일을 상속받거나 중복된 스타일이라도 선택자 우선순위에 따라 계산되어 적용되는 방식을 Cascading이라고 부르며, 따라서 CSS가 Cascading StyleSheet 이라고 불리는 것…
4. 렌더링 트리 생성
렌더링 트리는 위에서 구성한 DOM 트리와 CSSOM 트리를 결합하여 최종적으로 브라우저에서 표시할 요소들이 무엇인지 판별한다
- 브라우저는 아까 생성한 DOM 트리의 루트부터 자식으로 내려가며 노드 각각을 읽어내려간다
- 각 노드마다 그에 대응하는 CSSOM 노드를 찾아 해당 노드가 담고 있는 스타일시트 규칙을 적용한다
display: none
등, CSS에 의해 숨겨지는 (레이아웃에서 완전히 배제되는) DOM 노드는 렌더링 트리에서도 제외된다- 렌더 트리는 브라우저에서 표시되는 요소들만 담기 때문에,
meta
,title
태그 등 실질적으로 페이지에 렌더링되지 않는 요소들 또한 제외된다
DOM 생성 + CSSOM 생성 + 렌더 트리 생성 까지가 객체 코드에 해당하며, Construction Part 라고도 불린다
따라서 DOM 에 변경사항이 생기면 DOM 생성부터 다시 진행되므로 Construction Part가 재실행된다고 볼 수 있다
5. 레이아웃
어떤 요소 (Render Item) 가 어떤 위치에 그려져야 하는지, 그 위치를 계산하는 과정이다
우리가 HTML과 CSS를 사용해서 레이아웃을 구성하면, 브라우저 측에서 DOM과 CSSOM을 구성한 뒤 렌더 트리를 보고 자동으로 레이아웃을 계산한다
레이아웃 과정이 전체 렌더링 과정 중에서도 가장 에너지 소모가 큰 작업인데, 이는 요소간 관계나 충돌 여부, 의존 여부까지 계산해가면서 레이아웃을 구성해야 하기 때문이다
<div class='box-b'>
<div class='box-a'>
A 박스
</div>
</div>
<div class='box-c'>
C 박스
</div>
box-a {
position: absolute;
top: 100;
left: 20;
}
위와 같은 HTML과 CSS가 있다고 할 때, A 박스를 렌더링하기 위해 A 박스가 다른 박스들 (예시에서는 B, C) 로부터 어떠한 영향을 받는지 계산한 뒤 렌더링해야 하므로, 레이아웃은 상당히 비용이 높은 작업이다
결론적으로 레이아웃 작업이 코스트가 쎈 이유는 요소끼리의 연관성을 매번 체크해야 하기 때문이며, 따라서 HTML 레이아웃을 잘못 설계할 경우 성능 이슈가 발생할 확률이 높다
canvas
에 요소를 그냥 그리는 것이 훨씬 비용이 싼 이유도 여기서 나오는데, 캔버스는 좌표만 계산해서 해당 위치에 색상을 넣기만 하면 되므로 (= 모든 박스끼리의 충돌 로직을 계산할 필요가 없으므로) 빠르게 동작한다
5.5. 리플로우
레이아웃 작업은 한 페이지 (프로세스) 당 딱 한 번만 수행되는 것이 아니라 사용자 조작에 따라 빈번히 발생하며 이를 리플로우라고 부르는데, 리플로우가 발생하면 레이아웃을 처음부터 다시 구성해야 하기 때문에 굉장히 비용이 큰 작업이다
리플로우는 다음과 같은 상황에서 발생한다
- 최초 페이지 로딩 (⇒ 레이아웃)
- HTML 요소 조작 (노드 추가, 제거, 수정) 또는 CSS 조작
- 이 경우, DOM 및 CSSOM 트리 생성부터 다시 수행되는 경우가 많다
- 브라우저 창 크기 조절
- (유저의 조작 등으로 인한) 요소의 위치나 크기의 변경
- 텍스트 내용물의 수정
- 텍스트의 길이가 길어지거나 짧아지면 그에 따라 요소의 크기가 변경될 수도 있으며, 이에 따른 리플로우가 발생한다
- JavaScript를 이용한 CSS 프로퍼티 조작 (Dynamic Layout Calculations)
- 다이나믹한 뷰 (애니메이션, 트랜지션 등) 를 위해 CSS 프로퍼티의 수치를 자바스크립트로 조작하는 경우
Emotion
,Styled Component
등을 사용하여 자바스크립트로 조건에 따라 다른 CSS 값을 지정하도록 조작할 경우
리플로우가 발생할 경우 아래의 리페인트 과정까지 수행되므로 비용이 매우 비싸지며, 현대 프론트엔드 성능 개선 (최적화) 은 리플로우가 적게 발생하도록 + 리플로우 시에 많은 연산이 발생하지 않도록 하는 싸움의 연속이라고 할 수 있다…
6. 페인팅, 리페인팅
위의 레이아웃 단계를 통해 요소들의 위치를 구성했다면, 이 요소들을 실제로 페이지에 그려내는 과정을 페인팅이라고 한다
페인팅 과정부터는 GPU를 사용할 수 있기 때문에 연산량이 많더라도 비용이 크게 들지 않으며, 대부분의 무거운 연산은 전부 레이아웃 과정에서 끝나 있기 때문에 상대적으로 싸다
크롬 계열 브라우저는 Skia Engine을 사용하여 페인팅을 수행한다고 한다 (여담)
리플로우가 발생할 경우 리페인팅도 같이 일어나는데, 사실상 대부분의 코스트는 리플로우 과정에서 잡아먹으므로 리페인팅은 크게 신경쓰지? 않아도 된다
또한 레이아웃 + 페인팅 과정을 합쳐서 Operation Part라고 불린다
성능 이슈
DOM 변경
위에 적었듯 DOM에 조금이라도 변경사항이 생기면 DOM 트리부터 렌더링 트리까지 재생성되고 이후 과정까지 다시 수행해야 하기 때문에 성능에 적잖은 영향을 끼친다 (특히 리플로우가 발생한다는 점에서)
한 페이지 내의 여러 요소들이 반복적으로 수정된다면, 각 요소가 수정될 때마다 DOM이 재생성되고, 렌더링 트리를 처음부터 다시 계산하고, 실제 렌더링을 다시 수행하고.. 이 과정이 자주 발생하며 성능 저하가 일어난다
React에서 Virtual DOM을 사용하는 것도, DOM에서 어떤 부분이 수정되었는지 자동으로 파악하고 변화를 모아서 한번에 수정해 주기 때문에 실제로 렌더링 트리 계산과 재렌더링은 딱 한 번만 수행되게끔 해 주는 것
리플로우 발생 시 고려해야 할 점
- 박스 내부의 컨텐츠가 변경될 경우
width
와height
가 변경될 경우, 영향을 받는 요소들이 늘어나므로 비용이 커질 수 있다overflow: hidden
또는scroll
을 적용하여 박스 자체의width
와height
가 변경되지 않을 경우, 비용을 절약할 수 있다- 박스의 컨텐츠가 변경되었을 때 주위 요소들에 영향을 주는지 여부가 매우 중요하다
- 화면 전체에 영향을 줄 수 있는 요소의 기하학적 스타일 (너비, 높이, 위치 등) 이 변경될 경우
position: fixed
또는absolute
등으로 다른 요소에 영향을 최대한 덜 미치게끔 하지 않는 이상, 대부분 코스트가 매우 비싸다
- 한 요소 (노드) 의 변경이 얼마나 많은 다른 요소 (노드) 들에 영향을 끼치는지 알아보자
- 인접한 노드 (형제 노드), 자식 노드, 부모 노드까지 고려하는 것이 좋다
- 박스형 레이아웃에 영향을 주지 않는 선에서 레이아웃을 변경하자
- 이미지, 비디오, 오디오같은 경우 로딩이 늦어 리플로우가 발생할 확률이 높기 때문에,
width
와height
를 미리 넣어주는 것으로 리플로우를 방지할 수 있다 - 차라리 자바스크립트를 사용해서라도 미디어의 사이즈를 미리 넣어두는 것이 좋다
- 이미지, 비디오, 오디오같은 경우 로딩이 늦어 리플로우가 발생할 확률이 높기 때문에,
- 잦은 변경은 잦은 리플로우를 초래한다
- 요소가 통째로 바뀌어야 하는 경우, DOM 트리 생성부터 다시 해야 할 가능성이 높다
- DOM 트리부터 생성한다는 것은 곧, 렌더링 트리도 다시 생성해야 하고, 레이아웃과 페인트 과정까지 다시 발생하므로 매우 코스트가 큰 작업이다 (물론 변화가 일어난 노드 기준으로만 다시 생성하므로 최초 렌더링보다는 싸다)
- 최적화를 위한 꼼수가 매우 많다…
- 자바스크립트 상에서
isShown ? <ComponentA /> : <ComponentB>
형태로 컴포넌트를 렌더링하는 것보다display: none
과display: block
등을 이용하는 것이 조금 더 싸다
- 자바스크립트 상에서
이처럼 레이아웃 과정이 다시 발생한다고 해서 무조건적으로 무거운 것이 아니라, 어떤 상황에서 어떻게 발생하는지에 따라 코스트가 천차만별로 달라지고, 이를 이용하여 최적화를 수행할 수 있다
참고 자료
https://sangcho.tistory.com/entry/browser-rendering-construction
https://developer.mozilla.org/ko/docs/Web/API/Document_Object_Model/Introduction