• Development
  • Translation

최신 웹 브라우저 살펴보기

2023년 03월 13일

4부로 구성된 해당 시리즈에서 높은 수준의 아키텍처부터 렌더링 파이프라인의 세부 내용에 이르기까지 크롬 브라우저 내부를 살펴보도록 하겠습니다.

해당 포스팅은 원문인 Inside look at modern web browser를 필자의 입맛에 맞게 번역 및 정리한 글입니다. 자세한 내용은 원문을 참고해주세요.

핵심 컴퓨팅 용어와 크롬의 다중 프로세스 아키텍처

이 시리즈 1부에서는 핵심 컴퓨팅 용어와 크롬의 다중 프로세스 아키텍처에 대해 살펴보겠습니다.

컴퓨터의 핵심은 CPU와 GPU

브라우저 실행을 이해하기 위해 몇 가지 부품들과 그 기능을 이해해야 합니다. 해당 글에선 주요 부품인 GPU와 CPU에 대해서 알아보도록 하겠습니다.

CPU

중앙 처리 장치인 CPU컴퓨터의 두뇌라고 할 수 있으며, 다음의 특징을 갖습니다.

GPU

그래픽 처리 장치인 GPU는 컴퓨터의 또 다른 부분이며 다음의 특징을 갖습니다.

컴퓨터나 휴대폰에서 응용 프로그램을 시작하면 위 두 가지 부품이 응용 프로그램을 구동합니다. 일반적으로 응용 프로그램은 운영 체제에서 제공하는 메커니즘을 사용하여 CPU 및 GPU에서 실행됩니다.

프로세스 및 스레드에서 프로그램 실행

브라우저 아키텍처를 이해하기 전 알아야 할 또 다른 개념으로 프로세스 및 스레드가 있습니다. 간단히 말하자면, 프로세스는 응용 프로그램 내 실행 프로그램이며, 스레드는 프로세스 내부에서 프로그램 일부를 실행하는 것입니다. 응용 프로그램 실행 과정을 통해 이를 더 자세히 알아보도록 합시다.

우선 응용 프로그램을 실행하면 프로세스가 생성됩니다. 이때 스레드를 생성할 수 있지만, 선택 사항입니다. 운영 체제는 작업할 메모리의 판을 프로세스에 제공하고 모든 응용 프로그램 상태는 해당 메모리에 저장됩니다. 응용 프로그램을 닫으면 프로세스도 사라지고, 운영 체제에서 메모리를 확보합니다.

여기서 (원문 내 "slab")은 사전적으로 건축에서 판 형상의 구조물을 의미하는데, 원문에서는 애플리케이션 메모리를 저장하는 영역을 정의하기 위해 비유적으로 활용한 표현이라고 생각됩니다.

프로세스는 다른 작업을 실행하기 위해 다른 프로세스를 가동하도록 운영 체제에 요청할 수 있습니다. 이렇게 되면 다른 메모리의 일정 부분이 새 프로세스에 할당됩니다. 두 프로세스가 통신해야 하는 경우 IPC(Inter Process Communication)를 활용하여 통신할 수 있습니다. 많은 응용 프로그램은 작업자 프로세스가 응답하지 않는 경우, 응용 프로그램의 다른 부분을 실행하는 다른 프로세스를 중지하지 않고 다시 시작할 수 있도록 이러한 방식으로 설계되었습니다.

브라우저 아키텍쳐

그렇다면 프로세스와 스레드를 활용하여 웹 브라우저를 구축하는 방법을 알아봅시다. 여러 스레드가 있는 하나의 프로세스이거나 IPC를 통해 통신하는 몇 개의 스레드가 있는 여러 프로세스일 수 있습니다. 이러한 아키텍쳐들은 구현 세부 사항이며 표준 사양이란 없습니다.

해당 시리즈에선 최신 크롬 아키텍처를 활용하여 설명합니다.

최상단엔 응용 프로그램 내 다른 부분들을 관리하는 프로세스와 협력하는 브라우저 프로세스가 있습니다. 렌더러 프로세스의 경우 여러 프로세스가 생성되어 각 탭에 할당됩니다.

최근까지 크롬은 가능한 경우 각 탭에 프로세스를 제공했으나, 이제 iframe 을 포함하여 각 사이트에 자체 프로세스를 제공하려는 시도를 합니다. (사이트 격리 참조)

프로세스의 제어 대상

크롬 내 프로세스들과 제어 대상은 다음과 같습니다.

이외에도 확장 프로세스 및 유틸리티 프로세스와 같은 더 많은 프로세스들이 있습니다.

크롬 내에서 실행 중인 프로세스 수를 확인하려면 오른쪽 상단 모서리에 있는 케밥 아이콘(⋮)을 클릭하고 추가 도구를 클릭한 후, 작업 관리자를 선택하면 현재 실행 중인 프로세스 목록과 사용 중인 CPU/메모리 양이 표시된 창이 열립니다.

크롬 다중 프로세스 아키텍처의 이점

앞에서 크롬이 다중 렌더러 프로세스를 사용한다고 언급했었는데, 가장 간단한 경우는 각 탭에 자체 렌더러 프로세스가 있다고 생각할 수 있습니다. 이렇게 하면 하나의 탭이 응답하지 않아도 나머지 탭을 활성 상태로 유지하며 진행할 수 있습니다.

각 프로세스는 고유한 메모리 공간을 갖지만 공통 인프라(예를 들어, 크롬의 자바스크립트 엔진인 V8)의 복사본을 포함하는 경우가 많습니다. 이는 동일 프로세스 내부의 스레드인 경우 공유할 수 없기 때문에, 더 많은 메모리 사용량을 초래합니다. 메모리 절약을 위해 크롬은 가동 가능한 프로세스 수를 제한합니다. 한도는 기기 메모리 및 CPU 성능에 따라 다르겠지만, 크롬이 한도에 도달하면 한 프로세스에서 같은 사이트의 여러 탭을 실행합니다.

더 많은 메모리 절약을 위한 크롬의 서비스

동일 접근 방식이 브라우저 프로세스에 적용됩니다. 크롬은 브라우저 프로그램의 각 부분을 서비스로 실행하여 여러 프로세스로 쉽게 분할하거나 하나로 통합할 수 있도록 아키텍처를 변경하고 있습니다.

일반적으로 크롬이 좋은 성능의 하드웨어에서 실행될 때에는 각 서비스를 다른 프로세스로 분할하여 높은 안정성을 제공하지만, 리소스가 제한된 장치의 경우 크롬은 서비스를 하나의 프로세스(단일 브라우저 프로세스 등)로 통합하여 메모리 공간을 절약합니다. 메모리 절약을 위한 프로세스 통합과 같은 방식이 이전에 안드로이드와 같은 플랫폼에서 사용되었습니다.

프레임 별 렌더러 프로세스, 사이트 격리

사이트 격리(Site Isolation)사이트 내 각 iframe 에 대해 별도의 렌더러 프로세스를 실행하는 크롬의 최신 기능입니다. 우리는 서로 다른 사이트 간 메모리 공간을 공유하는 단일 렌더러 프로세스 내 사이트 간 iframe 을 실행할 수 있는 탭 모델당 하나의 렌더러 프로세스에 대해 이야기한 바가 있습니다. 동일한 렌더러 프로세스에서 두 가지 사이트를 실행하는 것이 좋아보일 수 있습니다. 동일 출처 보안 방식은 웹의 핵심 보안 모델이며, 이는 한 사이트가 동의 없이 다른 사이트의 데이터에 액세스할 수 없도록 합니다. 이 정책을 우회하는 것이 주요 공격 방식이며, 프로세스 격리는 사이트를 분리하는 가장 효과적인 방법입니다. Meltdown과 Spectre로 인해 프로세스를 활용하여 사이트를 분리해야 한다는 것이 더욱 확실해졌기에, 크롬 67부터 기본적으로 데스크톱에서 사이트 격리가 활성화되어 있으므로 탭의 각 교차 사이트 iframe 은 별도의 렌더러 프로세스를 가져옵니다.

사이트 격리는 다른 렌더러 프로세스를 할당하는 것처럼 단순하지 않습니다. iframe 이 서로 커뮤니케이션 하는 방식을 근본적으로 변경합니다. 다른 프로세스에서 실행되는 iframe 이 있는 페이지에서 개발자 도구를 여는 것은 개발자 도구가 매끄럽게 보이도록 백그라운드 작업을 구현해야 한다는 것을 의미합니다. 페이지에서 단어를 찾기 위해 Ctrl+F를 실행하는 것조차 다른 렌더러 프로세스에서 검색하는 것을 의미합니다. 브라우저 엔지니어가 사이트 격리 릴리스를 주요 이정표로 언급하는 이유를 알 수 있습니다!

네비게이션에서 일어나는 일

해당 파트에선 웹 사이트를 표시하기 위해 각 프로세스와 스레드가 통신하는 방법에 대해 자세히 알아봅니다. 웹 브라우징의 간단한 사례로, 브라우저에 URL을 입력하여 브라우저가 인터넷에서 데이터를 가져와 페이지를 표시하는 경우 살펴보겠습니다. 이 중 사용자가 사이트를 요청하고 브라우저가 페이지를 렌더링할 준비를 하는 부분(네비게이션이라고도 함)에 중점을 둡니다.

브라우저 프로세스로 시작합니다

1부에서 다룬 것처럼 탭 외부의 모든 것은 브라우저 프로세스에서 처리됩니다. 브라우저 프로세스에는 다음과 같은 스레드들이 있습니다.

간단한 탐색

1단계, 입력 처리

사용자가 주소 표시줄에 입력을 하면 UI 스레드는 우선적으로 입력 내용이 검색어인지 URL인지를 판단합니다. 이에 따라 사용자를 검색 엔진으로 보낼지, 요청한 사이트로 보낼지 여부를 구문 분석을 통해 결정해야 합니다.

2단계, 네비게이션 시작

사용자가 엔터키를 누르면 UI 스레드가 네트워크 호출을 시작하여 사이트 콘텐츠를 가져옵니다. 로딩 스피너가 탭 모서리에 표시되며, 네트워크 스레드는 DNS 조회 및 요청에 대한 TLS 연결 설정과 같은 적절한 프로토콜을 거칩니다.

DNS(Domain Name System)

사람이 읽을 수 있는 도메인 이름을 기계가 읽는 IP 주소로 변환하는 것

TLS(Transport Layer Security)

인터넷 커뮤니케이션을 위한 개인 정보와 데이터 무결성을 제공하는 보안 프로토콜

이 시점에서 네트워크 스레드는 HTTP 301과 같은 서버 리디렉션 헤더를 수신할 수 있습니다. 이 경우 네트워크 스레드는 서버가 리디렉션을 요청하는 UI 스레드와 통신합니다. 그런 다음 다른 URL 요청이 시작됩니다.

3단계, 응답 읽기

응답 본문(페이로드)이 들어오기 시작하면 네트워크 스레드는 필요한 경우 스트림의 처음 몇 바이트를 확인합니다. 응답의 Content-Type 헤더는 데이터 유형을 말해야 하지만 누락되거나 잘못되었을 수 있으므로 여기서 MIME 타입 스니핑이 수행됩니다.

MIME 타입

클라이언트에게 전송된 문서의 다양성을 알려주기 위한 메커니즘.

MIME 스니핑

MIME 타입이 없을 때, 혹은 클라이언트가 타입이 잘못 설정되었다고 판단한 어떤 다른 경우 리소스를 훑어 정확한 MIME 타입을 추측해내는 것.

응답이 HTML 파일인 경우 다음 단계는 렌더러 프로세스에 데이터를 전달하는 것이지만 zip 파일이나 다른 파일인 경우 다운로드 요청이므로 데이터를 다운로드 관리자에게 보내야합니다. 세이프 브라우징 확인이 이루지기도 합니다. 도메인과 응답 데이터가 악성 사이트와 일치하는 것으로 간주되면 네트워크 스레드가 이에 대한 경고를 표시하도록 전달합니다. 또한 민감한 사이트 간 데이터가 렌더러 프로세스에 전달되지 않도록 하기 위해 CORB(Cross Origin Read Blocking) 검사가 수행됩니다.

4단계, 렌더러 프로세스 찾기

모든 검사가 완료되어 네트워크 스레더가 요청된 사이트로 브라우저 이동이 가능하다고 확신하면 네트워크 스레드는 데이터가 준비되었음을 UI 스레드에게 알립니다. 그러면 UI 스레드는 웹 페이지 렌더링을 수행할 렌더러 프로세스를 찾습니다.

하지만 네트워크 요청에 대한 응답을 받는데 수백 밀리초가 걸릴 수 있기에, 프로세스 속도를 높이기 위한 다음의 최적화가 진행됩니다. UI 스레드가 2단계에서 네트워크 스레드에 URL 요청을 보낼 때, 탐색 중인 사이트를 이미 알고 있습니다. 그래서 UI 스레드는 네트워크 요청과 병렬로 렌더러 프로세스를 사전에 찾거나 시작하려고 시도합니다. 이렇게 모든 것이 예상대로 진행되면 렌더러 프로세스는 네트워크 스레드가 데이터를 수신했을 때, 이미 대기하고 있는 상태일 것입니다. 탐색이 교차 사이트로 리디렉션 되는 경우 해당 대기 프로세스가 사용되지 않을 수 있으며, 이 경우 다른 프로세스가 필요할 수 있습니다.

5단계, 탐색 커밋

이제 데이터와 렌더러 프로세스가 준비되었기에 탐색을 커밋하기 위해 브라우저 프로세스에서 렌더러 프로세스로 IPC가 전송됩니다. 또한, 렌더러 프로세스가 HTML 데이터를 계속 수신할 수 있도록 데이터 스트림을 전달합니다. 브라우저 프로세스가 렌더러 프로세스에서 커밋이 발생했다는 확인을 받으면 탐색이 완료되고 문서 로드 단계가 시작됩니다.

이떄 주소 표시줄이 업데이트되고 보안 표시 및 사이트 설정 UI에 새 페이지의 사이트 정보가 반영됩니다. 탭의 세션 기록이 업데이트되어 뒤로/앞으로 버튼이 방금 탐색한 사이트를 통해 이동합니다. 탭이나 창을 닫을 때, 탭/세션 복원을 용이하게 하기 위해 세션 기록이 디스크에 저장됩니다.

추가 단계, 초기 로드 완료

탐색이 커밋되면 렌더러 프로세스는 리소스 로드를 계속하고 페이지를 렌더링합니다. 그리고 렌더링을 "완료"(페이지의 모든 프레임에서 모든 onload 이벤트가 실행이 완료된 후)하면 IPC를 다시 브라우저 프로세스로 보냅니다. 이 시점에서 UI 스레드는 탭에서 로딩 스피너를 중지합니다.

클라이언트 측 자바스크립트는 이 시점 이후에도 추가 리소스를 불러오고 새 뷰를 렌더링할 수 있기에 "완료"라고 말합니다.

다른 사이트로 이동

간단한 네비게이션이 완성되었습니다. 하지만 사용자가 다시 주소 표시줄에 다른 URL을 입력하면 어떻게 될까요? 브라우저 프로세스는 동일한 단계를 거쳐 다른 사이트로 이동합니다. 그러나 그 전에 현재 렌더링 된 사이트에서 beforeunload 이벤트에 관심이 있는 지를 확인해야 합니다.

beforeunload 는 다른 패이지로 이동하려고 하거나 탭을 닫으려고 할 때, "이 사이트를 떠나시겠습니까?" 라는 경고를 줄 수 있습니다. 자바스크립트 코드를 포함하여 탭 내부의 모든 것은 렌더러 프로세스에 의해 처리되므로 브라우저 프로세스는 새 탐색 요청이 들어올 때 현재 렌더러 프로세스를 확인해야 합니다.

무조건 beforeunload 핸들러를 추가하지 마세요

이 경우 네비게이션을 시작하기 전에 핸들러를 실행해야 하므로 더 많은 대기 시간이 발생합니다. 이 이벤트 핸들러는 사용자가 페이지에 입력한 데이터가 손실될 수 있다는 경고를 받아야 하는 경우 같이 필요한 경우에만 추가해야 합니다.

탐색이 렌더러 프로세스에서 시작된 경우(예를 들어, 사용자가 링크를 클릭했거나 클라이언트 측의 자바스크립트가 window.location = "https://newsite.com" 를 실행한 경우), 렌더러 프로세스는 beforeunload 핸들러를 우선적으로 확인합니다. 그런 다음 브라우저 프로세스 시작 탐색과 동일한 프로세스를 거칩니다. 유일한 차이점은 탐색 요청이 렌더러 프로세스에서 브라우저 프로세스로 시작된다는 것입니다.

현재 렌더링된 사이트가 아닌 다른 사이트로 새 탐색이 수행되면 현재 렌더링 프로세스가 unload 와 같은 이벤트를 처리하는 동안 새 탐색을 처리하기 위해 별도의 렌더링 프로세스를 호출합니다. 자세한 내용은 페이지 수명 주기 상태 개요페이지 수명 주기 API로 이벤트에 연결하는 방법을 통해 알아보세요.

서비스 워커의 경우

해당 탐색 프로세스에 대한 최근 변경 사항 중 하나는 서비스 워커의 도입입니다. 서비스 워커는 애플리케이션 코드에 네트워크 프록시를 작성하는 방법입니다. 웹 개발자가 로컬로 캐시할 항목과 네트워크에서 새 데이터를 가져올 시기를 더 잘 제어할 수 있도록 만들어줍니다. 서비스 워커가 캐시에서 페이지를 로드하도록 설정되어 있으면 네트워크에서 데이터를 요청할 필요가 없습니다.

기억해야 할 중요한 부분은 서비스 워커가 렌더러 프로세스에서 실행되는 자바스크립트 코드라는 점입니다. 그러나 탐색 요청이 들어올 때 브라우저 프로세스는 사이트에 서비스 작업자가 있는 지 어떻게 알 수 있을까요?

서비스 워커가 등록되면 서비스 워커의 스코프가 참조로 유지됩니다.

스코프에 대한 자세한 내용은 The Service Worker Lifecycle 참조

탐색이 발생하면 네트워크 스레드는 등록된 서비스 워커 스코프에 대해 도메인을 확인하고 서비스 워커가 해당 URL에 등록된 경우 UI 스레드는 서비스 워커 코드를 실행하기 위해 렌더러 프로세스를 찾습니다. 그러면 서비스 워커는 캐시에서 데이터를 로드하여 네트워크에서 데이터를 요청할 필요가 없도록 하거나 네트워크에서 새 리소스를 요청할 수 있습니다.

네비게이션 프리로드

결국 서비스 워커가 네트워크에서 데이터를 요청하기로 결정했다면, 브라우저 프로세스와 렌더러 프로세스 간의 왕복으로 지연이 발생할 수 있음을 알 수 있습니다. 네비게이션 프리로드는 이런 상황에서 서비스 워커 시작과 함께 병렬로 리소스를 로드하여 해당 프로세스의 속도를 높이는 메커니즘입니다. 헤더로 이러한 요청을 표시하여 서버가 이러한 요청에 대해 다른 콘텐츠를 보낼지 결정할 수 있도록 합니다.

렌더러 프로세스 내부에서 일어나는 일

렌더러 프로세스는 웹 성능의 여러 측면과 관련이 있습니다. 해당 파트의 내용 외에도 렌더러 프로세스 내부에서는 많은 일이 발생합니다.

더 자세히 알아보려면 The Performance section of Web Fundamentals 를 참고하세요

렌더러 프로세스가 웹 콘텐츠를 처리합니다

렌더러 프로세스는 탭 내부에서 발생하는 모든 일을 담당합니다. 렌더러 프로세스에서 메인 스레드는 사용자에게 보내는 대부분의 코드를 처리합니다. 웹 워커 혹은 서비스 워커를 사용하는 경우 자바스크립트 일부를 워커 스레드에 의해 처리되는 경우가 있습니다. 컴포지터 및 래스터 스레드도 렌더러 프로세스 내부에서 실행되어 페이지를 효율적이고 원활하게 렌더링 합니다. 렌더러 프로세스의 핵심 작업은 HTML, CSS 및 자바스크립트를 사용자가 상호 작용할 수 있는 웹페이지로 변환하는 것입니다.

렌더러 프로세스의 구성

메인 스레드, 워커 스레드, 컴포지터 스레드, 래스터 스레드로 구성됩니다.

파싱

DOM의 구성

렌더러 프로세스가 탐색을 위한 커밋 메시지를 받고 HTML 데이터를 받기 시작하면 메인 스레드는 텍스트 문자열(HTML)을 구문 파싱하여 DOM(Document Object Model)으로 변환합니다.

DOM은 웹 개발자가 자바스크립트를 통해 상호 작용할 수 있는 데이터 구조 및 API 뿐만 아니라 페이지에 대한 브라우저의 구조 표현 방식입니다.

HTML 문서를 DOM으로 파싱하는 것은 **HTML 표준**에 의해 정의됩니다. HTML을 브라우저에 제공하면 오류가 발생하지 않는다는 사실을 알고 계실 것입니다. 예를 들어, 닫힘 태그만 존재하는 </p> 태그는 유효한 HTML입니다. 당신이 Hi! <b>I'm <i>Chrome</b>!</i> 와 같은 잘못된 코드를 작성해도 앞의 코드는 Hi! <b>I'm <i>Chrome</i></b><i>!</i> 로 간주됩니다. 이는 HTML이 이러한 오류를 정상적으로 처리하도록 설계되었기 때문입니다.

파서의 오류 처리에 대한 내용이 궁금하다면 다음 링크를 참고하세요.

하위 리소스 로드

웹 사이트는 일반적으로 이미지, CSS 및 자바스크립트와 같은 외부 리소스를 사용하며, 이러한 파일은 네트워크 또는 캐시에서 불러와야 합니다.

메인 스레드는 DOM을 빌드하기 위해 파싱하는 동안 찾는대로 하나씩 요청할 수 있지만, 속도를 높이기 위해 프리로드 스캐너가 동시에 실행됩니다. 예를 들어, <img> 혹은 <link> 와 같은 태그들이 있다면 프리로드 스캐너는 HTML 파서가 생성한 토큰을 참고하여 브라우저 프로세스의 네트워크 스레드에 이를 요청합니다.

자바스크립트는 파싱을 막는다

HTML 파서가 <script> 태그를 찾으면, HTML 문서 파싱을 멈춘 후에 자바스크립트 코드를 불러오고 파싱 및 실행을 진행합니다. 왜냐하면, 자바스크립트가 document.write() 와 같은 함수를 활용하여 DOM 구조를 바꿀 수 있기 때문입니다.

자바스크립트 실행에서 어떤 일이 발생하는지 궁금하다면 해당 링크를 참고하세요.

리소스를 불러올 방법을 브라우저에게 힌트 주기

웹 개발자가 리소스를 제대로 불러오기 위해 브라우저에게 힌트를 보낼 수 있는 방법이 있습니다. 자바스크립트에서 document.write() 함수를 사용하지 않는 경우 <script> 태그에 asyncdefer 속성을 추가할 수 있습니다. 그렇다면 브라우저는 자바스크립트를 비동기적으로 로드하고 실행하여 구문 분석을 차단하지 않습니다. 적합한 경우 자바스크립트 모듈을 사용할 수도 있습니다. <link rel="preload"> 는 리소스가 현재 탐색에 확실히 필요하고 가능한 빨리 다운로드 하고 싶다는 것을 브라우저에게 알리는 방법입니다.

이에 대한 자세한 내용은 Resource Prioritization – Getting the Browser to Help You에서 확인할 수 있습니다.

스타일 계산

CSS에서 페이지 요소의 스타일을 지정할 수 있기 때문에 DOM이 있는 것만으로는 페이지가 어떻게 보이는지 알기에 충분치 않습니다. 메인 스레드는 CSS 구문을 분석하고 각 DOM 노드에 대해 계산된 스타일을 결정합니다. CSS 선택자를 기준으로 각 요소에 어떤 스타일이 적용되는지에 대한 정보입니다.

위 내용은 개발자 도구의 computed 섹션에서 확인할 수 있습니다.

CSS가 제공되지 않아도 각 DOM 노드에는 계산된 스타일이 있습니다. 이는 브라우저에 기본 스타일 시트가 있기 때문이며, 크롬의 기본 CSS가 궁금하다면 소스 코드를 참고하세요.

레이아웃

이제 렌더러 프로세스는 문서의 구조와 각 노드의 스타일을 알고 있지만 페이지를 렌더링하기에는 충분하지 않습니다.

전화로 친구에게 그림을 설명하려고 한다고 가정했을 때, "커다란 빨간색 원과 작은 파란색 사각형이 있어" 라는 설명은 친구가 그림이 정확히 어떻게 생겼는지 알 수 있는 정보가 충분하지 않은 것과 같습니다.

레이아웃은 요소의 위치를 파악하는 것이다. 기본 스레드는 DOM 및 계산된 스타일을 살펴보고 xy 좌표 및 바운딩 박스 사이징과 같은 정보가 있는 레이아웃 트리를 만듭니다. 레이아웃 트리는 DOM 트리와 유사한 구조일 수 있지만 페이지에 표시되는 것과 관련된 정보만 포함합니다.

스타일에 따른 레이아웃 포함 여부

display: none 이 적용된 경우, 해당 요소는 레이아웃 트리의 일부가 아니지만, visibility: hidden 의 경우 있는 요소는 레이아웃 트리에 있습니다. 이와 유사하게 pseudo class로 p::before{content:"Hi!"} 가 적용되면 DOM에는 없지만 레이아웃 트리엔 포함됩니다.

페이지 레이아웃을 결정하는 것은 어려운 작업입니다. 위에서 아래로의 블록 흐름과 같은 가장 단순한 페이지 레이아웃도 글꼴의 크기와 단락의 크기와 모양이 이에 영향을 미치기 때문에 글꼴의 줄 바꿈 위치도 고려해야 합니다. 이러한 변경 사항은 다음 단락이 있어야 하는 위치에도 영향을 미칩니다. CSS는 요소를 한쪽으로 띄우고, 오버플로우 된 요소를 숨기고, 쓰기 방향을 변경할 수 있습니다.

크롬에서는 전체 엔지니어 팀이 레이아웃 작업을 하며 그들의 작업에 대한 세부 사항을 보고 싶다면 BlinkOn Conference의 몇 가지 대화를 확인할 수 있습니다.

페인트

하지만 DOM, 스타일 및 레이아웃을 갖는 것만으로는 페이지를 렌더링하기에 충분하지 않습니다. 이제 페인트하는 순서를 판단해야 합니다.

예를 들어 특정 요소에 대해 z-index 가 설정될 수 있는데, 이 경우 HTML에 작성된 요소 순서대로 페인팅하면 잘못된 렌더링이 발생합니다. 페인트 단계에서 메인 스레드는 레이아웃 트리를 이동하여 페인트 레코드를 만들고, 이는 "배경 먼저, 그 다음은 텍스트, 그리고 다음은 직사각형"과 같은 페인팅 프로세스에 대한 메모입니다. 자바스크립트를 사용하여 <canvas> 요소에 그림을 그린 경우, 이 프로세스가 익숙할 수 있습니다.

렌더링 파이프라인의 업데이트는 비용이 많이 듭니다. 렌더링 파이프라인에서 파악해야 할 가장 중요한 점은 각 단계에서 이전 작업의 결과를 사용하여 새로운 데이터를 생성한다는 것입니다. 예를 들어, 레이아웃 트리에서 변경 사항이 있는 경우 문서 내 영향을 받는 부분에 대해 페인트 순서를 다시 생성해야 합니다.

요소에 애니메이션을 적용하는 경우 브라우저는 모든 프레임 사이에서 이러한 작업을 실행해야 합니다. 대부분의 디스플레이는 초당 60회(60fps) 화면을 새로 고치며, 애니메이션은 모든 프레임에서 화면을 가로 질러 물체를 움직일 때 사람의 눈에 부드럽게 나타납니다. 그러나 애니메이션이 그 사이의 프레임을 놓치면 페이지가 "버벅거림"으로 나타납니다. 렌더링 작업이 화면 새로 고침을 따라가더라도 이러한 계산은 메인 스레드에서 실행되므로 애플리케이션이 자바스크립트를 실행할 때 차단될 수 있습니다. 이 경우 requestAnimationFrame() 를 사용하여 자바스크립트 작업을 작은 청크로 나누고 모든 프레임에서 실행하도록 예약할 수 있습니다.

자세한 내용은 Optimize JavaScript Execution을 참고하세요

또한, 메인 스레드 차단을 피하기 위해 웹 워커에서 자바스크립트를 실행할 수도 있습니다.

합성

페이지를 어떻게 그릴까요?

앞의 정보들(문서의 구조, 각 요소의 스타일, 페이지의 레이아웃 및 페인트 순서)을 화면에 픽셀로 변환하는 것래스터링이라고 합니다.

영어로 Rasterisation 또는 Rasterization 라고 불리는 래스터화는 벡터 그래픽 형식의 이미지 데이터를 픽셀, 점 또는 선 시리즈와 같은 도형을 통해 표현된 이미지로 변환하는 작업입니다.

아마도 이것을 처리하는 단순한 방법은 뷰포트 내부의 요소들을 래스터화하는 것으로, 사용자가 페이지를 스크롤하면 래스터화 된 프레임을 이동하고 추가 래스터링 하여 누락된 부분을 채우는 방식입니다. 이것이 크롬이 처음 출시되었을 때 래스터화를 처리한 방식입니다. 그러나 최신 브라우저는 합성(compositing)이라는 보다 정교한 프로세스를 실행합니다.

합성이란

합성은 페이지의 일부를 레이어로 분리하고 개별적으로 래스터화한 다음, 컴포지터 스레드라는 별도의 스레드에서 페이지로 합성하는 기술입니다. 이 기술을 활용하면 스크롤이 발생했을 때 레이어가 이미 래스터화 되어 있으므로 새 프레임을 합성하기만 하면 됩니다. 애니메이션은 레이어를 이동하고 새 프레임을 합성하여 동일한 방식으로 가능합니다.

개발자 도구의 Layers 패널을 통해 웹 사이트가 어떻게 레이어로 나누어지는지 확인할 수 있습니다.

레이어로 나누기

어떤 요소가 어떤 레이어에 있어야 하는지 알아내기 위해 메인 스레드는 레이아웃 트리를 돌아다니며 레이어 트리를 만듭니다.

이 부분은 개발자 도구의 Performance 패널에서 "Update layer tree"라고 합니다.

별도의 레이어여야 하는 페이지의 특정 부분(예를 들어, 슬라이드 인사이드 메뉴)이 표시되지 않는 경우 CSS의 will-change 속성을 사용하여 브라우저에 힌트를 줄 수 있습니다.

모든 요소에 레이어를 제공하고 싶을 수도 있지만, 과도한 수의 레이어를 합성하면 매 프레임마다 페이지의 작은 부분을 래스터화하는 것보다 작업 속도가 느려질 수 있으므로 애플리케이션의 렌더링 성능을 측정하는 것이 중요합니다.

이에 대한 자세한 내용은 Stick to Compositor-Only Properties and Manage Layer Count를 참조하세요.

메인 스레드에서 래스터링 및 합성 해제

레이어 트리가 생성되고 페인트 순서가 결정되면 메인 스레드는 해당 정보를 컴포지터 스레드에 커밋합니다. 그런 다음, 컴포지터 스레드는 각 레이어를 래스터화하기 시작합다. 레이어는 페이지의 전체 길이만큼 클 수 있으므로 컴포지터 스레드는 레이어를 타일로 나누고, 각 타일을 래스터 스레드로 보냅니다. 래스터 스레드는 각 타일을 래스터화하여 GPU 메모리에 저장합니다.

컴포지터 스레드는 다른 래스터 스레드의 우선 순위를 지정하여 뷰포트(또는 근처)에 있는 항목을 먼저 래스터할 수 있으며, 또한, 레이어에는 확대 작업과 같은 작업을 처리하기 위해 다양한 해상도에 대한 여러 타일링이 있습니다. 타일이 래스터되면 컴포지터 스레드는 컴포지터 프레임을 생성하기 위해 드로우 쿼드라는 타일 정보를 수집합니다.

드로우 쿼드

페이지 합성을 고려하여 메모리에서 타일의 위치 및 페이지에서 타일을 그릴 위치와 같은 정보를 포함한다.

컴포지터 프레임

페이지의 프레임을 나타내는 드로우 쿼드 모음

그런 다음, 컴포지터 프레임이 IPC를 통해 브라우저 프로세스에 통신합니다. 이 시점에서 브라우저 UI 변경을 위해 UI 스레드에서, 또는 확장을 위해 다른 렌더러 프로세스에서 다른 컴포지터 프레임을 추가할 수 있습니다. 이러한 컴포지터 프레임은 GPU로 전송되어 화면에 표시됩니다. 만약 스크롤 이벤트가 발생하면, 컴포지터 스레드는 GPU로 보낼 또 다른 컴포지터 프레임을 생성합니다.

합성의 장점은 메인 스레드를 포함하지 않고 수행된다는 것입니다. 컴포지터 스레드는 스타일 계산이나 자바스크립트 실행을 기다릴 필요가 없고, 그래서 애니메이션만 합성하는 것이 원활한 성능을 위해 가장 좋은 것으로 간주됩니다. 레이아웃 또는 페인트를 다시 계산해야 하는 경우 메인 스레드가 관련되어야 합니다.

컴포지터에 입력이 들어왔다

해당 장에서는 웹 사이트를 표시하기 위해 코드를 처리하는 방법, 즉 컴포지터가 사용자 입력이 들어올 때 어떻게 원활한 상호 작용을 가능하게 하는지 살펴보자.

브라우저 관점에서 입력 이벤트

"입력 이벤트"라는 말을 들으면 텍스트 상자에 입력하거나 마우스를 클릭하는 것만 생각할 수 있지만, 브라우저의 관점에서 입력은 사용자의 모든 제스처를 의미합니다. 예를 들어, 마우스 휠 스크롤은 입력 이벤트이며, 터치 또는 마우스 오버도 입력 이벤트입니다.

화면 터치와 같은 사용자 제스처가 발생하면 브라우저 프로세스가 먼저 제스처를 수신한다. 그러나 브라우저 프로세스는 탭 내부의 콘텐츠가 렌더러 프로세스에 의해 처리되기 때문에 제스처가 발생한 위치만 인식합니다. 따라서 브라우저 프로세스는 이벤트 유형(예를 들어, touchstart)과 해당 좌표렌더러 프로세스로 전송한다. 그러면 렌더러 프로세스는 이벤트 대상을 찾고 연결된 이벤트 리스너를 실행하여 이벤트를 적절하게 처리합니다.

컴포지터는 이벤트를 수신한다

이전 게시물에서는 컴포지터가 래스터화 된 레이어를 합성하여 부드럽게 스크롤을 처리하는 방법을 살펴보았습니다. 입력 이벤트 리스너가 페이지에 연결되지 않은 경우 컴포지터 스레드는 메인 스레드와 완전히 독립적인 새 컴포지터 프레임을 만들 수 있습니다.

그러나 일부 이벤트 리스너가 페이지에 연결되어 있으면 어떻게 될까요? 컴포지터 스레드는 이벤트를 처리해야 하는지 어떻게 알 수 있을까요?

빠르게 스크롤할 수 없는 영역 이해

자바스크립트를 실행하는 것이 메인 스레드의 작업이므로 페이지가 합성될 때 컴포지터 스레드는 이벤트 핸들러가 연결된 페이지의 영역을 **"non-fast scrollable region"**으로 표시합니다.

이 정보를 가지고 있으면, 컴포지터 스레드는 해당 영역에서 이벤트가 발생했을 때 입력 이벤트를 메인 스레드로 보낼 수 있고, 입력 이벤트가 이 영역 외부에서 발생하면 컴포지터 스레드는 메인 스레드를 기다리지 않고 새 프레임 합성을 계속합니다.

이벤트 핸들러를 작성할 때 주의하세요

웹 개발에서 일반적인 이벤트 처리 패턴은 이벤트 위임입니다. 이벤트가 버블링 되기 때문에 최상위 요소에 하나의 이벤트 핸들러를 첨부하고 이벤트 대상에 따라 작업을 위임할 수 있습니다.

document.body.addEventListener("touchstart", (event) => {
if (event.target === area) {
event.preventDefault();
}
});

모든 요소에 대해 하나의 이벤트 핸들러만 작성하면 되므로 이 이벤트 위임 패턴은 인간 공학적인 측면에서 매력적입니다. 그러나, 브라우저의 관점에서 이 코드를 보면 이제 non-fast scrollable region으로 표시됩니다. 즉, 애플리케이션이 페이지의 특정 부분의 입력에 신경 쓰지 않더라도 컴포지터 스레드는 메인 스레드와 통신하고 입력 이벤트가 들어올 때마다 대기해야 한다는 것입니다. 그렇기에, 컴포지터의 원활하고 부드러운 스크롤 기능은 구현되기 어려워집니다.

이를 방지하기 위해 이벤트 리스너에 passive: true 옵션을 전달할 수 있습니다. 이는 여전히 메인 스레드에서 이벤트를 수신하기를 원하지만 컴포지터가 계속해서 새 프레임을 합성할 수 있음을 브라우저에 암시한다는 의미를 갖습니다.

document.body.addEventListener(
"touchstart",
(event) => {
if (event.target === area) {
event.preventDefault();
}
},
{ passive: true },
);

이벤트 취소 가능 여부 확인

페이지에 스크롤 방향을 가로 스크롤로만 제한하려는 요소가 있다고 상상해봅시다.

포인터 이벤트에서 옵션을 사용 passive: true 를 할당하면 페이지 스크롤이 원활할 수 있지만, 스크롤 방향을 제한하기 위해 preventDefault 를 원하는 시점에서 세로 스크롤이 진행될 수 있다는 것도 의미합니다. 이를, event.cancelable 메서드를 사용하여 확인할 수 있습니다.

document.body.addEventListener(
"pointermove",
(event) => {
if (event.cancelable) {
event.preventDefault(); // block the native scroll
/*
* do what you want the application to do here
*/
}
},
{ passive: true },
);

또는, CSS 규칙 touch-action 을 사용하여 이벤트 핸들러를 완전히 제거할 수도 있습니다.

#area {
touch-action: pan-x;
}

이벤트 대상 찾기

컴포지터 스레드가 메인 스레드에 입력 이벤트를 보낼 때 가장 먼저 실행하는 것은 이벤트 대상을 찾기 위한 Hit Test입니다.

Hit Test

사용자 이벤트가 발생할 때, view 계층에서 subview들을 탐색하여 event를 처리할 view를 결정하는 과정

Hit Test는 렌더링 프로세스에서 생성된 페인트 레코드 데이터를 사용하여 이벤트가 발생한 지점 좌표 아래에 무엇이 있는지 알아낸다.

메인 스레드로의 이벤트 디스패치 최소화

이전 게시물에서 일반적인 디스플레이가 초당 60회 화면을 새로 고치는 방법과 부드러운 애니메이션을 위해 리듬(원문에선 cadence 라고 언급되어 있는데 페이스, 속도 등을 의미)을 따라가야 하는 방법에 대해 논의한 바가 있습니다. 입력을 위해 일반적인 터치 스크린 장치는 초당 60-120번의 터치 이벤트를 전달하고 일반적인 마우스는 초당 100번의 이벤트를 전달합니다. 입력 이벤트는 화면을 새로 고칠 수 있는 수준보다 이에 더욱 충실합니다.

그렇기에, touchmove 와 같은 연속 이벤트가 1초에 120번 메인 스레드로 전송되면 화면 새로고침 속도에 비해 과도한 Hit Test 및 자바스크립트 실행이 발생할 수 있습니다.

기본 스레드에 대한 과도한 호출을 최소화하기 위해 크롬은 연속 이벤트(예를 들어, wheel, mousewheel, mousemove, pointermove, touchmove)를 컴포징하고 다음 requestAnimationFrame 직전까지 전달을 지연합니다.

keydown, keyup, mouseup, mousedown, touchstarttouchend 와 같은 개별 이벤트즉시 전달됩니다.

프레임 내 이벤트를 가져오는 데 getCoalescedEvents 사용하라

대부분의 웹 애플리케이션에서 병합된 이벤트들은 좋은 사용자 경험을 제공하기에 충분해야 합니다. 그러나 그리기 응용 프로그램과 같은 것들을 만들 때, touchmove 좌표를 기반으로 경로를 지정하는 경우 부드러운 선을 그리기 위해 중간 좌표를 잃을 수 있습니다. 이 경우 포인터 이벤트의 getCoalescedEvents 메서드를 사용하여 병합된 이벤트에 대한 정보를 얻을 수 있다.

window.addEventListener("pointermove", (event) => {
const events = event.getCoalescedEvents();
for (let event of events) {
const x = event.pageX;
const y = event.pageY;
// draw a line using x and y coordinates.
}
});

다음 단계

이 시리즈에서 우리는 웹 브라우저의 내부 동작을 다루었습니다. 개발자 도구에서 이벤트 핸들러에 { passive: true } 를 추가하도록 권장하는 이유나 스크립트 태그에 async 속성을 작성해야 하는 이유에 대해 생각해 본 적이 없다면, 이 시리즈를 통해 브라우저가 더 빠르고 원활한 웹 경험을 제공하기 위해 이러한 정보가 필요한 이유를 설명할 수 있기를 바랍니다.

라이트하우스 사용하기

코드를 브라우저에 적합하게 만들고 싶지만 어디서부터 시작해야 할지 모르겠다면 라이트하우스를 사용해봅시다. 라이트하우스는 모든 웹 사이트의 감사를 실행하고 올바르게 수행되고 있는 것과 개선이 필요한 것에 대한 보고서를 제공하는 도구입니다. 감사 목록을 읽으면 브라우저가 어떤 종류의 항목에 관심을 갖는지 알 수 있습니다.

성능 측정 방식 알아보기

성능 조정은 사이트마다 다를 수 있으므로 사이트의 성능을 측정하고 사이트에 가장 적합한 것을 결정하는 것이 중요합니다. 크롬 개발자 도구 팀에는 사이트 성능을 측정하는 방법에 대한 약간의 튜토리얼이 있습니다.

사이트에 Feature Policy 추가

Feature Policy는 웹 사이트 소유자가 특정 웹 브라우저 기능 및 API를 켜거나 끌 수 있도록 하는 HTTP 헤더입니다.

추가적인 단계를 밟고 싶다면, 프로젝트를 빌드할 때 가드 레일이 될 수 있는 Feature Policy라는 새로운 웹 플랫폼 기능을 알아봅시다. Feature Policy를 켜면 앱의 특정 동작이 보장되고 실수를 방지할 수 있습니다.

예를 들어, 앱이 파싱을 차단하지 않도록 하려면 동기 스크립트 정책에서 앱을 실행할 수 있습니다. sync-script: 'none' 이 활성화 되면 파서 차단 자바스크립트가 실행되지 않습니다. 이것은 코드가 파서를 차단하는 것을 방지하기에 브라우저는 파서를 일시 중지하는 것에 대해 걱정할 필요가 없다.

마무리

웹 사이트 구축을 시작했을 때, 저자는 코드를 작성하는 방법과 생산성을 높이는 데 도움이 되는 항목에만 거의 관심을 가졌습니다. 그것들 또한 중요하지만 우리는 브라우저가 우리가 작성한 코드를 어떻게 받아들이는지에 대해서도 생각해야 합니다. 최신 브라우저는 사용자에게 더 나은 웹 경험을 제공하기 위한 방법에 투자해왔으며 계속해서 노력을 기울이고 있습니다. 코드를 구성하여 브라우저에 친절하게 대하면 사용자 경험이 향상되기 때문에, 브라우저와 친밀해지기 위한 탐구에 나와 함께합시다!