Google Developers 사이트에서 읽은 내용을 정리한 글입니다.
렌더러 프로세스의 내부 동작
저번 글에서 탐색에 대해서 자세히 알아봤다면 이번 글에서는 렌더러 프로세스의 내부에선 무슨 일이 일어나는지 알아보자. 렌더러 프로세스 내부에서는 많은 일들이 일어나기 때문에 여러 가지 측면에서 웹 성능과 연관이 있다. 본 글에서 일반적인 개요를 살펴 보자.
렌더러 프로세스의 웹 컨텐츠 처리
렌더러 프로세스는 브라우저 탭 안에서 일어나는 모든 일들을 담당한다. 여기서 메인 스레드가 우리가 구현한 대부분의 코드를 처리한다. 웹 워커 또는 서비스 워커를 사용할 경우엔 워커 스레드가 자바스크립트 코드 일부분을 처리하게 된다. 컴포지터와 래스터 스레드는 렌더러 프로세스 내부에서 페이지를 효율적이고 매끄럽게 렌더하기 위해 실행된다.
렌더러 프로세스의 핵심 역할은 HTML, CSS 그리고 자바스크립트를 사용자가 상호작용(interact) 할 수 있는 웹 페이지로 만드는 것이다.
01. 구문 분석(parsing)하기
DOM 생성
렌더러 프로세스가 탐색을 위한 커밋 메시지를 받고 HTML 데이터를 받기 시작할 때(5단계 탐색 수행) 메인 스레드는 텍스트 문자열(HTML)을 파싱하기 시작하고 이를 Document Object Model(DOM)으로 변환한다.
💡 DOM은 페이지에 대한 브라우저의 내부 표현일 뿐만 아니라 웹 개발자가 자바스크립트를 통해 상호작용 할 수 있는 데이터 구조 및 API이다.
추가 리소스 로딩
웹사이트는 종종 이미지, CSS 그리고 자바스크립트와 같은 외부 리소스를 사용한다. 이러한 파일들은 네트워크 혹은 캐시로부터 로드 되어야 한다. 메인 스레드는 DOM을 구성하기 위한 파싱 중에 그런 리소스들을 찾을 때마다 요청할 수는 있지만 속도를 높이기 위해 '사전 로드 스캐너(preload scanner)'가 동시에 실행된다. HTML 문서에 <img> 또는 <link> 태그가 있는 경우, 사전 로드 스캐너는 HTML 파서에 의해 생성된 토큰들을 살짝 보고 브라우저 프로세스에 있는 네트워크 스레드에게 요청을 보낸다.
파싱을 중단 할 수도 있는 자바스크립트
HTML 파서는 <script> 태그를 발견하면 HTML 문서를 파싱하는 과정을 잠시 멈추고 자바스크립트 코드를 로드, 분석 그리고 실행해야만 한다. 왜 그래야 할까? 자바스크립트는 전체 DOM 구조를 바꾸는 document.write()와 같은 API 사용으로 문서의 구성을 바꿀 수 있기 때문이다. 따라서 HTML 파서가 HTML 문서를 다시 파싱하기 전에 자바스크립트를 기다려주는 것이다.
브라우저야 이렇게 로드하는건 어때?
리소스를 잘 로드하기 위해 웹 개발자들이 브라우저에게 알려주는 방법은 많이 있다. 만약 document.write()를 사용하지 않는다면, <script> 태그 속성으로 async 또는 defer를 추가할 수 있다.(전체 DOM 구조를 바꾸는 게 document.write()에만 해당하는 것인지, 원문에도 해당 API만 콕 집어서 설명하기 때문에 다른 것은 모르겠다.) 그러면 브라우저는 자바스크립트 코드를 비동기적으로 로드하고 실행하며 파싱을 막지 않는다. 또한 자바스크립트 모듈을 적절히 사용할 수도 있다. <link rel="preload">는 브라우저에게 리소스가 현재 탐색에서 반드시 필요하고 가능하면 빨리 다운로드를 하고 싶다는 것을 알려주는 방법이다.
02. 스타일 계산하기
DOM만으로는 페이지를 표현하기에 충분하지 않다. CSS를 통해 페이지 요소들에 대한 스타일을 정의하자. 메인 스레드는 CSS를 분석하여 각 DOM 노드에 대한 계산된 스타일을 결정한다. 이는 CSS 선택자에 기반하여 각 요소들에 어떤 스타일이 적용되었는지를 의미한다. 이러한 정보는 개발자 도구의 computed 섹션에서 볼 수 있다.
우리가 CSS를 전혀 사용하지 않더라도, 브라우저는 기본 스타일 시트를 가지기 때문에 각 DOM 노드는 계산된(computed) 스타일을 갖고 있다. 예를 들어 <h1> 태그는 <h2> 태그보다 더 크게 보여지며 margins는 각 요소마다 정의된다.
03. 레이아웃
이제 렌더러 프로세스는 문서의 구조(DOM)와 각 노드에 대한 스타일(CSSOM)을 알게 되었다. 하지만 아직은 페이지를 렌더하기에 충분하지 않다. 어떤 그림을 친구에게 전화로 설명한다고 상상해보자. "큰 빨간 원 하나와 작은 파란 사각형 하나가 있어"의 설명으로는 친구가 정확히 그림이 어떻게 생겼는지 알기 쉽지 않다.
레이아웃은 요소들의 기하학적인 구조를 찾는 과정이다. 메인 스레드는 DOM과 계산된 스타일을 따라가며 x y 좌표 및 bounding box의 크기와 같은 정보를 가지는 레이아웃 트리를 생성한다. 레이아웃 트리는 DOM 트리와 유사하게 생겼지만 페이지에 보이는 정보만을 담고 있다.
만약 임의의 요소에 display: none이 적용되면 해당 요소는 레이아웃 트리에 포함되지 않는다. 하지만 visibility: hidden이 명시된 요소는 레이아웃 트리에 포함된다. 유사하게 만약 p::before{ content: "Hi!" }와 같이 유사 클래스가 있는 콘텐츠는 DOM에는 없지만 레이아웃 트리에는 포함된다.
04. 페인트
DOM, 스타일, 레이아웃까지 왔다. 하지만 여전히 페이지를 렌더하기엔 충분하지 않다. 한 그림을 다시 그린다고 했을 때 요소들의 크기, 모양 그리고 위치를 알지만 어떤 순서로 그릴지 판단해야 한다. 이것이 페인트 단계이다.
예를 들어 z-index 속성은 임의의 요소들에 설정될 수 있는데 이 경우 HTML로 작성된 요소 순서로 그리면 잘못된 렌더링이 발생하게 된다.
페인트 단계에서 메인 스레드는 레이아웃 트리를 따라가 페인트 기록을 생성한다. 페인트 기록은 "먼저 배경, 그리고 텍스트, 그리고 사각형!"과 같은 페인팅 과정의 내용이다.
많은 비용이 드는 렌더링 파이프라인 업데이트
여기서 중요한 점은 각 단계에서 그 전 단계 실행 결과물이 새로운 데이터를 생성하는데 쓰인다는 것이다. 예를 들어 레이아웃 트리에서 무엇인가 변한다면 문서에서 영향 받은 부분에 대하여 페인트하는 순서도 갱신될 필요가 있다.
💡 요소들에 애니메이션을 추가할 경우 이런 동작을 매 프레임마다 실행해야 한다. 대부분의 디스플레이는 매 초당 60번(60fps) 스크린을 새로 고침하는데, 이 정도가 매 프레임마다 스크린을 가로질러 무엇인가 이동하도록 만들어진 애니메이션이 사람 눈에 매끄럽게 보여진다. 여기서 만약 애니메이션이 중간 프레임을 손실할 경우 그 페이지는 버벅인다.
렌더링 동작이 스크린의 새로 고침에 뒤쳐지지 않는다고 하더라도 이러한 연산은 메인 스레드에서 실행된다. 이는 자바스크립트가 앱이 실행 중일 때 방해(blocked) 할 수 있다는 뜻이다.
따라서 자바스크립트 동작을 requestAnimationFrame()을 이용하여 작은 단위로 나누어 매 프레임마다 실행하는 것을 미리 설정 할 수 있다.
05. 컴포지팅
페이지를 그리는 방법
이제 브라우저는 문서의 구조, 각 요소의 스타일, 페이지의 기하학 구조, 그리고 페인트 순서를 알게 되었다. 그럼 어떻게 페이지를 그릴까? 이러한 정보들을 바탕으로 스크린의 픽셀로 바꾸는 것을 래스터화(rasterizing)라고 한다.
아마 이를 처리하는 가장 단순한 방법은 화면에 보이는 영역(viewport)을 래스터하는 것일 거다. 만약 사용자가 페이지를 스크롤하면 래스터된 프레임을 움직이고 더 래스터링을 해서 부족한 부분을 메꾼다. 이것이 최초 크롬의 래스터라이징을 처리하는 방법이었다. 하지만 모던 브라우저는 컴포지팅이라는 더 세련된 방식으로 동작한다.
컴포지팅이란?
컴포지팅은 한 페이지의 부분들을 여러 레이어로 나누고 그것들을 각각 래스터하며 컴포지터 스레드에서 페이지를 합성하는 기술이다. 만약 스크롤이 발생하면 레이어들이 이미 래스터 되었기 때문에 해야 할 것은 새로운 프레임을 합성하는 것뿐이다. 애니메이션은 레이어들이 움직이는 동일한 방식으로 이뤄지고 새로운 프레임을 합성한다. 해당 레이어는 개발자 도구의 Layers panel에서 볼 수 있다.
레이어에 대한 고찰
어떤 요소들이 어떤 레이어에 있어야 하는지 알기 위해서 메인 스레드는 레이아웃 트리를 순회하여 레이어 트리를 생성한다.(개발자 도구 탭의 Update Layer Tree)
만약 별도의 레이어에 있어야만 하는 페이지의 어떤 부분(슬라이드 되어 들어오는 사이드 메뉴 등)이 처리되지 않은 경우엔 CSS 속성인 will-change를 이용하여 브라우저에게 미리 알려줄 수 있다.
모든 요소들에 대해 레이어를 지정하고 싶을 수도 있다. 하지만 지나친 수의 레이어에 대해 컴포지팅하는 것은 모든 프레임마다 한 페이지의 작은 부분을 래스터화 하는 것보다도 느린 동작이 된다.
메인 스레드 개입 없이 래스터와 컴포지트 하기
레이어 트리가 생성되고, 페인트 순서까지 결정되고 나면 메인 스레드는 다음과 같은 과정을 실행한다.
- 컴포지터 스레드에게 정보를 커밋한다.
- 그 후 컴포지터 스레드가 각 레이어를 래스터화한다.
- 레이어는 한 페이지의 전체 길이만큼 클 수가 있기 때문에 컴포지터 스레드는 레이어들을 여러 타일로 쪼갠다.
- 쪼갠 각 타일을 다수의 래스터 스레드에게 보낸다.
- 래스터 스레드들은 각 타일을 래스터화하고 GPU 메모리에 저장한다.
컴포지터 스레드는 서로 다른 래스터 스레드들에 대해 우선 순위를 정할 수 있다. 화면 안에 보이는(또는 가까이 있는) 것들이 먼저 래스터 될 수 있으며 한 레이어는 다른 해상도에 따라 다수의 타일링을 가질 수 있는데 이것은 줌 인 동작을 처리하기 위함이다.
이제 타일들이 래스터 되면 컴포지터 스레드는 쿼드 그리기(draw quads)라 하는 타일 정보를 모아 컴포지터 프레임을 생성한다.
쿼드 그리기 | 컴포지터 프레임 |
메모리에서 타일의 위치 및 페이지 합성(compositing)을 고려하여 타일을 그릴(draw) 페이지의 위치와 같은 정보를 포함 | 한 페이지의 프레임을 나타내는 쿼드 그리기의 컬렉션 |
그 후 과정은 다음과 같다.
- 컴포지터 프레임은 IPC를 통해 브라우저 프로세스에게 넘어간다.(이 때, 다른 컴포지터 프레임이 브라우저 UI 변화에 따라 UI 스레드에 의해 또는 확장 기능에 대한 다른 렌더러 프로세스들에 의해 추가 될 수 있다)
- 컴포지터 프레임들은 GPU에게 보내져 화면에 보여진다.
- 만약 스크롤 이벤트가 발생하면 컴포지터 스레드는 GPU에게 보내질 다른 컴포지터 프레임을 생성한다.
컴포지팅의 장점은 메인 스레드의 개입 없이 수행된다는 것이다. 컴포지터 스레드는 스타일 계산 또는 자바스크립트 실행을 기다릴 필요가 없다. 이것이 컴포지팅만 일어나는 애니메이션이 부드러운 성능을 위한 가장 좋은 방법으로 여겨지는 이유이다. 만약 레이아웃 또는 페인트가 다시 계산 된다면 메인 스레드가 관려해야만 한다.
좀 더 이해하기 또는 관련 글
CSSOM(CSS Object Model)
렌더 트리(Render Tree), CRP(Critical Rendering Path)
마무리
어느 유튜브 영상에서 시니어 개발자분께서 말씀하신게 떠올랐다.
"신입 면접에서 브라우저에 주소를 치면 어떤 일이 일어나는지 자주 묻잖아요? 시니어 정도가 되면 이 주제로 1시간은 토론 할 수도 있어요."
정말일 것 같다..렌더하는 과정만 해도 이렇게 많은 기술과 과정이 들어가는 것을 알게되니 다시 한번 대단함을 느끼게 되었다.
'Web' 카테고리의 다른 글
포스트맨(Postman) 사용법과 다운로드(feat. 유튜브 API) (0) | 2021.08.08 |
---|---|
크롬 브라우저는 어떻게 작동 할까? - 04 (0) | 2021.08.08 |
크롬 브라우저는 어떻게 작동 할까? - 02 (0) | 2021.07.25 |
크롬 브라우저는 어떻게 작동 할까? - 01 (0) | 2021.07.22 |
Firebase Warning: "It looks like you're using the..." 문구 없애기 (0) | 2021.06.01 |