렌더 트리는 실존하는 것일까? (CRP와 실제 Blink의 차이)

정이현·2026년 4월 23일

브라우저 렌더링의 오해

브라우저의 렌더링 과정을 흔히 Critical Rendering Path(CRP)라고 부른다.
HTML을 파싱해서 DOM을 만들고, CSS로 CSSOM을 만들고, 둘을 합쳐 렌더 트리 또는 그에 해당하는 렌더링 구조를 만든 다음, layout으로 위치와 크기를 계산하고, 마지막에 paint해서 화면의 픽셀로 바꾼다. MDN은 이 과정을 DOM, CSSOM, render tree, layout, paint의 흐름으로 설명한다.

하지만 이 과정은 추상적이고 대표적인 개념이라고 생각하는 편이 좋다.

브라우저 별로 렌더링 파이프라인을 구현하는 방식은 다 다르고, 언제든 파이프라인이 변경될 수도 있기 때문이다.

이번 글에서는 Chrome에서 사용되고 있는 Blink 브라우저 엔진을 기준으로 설명할 예정인데, 실제로 최근 작동방식을 보면 render tree라는 것이 존재하지 않는다. (과거에는 존재했었다.)

이렇듯 우리가 공부하는 개념과 실제 작동방식은 다를 수 있다.

그래서 적어도 이 글에서만큼은 헷갈리지 않도록 Blink 브라우저 엔진을 기준으로 구체적인 렌더링 파이프라인을 알아보겠다.


최신 브라우저 렌더링 과정

이 글에서 사용할 브라우저 렌더링 과정을 그림과 함께 정리해보자

요약하면 Blink의 메인 스레드에서는 다음과 같은 흐름으로 화면 갱신이 진행된다.

  1. Style
  2. Layout
  3. Pre-Paint
  4. Paint
  5. Layerize
  6. Commit

주의할 점은 이렇게 나눈 단계 또한 완전하지 않다는 것이다.
이 글에서 다루고자 하는 개념들을 설명하기 위해 보다 큰 단위로 렌더링 과정을 단순화시킨 것이다.
실제로는 더 복잡한 과정으로 이루어져 있다.

HTML 파싱, DOM 생성, CSSOM 생성은요?

물론 기존 CRP 설명처럼 HTML을 파싱해 DOM을 만들고 CSS를 파싱해 CSSOM을 만드는 과정은 여전히 렌더링의 필수적인 첫 단계이다.

하지만 현대의 웹은 단순히 한 번 그리고 끝나는 것이 아니라, JS 애니메이션이나 사용자 스크롤에 의해 화면이 끊임없이 변한다.

그래서 이 글에서는 DOM과 CSSOM이 이미 준비된 이후, 한 프레임을 갱신하는 동안 Blink 엔진 내부에서 어떤 파이프라인이 동작하는지에 집중해서 살펴보려고 한다.

Composite 단계는 어디갔나요?

브라우저 렌더링 파이프라인을 설명할 때 마지막에 보통 Composite 단계가 등장한다.
그런데 위 그림에는 이 단계가 빠져 있다.

이유는 간단하다.
앞에서 본 Style ~ Commit 단계는 메인 스레드에서 수행되지만, Composite는 그 결과를 넘겨받아 컴포지터 스레드에서 수행되기 때문이다.

이 글은 메인 스레드 내부의 렌더링 과정을 집중해서 설명하기 위해 Composite를 그림에서 제외했다.

물론 Composite가 덜 중요한 것은 아니다.
프론트엔드 성능 최적화를 위해서 transform, opacity 같은 속성 위주로 사용해야 된다고 자주 언급되는 이유도 Composite 단계와 밀접한 관련이 있다.

이제 본격적으로 렌더링 파이프라인을 알아보자

브라우저는 한 프레임 안에서 무엇을 하나

전에 언급했다시피 브라우저는 화면을 한 번 그리고 끝내지 않는다.
스크롤, 클릭, 애니메이션, JavaScript 실행에 따라 화면은 계속 바뀌고, 브라우저는 이 변화를 프레임 단위로 반영한다.

만약 본인의 모니터가 60Hz 환경이라면 브라우저는 대략 16.7ms 마다 한 번씩 새로운 프레임을 준비한다.

60Hz = 1초에 60번 화면 갱신
1000ms / 60 ≈ 16.7ms

중요한 점은, DOM이나 스타일이 바뀔 때마다 브라우저가 즉시 화면을 다시 그리지는 않는다는 것이다.
브라우저는 변경 사항을 바로 처리하지 않고, 다음 프레임을 그릴 시점에 맞춰 사용자 눈에 보이기 직전에 한꺼번에 반영하려고 한다.

어떤 한 프레임을 그리는 시점 내부를 예시로 들어보자

JS 작업 단계에서 아래와 같은 코드를 실행했다고 해보자

div.style.color = "red"

이 코드는 div의 스타일 값을 즉시 바꾸지만, 화면에 보이는 결과까지 그 자리에서 바로 그린다는 뜻은 아니다.
브라우저는 일단 “이 요소는 다시 그려야 한다”는 사실을 표시해 두고, 보통 다음 프레임이 나타나기 직전에 렌더링을 실시한다.

흐름을 단순화시키면 이런 느낌이다.

JavaScript로 DOM / 스타일 변경
→ 브라우저가 변경 사항 표시
→ 다음 프레임이 화면에 보이기 직전에 필요하다면 Layout / Paint 수행
→ 화면 렌더링 (우리 눈에 변화가 보임)

여기서 우리가 흔히 말하는 Reflow와 Repaint가 등장한다.
브라우저는 CSS 변경 사항의 종류에 따라 어떤 작업을 다시 해야 할지 결정하는데, 위치나 크기에 영향을 주는 변경이면 Layout(Reflow)이 다시 필요하고, 색상처럼 모양만 바뀌면 Paint(Repaint) 만 다시 필요할 수 있다.

아직까진 Reflow는 Layout 변경이 필요한 경우
Repaint는 색상처럼 모양 변경이 필요한 경우에 발생한다고 이해하면 된다.

Invalidation (무효화)

Blink 엔진 내부에서 Invalidation은 "이 요소의 상태가 변경되었으니, 다음 프레임에서는 이 부분의 파이프라인을 다시 실행해야 해!"라고 꼬리표🔖를 붙이는 작업이다.
이 상태를 흔히 Dirty 상태라고 부른다.

자바스크립트나 CSS로 인해 DOM에 변화가 생기면, 브라우저는 즉시 화면을 고치는 것이 아니라 해당 노드를 Dirty상태로 마킹(SetNeedsStyleRecalc 등)해둔다.
그리고 다음 프레임의 렌더링 주기가 찾아왔을 때, 파이프라인을 돌며 오직 Dirty 마킹이 된 녀석들만 골라서 업데이트를 진행한다.

Dirty 마킹의 종류는 크게 3가지를 소개하겠다.
1. Style 재계산 필요
2. Reflow 필요
3. Repaint 필요

각각 Dirty 상태가 되는 시점이 다르므로 주의해야 한다.

이제 본격적으로 렌더링 과정을 알아보자

Style Recalculation (스타일 재계산)

렌더링 파이프라인의 Style단계이다.

Style 단계 이전에 자바스크립트 등으로 DOM 요소가 추가 및 제거되거나 CSS가 변경되는 경우 즉시 Style Recalc 마킹(Invalidation)이 붙는다.

브라우저가 "이 노드는 이제 최신 스타일을 보장할 수 없다"는 마킹을 해놓은 것이다.

Style 단계가 되면 Style Recalc 마킹을 바탕으로 스타일 재계산이 필요한 DOM 요소들의 최종 스타일을 다시 계산한다.
즉, 어떤 CSS 규칙이 어떤 요소에 적용되는지 다시 판단하고, 그 결과로 새로운 ComputedStyle을 만든다.

여기서 중요한 점은 CSSOM과 ComputedStyle이 같지 않다는 것이다.
CSSOM은 단순히 규칙들의 집합이고, ComputedStyle은 특정 요소 하나에 대해 스타일적으로 받을 수 있는 영향들을 모두 반영한 최종 결과다.

쉬운 예시로 이해해보자

.parent {
	color: black;
}

.child {
	width: 100px;
}

CSSOM에서 .child에 직접 매칭되는 규칙은 width: 100px뿐이다.
하지만 자식 요소는 부모의 color 값을 상속받기 때문에, .child의 ComputedStyle은 아래와 같은 모습이 된다.

{
	color: black;
    width: 100px;
}

그래서 스타일 재계산은 단순히 CSS 파일을 다시 읽는 작업이 아니라, “이 요소는 지금 어떤 스타일을 가져야 하는가”를 다시 결정하는 과정이라고 보는 편이 맞다.

이 단계가 중요한 이유는, 여기서 끝날 수도 있고 더 무거운 단계로 이어질 수도 있기 때문이다.
브라우저는 특정 요소의 이전 ComputedStyle과 새로 계산한 ComputedStyle을 비교해 이 노드가 추가 계산을 필요로 하는지 확인한다.

width가 변경된 경우에는 reflow가 필요하기 때문에 Reflow 마킹(invalidation)이 붙고
color가 변경된 경우에는 repaint가 필요하기 때문에 Repaint 마킹(invalidation)이 붙게 된다.

Reflow와 이로 인한 문제

Reflow

렌더링 파이프라인의 Layout단계이다.

앞선 Style 단계에서 width, height, margin처럼 요소의 크기나 위치에 영향을 주는 변화가 있다고 판단되면, 브라우저는 해당 요소의 레이아웃을 다시 계산해야 한다고 표시한다.

Reflow라고 불리는 이 단계는 결국 Layout이 다시 수행되는 것이다.
이 단계에서 브라우저는 요소들의 정확한 X, Y 좌표와 너비, 높이를 계산한다.

프론트엔드 성능 최적화에서 “Reflow를 최대한 피하라”는 말이 있다.
어떤 요소의 width가 조금만 변해도, 그 요소 안의 줄바꿈이 달라질 수 있고, 그 결과 높이가 변하면 아래 형제 요소의 위치도 다시 계산해야 할 수 있다.

경우에 따라서는 부모 요소의 크기까지 다시 맞춰야 한다.

Reflow는 특정 요소와 관련된 전체 레이아웃 관계를 다시 맞추는 작업이기 때문에 비용이 커질 수 있다.

여기서 더 중요한 문제는, Layout이 필요한 상황이 단순히 CSS 속성을 바꿀 때만 생기는 것이 아니라는 점이다.

브라우저는 성능을 위해 Layout 계산을 가능한 한 뒤로 미뤄두려고 하지만, JavaScript 코드가 “지금 당장 최신 위치와 크기 정보를 알려줘”라고 요구하면 미뤄둔 Layout을 즉시 수행해야 할 수 있다.

대표적인 예가 getBoundingClientRect() 같은 레이아웃 정보 읽기 API다.

왜 getBoundingClientRect()는 비용이 커질 수 있을까

이전에 언급했다시피 브라우저는 효율을 위해 모니터 주사율에 맞춰 프레임이 완성되기 직전에 미뤄둔 작업(스타일 계산, 레이아웃 등)을 한 번에 모아서 처리하려고 한다.

그래서 자바스크립트로 요소의 width를 바꾸더라도, 브라우저는 즉시 레이아웃을 계산하지 않고 마킹만 남겨둔 채 실제 계산은 다음으로 미뤄둔다.

그런데 이때 getBoundingClientRect()를 호출하면 브라우저의 계획을 망가뜨릴 수 있다.

이 API는 요소의 가장 최신 위치와 크기를 지금 당장 내놓으라고 요구하기 때문이다.

브라우저는 지금 당장 정확한 좌표를 반환해야 한다.
그래서 원래 프레임 끝에 하려던 스타일 재계산과 Reflow를 중간에 먼저 수행할 수 있다.

Reflow할 요소가 없으면 상관 없다.

이렇게 브라우저의 원래 스케줄을 깨고 억지로 레이아웃을 계산하게 만드는 것을 강제 리플로우라고 한다. 이 강제 계산이 중간에 자꾸 끼어들면, 제때 수행되어야 할 Repaint 및 Composite 작업이 다음 프레임으로 밀려버리면서 화면이 뚝뚝 끊기는 프레임 드랍 현상이 발생하게 된다.

Layout Thrashing

getBoundingClientRect() 같은 읽기 API가 한 번 비싸질 수 있다는 것까지는 이해했다.
그런데 진짜 문제가 되는 것은 이런 읽기와 쓰기를 반복해서 섞는 경우다.

이 패턴을 흔히 Layout Thrashing이라고 부른다.

예를 들어 아래 코드를 보자.

box.style.width = "200px";             // write
box.getBoundingClientRect();           // read

box.style.width = "300px";             // write
box.getBoundingClientRect();           // read

브라우저 입장에서 첫 번째 width 변경은 원래 미뤄 둘 수 있는 작업이다.

하지만 바로 뒤에서 getBoundingClientRect를 통해 위치 정보를 읽어 버리면, 최신 값을 반환하기 위해 layout을 먼저 수행해야 할 수 있다.

그리고 그 다음 또 다시 width를 바꾸고 다시 읽으면, 같은 일이 또 반복된다.
이렇게 되면 원래 한 번에 묶어서 처리할 수 있었던 계산을, JavaScript 실행 중간중간 여러 번 강제로 하게 된다.

결국 문제의 핵심은 getBoundingClientRect() 자체라기보다, write와 read를 번갈아 사용하는 것이다.

브라우저는 원래 변경 사항을 모아서 프레임 완성 직전에 처리하려고 하는데, 위와 같은 예제가 이를 깨버릴 수 있다.

그래서 성능 최적화에서는 가능한 한 읽기는 읽기끼리, 쓰기는 쓰기끼리 모으는 것이 중요하다.

Pre-paint

Reflow 단계가 끝나면 각 요소의 위치와 크기는 정해진다.
여기까지만 보면 바로 Paint로 넘어가도 될 것 같지만, Blink는 그 전에 Pre-Paint 단계를 한 번 더 거친다.

이유는 간단하다.
브라우저는 이제 어디에 무엇이 있는지는 알지만, 아직 어떻게 그려야 하는지는 완전히 정리되지 않았기 때문이다.

예를 들어 Paint를 하기 전에 이런 것들을 먼저 알아야 한다.

  • 이 요소는 투명한가?
  • 이미지는 어디까지 잘려서 보여야 하는가?
  • 스크롤의 영향을 받는가?

Pre-Paint는 바로 이런 정보들을 정리하는 단계다.

브라우저는 opacity, transform, clip, scroll 같은 시각 효과를 따로 정리해 둔다.
왜냐하면 이런 효과는 요소 하나만 보고 알 수 있는 경우가 아니라, 부모의 영향이 자식에게 같이 내려오는 경우가 많기 때문이다.

예를 들어 부모 요소에 opacity: 0.5가 걸려 있으면, 자식도 그 영향을 받는다.
요소를 그릴 때마다 부모 쪽을 계속 거슬러 올라가며 opacity가 있는지 다시 확인하면, 같은 계산을 여러 번 반복하게 된다.

결국 Pre-Paint의 역할은, Paint가 요소를 그릴 때 필요한 시각 효과 정보를
그때그때 다시 계산하지 않도록 미리 정리해 두는 것이다.

Repaint

렌더링 파이프라인의 Paint단계이다.

HTML이 파싱되고 최초로 그려지면 Paint, 그 이후 다시 그려지면 Repaint이다

이름만 보면 이제 브라우저가 모니터에 진짜 픽셀을 색칠하는 단계처럼 느껴진다.
하지만 이것도 많이 하는 오해 중 하나다.

Blink의 Paint 단계는 화면에 바로 색을 칠하는 과정이 아니다.

Paint 단계에서는 이전 단계에서 계산된 결과를 바탕으로

  • 여기에 하늘색 배경을 그려라
  • 이 위치에 텍스트를 그려라
  • 여기에 검정 테두리를 그려라

같은 그리기 명령을 차곡차곡 기록한다.

Paint는 화면을 우리 눈에 보이게 하는 단계가 아니다.
나중에 실제 화면을 만들 수 있도록 명령어 목록을 만드는 단계라고 보면 된다.

명령하는 대상은 Skia라는 2D 그래픽 엔진이다

정리하면 Paint의 핵심은
픽셀을 직접 칠하는 것이 아니라
어떻게 그릴지 기록해 두는 것이다.

우리가 눈으로 보는 최종 화면은 이 다음 단계들까지 모두 거친 뒤에야 완성된다.

Layerize

브라우저가 화면 전체를 항상 한 장으로만 다룬다고 생각해 보자.
이 상태에서 카드 요소 하나가 움직이거나, 버튼 하나가 서서히 사라지면 그 작은 변화 때문에 화면의 큰 부분을 다시 처리해야 할 수 있다.

그래서 브라우저는 특정 부분들을 하나의 Layer로 따로 떼어 관리하는 편이 더 유리한지 판단한다.
이 단계가 Layerize다.

예를 들어 어떤 카드 요소가 opacity로 서서히 사라지거나 transform으로 움직인다고 해보자.
이 카드가 다른 것들과 분리된 하나의 Layer로 관리되고 있다면, 화면 전체를 다시 그리지 않고 그 카드가 있는 층만 업데이트하면 된다.

즉, Layerize는
자주 변하는 부분과 그렇지 않은 부분을 나누어, 화면 변경을 더 효율적으로 처리하기 위한 단계라고 보면 된다.

다만 opacity나 transform을 썼다고 해서 항상 무조건 층이 하나 생기는 것은 아니다.
브라우저가 따로 분리해서 다루는 편이 더 낫다고 판단할 때 레이어로 관리한다고 이해하면 된다.

Commit

Commit은 메인 스레드가 지금까지 정리한 렌더링 결과를 다음 단계로 넘기는 마지막 전달 단계다.

앞선 Style, Layout, Pre-Paint, Paint, Layerize 단계가
화면을 어떻게 만들지 정리하는 과정이었다면,
Commit은 그 결과를 바탕으로 실제 화면 표시가 이어질 수 있도록 넘겨주는 단계라고 보면 된다.

Composite 맛보기

이렇게 넘겨진 결과는 이후 Composite 단계로 이어진다.
Composite는 앞에서 나누어 둔 여러 Layer들을 다시 조합해서, 사용자가 실제로 보게 될 최종 화면을 만드는 과정이다.

정리하면 Commit은
메인 스레드가 준비한 렌더링 결과를 넘기고, 그다음 Composite가 실제 화면 조합을 이어받을 수 있게 만드는 연결 지점이다.

마무리

이렇게 크롬의 브라우저 엔진인 Blink가 어떻게 작동하는지 살펴보았다.

어떤 것이든 처음에 빠르게 이해를 하기 위해서는 근본적이고 전통적인 개념을 공부하는 것이 좋다고 생각한다.

하지만 이번 기회를 통해 "내가 공부한 것이 실제로 그대로 쓰이고 있는 게 맞나?"라고 한 번 더 질문을 던져보는 것도 꽤나 중요하다는 것을 느꼈다.

Invalidation, Reflow와 Style Recalculate에 대해 더 세부적으로 작성하고 싶었지만 글이 너무 길어질 것 같아서 별도의 글로 작성할 예정이다.

추가로 크롬의 성능 탭을 살펴보면 아래와 같이 언급했던 과정들을 실제로 확인할 수 있다.

0개의 댓글