지난번 브라우저 최적화라는 주제로 [브라우저 최적화(1)] CRP의 이해와 브라우저 리소스 식별 방법 글로 잠깐 소개한 적이 있다. 오늘은 그보다 딥 다이브할 수 있는 주제를 가져왔다.
개요
우리가 자주 마주하는 브라우저 렌더링 프로세스의 내용은 위 그림 처럼 자주 접해왔다.
요약: HTML에서 DOM 트리 생성하고, CSS 받아서 CSSOM 생성하고, 이를 합져서 자바스크립트를 실행해서 렌더 트리를 구성하고, 레이아웃 실행 후 페인트 처리가 되고 마지막에 디스플레이에 짜잔~!
하지만 우리는 이론적으로 렌더링 플로우가 위 이미지처럼 흘러가고 있다 정도만 이해하는 경우가 대다수다. 실제로 브라우저라는 애플리케이션에선 저 플로우를 수행하기 위해 엄청나게 많은 컴퓨터 내부 작업들이 이루어진다.
프론트엔드 개발을 하면서 프로세스와 스레드에 대해 특별히 고려하지 않고 개발하는 경우가 많이있다. 있어도 자바스크립트 로직을 작성하면서 '웹 워커' 정도만 사용해서 메인 스레드의 부하를 덜어내는 정도만 접해보는게 대부분이지 않을까?
컴퓨터에서 브라우저 애플리케이션이 어떤 방식으로 렌더링 프로세스가 할당되어 수행되는지, 실제 레이아웃과 페인트는 어떤 원리로 구성되는지, 사용자 화면에 보여지기 위해 어떤 방식과 원리 변화하여 지금 처럼 자연스럽게 사용자에게 웹 페이지를 보여줄 수 있게 되는지 알지 못한다면 어떻게 될까?
실제로 브라우저 내부 동작을 알지 못해도 개발하는데는 큰 무리는 없을 수 있다. 하지만 대규모 애플리케이션 운영과 더욱 깊은 레벨의 브라우저 최적화 단계를 접하게 될 때 접근법을 찾지 못해 헤매는 경우가 생길 수 있게된다.
이번 내용의 기반은 최신 브라우저의 내부 살펴보기 3 - 렌더러 프로세스의 내부 동작를 참고 했고, 사내에서 "브라우저 아키텍처 이해하기" 라는 주제로 강의를 할일이 있어 강의자료를 대부분 차용했다.
1. 렌더러 프로세스(Render Process)의 내부 동작
사실 지금부터 작성하는 글의 내용은 아래에 대한 지식이 전반적으로 필요하다. 나중에 기회가 된다면 강의자료를 차용해서 앞부분에 대한 내용도 작성하겠다. (아래 내용을 먼저 접해도 무방)
먼저 렌더러 프로세스의 내부 동작으로는 아래 그림 처럼, 메인(Main) 스레드, 워커(Worker) 스레드, 컴포지터(Compositor) 스레드, 래스터(Raster) 스레드로 이루어져있다. 그 외 다른 스레드도 존재할테지만 현재는 이 네가지 스레드만 집중적으로 살펴보자.
렌더러 프로세스는 브라우저의 탭(Tab) 내부의 모든 작업(DOM이라고 불리는 화면 영역의 작업)을 담당하게 된다. 이때 메인 스레드가 브라우저로 전송된 대부분의 코드를 처리하게 된다. 실제 자바스크립트를 통한 코드 처리는 메인 스레드에서 작업된다. 이때 자바스크립트가 '싱글 스레드'라고 불리는 이유다.
하지만 스레드는 하나의 행동을 처리하기 위한 '처리 단위'이면서, 프로세스에 할당된 '실행 단위'이기도 하다. 때문에 렌더러 프로세스에선 '멀티 스레드'를 구성하고 있고, 실제 자바스크립트를 통해 '웹 워커'를 통해 워커 스레드에 접근하여 연산 작업을 나누어 처리할 수 있다.
또한 컴포지터 스레드와 래스터 스레드는 GPU 프로세스에 할당 되어 탭 마다 할당된 각 렌더러 프로세스와 공유하여 IPC(Inter-Process Communication)을 통해 하드웨어 가속을 이용하여 Viewport의 화면 연산 최적화에 큰 역할을 한다.
도대체 이게다 뭔 이야기인가 싶을 수 있다. 일단 지금 알아야하는 것은 렌더러 프로세스는 "HTML, CSS, 자바스크립트를 사용자와 상호작용 할 수 잇는 웹 페이지로 전환하는 작업"을 수행한다 정도만 알아두고 넘어가자.
2. DOM 파싱 (구축과 리소스 로딩)
페이지 이동시 '네비게이션 실행 메세지(네트워크에서 받은 Document 파일 내용)'를 렌더러 프로세스가 전달 받고 HTML 데이터를 수신하여 렌더러 프로세스의 메인 스레드는 문자열로 구성된(HTML)을 파싱하여 DOM(Document Object Model)로 변환하게 된다.
이때 문자열로된 HTML을 파싱하는 과정에서 DOM 구축을 위해 파싱하는 동안 이런 외부 리소스를 만나게 되면 메인 스레드가 차단되는 현상이 일어날 수 있게된다. 대표적으로 두 가지 케이스가 있다.
리소스 차단 케이스
1) 하위 리소스(subresource)
하위 리소스를 로딩할 수 있는데 일반적으로 이미지, CSS, 자바스크립트 같은 외부 리소스를 사용하게 된다. 이 파일을 네트워크나 캐시에서 로딩을 해야한다.
때문에 속도를 높이기 위해 "preload 스캐너"가 동시에 실행된다. HTML 문서에 img
, link
태그가 있으면 preload 스캐너는 _ HTML 파서가 생성한 토큰을 확인하고 브라우저 프로세스의 네트워크 스레드에 요청_된다.
2) 자바스크립트 파싱
script
태그를 만나게 되면 HTML 파서는 문서 파싱을 일시 중지하고 자바스크립트 코드를 먼저 로딩하고 파싱하여 실행해야한다. 왜냐하면 자바스크립트는 DOM 구조 전체를 바꿀 수 있는 "document.write()" 메서드와 같은 것을 사용해서 문서의 모양을 변경할 수 있기 때문이다.
HTML 명세의 "Overview of the parsing model"(파싱 모델 개요)에 있는 다이어그램
따라서 HTML 파싱을 재개하기 전에는 HTML 파서는 자바스크립트의 실행이 끝나기를 기다려야한다.
리소스 차단 해결하기
차단을 해결할 수 있는 방법으로는 브라우저에 힌트를 주는 방법이 있다.
1) script
태그 사용시 async
속성이나 defer
속성부여
이는 자바스크립트 코드를 비동기적으로 로딩하고 실행하면서 HTML 파싱을 막지않고 수행할 수 있는 방법이다.
async
는 순서없이 실행될 수 있지만, defer
는 마크업에 표시된 순서대로 실행된다.
2) 자바스크립트 모듈 사용하기 <link="preload">
현재 네비게이션을 실행하기 위해 리소스가 '반드시' 필요하다는 것을 브라우저에 알려 리소스를 가능한 빨리 다운로드를 요청하는 힌트다.
3. 스타일 계산(Computed Style)
메인 스레드는 CSS를 파싱하고 각 DOM 노드에 해당되는 계산된 스타일(Computed Style - CSS 선택자(selector))로 구분되는 요소에 적용될 스타일 정보를 확정 짓는다.
그리고 기본적으로 HTML에는 기본 스타일 시트가 적용되어 있다.(h1, h2, li, button 등)
개발자 도구의 Element 탭에서 보면 Computed 탭을 확인할 수 있다.
4. 레이아웃 (Layout)
레이아웃은 요소의 기하학적 속성(geometry)를 찾는 과정이다. 메인 스레드에선 DOM과 계산된 스타일을 훑어가면서 레이아웃 트리를 만든다.
레이아웃 트리를 각 좌표, 박스 영역(bounding box)의 크기 정보를 가진다. 이때 DOM 트리와 비슷한 구조일 수 있으나 보이는 요소에 관련된 정보만 갖는다.
- 레이아웃 트리 포함 X:
display:none
- 레이아웃 트리 포함 O:
visibilitiy: hidden
,p::before{ content: "" }
(DOM 트리는 포함 X)
이제 메인 스레드는 계산된 스타일(Computed Style)의 DOM 트리를 돌며 레이아웃 트리를 생성하게 된다.
이 과정에서 줄 바꿈과 같이 단락의 박스 레이아웃 변경이 일어나는 경우 폰트 크기에 따라 변경 여부를 고려해야하는데 이를 고려하는 일은 매우 어렵다.
따라서 폰트나 블록 영역에 대한 레이아웃을 결정하기 위해 float을 한쪽으로 몰아서 처리하거나 elipsis(말줄임) 처리 등을 통해 제어가 필요하다.
정보: 크롬 개발 팀에선 레이아웃만 전담하는 팀이 있을 정도로 어렵고 큰 일이다. (출처: BlinkOn 8 레이아웃 팀 발표 영상)
5. 페인트 (Paint)
DOM, 스타일, 레이아웃을 가지고 요소의 크기, 모양, 위치를 어떤 순서로 그려야할지 판단해야한다.
페인트 단계에서 메인 스레드는 페인트 기록(Paint Record)를 생서하기 위해 레이아웃 트리를 순회한다.
기록 순서: 배경 > 텍스트 > 직사각형
자바스크립트의 canvas의 요소를 그리는 과정과 유사하다.
이제 DOM 트리 및 스타일 -> 레이아웃 트리 -> 페인트 트리 순서로 생성했다.
렌더링 파이프라인의 갱신 비용은 많이 든다. 레이아웃 트리에서 변경이 생겨 문서에 영향을 받으면 페인팅 순서도 새로 생성해야한다. 그래서 리플로우/리페인팅을 막기위해 최적화를 하기위한 다양한 방법들이 존재한다.
그 중에서 애니메이션 처리를 위해 어떤 식으로 동작할까?
애니메이션 적용시 모든 프레임에 이런 작업을 해야하고, 대부분의 디스플레이 장치는 화면을 초당 60fps 단위로 새로 고치게 된다. 이 프레임이 누락되면 페이지가 버벅(janky)이게 된다.
화면 주사율에 맞춰 렌더링이 이루어져도 메인 스레드에서 실행되어 애플리케이션이 자바스크립트를 실행하는 동안 렌더링이 막히게 된다.
이때 자바스크립트 작업(Task)를 작게 나누어 requestAnimationFrame()
메서드를 활용하게 되면 프레임마다 실행하도록 스케쥴 관리가 가능하다. 또한 메인 스레드를 마지 않도록 웹 워커를 실행하는 것도 방법이다.
6. 합성(Composition)
이제 브라우저는 문서의 구조와 요소의 스타일, 속성, 페인트 순서를 안다. 그럼 어떻게 그릴까?
이 정보를 픽셀로 변환하는 작업을 래스터화(Rasterizing) 이라고한다.
가장 단순한 래스터화는 뷰포트 안쪽을 래스터하는 것. 스크롤하면 이미 래스터화한 프레임을 움직이고 나머지 빈 부분을 추가로 래스터화한다.
최신 브라우저에선 합성(Composition)을 통해 보다 정교한 과정을 가지게 된다.
정보: 렌더링 파이프라인에선 GPU가 많이 언급 되는데 CPU보다 GPU가 레이어를 합성할 때 더 유리한점이 있다. (출처: GPU vs CPU)
합성은 웹 페이지의 각 부분을 레이어로 분리해 별도로 래스터화하고 컴포지터 스레드(Compositor Thread)의 별도 스레드에서 합성하는 기술이다.
스크롤할 때 레이어는 이미 래스터화 되어 있고 새 프레임을 합성하기만 함녀 된다. 애니메이션 역시 레이어를 움직이고 합성하는 방식이다.
만약 컴포지터 스레드를 별도로 유지하는 것이 부담스러울 때 싱글 스레드에서 합성 하기도 한다.
어느 요소에 레이어를 넣어야할지 메인 스레드는 레이아웃 트리를 순회하면서 레이어 트리생성하게된다. 모든 요소에 레이어를 할당하지는 않는데 이는 레이어 합성 작업이 매 프레임마다 새로 래스터화 하는 것보다 더 오래 걸린다.
왜냐하면 레이어가 많을 수록 합성 비용이 높아지고 메모리에 가져갈 부담도 커져 크롬에선 과도한 레이어 생성을 막기 위해 레이어를 생성하지 않거나 합치기도한다.
정보: 만약 슬라이드/스와이프 기능 처럼 별도의 레이어를 구성해야하는 경우 CSS의 wii-change 속성을 사용해서 브라우저 레이어를 무조건 생성하도록 힌트 를 줄 수 있다.
7. 래스터화와 합성 (Rasterizing & Compositor)
레이어 트리 생성 후 페인트 순서가 결정되면 메인 스레드가 그 정보를 가지고 컴포지터 스레드로 커밋(commit) 한다. 그 후 컴포지터 스레드는 각 레이어를 래스터화한다.
컴포지터 스레드는 각 레이어를 타일(tile) 형태로 래스터 스레드로 보내고, 래스터 스레드는 각 타일을 래스터화해 GPU 메머리에 저장한다.
네이버 메인 페이지를 가져와 봤다. 이미지를 보게되면 뷰포트(Viewport) 영역이 현재 사용자가 바라보고 있는 화면의 영역이다.
자세히 보면 층이 쌓여있는 것을 볼 수 있는데, 이것들이 모두 '레이어(Layor)'이다. 평평하게 보여지는 부분들은 컴포지터 스레드를 통해 합성되어 프레임을 합성하여 레이어를 최적화하고 미리 만들어진 것이다.
그 중 붕 떠있는 부분들도 합성된 부분들도 있고 별도로 떠있는 부분들도 있는데 위에 설명했듯이 합성 비용이 높은 영역은 레이어를 새로 두어 레이어트리를 구성한 것을 확인할 수 있다.
또한 컴포지터 스레드는 뷰포트 내부의 근처에 있는 먼저 래스터화 하여 우선순위 처리도 가능하다.
아래를 보면 모든 타일이 래스터화되어 있지 않는 것을 알 수 있다. scale 값을 통해 최대한 구멍(래스터화하지 않은 것)을 메울 수 있는 방식으로 타일을 조합하는 것을 알 수 있다. (이 단계의 큰 목표는 구멍을 줄이는 것)
마지막으로 타일의 래스터화 후 컴포지터 스레드는 합성 프레임(Compositor Frame) 을 생성하기 위해 타일 모으기(Draw Squads, 드로 스쿼드) 를 진행한다.
합성 프레임이 IPC를 통해 브라우저 프로세스로 메시지를 전송한다. 이 시점에 UI의 변경하려는 UI 스레드나 확장 앱을 위한 다른 렌더러 프로세스 에 의해 합성 프레임이 추가될 수 있다.이런 합성 프레임은 GPU로 전송하여 화면에 표시 하게 된다.
정보: 스크롤 이벤트 발생시 컴포지터 스레드는 GPU로 보낼 합성 프레임을 생성한다.
합성의 이점은 메인 스레드와 별개로 동작한다는 것이다. 컴포지터 스레드는 자바스크립트 실행이나 스타일 계산을 기다리지 않는다.
때문에 "합성만"하는 애니메이션이 부드럽게 보여지는 이유이다.
정보: 레이아웃이나 페인트를 다시 계산(리플로우 & 리페인트)시 메인 스레드가 관여하기 때문이다.
정리
지금까지 브라우저 아키텍처 정보를 기반한 렌더러 프로세스를 아주 자세히 살펴보았다. 평소 왜 이런 것들을 해야하지? 라는 의문이 드는 경우가 많았다.
- preload, prefetch를 사용해서 리소스 최적화가 되는거지?
display: none
을 사용하면 스크린 리더기에 읽히지 않아 웹 접근성에 안좋은데 왜 안좋은거지?- 박스 레이아웃 변경을 제어하기가 왜 이렇게 어려운거지?
- 애니메이션 처리를 위해
requestAnimationFrame
를 사용하는데 어떤 원리 때문인거지? - 스크롤하거나 고해상도의 화면에서 페이지를 빠르게 보여주고, 버벅거림 없이 어떻게 사용자에게 제공할 수 있는 거지?
- 내부적으로 웹 페이지는 어떻게 사용자에게 보여지게되고 최적화한다고하면 정확히 어떤 원리로 최적화가 되는거지?
지금까지 의문은 많았지만, 해결 방법을 처리하거나 단순히 면접용 대답을 암기해서 대답할 뿐이었다.
하지만 브라우저 애플리케이션이 어떻게 돌아가는지 궁금증을 시작해서 브라우저 아키텍처의 내부 동작 원리를 살펴보게 되었고, 지금까지 모든 의문들이 풀리게 되었다. 그것도 원리를 이해하면서까지.
이 내용은 사실 이것보다 더 많이 있다. 기본적으로 컴퓨터가 운영체제에 할단된 메모리를 가지고 브라우저 애플리케이션을 어떻게 돌리고 메모리를 할당하는지, 그 제한된 리소스를 가지고 브라우저 개발자들이 어떤 아키텍처를 설계하여 동작하게 되었는지, 브라우저 내부에 또 제한된 리소스를 가지로 우리 웹 개발자들이 어떻게 사용자에게 좋은 UI/UX를 제공할 수 있게 되었는지. 꼬리에 꼬리를 물고, 계속 파고 들다보니 여기까지 오게 되었다.
막연하고 방대한 정보일 수 있으나, 필자는 실제 브라우저가 내부에 어떤 원리로 돌아가는지 파악할 수 있게 되었고, 어디서부터 어디까지 염두하고 문제를 해결할 수 있을지 조금은 알게 되었다. 이 글을 읽는 분들도 꼭 최신 브라우저 아키텍처 시리즈 글을 정독해보길 추천한다.
'개발 > 테크톡' 카테고리의 다른 글
Typescript 모듈 해석 방식(2) - Module Resolution (0) | 2025.03.09 |
---|---|
Typescript 모듈 해석 방식(1) - tsconfig 설정과 실행 환경에 따른 차이 (0) | 2025.03.02 |
[브라우저 최적화(2)] 브라우저 리소스 로딩 최적화의 다양한 방법 (1) | 2024.12.22 |
[브라우저 최적화(1)] CRP의 이해와 브라우저 리소스 식별 방법 (1) | 2024.11.24 |
[Radix-UI] React의 Automatic Batching에 의한 문제 해결 사례 (1) | 2024.10.27 |