현대(Modern) 브라우저는 어떻게 동작하는가?
TMT웹 개발자들은 흔히 브라우저를 HTML, CSS, 자바스크립트를 마법처럼 인터랙티브한 웹 애플리케이션으로 바꿔주는 블랙박스로 여기곤 합니다. 하지만 실제로 Chrome(Chromium), Firefox(Gecko), Safari(WebKit) 같은 현대 웹 브라우저는 대단히 복잡한 소프트웨어입니다. 브라우저는 네트워킹을 지휘하고, 코드를 파싱·실행하며, GPU 가속으로 그래픽을 렌더링하고, 보안을 위해 콘텐츠를 샌드박스 프로세스 안에 격리합니다.
이 글은 현대 브라우저가 어떻게 동작하는지를 깊이 있게 파고듭니다. 특히 Chromium의 아키텍처와 내부 구조에 초점을 맞추되, 다른 엔진과 어떻게 다른지도 짚어봅니다. 네트워킹 스택과 파싱 파이프라인부터 Blink를 통한 렌더링 과정, V8을 통한 자바스크립트 엔진, 모듈 로딩, 멀티 프로세스 아키텍처, 보안 샌드박싱, 개발자 도구에 이르기까지 모든 것을 살펴볼 것입니다. 목표는 무대 뒤에서 벌어지는 일을 쉽게 풀어내어 개발자 친화적으로 설명하는 것입니다.
그럼 브라우저 내부를 향한 여정을 시작해 봅시다.
네트워킹과 리소스 로딩
모든 페이지 로딩은 브라우저의 네트워킹 스택이 웹에서 리소스를 가져오는 것으로 시작합니다. URL을 입력하거나 링크를 클릭하면, ("브라우저 프로세스"에서 실행되는) 브라우저의 UI 스레드가 내비게이션 요청을 시작합니다.
브라우저 프로세스는 다른 모든 프로세스와 브라우저의 사용자 인터페이스를 관리하는 중심 제어 프로세스입니다. 특정 웹 페이지 탭 바깥에서 일어나는 모든 일은 브라우저 프로세스가 제어합니다.
이 과정은 다음 단계를 포함합니다:
URL 파싱과 보안 검사: 브라우저는 URL을 파싱하여 스킴(http, https 등)과 대상 도메인을 판별합니다. 또한 (예를 들어 Chrome의 옴니박스에서) 입력이 검색어인지 URL인지도 판단합니다. 피싱 사이트를 피하기 위해 차단 목록 같은 보안 기능이 이 단계에서 확인될 수 있습니다.
DNS 조회: 네트워크 스택은 (캐시되어 있지 않다면) 도메인 이름을 IP 주소로 변환합니다. 이때 DNS 서버에 접속해야 할 수도 있습니다. 현대 브라우저는 OS의 DNS 서비스를 사용하거나, 설정에 따라 DNS over HTTPS(DoH)를 사용할 수도 있지만, 결국에는 호스트의 IP를 얻어냅니다.
연결 수립: 서버로 열려 있는 연결이 없다면 브라우저는 새 연결을 엽니다. HTTPS URL의 경우, 키를 안전하게 교환하고 인증서를 검증하기 위한 TLS 핸드셰이크가 여기에 포함됩니다. 브라우저의 네트워크 스레드는 TCP/TLS 설정 같은 프로토콜을 투명하게 처리합니다.
HTTP 요청 전송: 연결이 되면 해당 리소스에 대한 HTTP GET 요청(또는 다른 메서드)이 전송됩니다. 오늘날의 브라우저는 서버가 지원할 경우 기본적으로 HTTP/2나 HTTP/3을 사용하며, 이를 통해 하나의 연결에서 여러 리소스 요청을 다중화(multiplexing)할 수 있습니다. 이는 호스트당 약 6개의 병렬 연결로 제한되던 과거 방식(HTTP/1.1)을 피함으로써 성능을 향상시킵니다. 예를 들어 HTTP/2에서는 HTML, CSS, JS, 이미지를 하나의 TCP/TLS 링크에서 동시에 가져올 수 있고, (QUIC UDP 위에서 동작하는) HTTP/3에서는 설정 지연 시간이 더욱 줄어듭니다.
응답 수신: 서버는 HTTP 상태와 헤더로 응답하고, 그 뒤에 응답 본문(HTML 콘텐츠, JSON 데이터 등)이 따라옵니다. 브라우저는 응답 스트림을 읽습니다. Content-Type 헤더가 없거나 잘못된 경우, 콘텐츠를 어떻게 처리할지 결정하기 위해 MIME 타입을 추론(sniff)해야 할 수도 있습니다. 예를 들어 어떤 응답이 HTML처럼 보이지만 그렇게 명시되어 있지 않더라도, 브라우저는 (관대한 웹 표준에 따라) 여전히 그것을 HTML로 다루려 시도합니다. 여기에도 보안 조치가 있습니다. 네트워크 계층은 Content-Type을 확인하고, 의심스러운 MIME 불일치나 허용되지 않은 교차 출처(cross-origin) 데이터를 차단할 수 있습니다(Chrome의 CORB, 즉 Cross-Origin Read Blocking이 그러한 메커니즘 중 하나입니다). 또한 브라우저는 알려진 악성 페이로드를 차단하기 위해 세이프 브라우징(Safe Browsing)이나 유사한 서비스를 참조합니다.
리다이렉트와 다음 단계: 응답이 HTTP 리다이렉트(예: Location 헤더가 포함된 301이나 302)인 경우, 네트워크 코드는 (UI 스레드에 알린 후) 리다이렉트를 따라가 새 URL로 요청을 반복합니다. 실제 콘텐츠가 담긴 최종 응답을 얻고 나서야 브라우저는 그 콘텐츠를 처리하는 단계로 넘어갑니다.
이 모든 단계는 네트워크 스택에서 일어나며, Chromium에서는 전용 네트워크 서비스(Network Service)에서 실행됩니다(이는 Chrome의 "서비스화" 노력의 일환으로, 이제 보통 별도의 프로세스로 동작합니다). 브라우저 프로세스의 네트워크 스레드는 내부적으로 OS 네트워킹 API를 사용하여 소켓 통신이라는 저수준 작업을 조율합니다. 중요한 점은, 이 설계 덕분에 (페이지의 코드를 실행하게 될) 렌더러가 네트워크에 직접 접근하지 않는다는 것입니다. 렌더러는 필요한 것을 가져오도록 브라우저 프로세스에 요청하며, 이는 보안상 이점입니다.
추측성 로딩과 리소스 최적화
현대 브라우저는 네트워킹 단계에서 정교한 성능 최적화를 구현합니다. Chrome은 링크 위에 마우스를 올리거나 URL을 입력하기 시작할 때 (Predictor나 preconnect 메커니즘을 사용하여) 선제적으로 DNS 프리페치를 수행하거나 TCP 연결을 열어두며, 그래서 실제로 클릭했을 때 일부 지연 시간이 이미 줄어 있게 됩니다. HTTP 캐싱도 있습니다. 리소스가 캐시되어 있고 아직 유효하다면, 네트워크 스택은 네트워크 왕복 없이 브라우저 캐시에서 요청을 처리할 수 있습니다.
프리로드 스캐너 동작: Chromium은 메인 파서보다 앞서서 HTML 마크업을 토큰화하는 정교한 프리로드 스캐너를 구현합니다. 기본 HTML 파서가 CSS나 동기 자바스크립트에 의해 막혀 있는 동안, 프리로드 스캐너는 원시 마크업을 계속 살펴보며 병렬로 가져올 수 있는 이미지, 스크립트, 스타일시트 같은 리소스를 식별합니다. 이 메커니즘은 현대 브라우저 성능의 핵심이며, 개발자의 개입 없이 자동으로 동작합니다. 다만 프리로드 스캐너는 자바스크립트로 주입된 리소스는 발견하지 못하므로, 그런 리소스는 동시에 로드되기보다 순차적으로 로드될 가능성이 큽니다.
Early Hints(HTTP 103): Early Hints는 서버가 메인 응답을 생성하는 동안 HTTP 103 상태 코드를 사용해 리소스 힌트를 미리 보낼 수 있게 해줍니다. 이를 통해 서버가 응답을 준비하는 시간 동안 preconnect와 preload 힌트를 보낼 수 있어, Largest Contentful Paint를 수백 밀리초 단축할 수 있습니다. Early Hints는 내비게이션 요청에서만 사용할 수 있으며 preconnect와 preload 지시문은 지원하지만 prefetch는 지원하지 않습니다.
Speculation Rules API: Speculation Rules API는 사용자 상호작용 패턴에 따라 URL을 동적으로 프리페치하고 프리렌더링하는 규칙을 정의할 수 있게 해주는 최신 웹 표준입니다. 전통적인 링크 프리페치와 달리, 이 API는 자바스크립트 실행을 포함한 페이지 전체를 프리렌더링할 수 있어 거의 즉각적인 로딩 시간을 구현합니다. 이 API는 script 요소나 HTTP 헤더 안에서 JSON 구문을 사용해 어떤 URL을 추측성으로 로드할지 지정합니다. Chrome은 과도한 사용을 막기 위한 제한을 두고 있으며, 긴급도 수준에 따라 서로 다른 용량 설정을 적용합니다.
HTTP/2와 HTTP/3: 대부분의 Chromium 기반 브라우저와 Firefox는 HTTP/2를 완전히 지원하며, (QUIC 기반의) HTTP/3 역시 폭넓게 지원됩니다(Chrome은 지원하는 사이트에 대해 기본적으로 활성화하고 있습니다). 이 프로토콜들은 동시 전송을 허용하고 핸드셰이크 오버헤드를 줄여 페이지 로딩을 개선합니다. 개발자 관점에서 보면, 이제 스프라이트 시트나 도메인 샤딩 같은 트릭이 더 이상 필요하지 않을 수 있다는 뜻입니다. 브라우저가 하나의 연결에서 수많은 작은 파일을 효율적으로 병렬로 가져올 수 있기 때문입니다.
리소스 우선순위 지정: 브라우저는 특정 리소스를 다른 것보다 우선시하기도 합니다. 일반적으로 HTML과 CSS는 (렌더링을 막기 때문에) 높은 우선순위를 가지고, 스크립트는 중간 우선순위(또는 defer/async가 적절히 표시되면 높은 우선순위)를, 이미지는 그보다 낮은 우선순위를 가질 수 있습니다. Chromium의 네트워크 스택은 가중치를 부여하며, 초기 렌더링에 필요한 것을 우선시하기 위해 요청을 취소하거나 미룰 수도 있습니다. 개발자는 link rel=preload와 Fetch Priority를 사용해 리소스 우선순위에 영향을 줄 수 있습니다.
네트워킹 단계가 끝날 무렵이면 브라우저는 (HTML 내비게이션이었다고 가정할 때) 페이지의 초기 HTML을 갖게 됩니다. 이 시점에서 Chrome의 브라우저 프로세스는 콘텐츠를 처리할 렌더러 프로세스를 선택합니다. Chrome은 종종 네트워크 요청과 병렬로 (추측성으로) 새 렌더러 프로세스를 미리 띄워 두어, 데이터가 도착했을 때 곧바로 작업을 시작할 수 있도록 준비합니다. 이 렌더러 프로세스는 격리되어 있으며(멀티 프로세스 아키텍처는 뒤에서 더 다룹니다), 페이지를 파싱하고 렌더링하는 역할을 넘겨받습니다.
응답이 완전히 수신되면(또는 스트리밍되어 들어오는 대로), 브라우저 프로세스는 내비게이션을 커밋합니다. 즉, 렌더러 프로세스에 바이트 스트림을 받아 페이지 처리를 시작하라고 신호를 보냅니다. 이 순간 주소 표시줄이 갱신되고, 새 사이트에 대한 보안 표시(HTTPS 자물쇠 등)가 나타납니다. 이제 작업의 무대는 렌더러 프로세스로 넘어갑니다. HTML을 파싱하고, 하위 리소스를 로드하고, 스크립트를 실행하고, 페이지를 그리는 일 말입니다.
HTML, CSS, 자바스크립트 파싱
렌더러 프로세스가 HTML 콘텐츠를 받으면, 메인 스레드는 HTML 명세에 따라 이를 파싱하기 시작합니다. HTML 파싱의 결과물은 DOM(Document Object Model), 즉 페이지 구조를 나타내는 객체 트리입니다. 파싱은 점진적으로 이루어지며 네트워크 읽기와 교차되어 진행될 수 있습니다(브라우저는 HTML을 스트리밍 방식으로 파싱하므로, HTML 파일 전체가 다운로드되기 전에도 DOM 구축을 시작할 수 있습니다).
HTML 파싱과 DOM 구축: HTML 파싱은 HTML 표준에서 오류에 관대한(error-tolerant) 과정으로 정의되어 있어, 마크업이 아무리 잘못되어 있어도 DOM을 만들어냅니다. 즉, 닫는 </p> 태그를 빼먹거나 태그를 잘못 중첩하더라도, 파서는 DOM 트리가 유효하도록 암묵적으로 고치거나 조정합니다. 예를 들어 <p>Hello <div>World</div>는 DOM 구조에서 <div> 앞에서 자동으로 <p>를 닫습니다. 파서는 HTML의 각 태그나 텍스트에 대해 DOM 요소와 텍스트 노드를 생성합니다. 각 요소는 소스의 중첩 구조를 반영한 트리에 배치됩니다.
한 가지 중요한 점은, HTML 파서가 진행하면서 가져와야 할 리소스를 만날 수 있다는 것입니다. 예를 들어 <link rel="stylesheet" href="...">를 만나면 브라우저는 (네트워크 스레드에서) CSS 파일을 요청하게 되고, <img src="...">를 만나면 이미지 요청을 유발합니다. 이런 요청들은 파싱과 병렬로 일어납니다. 이런 로드가 진행되는 동안에도 파서는 계속 작업할 수 있지만, 한 가지 큰 예외가 있습니다. 바로 스크립트입니다.
<script> 태그 처리: HTML 파서가 <script> 태그를 만나면, (기본적으로) 파싱을 멈추고 계속 진행하기 전에 스크립트를 먼저 실행해야 합니다. 이는 스크립트가 document.write()나 다른 DOM 조작을 사용해, 아직 들어오고 있는 페이지 구조나 콘텐츠를 바꿀 수 있기 때문입니다. 그 지점에서 즉시 실행함으로써 브라우저는 HTML에 대한 올바른 작업 순서를 보존합니다. 따라서 파서는 스크립트를 자바스크립트 엔진에 넘겨 실행하게 하고, 스크립트가 끝나고 (그것이 수행한 DOM 변경이 적용되고) 나서야 HTML 파싱을 재개할 수 있습니다. 이러한 스크립트 실행 차단 동작 때문에, head에 큰 <script> 파일을 포함하면 페이지 렌더링이 느려질 수 있습니다. 스크립트가 다운로드되고 실행될 때까지 HTML 파싱이 계속될 수 없기 때문입니다.
하지만 개발자는 속성을 사용해 이 동작을 바꿀 수 있습니다. <script> 태그에 defer나 async를 추가하거나(또는 최신 ES 모듈 스크립트를 사용하면) 브라우저가 이를 처리하는 방식이 달라집니다. async를 쓰면 스크립트 파일이 병렬로 가져와지고 준비되는 즉시 실행되며, HTML 파싱을 멈추지 않습니다(파싱은 기다리지 않고, 스크립트는 다른 async 스크립트들과의 원래 순서대로 실행된다는 보장이 없습니다). defer를 쓰면 스크립트는 병렬로 가져와지지만 실행은 HTML 파싱이 끝날 때까지 미뤄집니다(그리고 나중에 그 시점에 원래 순서대로 실행됩니다). 두 경우 모두 파서가 스크립트를 기다리느라 막히지 않으며, 이는 일반적으로 성능에 더 좋습니다. ES6 모듈(<script type="module"> 사용)도 자동으로 defer 처리됩니다(그리고 import 문도 사용할 수 있는데, 모듈 로딩은 별도로 다룹니다). 이런 기법을 사용하면 브라우저는 긴 멈춤 없이 DOM 구축을 이어갈 수 있어 페이지가 더 빨리 로드됩니다.
CSS 파싱과 CSSOM: HTML과 함께, CSS 텍스트도 브라우저가 다룰 수 있는 구조로 파싱되어야 하는데, 이를 흔히 CSSOM(CSS Object Model)이라고 부릅니다. CSSOM은 본질적으로 문서에 적용되는 모든 스타일(규칙, 셀렉터, 속성)을 표현한 것입니다. 브라우저의 CSS 파서는 CSS 파일(또는 <style> 블록)을 읽어 CSS 규칙 목록으로 변환합니다(그리고 스타일 해석 속도를 높이기 위한 블룸 필터 등도 많이 만듭니다). 그런 다음 DOM이 구축되는 동안(또는 DOM과 CSSOM이 모두 준비되면) 브라우저는 각 DOM 노드의 스타일을 계산합니다. 이 단계는 보통 스타일 해석(style resolution) 또는 스타일 계산(style calculation)이라고 부릅니다. 브라우저는 DOM과 CSSOM을 결합하여, 각 요소에 어떤 CSS 규칙이 적용되고 (캐스케이드, 상속, 기본 스타일을 적용한 후) 최종 계산된 스타일이 무엇인지 판별합니다. 그 결과물은 흔히 각 DOM 노드와 계산된 스타일(해당 요소의 해석된 최종 CSS 속성, 예를 들어 요소의 색상, 글꼴, 크기 등)의 연결로 개념화됩니다.
주목할 점은, 작성자가 작성한 CSS가 전혀 없어도 모든 요소에는 기본 브라우저 스타일(user-agent 스타일시트)이 적용된다는 것입니다. 예를 들어 <h1>은 사실상 모든 브라우저에서 기본 font-size와 margin을 가집니다. 브라우저의 내장 스타일 규칙은 가장 낮은 우선순위로 적용되며, 어느 정도 합리적인 기본 표현을 보장합니다. 개발자는 DevTools에서 계산된 스타일을 확인하여 어떤 요소가 결국 어떤 CSS 속성을 갖게 되는지 정확히 볼 수 있습니다. 스타일 계산 단계는 적용 가능한 모든 스타일(user-agent 스타일, 사용자 스타일, 작성자 스타일)을 사용해 각 요소의 스타일을 확정합니다.
렌더링 차단 동작: HTML 파싱은 CSS가 완전히 로드되지 않아도 진행될 수 있지만, 렌더링 차단 관계가 존재합니다. 브라우저는 보통 (<head>에 있는 CSS의 경우) CSS가 로드될 때까지 첫 렌더링을 미룹니다. 불완전한 스타일시트를 적용하면 스타일이 적용되지 않은 콘텐츠가 잠깐 번쩍일(flash) 수 있기 때문입니다. 실제로, async/defer로 표시되지 않은 <script>가 HTML에서 CSS <link>보다 앞에 나오면, 그 스크립트는 실행 전에 CSS가 로드되기를 추가로 기다립니다(스크립트가 DOM API를 통해 스타일 정보를 조회할 수 있기 때문입니다). 경험적으로, 스타일시트 링크는 head에 두고(렌더링을 막지만 일찍 필요합니다), 중요하지 않거나 큰 스크립트는 defer/async를 붙이거나 맨 아래에 두어 DOM 파싱을 지연시키지 않도록 하는 것이 좋습니다.
이제 브라우저는 (1) HTML로부터 구축된 DOM, (2) 파싱된 CSS 규칙(CSSOM), (3) 각 DOM 노드의 계산된 스타일을 갖추었습니다. 이들이 함께 다음 단계인 레이아웃의 기반을 이룹니다. 하지만 다음으로 넘어가기 전에, 자바스크립트 쪽을 좀 더 자세히 살펴봐야 합니다. 구체적으로는 JS 엔진(Chrome의 경우 V8)이 코드를 어떻게 실행하는지 말입니다. 스크립트 차단은 앞서 다뤘지만, JS가 실행될 때는 어떤 일이 일어날까요? V8과 JS 실행의 내부 구조는 뒤의 한 절을 통째로 할애해 다룰 것입니다. 지금은, 스크립트가 실행되면서 DOM이나 CSSOM을 수정할 수 있다고만(예: document.createElement 호출이나 요소 스타일 설정) 가정해 둡시다. 브라우저는 그러한 변경에 대응해 필요에 따라 스타일이나 레이아웃을 다시 계산해야 할 수 있습니다(이를 반복적으로 수행하면 성능 비용이 발생할 수 있습니다). 파싱 중에 이루어지는 스크립트의 초기 실행은 흔히 이벤트 핸들러 설정이나 DOM 조작(예: 템플릿 처리) 같은 작업을 포함합니다. 그 이후에는 보통 페이지가 완전히 파싱되어 레이아웃과 렌더링 단계로 넘어갑니다.
스타일링과 레이아웃
이 단계에서 브라우저의 렌더러 프로세스는 DOM의 구조와 각 요소의 계산된 스타일을 알고 있습니다. 다음 질문은 이것입니다. 이 모든 요소가 화면의 어디에 놓일까? 크기는 얼마나 될까? 이것이 바로 레이아웃("reflow" 또는 "layout calculation"이라고도 함)의 역할입니다. 이 단계에서 브라우저는 CSS 규칙(흐름, 박스 모델, 플렉스박스나 그리드 등)과 DOM 계층 구조에 따라 각 요소의 기하학적 정보, 즉 크기와 위치를 계산합니다.
레이아웃 트리 구축: 브라우저는 DOM 트리를 순회하며 레이아웃 트리(렌더 트리 또는 프레임 트리라고도 함)를 생성합니다. 레이아웃 트리는 구조상 DOM 트리와 비슷하지만, 시각적이지 않은 요소(예: script나 meta 태그는 박스를 만들지 않음)는 제외하며, 필요하다면 일부 요소를 여러 박스로 나누기도 합니다(예: 여러 줄에 걸쳐 흐르는 하나의 HTML 요소는 여러 개의 레이아웃 박스에 대응할 수 있습니다). 레이아웃 트리의 각 노드는 해당 요소의 계산된 스타일을 담고 있으며, 노드의 콘텐츠(텍스트나 이미지)와 레이아웃에 영향을 주는 계산된 속성(너비, 높이, 패딩 등) 같은 정보를 가집니다.
레이아웃 과정에서 브라우저는 각 요소 박스의 정확한 위치(x, y 좌표)와 크기(너비, 높이)를 계산합니다. 여기에는 CSS 명세가 정의한 알고리즘이 관여합니다. 예를 들어 일반적인 문서 흐름에서 블록 수준 요소는 위에서 아래로 쌓이며 기본적으로 전체 너비를 차지하는 반면, 인라인 요소는 줄 안에서 흐르다가 필요에 따라 줄바꿈을 일으킵니다. 플렉스박스나 그리드 같은 현대적인 레이아웃 모드는 각자의 알고리즘을 가지고 있습니다. 엔진은 줄을 나누기 위해 글꼴 메트릭을 고려해야 하고(따라서 텍스트 레이아웃에는 텍스트 런(text run)을 측정하는 작업이 포함됩니다), 마진, 패딩, 테두리 등도 처리해야 합니다. 수많은 예외 상황(예: 마진 상쇄 규칙, 플로트, 흐름에서 제거되는 절대 위치 요소 등)이 있어 레이아웃은 의외로 복잡한 과정입니다. "단순한" 위에서 아래로의 레이아웃조차도, 사용 가능한 너비와 글꼴 크기에 따라 달라지는 텍스트 줄바꿈을 알아내야 합니다. 브라우저 엔진은 레이아웃을 정확하고 효율적으로 처리하기 위해 전담 팀과 수년간의 개발을 투입해 왔습니다.
레이아웃 트리에 관한 몇 가지 세부 사항:
display:none인 요소는 레이아웃 트리에서 완전히 제외됩니다(어떤 박스도 만들지 않습니다). 반면 단순히 보이지 않을 뿐인 요소(예:visibility:hidden)는 레이아웃 박스를 가지긴 하지만(공간을 차지함) 나중에 그려지지만 않습니다.- 콘텐츠를 생성하는
::before나::after같은 의사 요소(pseudo-element)는 (시각적 박스를 갖기 때문에) 레이아웃 트리에 포함됩니다. - 레이아웃 트리의 노드는 자신의 기하학적 정보를 알고 있습니다. 예를 들어
<p>요소의 레이아웃 노드는 뷰포트를 기준으로 한 자신의 위치와 치수를 알고 있으며, 그 안의 각 줄이나 인라인 박스에 해당하는 자식을 가집니다.
레이아웃 계산: 레이아웃은 일반적으로 재귀적인 과정입니다. 루트(<html> 요소)에서 시작해 브라우저는 (<html>/<body>의) 뷰포트 크기를 계산하고, 그 안에 자식 요소들을 배치하는 식으로 이어집니다. 많은 요소의 크기는 자식이나 부모에 의존합니다(예: 컨테이너가 자식에 맞춰 늘어나거나, 자식이 부모 너비의 50%가 될 수 있음). 레이아웃 알고리즘은 플로트나 특정 복잡한 상호작용을 위해 여러 번의 패스(pass)를 거쳐야 할 때가 많지만, 일반적으로는 (필요하면 되돌아갈 가능성을 두고) 한 방향(위에서 아래로)으로 진행됩니다.
이 단계가 끝나면 페이지에서 각 요소의 위치와 크기가 결정됩니다. 이제 우리는 페이지를 (안에 텍스트나 이미지가 든) 박스들의 모음으로 개념적으로 떠올릴 수 있습니다. 하지만 아직 화면에 실제로 무언가를 그린 것은 아닙니다. 그것이 다음 단계인 페인팅입니다.
다만 한 가지 핵심 개념이 있습니다. 레이아웃은, 특히 반복적으로 수행되면 비용이 큰 작업일 수 있습니다. 나중에 자바스크립트가 어떤 요소의 크기를 바꾸거나 콘텐츠를 추가하면, 페이지의 일부 또는 전체에 대한 재레이아웃(relayout)을 강제할 수 있습니다. 개발자들은 레이아웃 스래싱(layout thrashing)을 피하라는 조언을 자주 듣습니다(예: DOM을 수정한 직후 JS에서 레이아웃 정보를 읽으면 동기적 재계산을 강제할 수 있습니다). 브라우저는 레이아웃 트리에서 어느 부분이 "더티(dirty)"한지 표시해 그 부분만 다시 계산하는 식으로 최적화하려 합니다. 하지만 최악의 경우 DOM 상위에서의 변경은 큰 페이지에서 전체 레이아웃을 다시 계산해야 할 수도 있습니다. 그래서 더 나은 성능을 위해 비용이 큰 스타일/레이아웃 작업은 최소화해야 합니다.
스타일과 레이아웃 정리: 요약하면, HTML과 CSS로부터 브라우저는 다음을 구축합니다:
- DOM 트리 — 구조와 콘텐츠
- CSSOM — 파싱된 CSS 규칙
- 계산된 스타일(Computed Styles) — CSS 규칙을 각 DOM 노드에 매칭한 결과
- 레이아웃 트리 — 시각적 요소만 걸러낸 DOM 트리로, 각 노드의 기하학적 정보를 포함
각 단계는 이전 단계 위에 쌓입니다. 어떤 단계가 바뀌면(예: 스크립트가 DOM을 변경하거나 CSS 속성을 수정하면), 그 이후 단계들도 갱신되어야 할 수 있습니다. 예를 들어 어떤 요소의 CSS 클래스를 바꾸면, 브라우저는 그 요소(그리고 상속이 바뀌면 자식들)의 스타일을 다시 계산하고, 그 스타일 변경이 기하학적 정보에 영향을 준다면(예: display나 크기) 레이아웃을 다시 수행해야 할 수 있으며, 그런 다음 다시 페인팅해야 합니다. 이 연쇄는 레이아웃과 페인트가 최신 스타일에 의존하고, 그런 식으로 이어진다는 것을 의미합니다. 이로 인한 성능적 영향은 DevTools 절에서 논의하겠습니다(브라우저는 이 단계들이 언제 발생하고 얼마나 걸리는지 볼 수 있는 도구를 제공합니다).
레이아웃이 끝났으니, 다음 주요 단계인 페인팅으로 넘어갑니다.
페인팅, 컴포지팅, GPU 렌더링
페인팅은 구조화된 레이아웃 정보를 받아 실제로 화면에 픽셀을 만들어내는 과정입니다. 전통적인 관점에서 브라우저는 레이아웃 트리를 순회하며 각 노드에 대해 그리기 명령("배경 그리기, 텍스트 그리기, 이 좌표에 이미지 그리기")을 내립니다. 현대 브라우저도 개념적으로는 여전히 이렇게 하지만, 흔히 작업을 여러 단계로 나누고 효율을 위해 GPU를 활용합니다.
페인팅 / 래스터화: 렌더러의 메인 스레드에서, 레이아웃 이후 Chrome은 레이아웃 트리를 순회하며 페인트 레코드(또는 디스플레이 리스트)를 생성합니다. 이는 기본적으로 좌표가 딸린 그리기 작업들의 목록으로, 마치 화가가 장면을 어떻게 그릴지 계획하는 것과 같습니다. 예를 들어 "(x,y)에 너비 W, 높이 H의 사각형을 파란색으로 채워 그리고, 그다음 (x2,y2)에 XYZ 글꼴로 'Hello' 텍스트를 그리고, 그다음 …에 이미지를 그린다"는 식입니다. 이 목록은 (겹치는 요소들이 올바르게 그려지도록) 올바른 z-index 순서로 되어 있습니다. 예를 들어 어떤 요소의 z-index가 더 높으면, 그 페인트 명령은 더 낮은 z-index 콘텐츠보다 나중에(그 위에) 옵니다. 브라우저는 올바른 순서를 얻기 위해 쌓임 맥락(stacking context), 투명도 등을 고려해야 합니다.
과거에 브라우저는 단순히 각 요소를 순서대로 화면에 직접 그렸을 수도 있습니다. 하지만 이 방식은 페이지의 일부가 바뀔 때 비효율적일 수 있습니다(전체를 다시 그려야 하니까요). 그래서 현대 브라우저는 대신 이런 그리기 명령을 기록해 두었다가, 특히 GPU 가속을 사용할 때 컴포지팅(compositing) 단계를 거쳐 최종 이미지를 조립하는 경우가 많습니다.
레이어링과 컴포지팅: 컴포지팅은 페이지를 독립적으로 다룰 수 있는 여러 레이어로 나누는 최적화 기법입니다. 예를 들어 CSS transform이나 애니메이션이 적용된 위치 지정 요소는 자기만의 레이어를 가질 수 있습니다. 레이어는 별도의 "스케치 캔버스"와 같습니다. 브라우저는 각 레이어를 개별적으로 래스터화(그리기)할 수 있고, 그런 다음 컴포지터(compositor)가 보통 GPU를 사용해 이들을 화면에서 합성합니다.
Chromium의 파이프라인에서는 페인트 레코드가 생성된 후, 레이어 트리를 구축하는 단계가 있습니다(이는 어떤 요소가 어떤 레이어에 있는지에 대응합니다). 일부 레이어는 자동으로 생성되며(예: video 요소, canvas, 또는 특정 CSS가 적용된 요소는 레이어로 승격됩니다), 개발자는 will-change나 transform 같은 CSS 속성을 사용해 레이어를 얻도록 힌트를 줄 수 있습니다. 레이어가 유용한 이유는, 한 레이어에서의 이동이나 불투명도 변경은 전체 페이지를 다시 페인팅하지 않고 컴포지팅만으로(즉, 그 레이어만 다시 렌더링하거나 이동시켜) 처리할 수 있기 때문입니다. 다만 레이어가 너무 많으면 메모리를 많이 잡아먹고 오버헤드가 더해지므로, 브라우저는 신중하게 선택합니다.
레이어가 결정되면 Chrome의 메인 스레드는 컴포지터 스레드에 작업을 넘깁니다. 컴포지터 스레드는 렌더러 프로세스 안에서 실행되지만 메인 스레드와는 분리되어 있어(따라서 메인 JS 스레드가 바쁠 때도 계속 작업할 수 있는데, 이는 부드러운 스크롤과 애니메이션에 매우 좋습니다), 컴포지터 스레드의 역할은 레이어들을 받아 래스터화하고(그리기를 실제 픽셀 비트맵으로 변환) 이들을 프레임으로 합성하는 것입니다.
GPU 지원을 받는 래스터화: 래스터 작업도 분산될 수 있습니다. Chrome에서 컴포지터 스레드는 레이어를 더 작은 타일로 나눕니다(256×256이나 512×512 픽셀 단위를 떠올리면 되는데, GPU 래스터가 켜져 있을 때는 거의 항상 더 큽니다). 그런 다음 이들을 여러 래스터 워커 스레드(여러 CPU 코어에 걸쳐 실행될 수도 있음)에 보내 동시에 래스터화합니다. 각 래스터 워커는 타일(본질적으로 레이어의 특정 영역에 대한 그리기 명령 목록)을 받아 비트맵(픽셀 데이터)을 생성합니다. 중요한 점은, (Chrome의 그래픽 라이브러리인) Skia가 CPU나 GPU를 사용해 래스터화할 수 있다는 것입니다. Chrome의 경우 이 래스터 스레드들은 보통 CPU로 픽셀을 렌더링한 다음 GPU 메모리에 업로드합니다. Firefox의 더 새로운 WebRender는 다른 접근을 취하는데, 이는 뒤에서 언급하겠습니다. 래스터화된 타일은 GPU 메모리에 텍스처로 저장됩니다. 필요한 모든 타일이 그려지면, 컴포지터 스레드는 사실상 텍스처가 입혀진 레이어들의 집합을 준비하게 됩니다.
그런 다음 컴포지터는 컴포지터 프레임(compositor frame)을 조립합니다. 이는 기본적으로 화면을 구성하는 모든 쿼드(레이어의 타일)와 그 위치 등을 담은, 브라우저 프로세스로 보내는 메시지입니다. 이 컴포지터 프레임은 IPC를 통해 브라우저 프로세스로 다시 제출되고, 최종적으로 브라우저의 GPU 프로세스(GPU에 접근하기 위한 Chrome의 별도 프로세스)가 이를 받아 표시하게 됩니다. 브라우저 프로세스 자체의 UI(예: 탭 바)도 컴포지터 프레임을 통해 그려지며, 이 모두가 마지막 단계에서 섞입니다. GPU 프로세스는 프레임들을 받아 GPU를 (OpenGL/DirectX/Metal 등을 통해) 사용해 이들을 합성합니다. 즉, 각 텍스처를 화면의 올바른 위치에 그리고 변환을 적용하는 일을 매우 빠르게 수행합니다. 그 결과가 바로 여러분이 화면에서 보게 되는 최종 이미지입니다.
이 파이프라인의 장점은 스크롤하거나 애니메이션을 할 때 분명히 드러납니다. 예를 들어 페이지를 스크롤하는 것은 대부분 더 큰 페이지 텍스처에서 뷰포트만 바꾸는 것입니다. 컴포지터는 단지 레이어 위치를 옮기고 시야에 새로 들어오는 부분을 다시 그리도록 GPU에 요청하기만 하면 되며, 메인 스레드가 전체를 다시 페인팅할 필요가 없습니다. 어떤 애니메이션이 단지 transform(예: 자기만의 레이어를 가진 요소를 이동)이라면, 컴포지터 스레드는 매 프레임 그 요소의 위치를 갱신하고 새 프레임을 만들어낼 수 있으며, 메인 스레드를 거치거나 스타일·레이아웃을 다시 실행하지 않아도 됩니다. 그래서 "컴포지팅만으로 처리되는" 애니메이션(레이아웃을 유발하지 않는 transform이나 opacity 변경)이 더 나은 성능을 위해 권장됩니다. 이런 애니메이션은 메인 스레드가 바쁘더라도 60 FPS로 부드럽게 실행될 수 있습니다. 반대로 height나 background-color 같은 것을 애니메이션하면 매 프레임 재레이아웃이나 재페인트를 강제할 수 있고, 메인 스레드가 따라가지 못하면 끊김(jank)이 생깁니다.
간단히 말해, Chrome의 렌더링 파이프라인은 DOM → 스타일 → 레이아웃 → 페인트(디스플레이 항목 기록) → 레이어화 → 래스터(타일) → 컴포지트(GPU) 순입니다. Firefox의 파이프라인은 디스플레이 리스트 단계까지는 개념적으로 비슷하지만, WebRender를 사용하면 명시적인 레이어 구축을 건너뛰고 대신 디스플레이 리스트를 GPU 프로세스에 보내며, GPU 프로세스가 GPU 셰이더를 사용해 거의 모든 그리기를 처리합니다(이에 대해서는 비교 절에서 더 다룹니다). WebKit(Safari) 역시 멀티스레드 컴포지터를 사용하며 macOS에서는 "CALayer"를 통해 GPU 렌더링을 합니다. 따라서 모든 현대 엔진은 높은 프레임 레이트를 달성하고 CPU의 부담을 덜기 위해, 특히 그래픽 집약적인 부분의 컴포지팅과 래스터화에 GPU를 활용합니다.
다음으로 넘어가기 전에, GPU의 역할을 좀 더 자세히 살펴봅시다. Chromium에서 GPU 프로세스는 그래픽 하드웨어와 연결하는 역할을 하는 별도의 프로세스입니다. 이 프로세스는 모든 렌더러 컴포지터와 브라우저 UI로부터 그리기 명령(대부분 "이 좌표에 이 텍스처들을 그려라" 같은 고수준 명령)을 받습니다. 그런 다음 이를 실제 GPU API 호출로 변환합니다. 이를 별도 프로세스로 격리해 두면, 충돌을 일으키는 결함 있는 GPU 드라이버가 브라우저 전체를 무너뜨리지 않고 GPU 프로세스만 영향을 받으며, 이 프로세스는 다시 시작할 수 있습니다. 또한 이는 샌드박스 경계를 제공합니다(GPU는 canvas 그리기, WebGL 등 신뢰할 수 없을 수 있는 콘텐츠를 처리하고, 드라이버에 보안 버그가 있었던 적이 있으므로, 이를 프로세스 밖에서 실행하면 위험을 완화합니다).
컴포지팅의 결과는 마지막으로 디스플레이(브라우저가 실행되고 있는 OS 창이나 컨텍스트)로 보내집니다. 매 애니메이션 프레임마다(부드러운 결과를 위해 60fps, 즉 프레임당 16.7ms를 목표로 함) 컴포지터는 한 프레임을 만들어내려 합니다. 메인 스레드가 바쁘면(가령 자바스크립트가 오래 걸렸다면) 컴포지터는 프레임을 건너뛰거나 갱신하지 못해 눈에 띄는 끊김이 생길 수 있습니다. 개발자 도구는 성능 타임라인에서 누락된 프레임을 보여줄 수 있습니다. requestAnimationFrame 같은 기법은 JS 갱신을 프레임 경계에 맞춰 정렬해 부드러운 렌더링에 도움을 줍니다.
요컨대, 브라우저의 렌더링 엔진은 페이지 콘텐츠와 스타일을 일련의 기하학적 정보(레이아웃)와 그리기 명령으로 세심하게 분해한 다음, 레이어와 GPU 컴포지팅을 사용해 이를 효율적으로 여러분이 보는 픽셀로 바꿉니다. 이 복잡한 파이프라인 덕분에 웹의 풍부한 그래픽과 애니메이션이 인터랙티브한 프레임 레이트로 동작할 수 있습니다. 다음으로는 자바스크립트 엔진을 들여다보며, 브라우저가 (지금까지 블랙박스로 다뤄온) 스크립트를 어떻게 실행하는지 알아보겠습니다.
자바스크립트 엔진(V8) 들여다보기
자바스크립트는 웹 페이지의 인터랙티브한 동작을 이끕니다. Chromium 브라우저에서는 V8 엔진이 자바스크립트(와 WebAssembly)를 실행합니다. V8의 동작 방식을 이해하면 개발자가 성능 좋은 JS를 작성하는 데 도움이 됩니다. 철저히 깊게 파고들면 책 한 권 분량이 되겠지만, 여기서는 JS 실행 파이프라인의 핵심 단계, 즉 코드 파싱/컴파일, 실행, 메모리 관리(가비지 컬렉션)에 초점을 맞춥니다. 또한 V8이 JIT(Just-In-Time) 컴파일 계층이나 ES 모듈 같은 현대적 기능을 어떻게 다루는지도 짚어보겠습니다.
현대 V8의 파싱 및 컴파일 파이프라인
백그라운드 컴파일: Chrome 66부터 V8은 자바스크립트 소스 코드를 백그라운드 스레드에서 컴파일하여, 일반적인 웹사이트에서 메인 스레드의 컴파일 시간을 5%에서 20% 줄였습니다. 버전 41부터 Chrome은 V8의 StreamedSource API를 통해 백그라운드 스레드에서 자바스크립트 소스 파일을 파싱하는 것을 지원해 왔습니다. V8은 네트워크에서 첫 청크가 다운로드되는 즉시 자바스크립트 소스 코드 파싱을 시작할 수 있고, 파일을 스트리밍하는 동안 병렬로 파싱을 이어갈 수 있습니다. 거의 모든 스크립트 컴파일은 백그라운드 스레드에서 일어나며, 짧은 AST 내부화(internalization)와 바이트코드 마무리 단계만이 스크립트 실행 직전에 메인 스레드에서 이루어집니다. 현재 최상위 스크립트 코드와 즉시 실행 함수 표현식(IIFE)은 백그라운드 스레드에서 컴파일되는 반면, 내부 함수는 여전히 처음 실행될 때 메인 스레드에서 지연(lazy) 컴파일됩니다.
파싱과 바이트코드: (HTML 파싱 중이든 나중에 로드되든) <script>를 만나면, V8은 먼저 자바스크립트 소스 코드를 파싱합니다. 이는 코드의 추상 구문 트리(AST, Abstract Syntax Tree) 표현을 만들어냅니다. 프리파서(preparser)는 파서의 복사본으로, 함수를 건너뛰는 데 필요한 최소한의 작업만 수행합니다. 프리파서는 함수가 구문적으로 유효한지 검증하고, 바깥 함수들이 올바르게 컴파일되는 데 필요한 모든 정보를 만들어냅니다. 프리파싱된 함수가 나중에 호출되면, 그제서야 필요에 따라 완전히 파싱되고 컴파일됩니다.
V8은 AST에서 직접 해석하지 않고, Ignition이라는 바이트코드 인터프리터(2016년 도입)를 사용합니다. Ignition은 자바스크립트를 컴팩트한 바이트코드 형식으로 컴파일하는데, 이는 본질적으로 가상 머신을 위한 명령어 시퀀스입니다. 이 초기 컴파일은 상당히 빠르고 바이트코드는 꽤 저수준입니다(Ignition은 레지스터 기반 VM입니다). 목표는 초기 비용을 최소화하면서 코드를 빠르게 실행하기 시작하는 것입니다(페이지 로딩 시간에 중요합니다).
AST 내부화 과정: AST 내부화는 생성된 바이트코드가 사용할 리터럴 객체(문자열, 숫자, 객체 리터럴 보일러플레이트)를 V8 힙에 할당하는 작업입니다. 백그라운드 컴파일을 가능하게 하기 위해 이 과정은 컴파일 파이프라인에서 바이트코드 컴파일 이후로 미뤄졌으며, 이를 위해 내부화된 온힙(on-heap) 값 대신 AST에 내장된 원시 리터럴 값에 접근하도록 수정이 필요했습니다.
명시적 컴파일 힌트(Explicit Compile Hints): V8은 "Explicit Compile Hints"라는 새로운 기능을 도입했는데, 이는 개발자가 V8에게 즉시(eager) 컴파일을 통해 로드 시 코드를 곧바로 파싱·컴파일하도록 지시할 수 있게 해줍니다. 이 힌트가 붙은 파일은 백그라운드 스레드에서 컴파일되는 반면, 지연된 컴파일은 메인 스레드에서 일어납니다. 인기 있는 웹 페이지를 대상으로 한 실험에서 20건 중 17건에서 성능이 향상되었으며, 포그라운드 파싱·컴파일 시간이 평균 630ms 줄었습니다. 개발자는 특별한 주석을 사용해 자바스크립트 파일에 명시적 컴파일 힌트를 추가하여, 중요한 코드 경로를 백그라운드 스레드에서 즉시 컴파일하도록 할 수 있습니다.
스캐너와 파서 최적화: V8의 스캐너는 상당히 최적화되어 전반적으로 개선되었습니다. 단일 토큰 스캔은 약 1.4배, 문자열 스캔은 1.3배, 여러 줄 주석 스캔은 2.1배, 식별자 스캔은 식별자 길이에 따라 1.2~1.5배 향상되었습니다.
스크립트가 실행되면 Ignition은 바이트코드를 해석하며 프로그램을 실행합니다. 해석(interpretation)은 일반적으로 최적화된 기계어보다 느리지만, 엔진이 실행을 시작할 수 있게 해주는 동시에 코드의 동작에 대한 프로파일링 정보를 수집할 수 있게 해줍니다. 코드가 실행되면서 V8은 그것이 어떻게 사용되는지에 대한 데이터를 모읍니다. 변수의 타입, 어떤 함수가 자주 호출되는지 등입니다. 이 정보는 이후 단계에서 코드를 더 빠르게 실행하는 데 사용됩니다.
JIT 컴파일 계층
V8은 해석에서 멈추지 않습니다. 핫(hot) 코드를 가속하기 위해 여러 계층의 JIT(Just-In-Time) 컴파일러를 사용합니다. 핵심 아이디어는, 한 번만 실행되는 코드를 최적화하는 데 시간을 낭비하지 않으면서, 많이 실행되는 코드에 더 많은 컴파일 노력을 들여 더 빠르게 만드는 것입니다.
- Ignition (바이트코드 해석).
- Sparkplug: Sparkplug라 불리는 V8의 베이스라인 JIT입니다(2021년경 출시). Sparkplug는 바이트코드를 받아 무거운 최적화 없이 빠르게 기계어로 컴파일합니다. 이는 해석보다 빠른 네이티브 코드를 만들어내지만, Sparkplug는 깊은 분석을 하지 않습니다. 인터프리터만큼 빠르게 시작하되 조금 더 빠르게 실행되는 코드를 만들어내는 것이 목표입니다.
- Maglev: 2023년 V8은 Maglev를 도입했는데, 이는 현재 실제로 활발히 배포되고 있는 중간 계층 최적화 컴파일러입니다. Maglev는 Sparkplug보다 거의 20배 느리게 코드를 생성하지만 TurboFan보다는 10~100배 빠르게 생성하여, 적당히 핫하지만 TurboFan 최적화를 적용할 만큼 핫하지는 않은 함수들의 공백을 효과적으로 메웁니다. Maglev는 어느 정도 핫하지만 TurboFan을 적용하기엔 충분히 핫하지 않은 함수, 또는 TurboFan의 컴파일 비용이 너무 클 경우에 동원됩니다. Chrome M117 기준으로 Maglev는 많은 경우를 처리할 수 있어, 베이스라인과 최상위 계층 JIT 사이의 공백을 메움으로써 "웜(warm)" 코드(차갑지도, 아주 뜨겁지도 않은)에 시간을 쓰는 웹 앱의 시작 속도를 높여줍니다.
- TurboFan: 함수나 루프가 여러 번 실행되면 V8은 가장 강력한 최적화 컴파일러를 동원합니다. TurboFan은 코드를 받아, 수집된 타입 피드백을 사용해 고도로 최적화된 기계어를 생성하며, 고급 최적화(함수 인라이닝, 경계 검사 제거 등)를 적용합니다. 참고로 2025년 기준 V8은 TurboFan의 내부 "Sea of Nodes" 중간 표현(IR)을 Turboshaft라는 CFG 기반 IR로 점진적으로 교체해 왔습니다. TurboFan의 자바스크립트 백엔드 전체가 이제 Turboshaft를 사용하며, Maglev의 IR을 프런트엔드로 사용해 TurboFan의 프런트엔드를 완전히 대체하려는 또 다른 프로젝트(Turbolev)도 진행 중입니다. 이 최적화된 코드는 가정이 유지되는 한 훨씬 빠르게 실행될 수 있습니다.
그래서 V8은 이제 사실상 네 개의 실행 계층을 갖습니다. Ignition 인터프리터, Sparkplug 베이스라인 JIT, Maglev 최적화 JIT, 그리고 TurboFan 최적화 JIT(그 백엔드는 점진적으로 Turboshaft로 교체되고 있습니다)입니다. 이는 Java의 HotSpot VM이 여러 JIT 레벨(C1과 C2)을 갖는 것과 유사합니다. 엔진은 실행 프로파일을 바탕으로 어떤 함수를 언제 최적화할지 동적으로 결정할 수 있습니다. 어떤 함수가 갑자기 백만 번 호출되면, 최대 속도를 위해 결국 TurboFan으로 최적화될 가능성이 큽니다.
Intel은 또한 V8의 효율을 높이는 Profile-Guided Tiering을 개발했으며, 이는 Speedometer 3 벤치마크에서 약 5%의 개선을 가져왔습니다. 최근 V8 업데이트에는 정적 루트(static roots) 최적화가 포함되는데, 이는 자주 사용되는 객체의 메모리 주소를 컴파일 시점에 정확히 예측할 수 있게 하여 접근 속도를 크게 향상시킵니다.
JIT 최적화의 한 가지 과제는 자바스크립트가 동적 타입 언어라는 점입니다. V8은 특정 가정(예: 이 변수는 항상 정수다) 하에 코드를 최적화할 수 있습니다. 이후의 호출이 그 가정을 위반하면(가령 그 변수가 문자열이 되면) 최적화된 코드는 무효가 됩니다. 그러면 V8은 역최적화(deoptimization)를 수행합니다. 즉, 덜 최적화된 버전으로 되돌아가거나 새로운 가정으로 코드를 다시 생성합니다. 이 메커니즘은 빠르게 적응하기 위해 "인라인 캐시(inline cache)"와 타입 피드백에 의존합니다. 역최적화가 존재한다는 것은, 코드의 타입이 예측 불가능하면 때때로 최고 성능이 지속되지 않을 수 있음을 의미합니다. 하지만 일반적으로 V8은 전형적인 패턴(예: 함수에 일관되게 같은 타입의 객체가 전달되는 경우)은 잘 처리하려고 합니다.
바이트코드 플러싱과 메모리 관리
V8은 바이트코드 플러싱(bytecode flushing)을 구현하는데, 어떤 함수가 여러 번의 가비지 컬렉션 후에도 사용되지 않은 채로 남아 있으면 그 바이트코드는 회수됩니다. 다시 실행될 때 파서는 이전에 저장해 둔 결과를 사용해 바이트코드를 더 빠르게 재생성합니다. 이 메커니즘은 메모리 관리에 매우 중요하지만, 엣지 케이스에서는 파싱 불일치를 일으킬 수 있습니다.
메모리 관리(가비지 컬렉션): V8은 가비지 컬렉터를 사용해 JS 객체의 메모리를 자동으로 관리합니다. 수년에 걸쳐 V8의 GC는 Orinoco GC로 알려진 형태로 발전했는데, 이는 세대별(generational), 점진적(incremental), 동시(concurrent) 가비지 컬렉터입니다. 핵심은 다음과 같습니다:
- 세대별(Generational): V8은 객체를 나이에 따라 분리합니다. 새 객체는 영(young) 세대("nursery"라고도 함)에 할당됩니다. 이들은 매우 빠른 스캐빈징(scavenging) 알고리즘으로 자주 수집됩니다(살아있는 객체를 새 공간으로 복사하고 나머지를 회수합니다). 충분한 주기를 살아남은 객체는 올드(old) 세대로 승격됩니다.
- 마크-앤-스윕/컴팩트(Mark-and-sweep/compact): 올드 세대의 경우 V8은 컴팩션(compaction)을 동반한 마크-앤-스윕 컬렉터를 사용합니다. 이는 가끔 "stop the world"(JS 실행을 잠시 멈춤)를 하고, (전역 객체 같은 루트에서 추적하여) 도달 가능한 모든 객체를 표시(mark)한 다음, 참조되지 않는 객체에서 메모리를 회수(sweep)함을 의미합니다. 또한 메모리를 컴팩션(객체를 옮겨 단편화를 줄임)할 수도 있습니다. 하지만 Orinoco는 마킹의 상당 부분을 동시적으로 만들어, 멈춤 시간을 최소화하기 위해 JS가 여전히 실행되는 동안 백그라운드 스레드에서 많은 마킹 작업을 할 수 있습니다.
- 점진적 GC(Incremental GC): V8은 가능한 경우 한 번의 큰 멈춤 대신 작은 조각들로 나누어 가비지 컬렉션을 수행합니다. 이 점진적 접근은 작업을 분산시켜 끊김을 피합니다. 예를 들어 유휴 시간을 활용해 스크립트 실행 사이사이에 약간의 마킹 작업을 끼워 넣을 수 있습니다.
- 병렬 GC(Parallel GC): 멀티코어 머신에서 V8은 GC의 일부(마킹이나 스위핑 등)를 병렬 스레드로 수행할 수도 있습니다.
그 결과, V8 팀은 수년에 걸쳐 GC 멈춤 시간을 대폭 줄여, 큰 애플리케이션에서도 가비지 컬렉션을 거의 알아차릴 수 없게 만들었습니다. 마이너 GC(새 객체 스캐빈지)는 보통 매우 빠르게 일어납니다. 메이저 GC(올드 세대)는 더 드물고 이제 대부분 동시적으로 수행됩니다. Chrome의 작업 관리자나 DevTools 메모리 패널을 열어 보면, V8의 힙이 이 세대별 설계를 반영해 "Young space"와 "Old space"로 나뉘어 있는 것을 볼 수 있습니다.
개발자에게 이는 수동 메모리 관리가 필요 없다는 뜻이지만, 그래도 몇 가지는 유념해야 합니다. 예를 들어 빡빡한 루프 안에서 수명이 짧은 객체를 잔뜩 만드는 것을 피하고(V8이 수명 짧은 객체를 꽤 잘 다루긴 하지만), 큰 자료 구조를 붙들고 있으면 그것이 메모리에 계속 남는다는 점을 인식해야 합니다. DevTools 같은 도구는 가비지 컬렉션을 강제하거나 메모리 프로파일을 기록해 무엇이 메모리를 사용하는지 볼 수 있게 해줍니다.
V8과 웹 API: 언급할 가치가 있는 점은, V8이 핵심 자바스크립트 언어와 런타임(실행, 표준 JS 객체 등)을 담당하지만, 많은 "브라우저 API"(예: DOM 메서드, alert(), 네트워크 XHR/fetch 등)는 V8 자체의 일부가 아니라는 것입니다. 그런 것들은 브라우저가 제공하며 바인딩을 통해 JS에 노출됩니다. 예를 들어 document.querySelector를 호출하면, 내부적으로는 C++ DOM 구현으로 들어가는 엔진의 바인딩으로 진입합니다. V8은 C++을 호출하고 결과를 돌려받는 일을 처리하며, 이 경계를 빠르게 만들기 위한 많은 기계 장치가 있습니다(Chrome은 효율적인 바인딩을 생성하기 위해 IDL을 사용합니다).
브라우저가 리소스를 가져오고, HTML/CSS를 파싱하고, 레이아웃을 계산하고, GPU로 페인팅하고, JS를 실행하는 방식을 다뤘으니, 이제 페이지를 로드하고 렌더링하는 전체 과정에 대한 그림이 그려졌습니다. 하지만 더 살펴볼 것이 있습니다. ES 모듈이 어떻게 처리되는지(모듈은 자체적인 로딩 메커니즘을 수반하므로), 브라우저의 멀티 프로세스 아키텍처가 어떻게 구성되는지, 그리고 샌드박싱과 사이트 격리 같은 보안 기능이 어떻게 동작하는지 말입니다.
모듈 로딩과 임포트 맵(Import Maps)
자바스크립트 모듈(ES6 모듈)은 기존 <script> 태그와는 다른 로딩 및 실행 모델을 도입합니다. 전역 변수를 만들 수 있는 하나의 큰 스크립트 파일 대신, 모듈은 값을 명시적으로 import/export하는 파일입니다. 브라우저(특히 Chrome의 V8)가 모듈을 어떻게 로드하는지, 그리고 동적 import()나 임포트 맵 같은 기능이 어떻게 작동하는지 살펴봅시다.
정적 모듈 임포트: 브라우저가 <script type="module" src="main.js">를 만나면, main.js를 모듈 진입점으로 취급합니다. 로딩 과정은 다음과 같이 진행됩니다. 브라우저는 main.js를 가져온 다음 이를 ES 모듈로 파싱합니다. 파싱 중에 import 문(예: import { foo } from './utils.js';)을 발견합니다. 코드를 즉시 실행하는 대신, 브라우저는 모듈 의존성 그래프를 구성합니다. 임포트된 모든 모듈(이 경우 utils.js)의 가져오기를 시작하고, 재귀적으로 그 각 모듈도 import를 파싱하고, 가져오고, 이런 식으로 이어집니다. 이 작업은 비동기적으로 일어납니다. 모듈 그래프 전체가 가져와지고 파싱되고 나서야 브라우저는 모듈을 평가(evaluate)할 수 있습니다. 모듈 스크립트는 본질적으로 defer 처리됩니다. 브라우저는 모든 의존성이 준비될 때까지 모듈 코드를 실행하지 않습니다. 그런 다음 의존성 순서대로 실행합니다(모듈 A가 B를 임포트하면 B가 먼저 실행되도록 보장합니다).
이 정적 임포트 과정 때문에, 어떤 경우에는 허용되지 않는 한 ES 모듈을 file://에서 로드할 수 없으며, 교차 출처 스크립트의 경우 기본적으로 CORS가 필요합니다. 브라우저가 단지 페이지에 <script>를 떨궈 넣는 것이 아니라, 여러 파일을 능동적으로 링크하고 로드하기 때문입니다.
동적 import(): 정적 import 문에 더해, ES2020은 표현식으로서의 import(moduleSpecifier)를 도입했습니다. 이는 코드가 즉석에서 모듈을 로드할 수 있게 해줍니다(모듈 export로 resolve되는 프로미스를 반환합니다). 예를 들어 사용자 동작에 반응하여 const module = await import('./analytics.js')를 실행해 애플리케이션을 코드 분할(code-splitting)할 수 있습니다. 내부적으로 import()는 브라우저가 요청된 모듈(그리고 아직 로드되지 않았다면 그 의존성)을 가져오게 한 다음, 인스턴스화하고 실행하여, 모듈 네임스페이스 객체로 프로미스를 resolve합니다. 여기서 V8과 브라우저가 협력합니다. 브라우저의 모듈 로더가 가져오기와 파싱을 처리하고, 준비되면 V8이 컴파일과 실행을 처리합니다. 동적 임포트는 모듈이 아닌 스크립트에서도 사용할 수 있어 강력합니다(예: 인라인 스크립트가 동적으로 모듈을 임포트할 수 있습니다). 이는 본질적으로 개발자에게 필요할 때 JS를 로드하는 제어권을 줍니다. 정적 임포트와의 차이는, 정적 임포트는 미리(어떤 모듈 코드가 실행되기 전에 전체 그래프가 로드됨) 해결되는 반면, 동적 임포트는 런타임에 새 스크립트를 로드하는 것에 더 가깝게 동작한다는 점입니다(다만 모듈 시맨틱과 프로미스를 동반합니다).
임포트 맵(Import maps): 브라우저에서 ES 모듈을 다룰 때의 한 가지 과제는 모듈 식별자(specifier)였습니다. Node나 번들러에서는 흔히 패키지 이름으로 임포트합니다(예: import { compile } from 'react'). 웹에서는 번들러 없이는 'react'가 유효한 URL이 아니어서, 브라우저가 이를 상대 경로로 취급해 실패하게 됩니다. 바로 여기서 임포트 맵이 등장합니다. 임포트 맵은 브라우저에게 모듈 식별자를 실제 URL로 어떻게 해석할지 알려주는 JSON 설정입니다. 이는 HTML의 <script type="importmap"> 태그를 통해 제공됩니다. 예를 들어 임포트 맵은 식별자 "react"가 "https://cdn.example.com/react@19.0.0/index.js"(실제 스크립트의 전체 URL)에 매핑된다고 명시할 수 있습니다. 그러면 어떤 모듈이 import 'react'를 하면, 브라우저는 그 맵을 사용해 URL을 찾아 로드합니다. 본질적으로 임포트 맵은 "베어(bare)" 식별자(패키지 이름 같은)를 CDN URL이나 로컬 경로에 매핑함으로써 웹에서 동작하게 해줍니다.
임포트 맵은 번들 없는 개발에서 판도를 바꿔놓았습니다. 2023년 이후 임포트 맵은 모든 주요 브라우저에서 지원됩니다(Chrome 89+, Firefox 108+, Safari 16.4+ — 세 엔진 모두). 이는 특히 빌드 단계 없이 모듈을 사용하고 싶은 로컬 개발이나 간단한 앱에 유용합니다. 프로덕션의 경우 큰 앱은 (요청 수를 줄이기 위해) 여전히 성능을 위해 번들링하는 경우가 많지만, 브라우저와 HTTP/2/3이 개선됨에 따라 많은 작은 모듈을 제공하는 것이 점점 더 현실적이 되고 있습니다.
따라서 브라우저의 모듈 로더는 모듈 맵(무엇이 로드되었는지 추적), 경우에 따라 임포트 맵(맞춤 해석을 위해), 그리고 가져오기/파싱 로직으로 구성됩니다. 일단 가져와지고 컴파일되면, 모듈 코드는 엄격 모드(strict mode)에서 자체적인 최상위 스코프로 실행됩니다(명시적으로 붙이지 않는 한 window로 새지 않습니다). export는 캐시되므로, 나중에 다른 모듈이 같은 모듈을 임포트해도 다시 실행하지 않고 이미 평가된 모듈 레코드를 재사용합니다.
한 가지 더 언급할 점은, ES 모듈은 스크립트와 달리 실행을 미루며 주어진 그래프에 대해 순서대로 실행한다는 것입니다. main.js가 util.js를 임포트하고 util.js가 dep.js를 임포트하면, 평가 순서는 dep.js가 먼저, 그다음 util.js, 그다음 main.js가 됩니다(깊이 우선, 후위 순회). 이 결정론적 순서는, 메인 모듈이 실행될 즈음이면 그 임포트들이 모두 로드되고 실행되어 있기 때문에, 어떤 경우에는 DOMContentLoaded 같은 것이 필요 없게 해줄 수 있습니다.
V8의 관점에서 모듈은 동일한 컴파일 파이프라인으로 처리되지만, 별도의 ModuleRecord를 만듭니다. 엔진은 모듈의 최상위 코드가 모든 의존성이 준비된 후에만 실행되도록 보장합니다. V8은 또한 순환 모듈 임포트(허용되며 부분적으로 초기화된 export로 이어질 수 있음)도 처리해야 합니다. 세부 사항은 명세에 따르지만, 본질적으로 엔진은 모든 모듈 인스턴스를 만든 다음, 플레이스홀더를 부여해 순환을 해결하고, 의존성을 존중하는 순서로 실행합니다(명세 알고리즘은 모듈 그래프의 "DAG" 위상 정렬입니다).
요컨대, 브라우저에서의 모듈 로딩은 네트워크(모듈 파일 가져오기), 모듈 리졸버(임포트 맵이나 표준 URL 해석 사용), 그리고 JS 엔진(올바른 순서로 모듈을 컴파일하고 평가) 사이의 조율된 춤사위입니다. 이는 옛 <script> 로딩보다 더 복잡하지만, 더 모듈화되고 유지보수하기 좋은 코드 구조를 낳습니다. 개발자에게 핵심 요점은 다음과 같습니다. 코드를 조직화하기 위해 모듈을 사용하고, 베어 임포트를 원한다면 임포트 맵을 사용하며, 필요할 때 import()로 모듈을 동적으로 로드할 수 있다는 것을 알아두라는 것입니다. 모든 것이 올바른 순서로 실행되도록 하는 무거운 작업은 브라우저가 처리해 줍니다.
단일 페이지의 내부가 어떻게 동작하는지 다뤘으니, 이제 시야를 넓혀 여러 페이지, 탭, 웹 앱이 서로 간섭하지 않고 동시에 실행될 수 있게 해주는 브라우저 아키텍처를 살펴봅시다. 이는 우리를 멀티 프로세스 모델로 이끕니다.
브라우저 멀티 프로세스 아키텍처
현대 브라우저(Chrome, Firefox, Safari, Edge 등)는 모두 안정성, 보안, 성능 격리를 위해 멀티 프로세스 아키텍처를 사용합니다. 브라우저 전체를 하나의 거대한 프로세스로 실행하는 대신(초기 브라우저는 그렇게 동작했습니다), 브라우저의 서로 다른 측면이 서로 다른 프로세스에서 실행됩니다. Chrome은 2008년에 이 접근의 선구자였고, 다른 브라우저들도 다양한 형태로 뒤를 따랐습니다. Chromium의 아키텍처에 초점을 맞추고 Firefox와 Safari의 차이를 짚어봅시다.
Chromium(Chrome, Edge, Brave 등)에는 중심이 되는 하나의 **브라우저 프로세스(Browser Process)**가 있습니다. 이 브라우저 프로세스는 UI(주소 표시줄, 북마크, 메뉴 등 모든 브라우저 크롬)와, 리소스 로딩 및 내비게이션 같은 고수준 작업의 조율을 담당합니다. Chrome을 열었을 때 OS 작업 관리자에서 항목 하나가 보인다면, 그것이 브라우저 프로세스입니다. 이 프로세스는 다른 프로세스들을 낳는 부모이기도 합니다.
그리고 각 탭마다(때로는 탭 안의 각 사이트마다) Chrome은 **렌더러 프로세스(Renderer Process)**를 생성합니다. 렌더러 프로세스는 해당 탭의 콘텐츠를 위해 Blink 렌더링 엔진과 V8 JS 엔진을 실행합니다. 일반적으로 각 탭은 최소 하나의 렌더러 프로세스를 갖습니다.
서로 관련 없는 여러 사이트를 열어두면, 그것들은 별도의 프로세스에 놓입니다(사이트 A는 하나에, 사이트 B는 다른 하나에, 이런 식으로). Chrome은 심지어 교차 출처 iframe도 별도의 프로세스로 격리합니다(이는 사이트 격리에서 더 다룹니다). 렌더러 프로세스는 샌드박스 처리되어 있어 여러분의 파일 시스템이나 네트워크에 임의로 직접 접근할 수 없으며, 그런 특권적 작업은 브라우저 프로세스를 거쳐야 합니다.
Chrome의 다른 주요 프로세스로는 다음이 있습니다:
- GPU 프로세스(GPU Process): (앞서 설명한 대로) GPU와 통신하는 전용 프로세스입니다. 렌더러로부터 오는 모든 렌더링·컴포지팅 요청은 GPU 프로세스로 가고, 이 프로세스가 실제로 그래픽 API 호출을 내립니다. 이 프로세스는 샌드박스 처리되어 분리되어 있으므로, GPU 충돌이 렌더러를 무너뜨리지 않습니다.
- 네트워크 프로세스(Network Process): (이전 Chrome 버전에서는 네트워크가 브라우저 프로세스 내의 스레드였지만, 이제는 "서비스화"를 통해 별도 프로세스인 경우가 많습니다.) 이 프로세스는 네트워크 요청, DNS 등을 처리하며 별도로 샌드박스 처리될 수 있습니다.
- 유틸리티 프로세스(Utility Processes): Chrome이 떠넘길 수 있는 다양한 서비스(오디오 재생, 이미지 디코딩 등)를 위한 것입니다.
- 플러그인 프로세스(Plugin Process): Flash와 NPAPI 플러그인 시대에는 플러그인이 자체 프로세스에서 실행되었습니다. 이제 Flash는 폐기되어 관련성이 줄었지만, 플러그인이 메인 브라우저 프로세스에서 실행되지 않도록 하는 아키텍처는 그대로 준비되어 있습니다.
- 확장 프로그램 프로세스(Extension Processes): (본질적으로 웹 페이지나 브라우저에 작용할 수 있는 스크립트인) Chrome 확장 프로그램도 별도의 프로세스에서 실행되며, 보안을 위해 웹사이트와 격리됩니다.
단순화하면 이렇습니다. 하나의 브라우저 프로세스가 여러 렌더러 프로세스(탭당 또는 사이트 인스턴스당 하나)와, 하나의 GPU 프로세스, 그리고 서비스를 위한 몇 개의 다른 프로세스를 조율합니다. Chrome의 작업 관리자(Windows에서는 Shift+Esc, 또는 More Tools > Task Manager)는 실제로 각 프로세스 유형과 그 메모리 사용량을 나열해 줍니다.
멀티 프로세스의 이점: 주요 이점은 다음과 같습니다:
- 안정성(Stability): 웹 페이지(렌더러 프로세스)가 충돌하거나 메모리를 누수해도 브라우저 전체가 충돌하지 않습니다. 해당 탭을 닫으면 나머지는 살아 있습니다. 단일 프로세스 브라우저에서는 잘못된 스크립트 하나가 모든 것을 무너뜨릴 수 있었습니다. Chrome은 한 탭의 프로세스가 죽으면 그 탭에 대해 "이런!(Aw, Snap)" 오류를 보여줄 수 있고, 여러분은 그것만 독립적으로 새로 고칠 수 있습니다.
- 보안(샌드박싱): 웹 콘텐츠를 제한된 프로세스에서 실행함으로써, 브라우저는 그 코드가 여러분의 시스템에서 할 수 있는 일을 제한할 수 있습니다. 공격자가 렌더링 엔진의 취약점을 찾아내더라도 샌드박스 안에 갇혀 있게 됩니다. 렌더러 프로세스는 일반적으로 여러분의 파일을 읽거나 임의로 네트워크 연결을 열거나 프로그램을 실행할 수 없습니다. 파일 접근 같은 일은 브라우저 프로세스에 요청해야 하며, 이는 검증되거나 거부될 수 있습니다. 이 샌드박스는 (플랫폼에 따라 job 객체, seccomp 필터 등을 사용해) OS 수준에서 강제됩니다.
- 성능 격리(Performance Isolation): 한 탭에서의 집약적인 작업(무거운 웹 앱이나 무한 루프)은 대부분 그 탭의 렌더러 프로세스에 국한됩니다. 다른 탭들(다른 프로세스)은 그 프로세스가 막혀 있지 않으므로 반응성을 유지할 수 있습니다. 또한 OS는 프로세스를 서로 다른 CPU 코어에 스케줄링할 수 있어, 두 개의 무거운 페이지가 하나의 프로세스의 스레드일 때보다 멀티코어 시스템에서 더 잘 병렬로 실행될 수 있습니다.
- 메모리 분할(Memory segmentation): 각 프로세스는 자체 주소 공간을 가지므로 메모리가 공유되지 않습니다. 이는 한 사이트가 다른 사이트의 데이터를 엿보는 것을 막고, 또한 탭이 닫힐 때 OS가 그 프로세스의 모든 메모리를 효율적으로 회수할 수 있게 합니다. 단점은 자원과 프로세스가 중복되어 약간의 오버헤드가 생긴다는 것입니다(각 렌더러가 JS 엔진의 사본을 따로 로드하는 등).
사이트 격리(Site Isolation): 처음에 Chrome의 모델은 탭당 하나의 프로세스였습니다. 시간이 지나면서 이를 사이트당 하나의 프로세스로 발전시켰습니다(특히 Spectre 이후 — 다음 보안 절 참고). 2024년 기준, 사이트 격리는 데스크톱 플랫폼 전반에서 Chrome 사용자의 99%에게 기본으로 활성화되어 있으며, Android 지원은 계속 다듬어지고 있습니다. 이는 example.com을 두 개의 탭에서 모두 열어두면, Chrome이 둘 다에 하나의 프로세스를 사용하기로 결정할 수 있다는 뜻입니다(메모리를 절약하기 위해서인데, 같은 사이트이므로 함께 두어도 덜 위험하기 때문입니다). 하지만 example.com 탭에 evil.com의 iframe이 있다면, 기본적으로 evil.com의 iframe을 부모 페이지와 별도의 프로세스에 둡니다(example.com의 데이터를 보호하기 위해서입니다). 이러한 강제를 Chrome은 "엄격한 사이트 격리(Strict Site Isolation)"라고 부릅니다(Chrome 67경에 기본으로 출시됨). 사이트 격리는 프로세스 생성 증가로 인해 Chrome이 시스템 자원을 10~13% 더 쓰게 하지만, 결정적인 보안 이점을 제공합니다.
Electrolysis(e10s)라 불리는 Firefox의 아키텍처는 역사적으로 모든 탭에 대해 하나의 콘텐츠 프로세스를 사용했습니다(수년간 Firefox는 단일 프로세스였고 2017년경에야 몇 개의 콘텐츠 프로세스를 활성화했습니다). 2021년 기준 Firefox는 여러 콘텐츠 프로세스를 사용합니다(웹 콘텐츠에 대해 기본 8개). Project Fission(사이트 격리)으로 Firefox는 비슷하게 사이트를 격리하는 방향으로 나아가고 있습니다. 교차 사이트 iframe을 위해 새 프로세스를 띄울 수 있고, Firefox 108+에서는 사이트 격리를 기본으로 활성화하여 Chrome처럼 잠재적으로 사이트당 하나의 프로세스로 프로세스 수를 늘렸습니다. Firefox에도 (WebRender와 컴포지팅을 위한) GPU 프로세스와 별도의 네트워킹 프로세스가 있어 Chrome의 분할과 유사합니다. 따라서 실제로 Firefox는 이제 매우 Chrome과 비슷한 모델을 갖습니다. 부모 프로세스, GPU 프로세스, 네트워크 프로세스, 몇 개의 콘텐츠(렌더러) 프로세스, 그리고 (확장 프로그램, 미디어 디코딩 등을 위한 — 예를 들어 미디어 플러그인이 격리되어 실행될 수 있는) 일부 유틸리티 프로세스 말입니다.
Safari(WebKit)도 마찬가지로 멀티 프로세스 모델(WebKit2)로 옮겨갔는데, 여기서는 각 탭의 콘텐츠가 별도의 WebContent 프로세스에 있고 중앙의 UI 프로세스가 이들을 제어합니다. Safari의 WebContent 프로세스 역시 샌드박스 처리되어 있어 UI 프로세스를 거치지 않고는 장치나 파일에 직접 접근할 수 없습니다. Safari에도 (공유되는) 네트워킹 프로세스가 있습니다(그리고 다른 헬퍼들도 있을 수 있습니다). 따라서 구현은 다르지만 개념은 일관됩니다. 각 웹페이지의 코드를 자체적인 샌드박스 환경에 격리한다는 것입니다.
한 가지 중요한 점은 프로세스 간 통신(IPC, inter-process communication)입니다. 이 프로세스들은 서로 어떻게 대화할까요? 브라우저는 IPC 메커니즘을 사용합니다(Windows에서는 흔히 명명된 파이프나 다른 OS IPC를, Linux에서는 Unix 도메인 소켓이나 공유 메모리를 사용할 수 있고, Chrome에는 자체 IPC 라이브러리인 Mojo가 있습니다). 예를 들어 네트워크 응답이 네트워크 프로세스에 도착하면, (브라우저 프로세스의 조율을 통해) 올바른 렌더러 프로세스로 전달되어야 합니다. 마찬가지로 DOM fetch()를 하면, JS 엔진이 네트워크 API를 호출하고 그것이 네트워크 프로세스로 요청을 보내는 식입니다. IPC는 복잡성을 더하지만, 브라우저는 (예를 들어 이미지 같은 큰 데이터를 효율적으로 전송하기 위해 공유 메모리를 사용하고, 막힘을 피하기 위해 비동기 메시지를 게시하는 등) 적극적으로 최적화합니다.
프로세스 할당 전략: Chrome이 항상 모든 탭마다 완전히 새로운 프로세스를 만드는 것은 아닙니다. 제한이 있습니다(특히 메모리가 적은 장치에서는 같은 사이트 탭에 대해 프로세스를 재사용할 수 있습니다). 같은 사이트로 다른 탭을 열면 Chrome은 메모리를 아끼기 위해 기존 렌더러를 재사용합니다(그래서 때때로 같은 사이트의 두 탭이 프로세스를 공유합니다). 또한 (RAM에 따라 확장될 수 있는) 총 프로세스 수에 대한 제한도 있습니다. 그 한계에 도달하면 서로 관련 없는 여러 사이트를 한 프로세스에 넣기 시작할 수 있지만, 사이트 격리가 활성화되어 있으면 사이트를 섞지 않으려고 최대한 노력합니다. Android에서는 메모리 제약 때문에 Chrome이 더 적은 프로세스를 사용합니다(콘텐츠에 대해 보통 최대 5~6개).
Chromium의 또 다른 개념은 **서비스화(servicification)**입니다. 브라우저 구성 요소를 별도 프로세스에서 실행될 수 있는 서비스로 분할하는 것입니다. 예를 들어 네트워크 서비스는 프로세스 밖에서 실행될 수 있는 별도의 모듈로 만들어졌습니다. 핵심 아이디어는 모듈성입니다. 강력한 시스템은 각 서비스를 자체 프로세스에서 실행할 수 있는 반면, 제약이 있는 장치는 오버헤드를 줄이기 위해 일부 서비스를 다시 하나의 프로세스로 통합할 수 있습니다. Chrome은 런타임이나 빌드 타임에 이 서비스들을 어떻게 배치할지 결정할 수 있습니다. 앞서 언급한 대로, 고사양에서는 모든 것을 분할하고(UI, 네트워크, GPU 등 모두 분리), 저사양(Android)에서는 오버헤드를 줄이기 위해 브라우저와 네트워크를 한 프로세스로 합칠 수 있습니다.
요점은 이렇습니다. Chromium의 아키텍처는 프로세스를 격리 경계로 사용하여, 브라우저 UI와 각 사이트를 서로 다른 샌드박스에서 실행하도록 설계되었습니다. Firefox와 Safari도 비슷한 설계로 수렴했습니다. 이 아키텍처는 더 많은 메모리 사용이라는 비용을 치르는 대신 보안과 신뢰성을 크게 향상시킵니다. 웹 콘텐츠 프로세스는 신뢰할 수 없는 것으로 취급되며, 바로 여기서 (다음 절의) 사이트 격리가 작동하여 별도의 프로세스 안에서 서로 다른 출처(origin)끼리도 격리합니다.
사이트 격리와 샌드박싱
사이트 격리와 샌드박싱은 멀티 프로세스 기반 위에 구축되는 보안 기능입니다. 이들은 설령 악성 코드가 브라우저에서 실행되더라도, 다른 사이트의 데이터를 쉽게 훔치거나 여러분의 시스템에 접근하지 못하도록 보장하는 것을 목표로 합니다.
사이트 격리(Site Isolation): 앞서 다뤘듯이, 이는 서로 다른 웹사이트(더 엄밀히는 서로 다른 사이트)가 서로 다른 렌더러 프로세스에서 실행됨을 의미합니다. Chrome의 사이트 격리는 2018년 Spectre 취약점이 드러난 후 강화되었습니다. Spectre는 악성 자바스크립트가 (CPU의 추측 실행을 악용하여) 접근해서는 안 되는 메모리를 잠재적으로 읽어낼 수 있음을 보여주었습니다. 두 사이트가 같은 프로세스에 있다면, 악성 사이트가 Spectre를 사용해 민감한 사이트(예: 여러분의 은행 사이트)의 메모리를 엿볼 수 있습니다. 유일하게 견고한 해결책은 그들이 프로세스를 공유하지 못하게 하는 것입니다. 그래서 Chrome은 사이트 격리를 기본으로 만들었습니다. 모든 사이트가 교차 출처 iframe을 포함해 자체 프로세스를 갖습니다. Firefox도 (최근 버전에서 기본 활성화된) Project Fission으로 뒤따라, 같은 목표를 지향합니다. 그들은 보안을 위해 모든 사이트를 자체 프로세스에 격리하는 것을 내세웁니다. 이는 부모 페이지와 여러 도메인의 iframe 여러 개가 (특히 한 탭에 있다면) 모두 한 프로세스에 살 수 있었던 과거와는 큰 변화입니다. 이제 그런 iframe들은 분리되어, 예를 들어 좋은 사이트 페이지의 <iframe src="https://evil.com">은 다른 프로세스로 강제되어, 저수준 공격조차 둘 사이에서 정보가 새는 것을 막습니다.
개발자 관점에서 사이트 격리는 대부분 투명합니다(눈에 띄지 않습니다). 한 가지 함의는, 이제 임베드된 iframe과 그 부모 사이의 통신이 프로세스 경계를 넘을 수 있다는 점입니다. 그래서 그들 사이의 postMessage 같은 것은 내부적으로 IPC를 통해 구현됩니다. 하지만 브라우저가 이를 매끄럽게 처리하므로, 개발자인 여러분은 그냥 평소처럼 API를 사용하면 됩니다.
샌드박싱(Sandboxing): 각 렌더러 프로세스(와 다른 보조 프로세스)는 제한된 권한을 가진 샌드박스에서 실행됩니다. 예를 들어 Windows에서 Chrome은 job 객체를 사용하고 권한을 낮춰, 렌더러가 시스템에 접근하는 대부분의 Win32 API를 호출하지 못하게 합니다. Linux에서는 네임스페이스와 seccomp 필터를 사용해 시스템 콜을 제한합니다. 렌더러는 기본적으로 콘텐츠를 계산하고 렌더링할 수 있지만, 파일이나 카메라, 마이크를 열려고 하면 차단됩니다(브라우저 프로세스를 통해 사용자 권한을 요청하는 적절한 경로를 거치지 않는 한). WebKit의 문서는 WebContent 프로세스가 파일 시스템, 클립보드, 장치 등에 직접 접근할 수 없으며, 이를 중재하는 UI 프로세스를 통해 요청해야 한다고 명시적으로 언급합니다. 그래서 예를 들어 사이트가 여러분의 마이크를 사용하려 하면, 권한 프롬프트는 브라우저 UI(브라우저 프로세스)가 보여주고, 허용되면 실제 녹음은 통제된 프로세스에서 이루어집니다. 샌드박스는 결정적인 방어선입니다. 설령 공격자가 렌더러에서 네이티브 코드를 실행할 버그를 찾아내더라도, 그다음 샌드박스 장벽에 부딪힙니다. 시스템으로 탈출하려면 별도의 익스플로잇("탈출(escape)")이 필요합니다. 이 계층적 접근(사이트 격리 + 샌드박스)은 브라우저 보안의 최첨단입니다.
Firefox의 샌드박싱도 이제 꽤 엄격합니다(초기 e10s 시절에는 약했지만 점차 강화했습니다). Firefox 콘텐츠 프로세스 역시 많은 것에 직접 접근할 수 없으며, Firefox도 그래픽 드라이버 문제에 대처하기 위해 GPU 프로세스를 샌드박스 처리합니다.
프로세스 외부 iframe(OOPIF, Out-of-Process iframes): Chrome의 사이트 격리 구현에서, 그들은 프로세스 외부 iframe을 가리키는 OOPIF라는 용어를 만들어냈습니다. 사용자 관점에서는 아무것도 달라지지 않지만, Chrome의 내부 아키텍처에서는 페이지의 각 프레임이 잠재적으로 서로 다른 렌더러 프로세스에 의해 뒷받침될 수 있습니다. 최상위 프레임과 동일 사이트 프레임은 한 프로세스를 공유하고, 교차 사이트 프레임은 다른 프로세스를 사용합니다. 이 모든 프로세스가 "협력"하여 하나의 탭 콘텐츠를 렌더링하며, 브라우저 프로세스가 이를 조율합니다. 이는 꽤 복잡하지만, Chrome에는 프로세스에 걸칠 수 있는 프레임 트리가 있습니다. 즉, 여러분의 탭 하나가 N개의 프로세스(메인 문서를 위한 하나, 각 교차 사이트 하위 문서를 위한 다른 것들)를 실행하고 있을 수 있습니다. 이들은 경계를 넘는 DOM 이벤트나 교차 컨텍스트를 수반하는 특정 자바스크립트 호출 같은 일을 위해 IPC를 통해 통신합니다. 웹 플랫폼은 (COOP/COEP, SharedArrayBuffer, 등의 명세를 통해) Spectre 이후 이러한 제약을 염두에 두고 진화하고 있습니다.
메모리와 성능 비용: 사이트 격리는 더 많은 프로세스를 사용하므로 메모리 사용량을 늘립니다. Chrome 개발자들은 어떤 경우에는 10~20%의 메모리 오버헤드가 될 수 있다고 언급했습니다. 그들은 동일 사이트에 대한 "최선 노력 프로세스 통합(best-effort process consolidation)"이라는 것과, (앞서 언급한) 생성 가능한 프로세스 수 제한을 통해 일부를 완화했습니다. Firefox는 처음에는 메모리 우려로 모든 사이트를 격리하지 않았지만, Spectre 이후 8개의 특권 프로세스 제한과 온디맨드 프로세스 생성으로 더 효율적으로 할 방법을 찾았습니다. Safari는 역사적으로 강력한 프로세스 모델을 갖고 있지만, 교차 사이트 iframe을 격리하는지는 확실하지 않습니다. WebKit2는 분명히 최상위 페이지를 격리합니다. Apple의 초점은 흔히 프라이버시이기도 합니다(지능형 추적 방지(Intelligent Tracking Prevention)가 쿠키를 분할하는 등). 하지만 그것은 다른 계층입니다.
교차 사이트 프리페치는 프라이버시 이유로 제한되며, 현재는 사용자가 목적지 사이트에 대한 쿠키를 설정해 두지 않은 경우에만 작동합니다. 이는 방문하지 않을 수도 있는 프리페치된 페이지를 통해 사이트가 사용자 활동을 추적하는 것을 막기 위함입니다.
종합하면, 사이트 격리는 최소 권한 원칙이 적용되도록 보장합니다. 출처 A의 코드는 (postMessage나 분할된 저장소처럼) 명시적 동의를 동반한 웹 API를 통하지 않고서는 출처 B의 데이터에 접근할 수 없습니다. 그리고 샌드박스는 설령 코드가 악성이더라도 여러분의 시스템에 직접 손대지 못하도록 보장합니다. 이러한 조치는 브라우저 익스플로잇을 훨씬 어렵게 만듭니다. 이제 공격자는 심각한 피해를 입히려면 보통 여러 개의 연쇄 익스플로잇(하나는 렌더러를 뚫고, 하나는 샌드박스를 탈출하기 위한)이 필요하며, 이는 진입 장벽을 상당히 높입니다.
웹 개발자로서 여러분은 사이트 격리를 직접 체감하지 못할 수 있지만, 더 안전한 웹을 통해 그 혜택을 누립니다. 한 가지 유념할 점은, 교차 출처 상호작용에는 (IPC 때문에) 약간 더 많은 오버헤드가 있을 수 있고, 프로세스 내 스크립트 공유 같은 일부 최적화는 출처를 넘나들 때는 불가능하다는 것입니다. 하지만 브라우저는 성능 저하를 최소화하기 위해 프로세스 간 메시징을 끊임없이 최적화하고 있습니다.
이제 보안을 다뤘으니, 도구와 성능 계측으로 눈을 돌려봅시다. 본질적으로, 우리 개발자들이 이 파이프라인을 어떻게 들여다보고 측정하거나 디버깅할 수 있는지 말입니다.
Chromium, Gecko, WebKit 비교
우리는 주로 Chrome/Chromium의 동작(HTML/CSS를 위한 Blink 엔진, JS를 위한 V8, Aura/Chromium 인프라를 통한 멀티 프로세스)을 설명해 왔습니다. 다른 주요 엔진들 — Mozilla의 Gecko(Firefox에서 사용)와 Apple의 WebKit(Safari에서 사용) — 도 동일한 근본 목표와 대체로 유사한 파이프라인을 공유하지만, 주목할 만한 차이와 역사적 분기가 있습니다.
공유되는 개념: 모든 엔진은 HTML을 DOM으로 파싱하고, CSS를 스타일 데이터로 파싱하고, 레이아웃을 계산하고, 페인트/컴포지트를 합니다. 모두 JIT와 가비지 컬렉션을 갖춘 JS 엔진을 가지고 있습니다. 그리고 모든 현대 엔진은 병렬성과 보안을 위해 멀티 프로세스(또는 적어도 멀티스레드)입니다.
CSS/스타일 시스템의 차이
흥미로운 차이 하나는 렌더링 엔진별로 CSS 스타일 계산이 어떻게 구현되는지입니다:
- Blink(Chromium): C++로 작성된 단일 스레드 스타일 엔진을 사용합니다(역사적으로 WebKit의 것에 기반함). DOM 트리에 대해 스타일을 순차적으로 계산합니다. 점진적 스타일 무효화(invalidation) 최적화는 있었지만, 대체로 하나의 스레드가 작업을 수행합니다(애니메이션에서의 약간의 병렬화를 제외하면).
- Gecko(Firefox): Quantum 프로젝트(2017)에서 Firefox는 Rust로 작성된 새 CSS 엔진인 Stylo를 통합했는데, 이는 멀티스레드입니다. Firefox는 모든 CPU 코어를 사용해 서로 다른 DOM 하위 트리의 스타일을 병렬로 계산할 수 있습니다. 이는 Gecko의 CSS에 있어 큰 성능 향상이었습니다. 그래서 Firefox에서의 스타일 재계산은 Blink가 1개 코어로 하는 일을 4개 코어로 할 수 있습니다. 이것이 (복잡성을 대가로 한) Gecko 접근법의 한 가지 장점입니다.
- WebKit(Safari): WebKit의 스타일 엔진은 Blink처럼 단일 스레드입니다(Blink가 2013년 WebKit에서 포크했으므로, 그 시점까지는 아키텍처를 공유했습니다). WebKit은 CSS 셀렉터 매칭을 위한 바이트코드 JIT 같은 흥미로운 일을 해왔습니다. CSS 셀렉터를 바이트코드로 변환하고 속도를 위해 매처(matcher)를 JIT 컴파일할 수 있습니다. Blink는 이를 채택하지 않았습니다(반복적 매칭을 사용합니다).
따라서 CSS에서는 Gecko가 Rust를 통한 병렬 스타일 계산으로 두드러집니다. Blink와 WebKit은 최적화된 C++과, (WebKit의 경우) 약간의 JIT 기법에 의존합니다.
레이아웃과 그래픽
세 엔진 모두 CSS 박스 모델과 레이아웃 알고리즘을 구현합니다. 특정 기능이 다른 것보다 한 엔진에 먼저 도입될 수 있습니다(예: 한때 WebKit이 CSS Grid 지원에서 앞섰다가 Blink가 따라잡았는데, 흔히 표준화 기구를 통해 코드를 공유합니다).
Firefox(Gecko)는 컴포지터/래스터라이저로 WebRender를 도입하는 큰 변화를 만들었습니다. WebRender는 이제 Firefox의 기본 렌더링 엔진이며, 특히 그래픽 집약적인 웹 콘텐츠에서 상당한 성능 향상에 기여했습니다. (역시 Rust로 작성된) WebRender는 기본적으로 디스플레이 리스트를 받아 GPU에서 직접 렌더링하며, 도형이나 텍스트의 테셀레이션(tessellation) 같은 일을 GPU로 처리합니다. 이는 더 많은 페인팅 작업을 GPU로 옮기는 것과 같습니다. Chrome의 파이프라인에서는 (대부분의 콘텐츠에 대해) 래스터화가 여전히 CPU에서 이루어진 다음 비트맵으로 GPU에 보내집니다. WebRender는 전체 레이어에 대한 비트맵을 만드는 것을 피하고 대신 GPU에서 벡터를 그리려 합니다(아틀라스 텍스처로 캐싱하는 텍스트 글리프는 예외). 이는 Firefox가 작은 부분만 바뀔 때 전부를 다시 래스터화할 필요가 없어 GPU를 통해 매우 빠르게 다시 그릴 수 있으므로, 잠재적으로 더 많은 콘텐츠를 높은 성능으로 애니메이션할 수 있음을 의미합니다. 이는 게임 엔진이 GPU 호출을 사용해 매 프레임 장면을 다시 그리는 방식과 유사합니다. 단점은 구현하고 튜닝하기 복잡하며 GPU에 더 큰 부담을 줄 수 있다는 점입니다. 하지만 GPU 성능이 커짐에 따라 이 접근은 미래지향적입니다. Chrome 팀도 비슷한 접근("SKIA GPU" 경로)을 고려했지만, WebRender 스타일의 전면적인 개편은 하지 않았습니다.
Safari(WebKit)는 더 오래된 Chrome과 비슷한 접근을 사용합니다. 레이어를 가진 컴포지터를 사용하는데(Mac과 iOS에서는 Core Animation 레이어를 사용하므로 CALayer라고 부릅니다). Safari는 GPU 컴포지팅으로 일찍 옮겨갔습니다(2009년 iPhone OS와 Safari 4는 transform 같은 특정 CSS에 대해 하드웨어 가속 컴포지팅을 가졌습니다). Safari와 Chrome은 갈라졌지만, 개념적으로는 둘 다 타일링과 컴포지팅을 합니다. Safari 역시 많은 것을 GPU로 떠넘기며 (특히 타일 그리기가 부드러운 스크롤에 핵심이었던 iOS에서) 타일링을 사용합니다.
모바일 최적화: 각 엔진은 모바일을 위한 특수한 경우들을 가지고 있습니다. 예를 들어 WebKit에는 스크롤을 위한 타일 커버리지(tile coverage) 개념이 있습니다(역사적으로 iOS의 UIWebView에서 사용됨). Android의 Chrome은 "타일링"을 사용하며 프레임 레이트를 맞추기 위해 래스터 작업을 최소로 유지하려 합니다. Firefox의 WebRender는 모바일 우선이었던 Servo 프로젝트에서 나왔습니다.
자바스크립트 엔진
- V8(Chromium): 앞서 설명했습니다. 2023년 기준 Ignition, Sparkplug, TurboFan, Maglev입니다.
- SpiderMonkey(Firefox): 역사적으로 인터프리터를 가졌고, 그다음 베이스라인 JIT와 최적화 JIT(IonMonkey)를 가졌습니다. Firefox 83(2021) 이후 IonMonkey는 WarpMonkey로 완전히 대체되었는데, WarpMonkey는 별도의 타입 추론 시스템 대신 CacheIR 데이터에 기반합니다. 현재 계층은 베이스라인 인터프리터(Baseline Interpreter), 베이스라인 JIT(Baseline JIT), 그리고 최상위 계층 최적화 컴파일러인 WarpMonkey입니다. SpiderMonkey도 다른 GC를 가집니다(역시 세대별이며, 2012년 이후 점진적 GC(Incremental GC)라 불리고, 이제 대부분 점진적/동시적입니다).
- JavaScriptCore(Safari): 앞서 언급했듯이 4개의 계층(LLInt, Baseline, DFG, FTL)을 가집니다. 다른 GC를 사용합니다(WebKit의 GC는 역사적으로 Butterfly나 Boehm 변형이라 불린 세대별 마크-스윕이었고, 이제는 bmalloc 등입니다). JSC의 FTL은 최적화에 LLVM을 사용하는데, 이는 독특합니다(V8과 SM은 자체 컴파일러를 가지지만, JSC는 한 계층에서 LLVM을 활용합니다). 이는 매우 빠른 코드를 만들어낼 수 있지만 컴파일이 무겁습니다. JSC는 특정 벤치마크에서 최고 성능을 우선시하는 경향이 있습니다(흔히 일부에서 빛을 발하지만 V8이 따라잡곤 하며, 서로 앞서거니 뒤서거니 합니다).
ES 기능 면에서는 test262와 서로 간의 경쟁 덕분에 세 엔진 모두 최신 표준에 거의 발맞추고 있습니다.
멀티 프로세스 모델의 차이
- Chrome: 일반적으로 각 탭이 분리되며, 출처(origin) 수준의 사이트 격리를 적용하고, 많은 프로세스(수십 개까지)를 가질 수 있습니다.
- Firefox: 기본적으로 더 적은 프로세스를 사용합니다(모든 탭을 처리하는 8개의 콘텐츠 프로세스, 그리고 Fission으로 교차 사이트 iframe이 필요하면 더 추가). 따라서 반드시 탭당 하나의 프로세스는 아니며, 탭들이 풀(pool) 안에서 콘텐츠 프로세스를 공유합니다. 이는 탭이 많은 상황에서 Firefox의 메모리 사용량이 더 낮을 수 있음을 의미하지만, 또한 콘텐츠 프로세스 하나가 충돌하면 여러 탭이 함께 죽을 수 있음을 의미하기도 합니다(다만 사이트별로 묶으려 하므로, 가령 모든 Facebook 탭이 한 프로세스에 있는 식입니다).
- Safari: 아마도 탭당 하나의 프로세스(또는 몇 개의 탭당 하나)일 것입니다. iOS에서 WKWebView는 분명히 각 웹뷰를 격리합니다. Safari 데스크톱도 역사적으로 각 탭을 분리했습니다. 교차 출처 iframe을 격리하는지는 확실하지 않습니다. Apple은 Spectre 완화에 대해 많이 이야기하지 않았지만, Safari는 적어도 최상위에 대해서는 도메인당 프로세스를 갖습니다.
프로세스 간 조율: 모든 엔진은 멀티 프로세스 환경에서 (JS를 막는) alert()를 어떻게 구현할지 같은 비슷한 문제를 해결해야 합니다. 일반적으로 브라우저 프로세스가 alert UI를 보여주고 그 스크립트 컨텍스트를 일시 중지합니다. 또는 prompt/confirm을 어떻게 처리할지, 모달 대화상자를 어떻게 할지 등도 마찬가지입니다. 미묘한 차이가 있습니다(예: Chrome은 alert를 위해 스레드를 진짜로 막지 않고 렌더러에서 중첩된 런루프(nested runloop)를 돌리는 반면, Firefox는 여전히 그 탭의 프로세스를 얼릴 수 있습니다).
충돌 처리: Chrome과 Firefox 둘 다 충돌한 콘텐츠 프로세스를 다시 시작하고 탭에 오류를 표시할 수 있는 충돌 리포터를 가지고 있습니다. Safari의 웹 콘텐츠 프로세스 충돌은 일반적으로 콘텐츠 영역에 더 단순한 오류 메시지를 표시합니다.
기능 구현의 분기
일부 웹 플랫폼 기능은 엔진별로 다릅니다. 예를 들어 (이전에 Chrome에서 실험적이었던) View Transitions API는 2025년 10월에 Baseline Newly Available 상태에 도달했으며, 동일 문서(same-document) 전환은 이제 Chrome 111+, Edge 111+, Firefox 133+, Safari 18+에서 지원됩니다. (다중 페이지 앱을 위한) 교차 문서(cross-document) 전환은 Chrome 126+, Edge 126+, Safari 18.2+에서 지원되며, Firefox 지원은 아직 보류 중입니다.
개발자 도구: Chrome의 DevTools는 매우 발전해 있습니다. Firefox의 DevTools도 매우 훌륭합니다(초기의 CSS Grid 하이라이터, 도형 편집기(shape editor) 같은 독특한 기능들과 함께). Safari의 Web Inspector는 괜찮지만 일부 영역에서는 그만큼 다기능은 아닙니다. 이런 차이는 각 브라우저에서 디버깅하는 개발자에게 중요할 수 있습니다.
성능 트레이드오프
역사적으로 Chrome은 멀티 프로세스와 V8 덕분에 더 빠른 JS와 전반적인 성능으로 칭송받았습니다. Quantum을 탑재한 Firefox는 많은 격차를 좁혔고, 때로는 그래픽에서 Chrome을 능가하기도 합니다(WebRender는 복잡한 페이지에서 매우 빠를 수 있습니다). Safari는 흔히 그래픽과 Apple 하드웨어에서의 낮은 전력 사용에서 뛰어납니다(전력을 위해 많이 최적화합니다).
메모리: Chrome은 (그 모든 프로세스 때문에) 높은 메모리 사용으로 명성이 있습니다. Firefox는 조금 더 보수적이려 합니다. Safari는 (제한된 RAM 때문에 어쩔 수 없이) iOS에서 매우 메모리 효율적이며, WebKit에서 많은 메모리 최적화를 합니다.
외부 기여자: 흥미로운 점은, 이 엔진들의 많은 개선이 Igalia 같은 외부 팀에서 나온다는 것입니다(예: WebKit과 Blink 양쪽에서 CSS Grid 구현). 그래서 때로는 기능이 거의 동시에 도입됩니다.
웹 개발자의 관점에서 이 차이들은 흔히 다음과 같이 나타납니다:
- 한 엔진의 CSS 기능이나 API 구현에 약간의 차이나 버그가 있을 수 있으므로 모든 엔진에서 테스트해야 한다는 점.
- 성능이 다를 수 있다는 점(예: 특정 JS 작업이 JIT 휴리스틱 때문에 한 엔진에서 다른 엔진보다 빠를 수 있음).
- 일부 API는 한 엔진에서 사용할 수 없을 수 있다는 점(Safari는 흔히 WebRTC나 IndexedDB 버전 같은 일부 새 API를 가장 늦게 구현하지만 결국에는 구현합니다).
하지만 우리가 논의한 핵심 개념(네트워크 → 파싱 → 레이아웃 → 페인트 → 컴포지트 → JS 실행)은, 내부 접근이나 이름은 다르더라도, 모두에 적용됩니다:
- Gecko에서: 파싱 → 프레임 트리 → 디스플레이 리스트 → WebRender 장면 또는 (WebRender가 비활성화된 경우) 레이어 트리 → 컴포지트.
- WebKit에서: 파싱 → 렌더 트리 → 그래픽 레이어 → (Core Animation을 통한) 컴포지트.
그리고 모두 유사한 하위 시스템(DOM, 스타일링, 레이아웃, 그래픽, JS 엔진, 네트워킹, 프로세스/스레드)을 가지고 있습니다.
이를 알면 디버깅에 도움이 됩니다. 예를 들어 Safari에서는 끊기는데 Chrome에서는 그렇지 않다면, WebKit의 페인팅이 다르기 때문일 수 있습니다. 또는 Firefox에서 CSS가 느리다면, Stylo가 병렬화하지 못하는 경로에 부딪힌 것일 수 있습니다(다만 그런 경우는 드뭅니다).
요약하면, Chromium, Gecko, WebKit은 서로 다른 구현과 심지어 서로 다른 혁신(Gecko의 병렬 CSS, WebRender GPU 등)을 갖고 있지만, 점점 더 동일한 웹 표준을 구현하며 많은 부분에서 협력하기까지 합니다. 엔진의 선택은 플랫폼 공급업체와 열린 웹의 다양성에 더 중요하지만, 개발자로서 여러분은 대부분 여러분의 사이트가 어디서나 동작하는지를 신경 씁니다. 내부적으로 각 엔진의 고유한 아키텍처는 서로 다른 성능 프로파일이나 버그로 이어질 수 있으며, 그래서 각 엔진에서 (Firefox의 성능 도구 대 Chrome의 것처럼) 테스트하고 성능 진단을 사용하는 것이 통찰을 줄 수 있습니다. 모든 차이를 나열하는 것은 우리의 범위를 벗어나지만, 바라건대 이것으로 그 지형에 대한 감을 잡았기를 바랍니다. 그들은 고수준 설계(멀티 프로세스, 유사한 파이프라인)에서는 수렴하면서도 구체적인 기술적 해법에서는 갈라집니다.
결론과 더 읽을거리
우리는 현대 브라우저 안에서 웹 페이지의 일생을 따라 여정을 떠나왔습니다. URL이 입력되는 순간부터, 네트워킹과 내비게이션을 거쳐, HTML 파싱, 스타일링, 레이아웃, 페인팅, 자바스크립트 실행, 그리고 마침내 GPU가 화면에 픽셀을 찍는 데까지 말입니다. 우리는 브라우저가 본질적으로 미니 운영체제임을 보았습니다. 웹 콘텐츠가 빠르게 로드되고 안전하게 실행되도록 보장하기 위해 프로세스, 스레드, 메모리, 그리고 수많은 복잡한 하위 시스템을 관리하니까요. 웹 개발자에게 이러한 내부 구조를 이해하는 것은, 특정 모범 사례(리플로우 최소화나 async 스크립트 사용 등)가 왜 성능에 중요한지, 또는 일부 보안 정책(iframe에서 출처를 섞지 않는 등)이 왜 존재하는지를 명쾌하게 밝혀줄 수 있습니다.
개발자를 위한 몇 가지 핵심 요점:
네트워크 사용 최적화: 더 적은 왕복과 더 작은 파일 = 더 빠른 시작 렌더링. 브라우저가 많은 것을 해줄 수 있지만(HTTP/2, 캐싱, 추측성 로딩), 그래도 리소스 힌트와 효율적인 캐싱 같은 기법을 활용해야 합니다. 네트워킹 스택은 고성능이지만, 지연 시간(latency)은 언제나 골칫거리입니다.
효율을 위해 HTML/CSS를 구조화하라: 잘 구조화된 DOM과 군더더기 없는 CSS(매우 깊은 트리나 지나치게 복잡한 셀렉터를 피하라)는 파싱과 스타일 시스템에 도움이 됩니다. CSS와 DOM이 계산된 스타일을 만들고, 그다음 레이아웃이 기하학적 정보를 계산하며, 무거운 DOM 조작이나 스타일 변경이 이러한 재계산을 유발할 수 있음을 이해하세요.
DOM 업데이트를 일괄 처리하라: 반복적인 스타일/레이아웃 스래싱을 피하기 위해서입니다. DevTools 성능 패널을 사용해 스크립트가 언제 많은 레이아웃이나 페인트를 유발하는지 포착하세요.
애니메이션에는 컴포지팅 친화적인 CSS를 사용하라: transform이나 opacity의 애니메이션은 메인 스레드를 벗어나 컴포지터에 머물러 부드러운 애니메이션을 만들어냅니다. 가능하면 레이아웃에 묶인 속성을 애니메이션하는 것을 피하세요.
JS 실행에 유의하라: JS 엔진이 매우 빠르긴 하지만, 긴 작업은 메인 스레드를 막습니다. (페이지가 반응성을 유지하도록) 긴 작업을 잘게 쪼개고, 경우에 따라 백그라운드 작업을 위해 Web Worker를 고려하세요. 또한 무거운 JS가 GC 멈춤을 유발할 수 있음을 기억하세요(요즘은 길게 가는 경우가 드물지만, 메모리가 부풀면 발생할 수 있습니다).
보안 기능: 이를 받아들이세요. 예를 들어 적절할 때 iframe sandbox나 rel=noopener를 사용하세요. 어차피 브라우저가 그것들을 격리한다는 것을 이제 알았으니, 그에 협력하는 것이 좋습니다.
DevTools는 여러분의 친구다: 특히 성능과 네트워크 패널은 브라우저가 정확히 무엇을 하고 있는지 보여주는 보물창고입니다. 무언가 느리거나 끊긴다면, 도구가 흔히 원인(긴 레이아웃, 느린 페인트 등)을 짚어줍니다.
더 깊이 파고들고 싶은 분들에게, 훌륭한 자료는 Pavel Panchekha와 Chris Harrelson의 Browser Engineering입니다( browser.engineering 에서 볼 수 있습니다).
이것은 본질적으로 간단한 웹 브라우저를 만들어 가는 과정을 안내하는 무료 온라인 책으로, 네트워킹, HTML/CSS 파싱, 레이아웃 등을 이해하기 쉽게 다룹니다. 우리가 논의한 모든 것에 대한 더 깊이 있는 동반서 역할을 하며, 예제를 통해 지식을 단단히 다져줄 수 있습니다. 추가로, Chrome 팀의 다부작 시리즈 "Inside look at modern web browser"는 다이어그램과 함께 읽기 좋은 개관을 제공합니다. V8 블로그(v8.dev)와 Mozilla의 Hacks 블로그는 엔진의 발전(예: 새로운 JIT 컴파일러 계층이나 WebRender 내부 구조)에 대해 배우기에 좋습니다.
결론적으로, 현대 브라우저는 소프트웨어 공학의 경이입니다. 브라우저는 이 모든 복잡성을 성공적으로 추상화해 주어, 개발자인 우리는 대부분 그저 HTML/CSS/JS를 작성하고 브라우저가 그것을 처리해 주리라 믿으면 됩니다. 하지만 그 내부를 들여다봄으로써, 우리는 더 성능 좋고 견고한 애플리케이션을 작성하는 데 도움이 되는 통찰을 얻습니다. 우리는 왜 특정 기법이 사용자 경험을 개선하는지(예: 메인 스레드 막기를 피하거나 불필요한 DOM 복잡성을 줄이는 것)를, 브라우저가 이면에서 어떻게 동작해야 하는지를 보았기에 이해하게 됩니다. 다음번에 웹페이지를 디버깅하거나 Chrome이나 Firefox가 왜 특정하게 동작하는지 궁금할 때, 여러분은 길잡이가 되어줄 브라우저 내부 구조에 대한 멘탈 모델을 갖게 될 것입니다.
즐겁게 만드시길 바라며, 웹 플랫폼의 깊이는 그것을 탐구하는 이들에게 보답한다는 것을 기억하세요. 배울 것은 언제나 더 있고, 그것을 배우도록 도와줄 도구도 있습니다.
이 글의 삽화는 Susie Lu가 그렸습니다.
더 읽을거리
- Browser Engineering은 Pavel Panchekha와 Chris Harrelson이 쓴 훌륭한 무료 책입니다
- Chromium University - Chromium이 어떻게 동작하는지 깊이 파고드는 무료 영상 시리즈로, 훌륭한 Life of a Pixel talk을 포함합니다
- Google Chrome at 17 - A history of our browser