RenderingNG 심층 분석: LayoutNG

Ian Kilpatrick
Ian Kilpatrick
Koji Ishi
Koji Ishi

저는 이시이 Koji와 함께 Blink 레이아웃팀의 엔지니어링 팀장인 이안 킬패트릭입니다 Blink팀에서 근무하기 전에는 Google에서 '프런트엔드 엔지니어'라는 직책이 생기기 전부터 프런트엔드 엔지니어로 Google Docs, Drive, Gmail 내에서 기능을 빌드했습니다. 이 역할을 수행한 지 약 5년 후, Blink팀으로 전환하여 직장에서 C++를 효과적으로 학습하고 대단히 복잡한 Blink 코드베이스를 확장하기 위해 큰 모험을 감행했습니다. 지금도 그중 일부만 이해하고 있습니다. 이 기간 동안 시간을 내주셔서 감사합니다. 많은 '회복 중인 프런트엔드 엔지니어'가 저보다 먼저 '브라우저 엔지니어'로 전환했다는 사실에 위안을 얻었습니다.

이전 경험을 바탕으로 Blink팀에서 개인적으로 많은 도움을 받았습니다. 프런트엔드 엔지니어로서 브라우저 비일관성, 성능 문제, 렌더링 버그, 누락된 기능을 끊임없이 경험했습니다. LayoutNG는 Blink의 레이아웃 시스템 내에서 이러한 문제를 체계적으로 해결할 수 있는 기회였으며, 수년간 많은 엔지니어의 노력의 결과를 보여줍니다.

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

레이아웃 엔진 아키텍처의 개요

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

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

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

이러한 객체는 렌더링 간에 유지되었습니다. 스타일이 변경되면 해당 객체와 트리의 모든 상위 요소를 더티로 표시했습니다. 렌더링 파이프라인의 레이아웃 단계가 실행되면 트리를 정리하고 더러운 객체를 탐색한 다음 레이아웃을 실행하여 깨끗한 상태로 만듭니다.

이 아키텍처로 인해 여러 유형의 문제가 발생한 것으로 확인되었습니다. 아래에서 설명하겠습니다. 그러나 먼저 한 걸음 물러서서 레이아웃의 입력과 출력이 무엇인지 생각해 봅시다.

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

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

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

프래그먼트 트리

변경 불가능한 프래그먼트 트리에 관해 다루면서 이전 트리의 상당 부분을 증분 레이아웃에 재사용하도록 설계된 방법을 설명했습니다.

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

인라인 (텍스트) 레이아웃 알고리즘도 새로운 불변 아키텍처와 일치하도록 재작성됩니다. 인라인 레이아웃을 위한 변경 불가능한 평면 목록 표현을 생성할 뿐만 아니라 더 빠른 재레이아웃을 위한 단락 수준 캐싱, 요소와 단어에 글꼴 기능을 적용하기 위한 단락당 도형, ICU를 사용하는 새로운 Unicode 양방향 알고리즘, 수많은 정확성 수정사항 등을 제공합니다.

레이아웃 버그 유형

레이아웃 버그는 대략 네 가지 카테고리로 나뉘며 각 카테고리마다 근본 원인이 다릅니다.

정확성

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

이전 게시물에서 설명한 것처럼 이는 매우 취약한 시스템의 신호입니다. 특히 레이아웃의 경우 클래스 간에 명확한 계약이 없어 브라우저 엔지니어가 의존해서는 안 되는 상태에 의존하거나 시스템의 다른 부분에서 일부 값을 잘못 해석하게 되었습니다.

예를 들어 한때는 1년 넘게 플렉스 레이아웃과 관련된 버그가 약 10개 연쇄적으로 발생했습니다. 각 수정사항은 시스템의 일부에서 정확성 또는 성능 문제를 일으켜 또 다른 버그를 유발했습니다.

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

현재 안정화 버전 채널에서 실제 회귀를 출시하면 일반적으로 WPT 저장소에 연결된 테스트가 없으며 구성요소 계약을 잘못 이해하여 발생하지 않는 것으로 확인되었습니다. 또한 버그 수정 정책의 일환으로 항상 새로운 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;
}

이력 현상

이 버그 클래스는 무효화 부족과 유사합니다. 기본적으로 이전 시스템에서는 레이아웃이 아이디엠포트(idempotent)인지, 즉 동일한 입력으로 레이아웃을 다시 실행해도 동일한 출력이 나오는지 확인하기 매우 어려웠습니다.

아래 예에서는 CSS 속성을 두 값 간에 전환합니다. 하지만 이렇게 하면 '무한히 커지는' 직사각형이 됩니다.

동영상과 데모는 Chrome 92 이하의 히스테리시스 버그를 보여줍니다. 이 문제는 Chrome 93에서 수정되었습니다.

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

앞의 텍스트에 설명된 문제를 보여주는 트리입니다.
이전 레이아웃 결과 정보에 따라 비멱등 레이아웃이 생성됩니다.

LayoutNG에서는 명시적인 입력 및 출력 데이터 구조가 있고 이전 상태에 액세스할 수 없으므로 레이아웃 시스템에서 이러한 유형의 버그를 대폭 완화했습니다.

과도한 무효화 및 성능

이는 무효화 부족 버그 클래스의 정반대입니다. 무효화 부족 버그를 수정할 때 성능 절벽이 발생하는 경우가 많습니다.

성능보다 정확성을 우선하는 어려운 선택을 해야 하는 경우가 많았습니다. 다음 섹션에서는 이러한 유형의 성능 문제를 완화한 방법을 자세히 살펴보겠습니다.

투 패스 레이아웃과 성능 급의 증가

Flex 및 그리드 레이아웃은 웹에서 레이아웃의 표현력에 변화를 가져왔습니다. 그러나 이러한 알고리즘은 이전의 블록 레이아웃 알고리즘과 근본적으로 다릅니다.

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

예를 들어 모든 하위 요소의 크기를 가장 큰 요소의 크기로 확장하는 경우가 많습니다. 이를 지원하기 위해 상위 레이아웃 (플렉스 또는 그리드)은 측정 패스를 실행하여 각 하위 요소의 크기를 결정한 다음 레이아웃 패스를 실행하여 모든 하위 요소를 이 크기로 늘립니다. 이 동작은 플렉스 레이아웃과 그리드 레이아웃 모두에 기본값으로 적용됩니다.

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

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

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

이전에는 이러한 유형의 성능 저하를 방지하기 위해 플렉스 및 그리드 레이아웃에 매우 구체적인 캐시를 추가하려고 했습니다. 이 방법은 작동했지만 (Flex로 상당히 진행됨) 무효화 부족 및 초과 버그와 지속적으로 싸워야 했습니다.

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

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

요약

레이아웃은 매우 복잡한 영역이며 인라인 레이아웃 최적화(전체 인라인 및 텍스트 하위 시스템의 작동 방식)와 같은 다양한 흥미로운 세부정보는 다루지 않았습니다. 여기서 다룬 개념조차도 표면적으로만 다루었고 많은 세부정보는 간략히 언급했습니다. 하지만 시스템 아키텍처를 체계적으로 개선하면 장기적으로 상당한 이익을 얻을 수 있다는 점을 보여드렸기를 바랍니다.

하지만 아직 해야 할 일이 많이 남아 있습니다. Google은 해결하기 위해 노력하고 있는 문제 클래스 (성능 및 정확성 모두)를 인지하고 있으며 CSS에 제공될 새로운 레이아웃 기능에 대해 기대하고 있습니다. LayoutNG의 아키텍처를 사용하면 이러한 문제를 안전하고 쉽게 해결할 수 있습니다.

Una Kravets의 이미지 1개 (알고 계시죠?)