CSSOM 구축부터 레이아웃·페인트·합성까지 픽셀 파이프라인의 내부 동작을 이해한다.
브라우저 렌더링 엔진과 CSS 동작 원리
우리가 CSS를 작성할 때 .box { width: 100px; } 한 줄을 바꾸면 화면이 즉시 업데이트됩니다. 하지만 그 "즉시"라는 순간 뒤에는 브라우저가 수행하는 정교한 파이프라인이 숨어 있습니다. 입문편에서 트랜지션과 애니메이션을 배우며 transform이 left보다 부드럽다는 사실을 경험했을 것입니다. 이번 강에서는 그 이유를 픽셀이 화면에 그려지는 전 과정을 통해 낱낱이 파헤칩니다.
이 원리를 이해하면 성능 문제의 원인을 직감적으로 짚어낼 수 있고, 코드 리뷰에서 렌더링 비용이 높은 스타일을 걸러낼 수 있습니다. 3강(CSS 성능 최적화)에서 다룰 최적화 기법들도 이 강의 내용을 바탕으로 합니다.
학습 목표
- CSSOM이 구축되는 과정과 DOM과 결합해 렌더 트리가 만들어지는 원리를 설명할 수 있다.
- 레이아웃(reflow), 페인트(paint), 합성(composite) 세 단계의 역할과 비용 차이를 구분할 수 있다.
- 특정 CSS 속성이 파이프라인의 어느 단계를 트리거하는지 판단할 수 있다.
- 합성 전용 레이어가 GPU에서 처리되는 이유를 설명하고 의도적으로 활용할 수 있다.
- 브라우저의 스타일 무효화(invalidation) 범위를 이해하고 불필요한 재계산을 줄이는 패턴을 적용할 수 있다.
CSSOM 트리 구축과 DOM과의 결합
브라우저는 HTML을 파싱해 DOM(Document Object Model) 트리를 만드는 동시에, <link> 또는 <style> 태그를 만나면 CSS를 파싱해 CSSOM(CSS Object Model) 트리를 별도로 구축합니다. CSSOM은 DOM과 구조가 유사하지만 순수하게 스타일 정보만 담은 트리입니다.
CSSOM 파싱 과정
브라우저는 CSS를 바이트 스트림으로 수신한 뒤 다음 순서로 처리합니다.
- 토큰화(Tokenization) — 문자열을 CSS 문법 토큰(선택자, 속성, 값 등)으로 분리
- 파싱(Parsing) — 토큰을 규칙 구조로 조립
- CSSOM 트리 생성 — 각 노드에 상속·캐스케이드가 적용된 최종 스타일을 계산해 저장
/* 이 CSS가 어떻게 CSSOM으로 변환되는지 구조를 떠올려 보세요 */
body {
font-size: 16px;
color: #333;
}
.article {
font-size: 1.2em; /* 부모 body에서 상속 후 계산 → 19.2px */
}
.article p {
line-height: 1.6; /* 상속값: font-size 19.2px 기준 → 30.72px */
}
💡 TIP CSSOM은 "렌더 블로킹(render-blocking)" 리소스입니다. 브라우저는 CSSOM이 완전히 구축될 때까지 렌더 트리 생성을 시작하지 않습니다. 그래서 무거운 CSS 파일이 First Contentful Paint를 늦추는 직접적인 원인이 됩니다.
렌더 트리 생성
CSSOM과 DOM이 모두 준비되면 브라우저는 두 트리를 결합해 **렌더 트리(Render Tree)**를 만듭니다. 렌더 트리에는 화면에 실제로 그려질 노드만 포함됩니다.
<!-- DOM 노드지만 렌더 트리에서 제외되는 경우 -->
<head> <!-- 화면에 표시되지 않음 -->
<script> <!-- 화면에 표시되지 않음 -->
<div style="display: none"> <!-- display:none → 렌더 트리에서 완전히 제외 -->
<!-- visibility: hidden은 렌더 트리에 포함되지만 투명하게 그려짐 -->
<span style="visibility: hidden">공간은 차지하지만 보이지 않음</span>
| 속성 | 렌더 트리 포함 여부 | 공간 차지 | GPU 레이어 |
|---|---|---|---|
display: none | 제외 | 아니오 | 아니오 |
visibility: hidden | 포함 | 예 | 아니오 |
opacity: 0 | 포함 | 예 | 합성 레이어 생성 가능 |
스타일 계산(Recalc Style) 단계
렌더 트리 생성 후, 각 노드의 최종 스타일을 결정하는 스타일 계산(Recalculate Style) 단계가 실행됩니다. Chrome DevTools의 Performance 패널에서 보라색 "Recalc Style" 항목이 바로 이 단계입니다.
계산 비용을 높이는 선택자 패턴
CSS 선택자는 오른쪽에서 왼쪽으로 매칭됩니다. 이 특성 때문에 복잡한 선택자는 매칭 후보가 많아져 계산 비용이 커집니다.
/* ❌ 오른쪽 .btn 후보를 먼저 찾은 뒤 모든 조상을 역방향 탐색 */
.layout .sidebar .nav ul li a.btn { color: red; }
/* ✅ 단일 클래스로 직접 지정 — 매칭 비용 최소화 */
.nav-link-btn { color: red; }
스타일 무효화(Style Invalidation)
JavaScript나 사용자 인터랙션이 DOM을 변경하면 브라우저는 영향을 받는 노드의 스타일을 "무효(invalid)" 상태로 표시하고 다음 렌더링 프레임에서 재계산합니다. 이 과정을 스타일 무효화라 합니다.
무효화 범위는 변경된 내용에 따라 달라집니다.
const box = document.querySelector('.box');
// 단일 요소만 무효화 — 영향 범위 최소
box.classList.add('is-active');
// ❌ 강제 동기 레이아웃(layout thrashing) 유발
// 스타일 쓰기 직후 읽기를 반복하면 매 읽기마다 강제로 레이아웃 재계산 발생
for (let i = 0; i < 100; i++) {
box.style.width = box.offsetWidth + 1 + 'px'; // 쓰기 후 즉시 읽기 → 강제 reflow
}
// ✅ 읽기/쓰기를 분리해 배치
const currentWidth = box.offsetWidth; // 읽기 한 번
box.style.width = currentWidth + 100 + 'px'; // 쓰기 한 번
⚠️ 주의
offsetWidth,offsetHeight,getBoundingClientRect(),scrollTop등을 읽으면 브라우저가 최신 레이아웃 값을 반환하기 위해 보류 중인 레이아웃을 즉시 강제 실행합니다. 이를 "강제 동기 레이아웃(Forced Synchronous Layout)"이라 하며, 루프 안에서 반복되면 치명적인 성능 병목이 됩니다.
레이아웃(Reflow)·페인트(Paint)·합성(Composite) 파이프라인
렌더 트리가 확정된 이후 브라우저는 세 단계를 거쳐 픽셀을 화면에 표시합니다.
[스타일 계산] → [레이아웃] → [페인트] → [합성]
Recalc Style Layout Paint Composite
(CPU) (CPU) (CPU) (GPU)
1단계: 레이아웃(Layout / Reflow)
레이아웃 단계에서 브라우저는 각 요소의 정확한 위치와 크기를 계산합니다. 뷰포트 크기, width, height, margin, padding, font-size 등 기하학적 속성이 변경될 때 트리거됩니다.
레이아웃은 트리 전체 또는 특정 서브트리에서 발생할 수 있으며, 영향 범위가 넓을수록 비용이 큽니다.
/* 레이아웃을 트리거하는 속성들 — 변경 시 reflow 발생 */
.box {
width: 200px; /* 기하 변경 → reflow */
height: 100px; /* 기하 변경 → reflow */
margin: 10px; /* 기하 변경 → reflow */
padding: 8px; /* 기하 변경 → reflow */
font-size: 18px; /* 텍스트 크기 → reflow */
top: 20px; /* position:absolute여도 reflow */
left: 30px; /* position:absolute여도 reflow */
}
2단계: 페인트(Paint)
레이아웃이 끝나면 브라우저는 각 요소를 레이어별로 픽셀로 칠합니다. 텍스트, 색상, 그림자, 테두리 등 시각적 스타일이 이 단계에서 처리됩니다. 레이아웃을 트리거하지 않는 시각적 변경(예: color, background-color, box-shadow)도 페인트는 다시 실행합니다.
/* 페인트만 트리거 (레이아웃은 건드리지 않음) */
.box {
color: red; /* 텍스트 색 → repaint */
background-color: rgba(0,0,0,0.5); /* 배경색 → repaint */
box-shadow: 0 2px 8px #000; /* 그림자 → repaint */
border-color: blue; /* 테두리 색 → repaint */
outline: 2px solid green; /* 아웃라인 → repaint */
}
3단계: 합성(Composite)
페인트된 레이어들을 GPU가 올바른 순서로 합쳐 최종 프레임을 만드는 단계입니다. 합성만 발생하는 경우 CPU는 거의 개입하지 않고 GPU가 독립적으로 처리하므로 메인 스레드 부담이 없어 60fps를 안정적으로 유지할 수 있습니다.
합성 전용 속성이 빠른 이유
transform과 opacity는 레이아웃과 페인트를 건너뛰고 합성 단계만 트리거합니다. 왜 그럴까요?
합성 레이어(Compositing Layer)의 작동 원리
브라우저는 특정 조건의 요소를 **별도의 합성 레이어(Compositor Layer)**로 분리합니다. 이 레이어는 페인트 결과를 GPU 텍스처(texture)로 업로드해 놓습니다. 이후 해당 레이어에만 영향을 주는 변경이 발생하면, CPU에서 레이아웃·페인트를 다시 실행하지 않고 GPU가 텍스처를 변환(이동·회전·스케일)하거나 투명도를 조절하는 것만으로 새 프레임을 만들어냅니다.
레이어 A (텍스처 → GPU 메모리)
레이어 B (텍스처 → GPU 메모리)
↓
GPU가 두 텍스처를 합성(compositing)해 화면에 출력
transform/opacity 변경 시 텍스처를 다시 그리지 않고 행렬 연산만 수행
/* ❌ left/top 변경 — 매 프레임마다 레이아웃 재계산 발생 */
.ball {
position: absolute;
left: 0;
transition: left 0.3s ease;
}
.ball:hover {
left: 200px; /* reflow → repaint → composite */
}
/* ✅ transform 사용 — 합성만 발생, GPU 처리 */
.ball {
position: absolute;
transform: translateX(0);
transition: transform 0.3s ease;
}
.ball:hover {
transform: translateX(200px); /* composite only */
}
will-change로 레이어 승격 예고하기
will-change 속성은 브라우저에게 "이 요소는 곧 변경될 것"이라고 미리 알려 합성 레이어로 **사전 승격(pre-promote)**시킵니다.
/* ✅ 애니메이션 직전에 레이어 생성 비용을 미리 지불 */
.animated-card {
will-change: transform, opacity;
}
/* ❌ 모든 요소에 남용 금지 — 레이어마다 GPU 메모리 소비 */
* {
will-change: transform; /* 메모리 압박, 오히려 성능 저하 */
}
⚠️ 주의
will-change는 실제로 성능 병목이 확인된 요소에만 적용하세요. 과도하게 사용하면 GPU 메모리가 고갈되어 전체 렌더링 성능이 오히려 나빠집니다. 애니메이션이 끝난 후에는 JavaScript로will-change: auto를 되돌려 레이어를 해제하는 것이 좋습니다.
레이어 승격 조건 정리
/* 아래 조건 중 하나라도 해당되면 독립 합성 레이어로 승격 */
.promoted {
will-change: transform; /* 명시적 승격 */
transform: translateZ(0); /* 하드웨어 가속 해킹 (비권장) */
transform: translate3d(0, 0, 0); /* 동일 효과 (비권장) */
position: fixed; /* 고정 위치 요소 */
/* animation/transition on transform or opacity도 해당 */
}
| 변경 속성 | Layout | Paint | Composite | 비용 |
|---|---|---|---|---|
width, height, margin | O | O | O | 높음 |
color, background-color | X | O | O | 중간 |
transform, opacity | X | X | O | 낮음 |
리플로우·리페인트 트리거 조건과 실무 패턴
어떤 CSS 속성과 DOM 조작이 파이프라인의 어느 단계를 유발하는지 파악하는 것이 성능 최적화의 출발점입니다.
리플로우를 유발하는 주요 조건
// DOM 구조 변경
parent.appendChild(newElement); // reflow
parent.removeChild(element); // reflow
// 기하 속성 읽기 (강제 동기 레이아웃)
element.offsetWidth;
element.offsetHeight;
element.clientWidth;
element.getBoundingClientRect();
window.getComputedStyle(element);
// 기하 속성 쓰기
element.style.width = '200px';
element.style.padding = '10px';
element.style.fontSize = '18px';
DocumentFragment로 배치 처리
여러 DOM 변경을 한 번에 처리해 reflow 횟수를 줄이는 패턴입니다.
// ❌ 루프마다 reflow 발생
const list = document.querySelector('ul');
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.textContent = `Item ${i}`;
list.appendChild(li); // 매번 reflow
}
// ✅ DocumentFragment로 한 번에 삽입
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.textContent = `Item ${i}`;
fragment.appendChild(li); // 메모리 내 조작, reflow 없음
}
list.appendChild(fragment); // reflow 1회
CSS 클래스 교체로 일괄 스타일 변경
// ❌ 인라인 스타일을 여러 번 나눠 적용 → 여러 번 reflow/repaint 가능
element.style.width = '100px';
element.style.height = '50px';
element.style.backgroundColor = 'blue';
element.style.transform = 'rotate(45deg)';
// ✅ 클래스 하나로 모든 변경을 한 번에 적용
element.classList.add('card--active');
.card--active {
width: 100px;
height: 50px;
background-color: blue;
transform: rotate(45deg);
}
읽기/쓰기 배치(Batching) 패턴
// ❌ 쓰기 → 읽기 → 쓰기 → 읽기 반복 (강제 동기 레이아웃 × n)
elements.forEach(el => {
el.style.width = el.offsetWidth * 2 + 'px'; // 쓰기 후 즉시 읽기
});
// ✅ 모든 읽기를 먼저, 그 다음 모든 쓰기
const widths = elements.map(el => el.offsetWidth); // 읽기 배치
elements.forEach((el, i) => {
el.style.width = widths[i] * 2 + 'px'; // 쓰기 배치
});
💡 TIP
requestAnimationFrame을 활용하면 스타일 변경을 다음 렌더링 프레임 시작 시점으로 미뤄 브라우저가 최적 타이밍에 레이아웃/페인트를 한 번만 실행하도록 유도할 수 있습니다.
브라우저의 스타일 무효화(Invalidation) 범위
스타일 무효화는 "이 노드의 스타일이 바뀌었을 수 있으니 다시 계산해야 한다"는 표시를 달아두는 과정입니다. 브라우저는 무효화 범위를 최소화하기 위해 정교한 추적 시스템을 가지고 있습니다.
무효화 범위의 종류
[전체 트리 무효화] — 루트에서 시작, 모든 노드 재계산
예: :root의 CSS 변수 변경, body에 클래스 추가
[서브트리 무효화] — 특정 조상 아래만 재계산
예: .container의 font-size 변경 (자식들이 em 단위를 사용 중)
[단일 노드 무효화] — 해당 요소만 재계산
예: 상속에 영향 없는 non-inherited 속성 변경 (border, background 등)
/* ❌ CSS 변수를 :root에 두고 JS로 자주 변경하면 전체 트리 무효화 */
:root {
--base-font-size: 16px; /* JS에서 자주 업데이트하면 모든 em값 재계산 */
}
/* ✅ 무효화 범위를 좁히려면 변수를 사용하는 가장 가까운 조상에 선언 */
.widget {
--widget-size: 16px; /* 이 서브트리만 무효화 */
}
클래스 기반 상태 관리와 무효화 범위
<!-- ❌ 최상위 요소에서 상태를 관리하면 무효화 범위가 넓어짐 -->
<body class="theme-dark">
<main>...</main>
<aside>...</aside>
<!-- body 아래 모든 요소 재계산 -->
</body>
<!-- ✅ 상태가 실제로 필요한 컴포넌트 루트에 클래스를 적용 -->
<div class="card card--expanded">
<!-- 이 서브트리만 재계산 -->
</div>
💡 TIP Chrome DevTools의 Performance 패널에서 "Recalc Style" 항목을 클릭하면 "Elements Affected" 수치를 확인할 수 있습니다. 이 값이 크다면 무효화 범위가 과도하게 넓다는 신호입니다.
contain 속성으로 무효화 격리
CSS contain 속성은 브라우저에게 "이 요소의 변경이 외부에 영향을 주지 않는다"고 명시적으로 알려 무효화·레이아웃 범위를 해당 서브트리로 제한합니다.
/* contain: layout — 이 요소의 레이아웃이 외부에 영향 없음 */
.widget {
contain: layout;
}
/* contain: style — 카운터·따옴표 등 스타일 효과가 외부에 전파 안 됨 */
.isolated {
contain: style;
}
/* contain: strict — layout + style + paint + size 모두 격리 (가장 강력) */
.card-list-item {
contain: strict; /* 카드 리스트처럼 독립적인 컴포넌트에 적합 */
}
/* content-visibility: auto — 뷰포트 밖 요소의 렌더링을 완전히 건너뜀 */
.off-screen-section {
content-visibility: auto;
contain-intrinsic-size: 0 500px; /* 스크롤바 떨림 방지를 위한 추정 크기 */
}
요약
- CSSOM은 DOM과 별도로 구축되며, 둘이 결합해 렌더 트리가 만들어집니다.
display: none노드는 렌더 트리에서 제외됩니다. - 스타일 계산(Recalc Style) → 레이아웃(Layout) → 페인트(Paint) → 합성(Composite) 순서로 파이프라인이 진행되며, 뒤로 갈수록 CPU 개입이 줄고 비용이 낮아집니다.
width,height,margin등 기하 속성을 변경하면 리플로우가 발생해 파이프라인 전체가 다시 실행됩니다.transform과opacity는 레이아웃·페인트를 건너뛰고 합성 단계만 실행되며, GPU 텍스처 연산만으로 처리되어 성능이 훨씬 뜁니다.- JavaScript에서 기하 속성을 읽으면 강제 동기 레이아웃이 발생합니다. 읽기와 쓰기를 반드시 배치(batching)해야 합니다.
- CSS
contain과content-visibility를 활용하면 스타일 무효화와 레이아웃 범위를 서브트리로 제한해 재계산 비용을 크게 줄일 수 있습니다.
연습문제
- 다음 JavaScript 코드에서 성능 문제를 찾고, 개선된 버전을 작성하세요.
const items = document.querySelectorAll('.item');
items.forEach(item => {
const h = item.offsetHeight;
item.style.height = h * 2 + 'px';
item.style.marginBottom = item.offsetTop + 'px';
});
힌트 읽기/쓰기 순서와 강제 동기 레이아웃을 떠올려 보세요.
- 아래 두 CSS 클래스 중 애니메이션 성능이 더 좋은 쪽은 무엇이며, 그 이유를 파이프라인 단계를 언급해 설명하세요.
/* A */
.fade-move-a {
transition: left 0.3s, opacity 0.3s;
}
/* B */
.fade-move-b {
transition: transform 0.3s, opacity 0.3s;
}
힌트 각 속성이 어느 파이프라인 단계를 트리거하는지 비교하세요.
- 아래 코드에서
.card에contain: strict를 적용했을 때 기대되는 효과를 설명하고, 적용이 적합하지 않은 경우를 한 가지 제시하세요.
.card-list .card {
width: 300px;
height: 200px;
overflow: hidden;
}
힌트
contain의 격리 효과와 크기가 동적으로 변하는 경우를 생각해 보세요.
- 다음 중 전체 트리 스타일 무효화를 유발할 가능성이 가장 높은 코드를 고르고 이유를 설명하세요.
// (a)
document.querySelector('.card').classList.add('active');
// (b)
document.documentElement.style.setProperty('--primary-color', '#ff0000');
// (c)
document.querySelector('.btn').style.backgroundColor = 'red';
힌트 변경이 적용되는 위치와 CSS 변수의 상속 범위를 생각해 보세요.
💡 연습문제 풀이
불러오는 중…
댓글 0
“CSS 심화” 강좌에 대한 댓글입니다.