컴퓨터의 운영체제에 대해서 배우다보면 반드시 접하게 되는 용어가 있습니다. 프로세스와 스레드입니다. 이 용어는 컴퓨터에서 작업을 처리하기 위해서 CPU에서 할당하는 작업의 공간, 컨텍스트에 해당합니다. 이것을 공부하다 듣게된 비유가 있었습니다. "크롬에서 탭을 만들고 탭 하나에서 유튜브를 보는것이 프로세스와 같다." 라는 비유를 들었습니다. 이러한 말의 뜻을 이해하기 위해서 우선 CPU, GPU, 프로세스, 스레드에 대해서 다시 한 번 짚어보고 가겠습니다.
CPU
CPU(central processing unit, 중앙처리장치) 는 컴퓨터의 두뇌라고 할 수 있는 아주 중요한 부품입니다. CPU 는 컴퓨터내에서 이루어지는 모든 작업들의 스케쥴링을 관리하고 연산 처리 장치를 통해 연산을 처리하는등 컴퓨터에서는 절대로 없어서는 안되는 아주 중요한 장치입니다. 요즘 CPU는 기본적으로 코어를 갖고 있습니다. 과거에는 CPU가 하나의 칩만을 이용하여 작업을 처리할 수 있었지만 오늘날에는 8코어와 같이 작업을 멀티코어를 사용하여 분산시켜 작업을 수행할 수 있게 되었습니다. 여기서 분산이라는 것이 중요합니다. 동시에 여러 작업을 처리할 수 있는 것은 아닙니다.
GPU
GPU(graphics processing unit, 그래픽처리장치)는 CPU 와 달리 GPU는 특정 작업 처리에 특화되어 있습니다. GPU 도 CPU와 같이 코어를 갖고 있지만 CPU와 다른 점은 작업을 동시에 처리가 가능하다는 것입니다. 이러한 특징으로 인해서 GPU는 동시에 여러 작업을 Bathcing 처리한다는 특징을 갖고 있습니다. 이외에도 CPU의 코어수는 32코어로 연산을 수행할 수 있지만 GPU의 경우 수천 코어를 사용하여 연산이 가능하다는 차이점을 갖고 있습니다.
하지만 GPU는 CPU와 같이 복잡한 연산을 수행하기에는 적합하지 않습니다. GPU는 CPU에 비해 지니고 있는 코어의 수는 많지만 하나의 코어 성능은 CPU에 비할때가 되지 못합니다. GPU가 수행할 수 있는 연산은 다음과 같습니다.
GPU가 처리하는 작업의 예시
for (i = 0; i < 1_000_000; i++) {
y[i] = W * x[i]
}
CPU가 처리하는 작업의 예시
function dfs(node) {
if (node.left) dfs(node.left)
if (node.right) dfs(node.right)
}
위와 같이 단순한 연산이 반복되는 작업을 처리하기에 적합합니다. 반대로 분기가 나뉘고, 랜덤한 메모리의 값을 접근해야 하는 경우와 같이 복잡한 경우를 다루기에는 GPU가 적합하지 않습니다.
프로세스
프로세스는 애플리케이션이 실행하는 프로그램이라 할 수 있습니다. 스레드는 프로세스 내부에 있으며 프로세스로 실행되는 프로그램의 일부를 실행합니다.
애플리케이션을 시작하면 프로세스가 하나 만들어집니다. 프로세스가 작업을 하기 위해 스레드를 생성할 수도 있지만 이는 선택사항으로 프로세스가 실행된다고 해서 스레드가 반드시 생성되는 것은 아닙니다. 운영체제는 프로세스가 작업할 메모리를 "한 조각" 주는데, 이 전용 메모리 공간에 애플리케이션의 모든 상태가 저장됩니다. 애플리케이션을 닫으면 프로세스가 사라지고 운영체제가 메모리를 비워 공간을 확보합니다.
프로레스는 여러 작업을 수행하기 위해 운영체제에서 다른 프로세스를 실행하라고 요청할 수 있습니다. 이 요청으로 인해서 메모리의 다른 부분이 새 프로세스에 할당이 됩니다. 두 프로세스가 서로 정보를 공유해야 할 때는 IPC(inter process communication, 프로세스 간 통신) 를 사용합니다. 많은 애플리케이션이 이러한 방식으로 작동하도록 설계되어 있습니다. 그래서 작업 프로세스가 응답하지 않을 때 애플리케이션의 다른 부분을 실행하는 프로세스를 중지하지 않고도 응답하지 않는 프로세스를 다시 시작할 수 있게 됩니다.
브라우저 아키텍처
브라우저는 앞서 설명한 CPU 와 비슷하게 내부적으로 여러 프로세스와 스레드를 만들고, 역할별로 작업을 나누고, 각 실행 단위를 조율한다는 점에서 비슷한 작업을 수행하고 있습니다. 하지만 CPU는 프로세스이고 브라우저는 운영체제내에서 실행되는 애플리케이션이기 때문에 명확히 다른 점은 고려해야 합니다.
위의 그림과 같이 브라우저 내부에는 브라우저 프로세스, 렌더러 프로세스, 유틸리티 프로세스, GPU 프로세스, 플러그인 프로세스 와 같이 여러 프로세스들이 각자 맡은 역할들을 수행하고 처리함으로써 우리가 사용하는 브라우저를 사용할 수 있게 되는 것입니다. 그럼 이 프로세스들에 대해서 하나씩 이야기해보겠습니다.
어떤 프로세스가 무엇을 담당하나
다음은 Chrome의 각 프로세스가 무엇을 제어하는지 설명하는 표입니다.
| 프로세스 | 프로세스가 제어하는 부분 |
|---|---|
| 브라우저 프로세스 | 주소 표시줄, 북마크 막대, 뒤로 가기 버튼, 앞으로 가기 버튼 등 애플리케이션의 "chrome" 부분을 제어한다. 네트워크 요청이나 파일 접근과 같이 눈에 보이지는 않지만 권한이 필요한 부분도 처리한다. |
| 렌더러 프로세스 | 탭 안에서 웹 사이트가 표시되는 부분의 모든 것을 제어한다. |
| 플러그인 프로세스 | 웹 사이트에서 사용하는 플러그인(예: Flash)을 제어한다. |
| GPU 프로세스 | GPU 작업을 다른 프로세스와 격리해서 처리한다. GPU는 여러 애플리케이션의 요청을 처리하고 같은 화면에 요청받은 내용을 그리기 때문에 GPU 프로세스는 별도 프로세스로 분리되어 있다. |
다중 프로세스 아키텍처가 Chrome에 주는 이점
Chrome이 렌더러 프로세스를 여러 개 사용한다고 설명했습니다. 가장 간단한 예로 탭마다 렌더러 프로세스를 하나 사용하는 경우를 생각해봅시다. 3개의 탭이 열려 있고 각 탭은 독립적인 렌더러 프로세스에 의해 실행됩니다. 이때 한 탭이 응답하지 않으면 그 탭만 닫고 실행 중인 다른 탭으로 이동할 수 있습니다. 만약 모든 탭이 하나의 프로세스에서 실행 중이었다면 탭이 하나만 응답하지 않아도 모든 탭이 응답하지 못하게 됩니다.
브라우저 프로세스는 애플리케이션의 각 부분을 맡고 있는 다른 프로세스를 조정한다는 특징을 갖고 있습니다. 렌더러 프로세스는 여러 개가 만들어져 각 탭마다 하나씩 할당이 됩니다.
프로세스는 전용 메모리 공간을 사용하기 때문에 공통부분(예를 들어 Chrome의 JavaScript 엔진인 V8)을 복사해서 가지고 있는 경우가 많다. 동일한 프로세스의 스레드가 메모리를 공유할 수 있는 데 반해 서로 다른 프로세스는 메모리를 공유할 수 없어 메모리 사용량이 더 많아질 수밖에 없다. Chrome은 메모리를 절약하기 위해서 실행할 수 있는 프로세스의 개수를 제한한다. 정확한 한도는 기기의 메모리 용량과 CPU 성능에 따라 다르지만 프로세스의 개수가 한도에 다다르면 동일한 사이트를 열고 있는 여러 탭을 하나의 프로세스에서 처리한다.
렌더러 프로세스
렌더러 프로세스는 탭 내부에서 발생하는 모든 작업을 담당합니다. 렌더러 프로세스의 메인 스레드가 브라우저로 전송된 대부분의 코드를 처리합니다.
웹 페이지를 효율적이고 부드럽게 렌더링하기 위해 별도의 컴포지터 스레드와 래스터 스레드가 렌더러 프로세스에서 실행이 됩니다. 렌더러 프로세스의 주요 역할은 HTML와 CSS, JavaScript를 사용자와 상호작용을 할 수 있는 웹 페이지로 변환하는 것입니다.
HTML 파싱
페이지를 이동하는 내비게이션 실행 메시지를 렌더러 프로세스가 받고 HTML 데이터를 수신하기 시작하면 렌더러 프로세스의 메인 스레드는 HTML 문자열을 파싱해서 DOM(document object model) 으로 변환하기 시작합니다.
DOM은 브라우저가 내부적으로 웹 페이지를 표현하는 방법일 뿐만 아니라 웹 개발자가 JavaScript를 통해 상호작용을 할 수 있는 데이터 구조이자 API에 해당합니다.
하위 리소스 로딩
웹 사이트는 일반적으로 이미지, CSS, JavaScript 와 같은 외부 리소스를 사용합니다. 이러한 파일은 네트워크나 캐시에서 로딩해야 합니다. DOM 을 구축하기 위해 파싱하는 동안 이런 리소스를 만날 때마다 메인 스레드가 하나하나 요청할 수도 있지만 속도를 높이기 위해서 preload 스캐너가 동시에 실행됩니다. HTML 문서에 <img>나 <link> 와 같은 태그가 있으면 프리로드 스캐너는 HTML 파서가 생성한 토큰을 확인하고 브라우저 프로세스의 네트워크 스레드에 요청을 보냅니다.
JavaScript 로딩
<script> 태그를 만나게 되면 HTML 파서는 문서의 파싱을 일시 중지한 다음 JavaScript 코드를 로딩하고 파싱해 실행해야 합니다. 이러한 이유는 JS에서 어떠한 작업을 수행할지 알 수 없기 때문입니다. 예를들어, JS에서 document.write() 를 사용하여 문서를 임의로 조작할 경우 파싱을 멈추지 않는다면 이후의 모든 작업이 망가질 수 있습니다. 그렇기 때문에 HTML 파서는 JavaScript의 실행이 끝나기를 기다린 후 파싱을 마저 수행합니다.
브라우저의 리소스 로딩은 순차적으로 수행되는가? 브라우저에서는 자원의 로딩 방법을 조절할 수 있는 방법들이 존재합니다. 예를들어, HeroImage 를 로드하기 위해서
<link rel="preload">를 사용하여 다른 이미지들보다 빠른 우선순위로 자원을 로드할 수 있습니다. 이외에도 앞서 JS는 파싱 작업을 중단하기 때문에 이 작업을 개선하기 위하여<script>에async나defer속성을 사용하여 JS를 비동기적으로 로딩하고 실행함으로써 HTML의 파싱을 막지 않고 작업을 수행하게 할 수 있습니다.
JavaScript 실행
JavaScript 가 실행되는 구체적인 시기는 어느 시기일까요? 이 시기는 정확히 어느 지점이라고 정해져 있지 않습니다. 이 시점은 작성되어 있는 코드에 따라 변하기 때문입니다. 파싱 중 <script> 를 만나면 JS의 작업을 중지하고 async, defer을 통해서 비동기적으로 로딩을 수행 후 실행을 하게 될 경우 하더라도 실제로 실행되는 시점은 서로 다른 시점에 실행이 됩니다.
- defer 는
DOMContentLoaded전에 실행되고 문서 파싱 완료 후, 선언된 순서대로 실행됩니다. - async 는 DOM 파싱과 함께 병렬로 로드를 수행 중 로드가 모두 완료되는 시점에 실행되기 때문에 순서가 보장되지 않습니다.
그럼 이러한 실행을 수행하는 주체는 무엇일까요? 예를들어, setTimeout을 통해 타이머를 동작시킨다고 할 경우 이 타이머를 수행하는 주체는 무엇일까요? 우리가 알고 있는 V8 Engine일 까요? 그렇지 않습니다. V8 Engine이 웹에서 수행하는 역할은 명확합니다.
- JavaScript 코드 파싱
- 컴파일
- 실행
- Call Stack 관리
- Heap 관리(GC)
- Promise job(microtask) 실행
V8 엔진은 타이머 하드웨어/OS 대기하게 하거나 디스트 I/O을 수행하거나, DOM 이벤트의 발생을 감지하지 않습니다. 이러한 실제 작업을 수행하는 작업의 주체는 네트워크 계층이나, Blink와 같은 웹 브라우저 렌더링 엔진에게 작업을 위임하는 역할을 수행합니다.
JavaScript 내부 비동기
예를 들어 다음과 같은 코드는 외부 I/O 없이도 비동기처럼 보입니다.
Promise.resolve().then(() => {
console.log('microtask');
});
이 경우 Promise 후속 실행은 JavaScript 엔진과 이벤트 루프의 microtask 처리만으로 설명할 수 있기 때문에 이런 종류는 비교적 V8 엔진 내부 모델에서 수행된다고 이야기할 수 있습니다.
하지만 외부 시스템을 쓰는 비동기는 이야기가 다릅니다. 예를 들어 fetch와 setTimeout에 대해서 이야기해보겠습니다.
setTimeout(() => console.log('timer'), 1000);
fetch('/api/data');
여기서 시간 대기나 네트워크 송수신은 JavaScript 엔진이 하지 않습니다. 실제 수행은 브라우저의 타이머 시스템, 네트워크 시스템, 이벤트 시스템이 담당하고, 완료 후에만 JavaScript 실행이 다시 이어집니다.
즉 JavaScript는 비동기 작업을 직접 수행하는 것이 아니라, 브라우저의 비동기 시스템에 작업을 위임하고, 완료된 결과를 나중에 다시 실행받습니다.
이제 처음 질문으로 돌아가보겠습니다. 브라우저에서 JavaScript를 실행시키는 주체는 누구일까요?
가장 직접적인 답은 JavaScript 엔진이다. 하지만 실제 브라우저 동작을 정확히 설명하려면 이렇게 정리하는 것이 좋습니다.
브라우저에서 JavaScript 코드를 직접 실행하는 것은 렌더러 프로세스의 메인 스레드 위에서 동작하는 JavaScript 엔진이다.
다만 DOM, 타이머, 네트워크, 이벤트 같은 기능은 JavaScript 엔진이 직접 수행하지 않고, 브라우저가 제공하는 네이티브 시스템이 처리하며, 그 결과를 이벤트 루프를 통해 다시 JavaScript 실행으로 연결한다.
스타일 계산
DOM 만으로는 웹 페이지의 모양을 알 수 없기 때문에 CSS로 웹 페이지 요소의 모양을 결정합니다. 이 과정은 개발자가 CSS 코드를 작성하지 않더라도 기본적으로 웹 브라우저에서 모양을 유지하기 위한 CSS 설정이 있기 때문에 이 작업은 제외되지 않고 반드시 실행됩니다.
이 과정에서 중요한 작업은 CSS을 파싱하는 과정을 통해 CSSOM Tree 가 생성이 된다는 것입니다. CSS을 파싱하여 얻을 결과를 기반으로 스타일링을 계산하기 위해서는 DOM 의 구조를 파악하고 있어야 하기 때문에 DOM 의 구조와 함께 스타일링의 규칙을 쉽게 비교하고 계산한 computed style로 완성하기 위해서 CSSOM Tree 을 구성합니다.
레이아웃
레이아웃은 요소의 기하학적 속성(geometry)를 찾는 과정입니다. 메인 스레드는 DOM과 계산된 스타일을 훑어가며 레이아웃 트리를 만듭니다다. 레이아웃 트리는 x, y 좌표, 박스 영역(bounding box)의 크기와 같은 정보를 가지고 있습니다. 레이아웃 트리는 DOM 트리와 비슷한 구조일 수 있지만 웹 페이지에 보이는 요소에 관련된 정보만 가지고 있다는 특징을 갖고 있습니다.
즉, display: none, visibility: hidden 으로 viewport 에서 보이지 않게 숨겨진 요소들은 Layout Tree 에서 그려지는 대상에서 제외가 된다는 뜻입니다.
웹 페이지의 레이아웃을 결정하는 것은 어려운 작업입니다. 가장 단순하게 위에서 아래로 펼쳐지는 블록 영역 하나만 있는 웹 페이지의 레이아웃을 결정할 때에도 폰트의 크기가 얼마이고 줄 바꿈을 어디서 해야 하는지 고려해야 합니다. 단락의 크기와 모양이 바뀔 수 있고, 다음 단락의 위치에 영향이 있기 때문입니다.
이러한 과정에서 알 수 있듯이 레이아웃은 브라우저에서 굉장히 중요한 작업이기도 하지만 굉장히 비용이 많은 비용이 드는 과정이라고 할 수 있습니다.
페인트
DOM, 스타일, 레이아웃을 가지고도 여전히 페이지를 렌더링할 수 없다. 그림을 하나 따라 그리려고 한다고 생각해 보자. 요소의 크기, 모양, 위치를 알더라도 어떤 순서로 그려야 할지 판단해야 한다.
예를 들어 어떤 요소에 z-index 속성이 적용되었다면 HTML에 작성된 순서로 요소를 그리면 잘못 렌더링된 화면이 나온다. 즉, DOM에 선언된 노드 순서와 페인트 순서는 많이 다를 수 있다.
렌더링 파이프라인을 갱신하는 데는 많은 비용이 든다
렌더링 파이프라인에서 파악해야 할 가장 중요한 점은 각 단계에서 이전 작업의 결과가 새 데이터를 만드는 데 사용된다는 것입니다. 예를 들어 레이아웃 트리에서 변경이 생겨 문서의 일부가 영향을 받으면 페인팅 순서 또한 영향을 받아서 새롭게 생성해야 한다는 것입니다.
그렇기 때문에 매순간 사용자의 인터렉션이 발생하거나 변경이 발생하는 모든 순간에 JS를 실행시키고 결과를 받을 수 없습니다. 그래서 실제로는 사용자가 화면을 인식하는 순간을 기준으로 렌더링을 업데이트 시킵니다. 대부분의 디스플레이 장치는 화면을 초당 60번 새로 고치는, 60fps 에 맞춰져 있기 때문에 이 타이밍에 맞춰서 렌더링을 업데이트 시켜 프레임이 누락되어 화면이 버벅이는 Page Jank 현상이 나타나지 않게끔 합니다.
하지만 화면 주사율에 맞추어 렌더링 작업이 이루어져도 이 작업은 메인 스레드에서 실행되기 때문에 애플리케이션이 JavaScript를 실행하는 동안 렌더링이 막힐 수 있습니다. 그래서 JS의 작업을 작은 덩어리로 나누고 rAF(requestAnimationFrame) 메서르를 사용하여 프레임마다 실행하도록 스케쥴링을 관리하여 window.addEventListener("click", () => {}) 와 같이 너무 많은 빈도로 일어나는 작업을 Batch 처리하여 작업의 효율성을 높일 수 있습니다.
래스터화
지금까지 수행한 과정을 통해서 브라우저는 이제 문서의 구조와 각 요소의 스타일, 요소의 가하학적 속성, 페인트 순서를 알고 있습니다. 브라우저는 이제 웹 페이지를 그려내는 단계만 남아 있습니다. 브라우저는 이제 이 웹페이지를 그리기 위해서 화면의 픽셀로 변환하는 작업을 수행하는데 이를 Rasterizing(래스터화) 라고 합니다.
가장 단순한 래스터화는 viewport 안쪽을 래스터하는 것일 것입니다. 사용자가 웹 페이지를 스크롤하면 이미 래스터화한 프레임을 움직이고 나머지 빈 부분을 추가로 래스터화하는 작업을 반복합니다. 이 방식은 Chrome이 처음 출시되었을 때 래스터화한 방식이이고 현재 최신 브라우저는 합성(compositing)이라는 보다 정교한 과정을 수행합니다.
Composition, 합성
Composition(합성) 은 웹 페이지의 각 부분을 레이어로 분리해 별도로 레스터화하고 컴포지터 스레드(Compositor thread) 라고 하는 별도의 스레드에서 웹 페이지로 합성하는 기술입니다. 합성 이전에는 사용자의 스크롤의 위치, viewport 에 맞춰서 Paint 와 Raster 작업을 반복하여 수행하였지만 이제는 별도의 스레드를 활용하여 레이어 단위로 레스터화가 되어 있기 때문에 이 반복 작업이 더 이상 필요 없어졌습니다.
위의 그림과 같이 웹 페이지의 요소들을 별도의 레이어로 분리하여 레스터화를 수행하고 합성을 수행하는 방식을 사용합니다. 이 방법은 Photoshop 와 같은 프로그램에서 레이어를 분리하여 색상을 입히는 방식과 유사합니다.
이렇게 레이어를 분리하는 방법이 웹 성능 최적화에 유리한 이유는 배경은 그대로 두고 앞에서 움직여야 하는 전경만 별도의 셀로 만들어서 프레임을 촬영하는 방식을 사용할 수 있기 때문입니다. 만약 배경과 전경을 분리하지 않았다면(즉, 레이어를 나누지 않았다면) 애니메이션 프레임마다 배경도 같이 그려야 했기 때문에 레이어를 분리하여 작업하는 방법이 웹 성능 최적화에 많은 도움이 되었습니다.
어떤 요소가 어떤 레이어에 있어야 하는지 확인하기 위해 메인 스레드는 레이아웃 트리를 순회하며 레이어 트리를 만듭니다.(이 부분은 개발자 도구의 Performance 패널에서 Update Layer Tree라고 되어 있습니다).
뷰포트로 미끄러져 들어오는 들어오는 슬라이드인 메뉴처럼 별도의 레이어여야 하는 웹 페이지의 어떤 부분이 별도의 레이어가 아니라면 CSS의 will-change 속성을 사용해 브라우저가 레이어를 생성하게 힌트를 줘서 애니메이션의 프레임을 개선할 수 있습니다.
모든 요소에 레이어를 할당하면 좋을 것 같지만 수많은 레이어를 합성하는 작업은 웹 페이지의 작은 부분을 매 프레임마다 새로 래스터화하는 작업보다 더 오래 걸릴 수 있습니다. 어떤 것이든 너무 과하면 탈이나기 마련입니다.
레이어가 많으면 합성 비용이 높을 뿐만 아니라 레이어를 메모리에 가지고 있어야 하는 부담도 있습니다. Chrome은 레이어가 과도하게 많아지는 것(layer explosion)을 막기 위해 특정한 경우에는 레이어를 생성하지 않거나 합치기도 합니다.
메인 스레드 이후 래스터화와 합성
메인 스레이 이후의 레스터화와 합성의 과정은 다음과 같습니다.
- Commit Phase : 레이어 트리가 생성되고 페인트 순서가 결정되면 해당 정보를 컴포지터 스레드에 넘긴다.
- 컴포지터 스레드는 각 레이어를 레스터화한다.
- 컴포지터 스레드는 레이어를 Tile 형태로 나눠 각 타일을 래스터 스레드로 보낸다.
- 타일이 래스터화되면 컴포지터 스레드는 합성 프레임을 생성하기 위해 타일의 정보를 모은다. 이 타일의 정보를 드로 쿼드(draw quads) 라고 부른다.
- 이후에 합성 프레임이 IPC를 통해 브라우저 프로세스로 전송된다.
- 이 시점에 브라우저 UI의 변경을 반영하려는 UI 스레드나 확장 앱을 위한 다른 렌더러 프로세스에 의해 합성 프레임이 더 추가될 수 있다.
- 합성 프레임은 GPU로 전송되어 화면에 표시된다. 스크롤 이벤트가 발생하면 컴포지터 스레드는 GPU로 보낼 다른 합성 프레임을 만든다.
합성의 이점은 메인 스레드와 별개로 작동할 수 있다는 점입니다. 컴포지터 스레드는 JavaScript 실행이나 스타일 계산을 기다리지 않아도 되기 때문에 합성만 하는 애니메이션이 성능상 가장 부드럽게 동작하는 이유입니다. 레이아웃이나 페인트를 다시 계산해야 할 경우에는 메인 스레드가 관여해야 하기 때문에 프레임이 끊기는 현상이 나타날 수 있습니다.