굉장히 빠른 CSS 엔진, Quantum CSS(aka Stylo) 살펴보기

이 글은 Lin Clark이 쓴 Inside a super fast CSS engine: Quantum CSS (aka Stylo)의 한국어 번역본입니다.

여러분께선 아마 프로젝트 Quantum을 한번쯤은 들어보셨을겁니다. Firefox를 더욱 빠르게 만들기 위해 브라우저 내부를 개선하고 있는 프로젝트이죠. 저희는 실험중인 브라우저, Servo로부터 부분적으로 Firefox에 반영하고 있으며 CSS 엔진에 엄청난 개선을 진행 중에 있습니다.

이 프로젝트는 하늘을 날고 있는 비행기에서 제트 엔진을 교체하는 작업에 비유할 수 있습니다. 저희는 컴포넌트 단위로 당장의 실질적인 변화를 반영하면서 여러분들은 각각의 개선된 컴포넌트를 즉각적으로 Firefox에서 즉각 체감하실 수 있습니다.

Servo로부터 반영된 개선 사항 중 첫번째로 Quantum CSS(Stylo라고 알려졌던)라고 불리는 새로운 CSS 엔진을 Firefox Nightly 버전에서 테스트해보실 수 있습니다. about:config에 들어가서 layout.css.servo.enabled 설정이 true인지 확인함으로써 이 개선사항이 현재 동작하는 중인지 확인하실 수 있습니다.

이 새로운 엔진은 굉장한 CSS 엔진을 만들기 위해 각각의 다른 네가지 브라우저로부터 새로운 기술을 접목하여 만들어졌습니다.

4 browser engines feeding in to Quantum CSS

또한 이 엔진은 현대의 하드웨어를 충분히 활용하고 있는데, 모든 작업을 여러분의 기기가 가진 모든 코어를 활용하여 동시에 실행합니다. 즉, 기존의 작업보다 2배, 4배, 혹은 18배까지도 빨라진다는 의미입니다.

무엇보다 이 엔진은 기존의 다른 브라우저들이 가진 최신 기술들을 접목하였기 때문에 멀티 코어를 통해 동시에 작업을 진행하지 않더라도 가장 빠른 엔진일 것입니다.

Racing jets

CSS엔진이 하는 일은 무엇일까요? 우선, CSS 엔진이 무엇인지 알아보고 브라우저의 나머지 기능들과 어떻게 조화를 이루는지 살펴봅시다. 그러고 나면 Quantum CSS가 어떻게 CSS 엔진의 작업들을 더욱 빠르게 만드는지 이해할 수 있을 것입니다.

CSS가 하는 일은?

CSS 엔진은 브라우저의 렌더링 엔진의 일부입니다. 렌더링 엔진은 웹사이트의 HTML, CSS 파일을 받아와서 화면에 픽셀 단위로 변환하는 일을 합니다.

Files to pixels

각 브라우저는 고유한 렌더링 엔진을 가지고 있습니다. Chrome은 Blink, Edge는 EdgeHTML, Safari는 WebKit, 그리고 Firefox는 Gecko를 가지고 있습니다.

파일들을 픽셀 단위로 변환하기 위해서 서로 다른 렌더링 엔진들은 같은 작업을 수행합니다.

  1. 파일들을 브라우저가 인식할 수 있도록 DOM을 포함한 객체로 변환합니다. 이 단계에서, DOM을 통해 페이지 구조를 파악하고 엘리먼트들 간의 부모/자식 구조를 알 수 있습니다. 하지만 아직 각각의 엘리먼트들이 어떤 모습으로 보여지는 지는 알 수 없습니다. HTML을 DOM 트리로 변환하기
  2. 엘리먼트들이 어떻게 보여야 하는지 파악합니다. 각각의 DOM 노드에 대해서, CSS 엔진은 어떤 CSS 규칙이 적용되어야 하는지 알아냅니다. 그 후에는 각 DOM 노드에 대한 CSS 값들을 파악합니다. 계산된 스타일을 통해 각각의 DOM 노드에 적용하기
  3. 각 노드의 크기와 위치를 파악합니다. 화면에 보여질 내용을 위해 박스를 생성합니다. 하나의 박스가 하나의 DOM을 의미하는 것은 아닙니다… DOM 노드 내부에 있는 또 다른 것을 위한 박스, 예를 들면 텍스트에 그려질 선같은 것 일 수도 있습니다.프레임 트리를 생성할 박스 계산하기
  4. 박스들을 화면에 그립니다. 이 작업은 다중 레이어 위에서 발생할 수 있습니다. 저는 이 작업이 과거에 여러 장의 반투명 용지 위에 손수 그렸던 애니메이션 작업과 비슷하다고 생각합니다. 이 작업으로 인해 변경 사항이 생길 경우 다른 레이어에 매번 다시 그릴 필요 없이 단지 하나의 레이어만 변경하면 완성되도록 작업을 수월하게 만들어줍니다. 레이어 그리기
  5. 각각 다른 레이어들에 변화효과같은 compositor-only 값들을 우선 적용합니다. 그리고 이 레이어들을 하나의 이미지로 만들어냅니다. 이 작업은 기본적으로 서로 다른 레이어를 겹쳐놓은 뒤 사진을 찍는 작업과 비슷합니다. 최종적으로 이 이미지가 화면에 그려지게 되는 것입니다. 레이어를 조합하여 이미지 생성

위의 작업 목록을 통해 스타일을 계산하는 작업이 시작될 때 CSS 엔진이 두가지 작업을 하게 됨을 알 수 있습니다:

  • DOM 트리
  • 스타일 규칙 목록

각각의 DOM 노드를 하나씩 살펴 내려가면서 해당 DOM 노드에 적용될 스타일을 파악합니다. 이 과정에서 DOM 노드들에 CSS 속성들을 적용합니다.

저는 이 과정이 폼을 따라 값을 채워나가는 작업과 비슷하다고 생각합니다. CSS 엔진은 각각의 DOM 노드들에 해당하는 폼을 채워야 하는 것입니다. 그리고 각 필드는 값을 필요로 하는 것이구요.

CSS 속성을 위한 비어있는 폼

이 작업을 수행하기 위해 CSS 엔진은 두가지 일을 해야합니다:

  • 어떤 규칙이 DOM 노드에 적용되어야 하는지 파악합니다 – aka 선택자 매칭.
  • 부모로부터 상속받은 값과 기본값을 포함하여 비어있는 값을 채우게 됩니다 – aka 캐스캐이드.

선택자 매칭

이 단계에서는 DOM 노드에 맞는 규칙을 목록에 추가합니다. 하나의 DOM 노드에 복수의 규칙이 매치될 수 있으므로 같은 속성에 대하여 복수의 선언이 존재할 수 있습니다.

매칭된 CSS 규칙 옆에 확인 표시를 하는 사람

추가적으로 브라우저 스스로 기본 CSS를 추가합니다(사용자 에이전트 스타일시트로 불리는). CSS 엔진은 이 중 어떤 값이 적용될 지 어떻게 알 수 있을까요?

이 때 특수한 규칙이 필요합니다. CSS 엔진은 기본 스타일시트를 생성하고 서로 다른 항목에 대하여 선언된 속성을 정렬합니다.

스타일시트 내에서의 선언들

최종적으로는 더 높은 우선순위의 규칙이 이기게 됩니다. 그래서 이 스타일시트를 기준으로 CSS 엔진은 값들을 채워나가게 됩니다.

CSS 속성들로 채워진 폼

이제 캐스캐이드가 남았습니다.

캐스캐이드

CSS는 캐스캐이드 덕분에 더 쉽게 작성되고 유지됩니다. 캐스캐이드로 덕분에 여러분은 최상위 body에 color 속성을 설정할 수 있으며 p 안에 들어갈 텍스트를 알 수 있고 spanli 엘리먼트들이 위 body에 적용된 생상을 그대로 사용할 수 있게 할 수 있습니다(여러분이 특정한 값을 오버라이드하지 않는다는 가정 하에).

이를 위해 CSS 엔진은 폼 내부에 비어있는 박스를 확인합니다. 만약 속성이 기본값을 그대로 물려받는다면 CSS 엔진은 트리 구조에서 부모를 따라 거슬러 올라가 해당하는 값을 가지고 있는지 확인합니다. 만약 아무도 값을 가지지 않거나 해당 속성을 물려받고 있지 않다면 그대로 기본값이 적용됩니다.

모든 CSS 속성이 채워진 폼

드디어 이 DOM 노드를 위한 모든 스타일 계산이 완료되었습니다.

추가 노트: 스타일 구조 공유

제가 여러분께 보여드린 폼에는 약간의 오류가 있습니다. CSS는 수백개의 속성을 가지고 있다는 점입니다. 만약 CSS 엔진을 하나의 DOM 노드에 대한 각각의 속성값이 점유하고 있다면 금새 메모리를 다 써버리고 말 것입니다.

대신에 CSS 엔진은 스타일 구조 공유라고 불리는 작업을 수행합니다. CSS 엔진은 스타일 구조라고 불리는 서로 다른 객체에 함께 쓰이는 정보(font 속성과 같은)를 저장해놓습니다. 같은 객체에 해당하는 모든 속성을 가지고 있는 대신에, 계산된 스타일 객체는 포인터를 가지게 됩니다. 각각의 분류에 따라 해당하는 DOM 노드에 맞는 값을 가지고 있는 스타일 구조를 가리키는 포인터를 가지게 됩니다.

서로 구분된 객체를 가리키고 있는 폼

이 작업을 통해 메모리와 시간 모두를 절약할 수 있습니다. 비슷한 속성을 갖는 노드(형제 DOM과 같은)들은 그들이 공유하는 속성을 가진 동일한 구조를 가리키기만 하면 됩니다. 그리고 대부분의 많은 속성들이 상속을 통해 적용되기 때문에, 하나의 조상 DOM은 특정한 값으로 오버라이딩 되지 않은 후손 DOM들과 스타일 구조를 공유할 수 있습니다.

이제, 어떻게 일련의 작업들을 빠르게 만들면 될까요?

이제까지 알아본 작업은 아직 최적화가 되지 않은 채로 스타일을 계산하는 과정이였습니다.

CSS 스타일 계산 과정: 선택자 매칭, 우선순위 정렬, 속성값 계산

이 과정에서 굉장히 많은 작업들이 수행됩니다. 그리고 처음 페이지를 불러올 때 수행할 필요가 없는 작업도 존재합니다. 사용자가 엘리먼트 위에 마우스를 올려놓거나, DOM에 변화를 주어 스타일을 다시 계산하는 작업과 같이 페이지와 상호작용하며 발생하는 것처럼요.

마우스 오버와 같이 스타일을 다시 계산하는 작업 등이 포함된 최초 스타일 과정

이는 CSS 스타일 계산하는 과정에서 최적화가 이루어질 수 있는 요소가 존재함을 의미합니다… 그리고 각각의 브라우저들은 과거 20년 동안 이 과정을 최적화하기 위하여 다양한 전략들을 테스트해왔습니다. Quantum CSS는 서로 다른 브라우저 엔진들이 시도해왔던 전략들의 장점만을 접목하여 만든 결과물입니다.

자, Quantum CSS에서 일련의 작업들이 수행되는 과정을 세세하게 살펴봅시다.

모든 작업을 동시에 수행

Servo 프로젝트(Quantum CSS가 탄생하게 된)는 웹페이지를 그리기 위한 작업들을 동시에 실행하도록 시도하는 실험적인 브라우저입니다. 이게 무슨 의미일까요?

컴퓨터는 사람의 뇌와 같습니다. 컴퓨터에는 생각(ALU)을 관장하는 부분이 있습니다. 그 부분에는 레지스터라 불리는 단기 기억 저장소가 있습니다. 레지스터는 CPU에 함께 모여있습니다. 그리고 장기 기억 저장소인 RAM이 있습니다.

ALU(생각을 관장하는 부분)를 가진 CPU와 레지스터(단기 기억 메모리)

초기 컴퓨터는 이 CPU를 활용하여 한번에 하나의 생각만을 할 수 있었습니다. 수십년이 지난 지금, 복수의 CPU는 여러 코어에 걸쳐 복수의 ALU, 레지스터를 가지고 있습니다. 이를 통해 오늘날의 CPU는 한번에 여러 생각을 할 수 있는데 이를 동시성이라고 합니다.

복수의 ALU와 레지스터를 가진 다중 코어 CPU 칩

Quantum CSS는 이러한 최신 컴퓨터의 기술을 활용하여 다른 코어를 활용해 서로 다른 DOM 노드의 스타일 계산을 동시에 수행합니다.

트리 구조의 가지들을 단순히 구분지어 다른 코어에 배치하면 될 것 같은, 얼핏 들으면 매우 쉬워보이지만 실은 몇가지 이유로 생각보다 훨씬 어려운 작업입니다. 그 중 첫번째 이유는 일반적인 DOM 구조는 분포가 고르지 못하기 때문입니다. 이 말은 즉슨, 단순히 여러 코어에 작업을 배분할 경우 특정한 코어가 다른 코어들보다 훨씬 더 많은 작업량을 배분받게 된다는 뜻입니다.

분포가 고르지 못한 DOM 트리의 작업을 다중 코어에 배분하여 하나의 코어가 대부분의 작업을 수행하는 모습

보다 공평하게 작업량을 배분하기 위해서 Quantum CSS는 ‘작업 훔치기(work stealing)’이라는 기술을 사용합니다. DOM 노드가 그려지는 과정에서 ‘작업 훔치기’ 코드는 한단계 아래의 자식 노드를 1개 이상의 “작업 유닛”으로 쪼갭니다. 이 작업 유닛들은 큐에 담기게 됩니다.

배분된 작업을 작업 유닛으로 쪼개는 코어

하나의 코어가 큐에 작업 유닛을 담는 작업을 완료하고 난 뒤, 더 담을 수 있는 작업이 다른 코어가 가진 큐에 존재하는지 살피게 됩니다. 이러한 작업을 통해 우리는 쉽게 작업을 배분할 수 있으며 트리를 살피거나 보다 균형잡힌 배분을 위해 따로 시간을 쓰지 않아도 됩니다.

더 많은 작업을 가진 다른 코어로부터 '작업 훔치기'를 하는 코어들

대부분의 브라우저에서는 이 작업을 올바르게 수행하기가 꽤 어렵습니다. 동시성은 매우 어려운 문제로 알려져 있으며 CSS 엔진은 매우 복잡하기까지 합니다. 또한 렌더링 엔진의 매우 복잡한 두 분야 – DOM과 레이아웃 간의 문제이기도 합니다. 그렇기 때문에 버그가 발생할 확률도 높으며 동시성은 찾아내기 힘든 데이터 경쟁(data races)이라고 불리는 버그를 발생시킬 수 있습니다. 이 버그에 대해서는 다른 글에 자세히 소개해두었습니다.

수백, 수천만의 엔지니어들이 자유롭게 기여할 수 있는 환경에서 어떻게 하면 위 버그에 대한 두려움 없이 프로그램을 작성할 수 있을까요? 바로 이 부분을 해결하고자 나온 것이 Rust입니다.

Rust 로고

Rust에서는 통계적으로 데이터 경쟁이 발생하지 않는 것을 확인할 수 있습니다. 즉, Rust를 활용하면 여러분은 이 까다로운 버그를 쉽게 피할 수 있습니다. 컴파일러가 애초에 데이터 경쟁이 발생하는 것을 막기 때문입니다. 후에 이 부분에 대해서는 더 자세히 다루도록 하겠습니다. 대신에 Rust의 동시성을 다루는 영상 혹은 ‘작업 훔치기’에 대한 자세한 내용을 참고하실 수 있습니다.

이를 활용하면 CSS 스타일 계산은 동시성을 충분히 활용할 수 있습니다. 즉, 계산 속도가 선형에 가깝게 빨라질 수 있습니다. 여러분의 기기가 4개의 코어를 가지고 있다면, 4배가 더 빨라지는 것이죠.

CSS 규칙 트리를 활용하여 스타일 재계산을 빠르게 하기

각각의 DON 노드들에 대해 CSS 엔진은 선택자 매칭을 위해 모든 규칙을 전부 훑어야 합니다. 대부분의 노드는 일단 그려지고 나면 변화가 그렇게 많지 않습니다. 예를 들면, 사용자가 부모 노드 위에 마우스 오버를 하는 경우, 이에 해당하는 CSS 규칙이 바뀌어야 합니다. 이에 대해 자식 노드에게 상속되는 값을 다루기 위해 스타일을 다시 계산해야 하지만, 자식 노드들이 가지고 있는 규칙은 아마도 변하지 않을 것입니다.

만약 이 자식 노드들에 대해 어떤 규칙이 적용되고 있는지 기억할 수만 있다면, 그에 대해서는 선택자 매칭을 다시 수행하지 않아도 될 것입니다… 이 부분이 바로 Firefox의 이전 엔진에서 가져온 기능, 규칙 트리가 하는 일입니다.

CSS 엔진은 해당하는 선택자를 파악하는 과정을 거치고 난 뒤, 그것들을 우선순위에 따라 정렬합니다. 이 과정을 통해 엔진은 규칙들의 연결 리스트를 생성합니다.

이 리스트는 나중에 트리에 추가됩니다.

규칙 트리에 추가되는 규칙의 연결 리스트

CSS 엔진은 트리가 가진 가지의 숫자를 최소한으로 유지하기 위해 할 수 있는 많은 부분에서 가지를 재사용하려고 합니다.

이 리스트에 담겨있는 대부분의 선택자들은 기존의 가지와 동일하기 때문에 동일한 경로를 따르게 됩니다. 하지만 경우에 따라 리스트에 있는 다음 규칙이 트리의 가지에 없는 상황이 생길 수도 있습니다. 오직 이 경우에만 새로운 가지를 추가하게 됩니다.

트리에 추가되는 연결 리스트의 마지막 항목

DOM 노드는 마지막에 삽입된 규칙을 가리키는 포인터를 가지게 됩니다(예를 들면 div#warning 규칙같은).

스타일을 다시 그리는 과정에서 엔진은 부모 노드의 변화 중 자식 노드에 영향을 미치는 것이 있는지 빠르게 살펴봅니다. 만약 없다면 엔진은 모든 자식 노드들에 대하여 자식 노드들이 향하는 포인터를 그대로 따르게 됩니다. 이 때, 엔진은 트리를 그대로 따라 올라가면서 가장 우선순위가 높은 곳부터 낮은 곳까지 필요한 스타일 규칙의 전체 리스트를 그대로 가져올 수 있습니다. 즉, 선택자 매칭과 정렬 작업을 완전히 건너 뛸 수 있다는 뜻이 됩니다.

선택자 매칭과 우선순위 정렬을 건너뛰기

이 기능은 스타일을 다시 계산하는 동안 필요한 작업량을 줄여주는데 도움이 됩니다. 하지만 스타일을 처음 계산할 때는 여전히 많은 작업이 필요합니다. 만약 10,000개의 노드가 있다면, 엔진은 10,000번의 선택자 매칭을 해야할 것입니다. 이 때 속도를 개선하는 데에는 또 다른 방법이 있습니다.

스타일 공유 캐시를 활용한 첫 렌더링(캐스캐이드 포함) 속도 개선

수천개의 노드를 가진 페이지를 생각해보세요. 이 중 많은 노드들이 같은 스타일 규칙을 가질 것입니다. 예를 들면, 매우 긴 Wikipedia 페이지를 떠올려 보세요. 본문 영역의 문단들은 다 같은 스타일 규칙과 같은 계산된 결과값을 가질 수 밖에 없습니다.

만약 최적화 작업을 수행하지 않는다면, CSS 엔진은 각각의 문단에 대해 선택자 매칭을 수행하고 스타일을 계산해야 합니다. 하지만 각각의 문단이 정확히 같은 스타일을 갖는다는 것을 증명할 수만 있다면, CSS 엔진은 작업을 한번만 수행하면 될 것이고 같은 결과값을 모든 문단에 그대로 적용하면 될 것입니다.

이 기능이 바로 Safari와 Chrome으로 부터 영감을 받은 스타일 공유 캐시입니다. 이 과정이 끝나면 계산된 스타일 결과값을 캐시에 저장합니다. 그리고 다음 노드의 스타일을 계산하기 전에 캐시를 활용할 수 있는지 우선 캐시를 먼저 확인합니다.

확인하는 사항:

  • 두 노드가 같은 id나 class를 갖는가? 그렇다면 같은 규칙을 가질 것이다.
  • 선택자 기반의 규칙이 아니라면 – 예를 들면 인라인 스타일 같은 – 두 노드가 같은 값을 갖는가? 만약 그렇다면 상위의 규칙은 오버라이딩 되지 않거나 두 노드가 같은 방식으로 오버라이딩 될 것이다.
  • 두 노드의 부모 모두가 같은 계산된 스타일 객체를 가리키고 있는가? 그렇다면 상속받은 값 또한 같을 것이다.

모든 형제 노드들이 공유하고 있는 스타일 계산값과 조카 노드가 공유할 수 있는지를 묻는 질문. 답은: 예

위 작업은 렌더링 시작 단계부터 스타일 공유 캐시의 앞단계까지 진행됩니다. 하지만 간혹 스타일이 일치하지 않는 경우들이 꽤 있습니다. 예를 들어, CSS 규칙이 :first-child 선택자인 경우, 두 문단은 위 확인 작업을 통해 캐시가 추천되는 상황이지만 동일한 스타일을 공유하지 않게 됩니다.

Webkit과 Blink에서는 스타일 공유 캐시 기능이 이 경우에 동작하지 않고 캐시를 이용하지 않습니다. 결국 접근하는 사이트들이 최신의 선택자들을 쓸수록 최적화 작업이 쓸모없어지기 때문에 Blink 팀은 이 기능을 최근에 아예 없애버렸습니다. 하지만 이 경우에도 스타일 공유 캐싱을 활용할 수 있는 방법을 찾아냈습니다.

Quantum CSS에서는 이런 일반적이지 않은 선택자들을 모두 한데 모아서 DOM 노드에 영향을 미치는지 확인합니다. 그리고 결과값을 1과 0으로 저장합니다. 만약 두 엘리먼트가 같은 1과 0 값을 가진다면 두개가 정확히 일치한다고 볼 수 있는 것입니다.

:first-child 같은 선택자가 레이블로 함께 적힌 0과 1을 보여주는 점수판

만약 DOM 노드가 이미 계산이 완료된 스타일을 공유할수만 있다면, 여러분은 상당히 많은 양의 작업을 건너 뛸 수 있습니다. 웹페이지는 대게 같은 스타일을 공유하는 많은 DOM 노드를 가지고 있기 때문에 스타일 공유 캐시 기능은 메모리를 절약할 수 있을 뿐만 아니라 속도도 매우 빨라질 수 있습니다.

모든 작업 건너뛰기

결론

Quantum CSS는 Servo로부터 Firefox로 이식하는 첫번째 대형 프로젝트입니다. 그동안 우리는 Firefox의 중요한 기능들을 Rust로 작성하면서 현대적이고 최고의 성능을 가져오는 방법에 대해 많은 배움을 얻을 수 있었습니다.

우리는 사용자들에게 가장 첫번째로 프로젝트 Quantum을 선보이게 되어 매우 기쁩니다. 여러분들이 이 기능을 충분히 활용해보고 어떤 이슈라도 발견한다면 언제든지 저희에게 알려주세요.

About

Lin Clark

Lin은 Mozilla Developer Relation 팀에서 근무하는 엔지니어입니다. 그녀는 Javascript, WebAssembly, Rust, Servo를 다루며 그 외 코드 만화를 그리고 있습니다.

그 외 Lin Clark이 작성한 다른 글 보러가기

작성자: Hoony Chang

Web Programmer

Hoony Chang가 작성한 문서들…


1개 댓글

  1. ALam Kim

    내용이 좋아서 제가 번역해볼까 했었는데 먼저 하셨군요..ㅎㅎㅎ
    좋은 글 감사합니다.

    10월 13th, 2017 at 9:12 오전

댓글 쓰기