RenderingNG 심층 분석: LayoutNG

이안 킬패트릭
이안 킬패트릭
코지 이시
Koji Ishi

저는 Koji Ishii와 함께 Blink 레이아웃팀의 엔지니어링 책임자인 Ian Kilpatrick입니다. Blink팀에서 일하기 전에는 (Google이 '프런트엔드 엔지니어' 역할을 맡기 전에는) 프런트엔드 엔지니어로 일하면서 Google Docs, Drive, Gmail 내에서 기능을 만들었습니다. 그 역할을 담당한 약 5년 후, 저는 큰 도박에 종사한 후 Blink팀으로 옮겨가며 C++를 실용적으로 배우고, 매우 복잡한 Blink 코드베이스로 실력을 쌓으려고 노력했습니다. 지금도 이러한 정보의 극히 일부분만을 이해할 수 있습니다. 이 기간 동안 시간을 내주셔서 감사합니다. 많은 '프런트엔드 엔지니어 복구'가 저보다 먼저 '브라우저 엔지니어'로 전환되었다는 사실에 위안이 되었습니다.

이전에 Blink팀에서 근무하면서 저를 개인적으로 이끌었던 경험이 있습니다. 저는 프런트엔드 엔지니어로서 브라우저 비일관성, 성능 문제, 렌더링 버그, 기능 누락에 끊임없이 직면했습니다. LayoutNG는 제가 Blink의 레이아웃 시스템 내에서 이러한 문제를 체계적으로 해결하는 데 도움이 될 수 있는 기회였으며, 수년간 많은 엔지니어가 노력한 결과물이었습니다.

이 게시물에서는 이와 같은 대규모 아키텍처 변경이 다양한 유형의 버그와 성능 문제를 줄이고 완화하는 방법을 설명합니다.

레이아웃 엔진 아키텍처의 3,000피트 뷰

이전에는 Blink의 레이아웃 트리를 '변경 가능한 트리'라고 했습니다.

다음 텍스트에 설명된 대로 트리를 표시합니다.

레이아웃 트리의 각 객체에는 상위 요소가 부과한 사용 가능한 크기, 부동 소수점 수의 위치, 출력 정보(예: 객체의 최종 너비와 높이 또는 객체의 x 및 y 위치)와 같은 입력 정보가 포함되어 있습니다.

이러한 객체는 렌더기 사이에 보관되었습니다. 스타일이 변경되면 해당 객체를 더티로 표시했고 마찬가지로 트리의 모든 상위 객체를 더티로 표시했습니다. 렌더링 파이프라인의 레이아웃 단계가 실행되면 트리를 정리하고 더티 객체를 확인한 다음 레이아웃을 실행하여 깔끔하게 만듭니다.

이 아키텍처로 인해 여러 종류의 문제가 발생했고, 이에 대해서는 아래에서 설명하겠습니다. 하지만 먼저 한 걸음 물러서 레이아웃의 입력과 출력이 무엇인지 생각해 보겠습니다.

이 트리의 노드에서 레이아웃을 실행하면 개념적으로 '스타일과 DOM'을 사용하고 상위 레이아웃 시스템 (그리드, 블록, 플렉스)의 모든 상위 제약 조건을 가져와서 레이아웃 제약 조건 알고리즘을 실행하고 결과를 생성합니다.

앞에서 설명한 개념 모델입니다.

새로운 아키텍처는 이러한 개념 모델을 형식화합니다. 레이아웃 트리는 그대로 유지되지만 주로 레이아웃의 입력과 출력을 유지하는 데 사용합니다. 출력을 위해 프래그먼트 트리라는 완전히 새로운 변경 불가능한 객체를 생성합니다.

프래그먼트 트리

변경 불가능한 프래그먼트 트리는 이전에 다루었는데, 이전 트리의 상당 부분을 증분 레이아웃에 재사용하도록 디자인된 방식을 설명했습니다.

또한 해당 프래그먼트를 생성한 상위 제약 조건 객체를 저장합니다. 이 키는 아래에 자세히 설명되어 있는 캐시 키로 사용됩니다.

인라인 (텍스트) 레이아웃 알고리즘도 변경 불가능한 새로운 아키텍처에 맞게 다시 작성됩니다. 인라인 레이아웃의 변경 불가능한 플랫 목록 표현을 생성할 뿐 아니라 더 빠른 재레이아웃을 위한 단락 수준 캐싱, 요소와 단어에 글꼴 기능을 적용하는 단락별 도형, ICU를 사용하는 새로운 유니코드 양방향 알고리즘, 다양한 수정사항 수정 등도 제공합니다.

레이아웃 버그의 유형

일반적으로 레이아웃 버그는 네 가지 카테고리로 분류되며 각 카테고리에는 근본적인 원인이 다릅니다

정확성

렌더링 시스템의 버그에 관해 생각할 때는 일반적으로 정확성에 대해 생각합니다. 예를 들어 '브라우저 A는 X 동작이지만 브라우저 B에는 Y 동작이 있습니다.' 또는 '브라우저 A와 B가 모두 작동하지 않습니다.' 예전에는 이 부분에 많은 시간을 들였고 그 과정에서 끊임없이 시스템과 싸우곤 했습니다. 일반적인 장애 모드는 특정 버그에 고도로 타겟팅된 수정사항을 적용하는 것이지만, 몇 주 후에 시스템의 다른 부분 (관련성이 없는 것처럼 보이는) 부분에서 회귀를 야기한 것을 발견했습니다.

이전 게시물에서 설명했듯이 이는 시스템이 매우 불안정하다는 신호입니다. 특히 레이아웃의 경우 클래스 간에 완벽한 계약이 체결되지 않았기 때문에 브라우저 엔지니어가 실행해서는 안 되는 상태에 의존하거나 시스템의 다른 부분에서 일부 값을 잘못 해석하게 되었습니다.

예를 들어 어느 시점에 Flex 레이아웃과 관련하여 1년이 넘는 기간 동안 약 10개의 버그 체인이 있었습니다. 각각의 수정사항으로 인해 시스템의 일부에 정확성 또는 성능 문제가 발생하여 또 다른 버그가 발생했습니다.

LayoutNG가 레이아웃 시스템의 모든 구성요소 간 계약을 명확하게 정의했으므로 훨씬 더 확신을 갖고 변경사항을 적용할 수 있음을 알게 되었습니다. Google은 여러 당사자가 공통 웹 테스트 도구 모음에 기여할 수 있는 훌륭한 웹 플랫폼 테스트 (WPT) 프로젝트의 이점을 크게 누리고 있습니다.

현재 안정적인 채널에서 실제 회귀를 공개한다면 일반적으로 WPT 저장소에 관련 테스트가 없으며 구성요소 계약을 잘못 이해하여 비롯된 것이 아님을 알게 되었습니다. 또한 Google은 버그 수정 정책의 일환으로 항상 새로운 WPT 테스트를 추가하여 브라우저가 같은 실수를 다시 하지 않도록 하기 위해 노력하고 있습니다.

무효화

브라우저 창의 크기를 조절하거나 CSS 속성을 마술처럼 전환하면 버그가 사라지는 알 수 없는 버그가 발생한 적이 있다면 무효화 부족 문제가 발생한 것입니다. 변경 가능한 트리의 일부는 사실상 정상적인 것으로 간주되었지만 상위 제약조건의 일부 변경으로 인해 올바른 출력을 나타내지 못했습니다.

이는 아래에 설명된 2 패스(최종 레이아웃 상태를 결정하기 위해 레이아웃 트리를 두 번 이동) 레이아웃 모드에서 매우 일반적입니다. 이전 코드는 다음과 같습니다.

if (/* some very complicated statement */) {
  child->ForceLayout();
}

이러한 유형의 버그는 일반적으로 다음과 같이 수정합니다.

if (/* some very complicated statement */ ||
    /* another very complicated statement */) {
  child->ForceLayout();
}

이러한 유형의 문제를 수정하면 일반적으로 심각한 성능 저하(아래의 과도한 무효화 참고)를 야기할 수 있으므로 수정하기에는 매우 까다로웠습니다.

위에서 설명한 것처럼 현재 상위 레이아웃에서 하위 요소로의 모든 입력을 설명하는 변경 불가능한 상위 제약 조건 객체가 있습니다. 이를 변경 불가능한 결과 프래그먼트와 함께 저장합니다. 이로 인해 하위 요소가 또 다른 레이아웃 패스를 실행해야 하는지 확인하기 위해 중앙에서 이 두 입력을 차이로 결정합니다. 이 디핑 로직은 복잡하지만 잘 포함되어 있습니다. 이 클래스의 과소 무효화 문제를 디버깅하면 일반적으로 두 입력을 수동으로 검사하고 입력에서 변경된 부분을 결정하여 또 다른 레이아웃 패스가 필요합니다.

이 디핑 코드의 수정은 일반적으로 간단하며 이러한 독립 객체를 간단하게 만들 수 있기 때문에 쉽게 단위 테스트할 수 있습니다.

고정 너비 및 백분율 너비 이미지 비교
고정 너비/높이 요소는 주어진 사용 가능한 크기가 증가해도 상관하지 않지만 백분율 기반 너비/높이 요소는 고려됩니다. available-sizeParent Constraints 객체에 표시되며 디핑 알고리즘의 일부로 이 최적화를 수행합니다.

위 예의 디핑 코드는 다음과 같습니다.

if (width.IsPercent()) {
  if (old_constraints.WidthPercentageSize() 
    != new_constraints.WidthPercentageSize())
   return kNeedsLayout;
}
if (height.IsPercent()) {
  if (old_constraints.HeightPercentageSize() 
    != new_constraints.HeightPercentageSize())
   return kNeedsLayout;
}

이력 현상

이 버그 클래스는 무효화 부족과 유사합니다. 본질적으로 이전 시스템에서는 레이아웃이 멱등성을 갖는지 확인하기가 매우 어려웠습니다. 즉, 동일한 입력으로 레이아웃을 다시 실행하여 동일한 출력을 생성하는 것이었습니다.

아래 예에서는 두 값 간에 CSS 속성을 전환할 수 있습니다. 그러나 이로 인해 '무한 확장'되는 직사각형이 생성됩니다.

동영상 및 데모에서 Chrome 92 이하의 히스테리시스 버그를 보여줍니다. Chrome 93에서 해결되었습니다.

이전에 변경 가능한 트리를 사용하면 이런 버그를 쉽게 도입할 수 있었습니다. 코드가 잘못된 시간이나 단계에서 객체의 크기나 위치를 읽는 실수를 한 경우(예: 이전 크기나 위치를 '지우지' 않은 경우) 즉시 미묘한 이력 버그가 추가됩니다. 대부분의 테스트에서 단일 레이아웃 및 렌더링에 중점을 두기 때문에 이러한 버그는 일반적으로 테스트에 나타나지 않습니다. 더욱 우려스러운 점은 레이아웃 모드가 올바르게 작동하도록 하기 위해서는 이러한 히스테리시스가 필요하다는 사실입니다. 레이아웃 패스를 삭제하기 위해 최적화를 실행하는 버그가 있었지만 올바른 출력을 얻으려면 레이아웃 모드에 두 패스가 필요하기 때문에 '버그'가 발생했습니다.

앞의 텍스트에서 설명한 문제를 보여주는 트리
이전 레이아웃 결과 정보에 따라 비멱등 레이아웃이 발생함

LayoutNG를 사용하면 명시적인 입력 및 출력 데이터 구조가 있고 이전 상태에 액세스할 수 없으므로 레이아웃 시스템에서 이러한 버그 클래스를 광범위하게 완화했습니다.

과도한 무효화 및 성능

이는 무효화 미달 버그 클래스와 정반대입니다. 과소 무효화 버그를 수정할 때 종종 성능 급등이 발생합니다.

성능보다는 정확성을 기리기 위해 어려운 선택을 해야 할 때가 많았습니다. 다음 섹션에서는 이러한 유형의 성능 문제를 완화한 방법을 자세히 알아봅니다.

이중 패스 레이아웃의 부상과 성능 격변

Flex와 그리드 레이아웃은 웹 레이아웃의 표현력의 변화를 나타냅니다. 그러나 이러한 알고리즘은 그 이전의 블록 레이아웃 알고리즘과는 근본적으로 다릅니다.

블록 레이아웃 (거의 모든 경우)은 엔진이 모든 하위 요소에서 정확히 한 번만 레이아웃을 실행하도록 요구합니다. 성능에는 좋지만 웹 개발자가 원하는 만큼 표현력이 뛰어나지는 않습니다.

예를 들어 모든 하위 요소의 크기를 가장 큰 하위 요소의 크기로 확장하고자 하는 경우가 많습니다. 이를 지원하기 위해 상위 요소 레이아웃 (가변 또는 그리드)은 측정 패스를 실행하여 각 하위 요소의 크기를 확인한 다음 모든 하위 요소를 이 크기로 확장하는 레이아웃 패스를 실행합니다. 이 동작은 Flex와 그리드 레이아웃 모두의 기본값입니다.

두 세트의 상자입니다. 첫 번째는 측정 패스에 있는 상자의 고유한 크기를 보여주며, 두 번째는 레이아웃에서 모두 동일한 높이가 됩니다.

이러한 두 패스 레이아웃은 사람들이 일반적으로 깊이 중첩하지 않았기 때문에 처음에 성능 면에서 허용되었습니다. 그러나 더 복잡한 콘텐츠가 등장하면서 심각한 성능 문제가 발생하기 시작했습니다. 측정 단계의 결과를 캐시하지 않으면 레이아웃 트리가 측정 상태와 최종 레이아웃 상태 사이에서 스래싱됩니다.

캡션에 설명된 1, 2, 3 패스 레이아웃
위 이미지에는 세 개의 <div> 요소가 있습니다. 간단한 원패스 레이아웃 (예: 블록 레이아웃)은 3개의 레이아웃 노드 (복잡성 O(n))를 방문합니다. 하지만 가변 또는 그리드와 같은 2 패스 레이아웃의 경우 이 예에서 O(2n) 방문의 복잡성이 발생할 수 있습니다.
레이아웃 시간의 기하급수적 증가를 보여주는 그래프
이 이미지와 데모는 그리드 레이아웃의 지수 레이아웃을 보여줍니다. 이 문제는 Grid를 새 아키텍처로 이동한 결과로 Chrome 93에서 수정되었습니다.

이전에는 이러한 유형의 성능 급락에 대처하기 위해 가변 및 그리드 레이아웃에 매우 구체적인 캐시를 추가하려고 했습니다. 이 방법은 효과적이었지만 Flex와도 잘 협력했지만 무효화 버그와 관련하여 끊임없이 싸웠습니다.

LayoutNG를 사용하면 레이아웃의 입력과 출력 모두를 위한 명시적 데이터 구조를 만들 수 있으며, 그 위에 측정 및 레이아웃 패스의 캐시를 빌드했습니다. 이렇게 하면 복잡성이 다시 O(n)로 돌아가고 웹 개발자에게 예측 가능한 선형 성능을 제공합니다. 레이아웃이 3 패스 레이아웃을 실행하는 경우 그 패스도 캐시합니다. 이를 통해 RenderingNG가 근본적으로 확장성을 잠금 해제하는 방법에 대한 향후 예를 통해 고급 레이아웃 모드를 안전하게 도입할 기회를 얻을 수 있습니다. 경우에 따라 그리드 레이아웃에 3 패스 레이아웃이 필요할 수 있지만, 지금은 매우 드뭅니다.

특히 레이아웃과 관련된 성능 문제가 개발자에게 발생하는 경우 일반적으로 파이프라인의 레이아웃 단계의 원시 처리량이 아닌 지수 레이아웃 시간 버그로 인한 것입니다. 작은 증분 변경 (하나의 요소가 단일 CSS 속성을 변경)으로 인해 50~100ms의 레이아웃이 생성된다면 이는 지수 레이아웃 버그일 가능성이 높습니다.

요약

레이아웃은 굉장히 복잡한 영역입니다. 여기서는 인라인 레이아웃 최적화(실제로 전체 인라인 및 텍스트 하위 시스템의 작동 방식)와 같은 모든 종류의 흥미로운 세부정보를 다루지 않았으며, 여기에서 논의한 개념조차도 사실 표면에만 치우치고 많은 세부사항은 다루지 않았습니다. 그러나 시스템의 아키텍처를 체계적으로 개선하면 장기적으로 큰 이득을 얻을 수 있다는 것을 보여주었길 바랍니다.

하지만 아직 할 일이 많습니다. Google은 여러 종류의 문제 (성능 및 정확성 모두)를 해결하기 위해 노력하고 있으며, CSS에 새로운 레이아웃 기능을 도입하게 되어 기쁘게 생각합니다. Google은 LayoutNG의 아키텍처 덕분에 이러한 문제를 안전하고 쉽게 해결할 수 있다고 생각합니다.

Una Kravets의 이미지 1개 (어떤 이미지인지 알고 있음).