Navigation

Chromium Rendering Pipeline

예전에 팀 기술 문서로 Chromium의 렌더링 과정을 정리한 적이 있습니다. 그때 참고한 자료는 2018년 전후의 설명이 많아서 페인트 단계가 픽셀을 그린다거나 합성 결과를 브라우저 프로세스가 그린다고 적은 부분이 있었습니다. 이번에는 Chrome 팀의 RenderingNG 문서로 단계를 다시 맞추고 각 단계를 DevTools Performance 패널의 어디에서 확인하는지 연결했습니다.

메인 스레드와 컴포지터 스레드를 먼저 나눕니다

탭 안의 웹 콘텐츠는 렌더러 프로세스가 처리합니다. RenderingNG 아키텍처 문서에 따르면 메인 스레드는 스크립트 실행, 렌더링 이벤트 루프, 문서 생명 주기, 히트 테스트, HTML과 CSS 파싱을 맡습니다. 컴포지터 스레드는 입력 이벤트를 받아 스크롤과 애니메이션을 처리하고 레이어 구성과 래스터 작업을 조율합니다. 픽셀을 만드는 래스터와 화면 출력은 Viz 프로세스의 GPU 메인 스레드가 맡습니다. 여러 렌더러의 결과를 한 프레임으로 묶는 일은 디스플레이 컴포지터 스레드가 합니다. RenderingNG architecture

flowchart LR
    subgraph Renderer["렌더러 프로세스"]
        Main["메인 스레드<br/>스크립트, 스타일, 레이아웃, 페인트"]
        Comp["컴포지터 스레드<br/>입력, 스크롤, 애니메이션, 레이어 구성"]
        Helper["컴포지터 보조 스레드<br/>이미지 디코드, paint worklet"]
        Main -->|commit| Comp
        Comp --> Helper
    end
    subgraph Viz["Viz 프로세스"]
        Gpu["GPU 메인 스레드<br/>래스터, draw"]
        Display["디스플레이 컴포지터 스레드<br/>프레임 집계"]
    end
    OS["브라우저 프로세스: OS 입력"] --> Comp
    Comp -->|래스터 요청| Gpu
    Comp -->|컴포지터 프레임| Display
    Display -->|집계한 프레임| Gpu

두 스레드를 나눈 이유도 문서에 나와 있습니다. 애니메이션과 스크롤을 메인 스레드 작업에서 격리하기 위해서입니다. 메인 스레드가 긴 작업에 묶여 있어도 컴포지터만으로 처리할 수 있는 스크롤과 transform 애니메이션은 계속 움직입니다. 반대로 이벤트 핸들러, 레이아웃, 페인트가 필요한 변경은 메인 스레드가 비어야 화면에 나타납니다. 그래서 성능 문제를 볼 때는 “어느 스레드가 막혔는가”부터 묻습니다.

한 프레임을 이루는 열두 단계

RenderingNG 문서는 파이프라인을 열두 단계로 나눕니다. 실행 위치는 문서의 색 구분을 따랐고 두 곳에서 실행될 수 있는 단계는 둘 다 적었습니다.

단계하는 일실행 위치
Animate선언형 타임라인에 따라 계산된 스타일과 property tree 변경메인 또는 컴포지터
StyleDOM에 CSS를 적용해 계산된 스타일 생성메인
Layout요소의 크기와 위치를 정하고 변경 불가능한 fragment tree 생성메인
Pre-paintproperty tree 계산, 기존 display list와 GPU 타일 중 무효화할 부분 표시메인
Scrollproperty tree를 바꿔 문서와 스크롤 요소의 위치 갱신메인 또는 컴포지터
PaintGPU 타일을 래스터할 방법을 담은 display list 계산메인
Commitproperty tree와 display list를 컴포지터 스레드로 복사메인에서 컴포지터로
Layerizedisplay list를 독립적으로 래스터하고 움직일 레이어 목록으로 분할컴포지터
Raster, decode, paint workletdisplay list, 인코딩된 이미지, worklet 코드를 GPU 타일로 변환Viz, 컴포지터 보조 스레드
ActivateGPU 타일의 위치와 시각 효과를 담은 컴포지터 프레임 생성컴포지터
Aggregate보이는 컴포지터 프레임을 하나의 전역 프레임으로 결합Viz
Draw결합한 프레임을 GPU에서 실행해 픽셀 생성Viz
flowchart LR
    JS["스크립트, 이벤트 핸들러"] --> St["Style"] --> Ly["Layout"] --> Pp["Pre-paint"] --> Pt["Paint"] --> Cm["Commit"]
    Cm --> Lz["Layerize"] --> Rs["Raster"] --> Ac["Activate"] --> Ag["Aggregate"] --> Dr["Draw"]
    Fast["스크롤, 시각 효과 애니메이션"] -.->|Layout, Pre-paint, Paint 생략| Tree["컴포지터에서 property tree 갱신"]
    Tree -.-> Ac

예전 문서와 비교하면 네 군데를 고쳐 읽어야 했습니다.

스크롤과 transform이 메인 스레드 없이 움직일 수 있는 근거는 property tree입니다. 시각 속성을 transform, clip, effect, scroll 네 트리로 나눠 두면 컴포지터가 DOM 전체를 몰라도 위치와 효과만 바꿀 수 있습니다. 문서에 따르면 property tree는 합성을 페인트와 메인 스레드에서 떼어 내고 스레드와 프로세스 사이로 보내기에도 DOM보다 가볍습니다.

HTML 파싱에서 첫 화면을 늦추는 리소스

파서는 <script>를 만나면 멈춥니다. 스크립트가 document.write()로 문서 구조를 바꿀 수 있기 때문입니다. 앞선 스타일시트가 아직 로딩 중이면 스크립트 실행도 CSSOM이 준비될 때까지 기다립니다. CSS 파일 하나가 늦으면 스크립트와 그 뒤의 파싱이 함께 밀리는 이유입니다. Adding interactivity with JavaScript

선언다운로드실행 시점실행 순서
<script src>파싱이 멈춘 채 대기받는 즉시문서 순서
defer파싱과 병렬파싱 완료 뒤, DOMContentLoaded 전문서 순서
async파싱과 병렬받는 즉시보장하지 않음
type="module"파싱과 병렬기본 동작이 defer와 같음문서 순서

표는 MDN의 <script> 설명을 따랐습니다. async와 defer를 함께 쓰면 async만 있는 것처럼 동작합니다. The Script element

파싱이 진행되는 동안 프리로드 스캐너가 파서의 토큰을 미리 보고 이미지와 스타일시트 요청을 먼저 보냅니다. JavaScript로 DOM에 삽입하는 스크립트, 이미지, 스타일시트는 이 스캐너가 찾지 못합니다. 첫 화면에 필요한 리소스라면 HTML에 직접 선언하고 그럴 수 없을 때 rel=preload를 검토합니다. Don’t fight the browser preload scanner

측정은 Performance 패널의 Insights 사이드바(Chrome 131에서 추가)에서 Render blocking requests부터 봅니다. 첫 렌더링을 늦춘 요청 목록이 나옵니다. 네트워크 트랙과 메인 트랙을 함께 보면 어떤 요청을 기다리는 동안 파싱이 멈췄는지 확인할 수 있습니다. Insights sidebar

강제 레이아웃은 읽기와 쓰기의 순서에서 생깁니다

스타일을 바꾼 직후 offsetWidth나 getBoundingClientRect() 같은 레이아웃 값을 읽으면 브라우저는 답을 주기 위해 그 자리에서 스타일과 레이아웃을 계산합니다. web.dev는 이를 강제 동기 레이아웃(forced reflow)이라 부르고 연달아 일어나는 경우를 layout thrashing이라고 합니다. 레이아웃은 거의 항상 문서 전체가 범위라서 DOM이 클수록 비용이 커집니다. Avoid large, complex layouts and layout thrashing

쓰기 뒤의 읽기가 매 반복 레이아웃을 부릅니다ts
// 두 번째 반복부터 읽기 직전에 쓰기가 있어 레이아웃이 매번 다시 계산됩니다
function syncWidthsSlow(items: HTMLElement[], source: HTMLElement) {
  for (const item of items) {
    item.style.width = `${source.offsetWidth}px`
  }
}

// 읽기를 한 번에 끝내고 쓰기만 반복합니다
function syncWidths(items: HTMLElement[], source: HTMLElement) {
  const width = source.offsetWidth
  for (const item of items) {
    item.style.width = `${width}px`
  }
}

읽기가 여러 요소에 걸쳐 있으면 읽기 단계와 쓰기 단계를 나눈 두 반복문으로 바꿉니다. 쓰기를 requestAnimationFrame 콜백으로 모으는 방법도 있지만 같은 콜백 안에서 다시 읽으면 똑같이 강제 레이아웃이 일어납니다.

DevTools에서는 메인 트랙의 보라색 Layout 블록이 스크립트 작업 안쪽에 붙어 있는지 봅니다. Chrome 134부터 Insights 사이드바에 Forced reflow 항목이 생겼습니다. 강제 레이아웃을 일으킨 상위 함수와 호출 스택, 전체 reflow 시간을 보여 줍니다. New in DevTools 134

실험실 기록만으로는 실제 사용자 기기에서 얼마나 자주 일어나는지 알 수 없습니다. Long Animation Frames API는 렌더링이 50ms 넘게 늦어진 프레임마다 5ms를 넘은 스크립트의 forcedStyleAndLayoutDuration을 함께 줍니다. Chrome 123에서 정식으로 제공됐고 문서 갱신 시점에 Firefox와 Safari는 지원하지 않았습니다. Long Animation Frames API

현장에서 강제 레이아웃이 긴 스크립트를 모읍니다ts
type LoafScript = {
  invoker: string
  sourceURL: string
  forcedStyleAndLayoutDuration: number
}

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    const { scripts } = entry as PerformanceEntry & { scripts: LoafScript[] }
    for (const script of scripts) {
      // 기준값 10ms는 예시입니다. 실제로는 수집 서버로 보냅니다
      if (script.forcedStyleAndLayoutDuration < 10) continue
      console.log(script.invoker, script.sourceURL, script.forcedStyleAndLayoutDuration)
    }
  }
})

observer.observe({ type: 'long-animation-frame', buffered: true })

바꾸는 속성이 다시 실행할 단계를 정합니다

web.dev의 렌더링 성능 문서는 변경 종류에 따라 세 경로를 나눕니다. 크기나 위치가 바뀌면 레이아웃부터 다시 실행합니다. 색이나 그림자처럼 그리기만 바뀌면 레이아웃을 건너뜁니다. 둘 다 필요 없는 변경은 합성만 다시 하며 애니메이션과 스크롤에 가장 싼 경로입니다. RenderingNG 문서도 시각 효과 애니메이션과 스크롤은 Layout, Pre-paint, Paint를 건너뛰고 컴포지터 스레드에서 처리할 수 있다고 설명합니다. Rendering performance

변경다시 실행되는 단계Performance 패널에서 보이는 것
width, top, 글꼴 크기, DOM 추가Style, Layout, Paint, 합성스크립트 뒤의 Layout과 Paint
color, box-shadowStyle, Paint, 합성Layout 없는 Paint
transform, opacity 애니메이션합성프레임마다 메인 트랙 작업이 거의 없음

web.dev가 합성만으로 처리된다고 이름을 든 속성은 transform과 opacity 두 가지입니다. RenderingNG 문서는 범위를 시각 효과로 넓게 표현하지만 이 글에서는 문서에서 이름으로 확인한 두 속성만 적었습니다.

합성 경로를 타려면 움직일 요소가 별도 레이어에 있어야 합니다. will-change: transform으로 레이어를 미리 만들 수 있습니다. 다만 web.dev는 레이어마다 메모리와 관리 비용이 들고 메모리가 적은 기기에서는 이득보다 손해가 클 수 있으니 불필요한 승격을 하지 말라고 경고합니다. 목록의 모든 카드에 will-change를 거는 식의 일괄 적용은 피하고 실제로 움직이는 요소에만 겁니다. Stick to compositor-only properties

페인트 범위는 Rendering 탭에서 확인합니다. Paint flashing을 켜면 다시 그려지는 영역이 초록색으로 깜빡입니다. Layer borders는 레이어 경계를 주황색과 올리브색으로, 타일을 청록색으로 표시합니다. 레이어 구성은 Performance 패널의 캡처 설정에서 Enable advanced paint instrumentation을 켜고 기록한 뒤 프레임을 골라 Layers 탭에서 봅니다. 메인 트랙의 Paint 이벤트를 고르면 Paint profiler에서 그리기 명령도 볼 수 있습니다. Rendering 탭, Performance features reference

입력과 프레임 예산

60Hz 화면에서 한 프레임은 약 16.66ms입니다. web.dev는 브라우저 자체 작업을 빼고 애니메이션 중 개발자 코드가 쓸 수 있는 시간을 10ms 정도로 봅니다. 120Hz 기기라면 한 프레임이 약 8.3ms로 줄어듭니다. 클릭처럼 한 번 일어나는 변경은 기준이 달라 INP의 좋음 기준인 200ms 이하를 봅니다.

연속 입력은 프레임에 맞춰 모입니다. touchmove, pointermove 같은 연속 이벤트는 합쳐져 다음 requestAnimationFrame 직전에 전달되고 keydown, touchstart 같은 개별 이벤트는 바로 전달됩니다. 그리기 앱처럼 중간 좌표가 모두 필요하면 getCoalescedEvents()로 합쳐진 이벤트를 꺼냅니다. Inside look at modern web browser part 4

컴포지터는 이벤트 핸들러가 붙은 영역을 non-fast scrollable region으로 표시합니다. 이 영역의 입력은 메인 스레드의 처리를 기다립니다. document에 touchstart 핸들러를 걸면 페이지 전체가 이 영역이 됩니다. 핸들러가 preventDefault()를 쓰지 않는다면 { passive: true }로 등록해 컴포지터가 기다리지 않게 합니다. Rendering 탭의 Scrolling performance issues는 스크롤 성능을 해칠 수 있는 리스너가 붙은 요소를 표시해 줍니다.

메인 스레드를 50ms 넘게 점유하는 작업이 긴 작업(long task)입니다. 긴 작업이 도는 동안 브라우저는 입력을 처리하지 못하고 INP가 나빠집니다. Performance 패널의 메인 트랙은 긴 작업에 빨간 삼각형을 붙이고 50ms를 넘은 구간을 빨간색으로 칠합니다. Frames 트랙에서는 누락된 프레임이 빨간색, 일부만 표시된 프레임이 노란색입니다.

긴 작업은 일정 시간마다 메인 스레드에 양보해 나눕니다. scheduler.yield()는 양보한 뒤 이어지는 작업을 다른 작업보다 먼저 실행합니다. web.dev 지원 표 기준으로 Chrome 129와 Firefox 142에서 쓸 수 있고 Safari는 지원하지 않아 setTimeout 대체 경로가 필요합니다. 반복마다 양보하면 그 자체가 오버헤드라 50ms 정도 일한 뒤 양보합니다. Optimize long tasks

긴 반복을 50ms 단위로 나눕니다ts
type YieldScheduler = { yield?: () => Promise<void> }

function yieldToMain(): Promise<void> {
  const scheduler = (globalThis as { scheduler?: YieldScheduler }).scheduler
  if (scheduler?.yield) return scheduler.yield()
  return new Promise((resolve) => setTimeout(resolve, 0))
}

async function processInChunks<T>(items: T[], work: (item: T) => void) {
  let deadline = performance.now() + 50
  for (const item of items) {
    work(item)
    if (performance.now() < deadline) continue
    await yieldToMain()
    deadline = performance.now() + 50
  }
}

증상에서 단계를 거꾸로 찾습니다

기록 전에 재현할 동작 하나를 정하고 CPU 제한을 걸어 둡니다. DevTools의 CPU 제한은 내 컴퓨터 대비 배수라서 2x slowdown은 CPU를 절반 속도로 돌린다는 뜻입니다. 저사양 기기에 맞춘 사용자 지정 값을 보정해 쓸 수 있지만 문서도 모바일 CPU 구조까지 흉내 내지는 못한다고 밝힙니다.

증상먼저 볼 곳의심할 단계
스크롤이나 애니메이션이 끊김Frames 트랙의 빨간색, 노란색 프레임과 같은 시점의 메인 트랙긴 작업, Layout, Paint
클릭 뒤 반응이 늦음Insights의 INP by phase와 해당 상호작용의 작업이벤트 핸들러, Style, Layout
스크립트 안에서 Layout이 반복됨Insights의 Forced reflow와 호출 스택강제 레이아웃
작은 변경에 넓은 영역이 다시 그려짐Rendering 탭의 Paint flashingPaint 범위
레이어가 많고 메모리가 늘어남Layer borders와 Layers 탭과한 레이어 승격
첫 화면이 늦게 보임Insights의 Render blocking requests파싱, CSS와 스크립트 로딩

실험실 기록과 현장 지표는 섞지 않습니다. DevTools 기록은 원인을 좁히는 데 쓰고 사용자 환경에서의 빈도는 Long Animation Frames나 INP 같은 현장 지표로 따로 봅니다. 한 번에 하나만 바꾸고 같은 동작을 다시 기록해야 어떤 변경이 어떤 단계를 줄였는지 말할 수 있습니다.

이 글은 RenderingNG 아키텍처 문서(2021-07-26 갱신)를 기준으로 한 구조 설명입니다. Chromium 내부 구현이 바뀌면 단계 이름과 실행 위치도 달라질 수 있어 DevTools 기능은 글에 적은 Chrome 버전과 함께 읽어야 합니다.

참고 자료