브라우저Medium#97
브라우저의 Critical Rendering Path(CRP)를 설명해주세요.
#브라우저#CRP#렌더링#성능최적화
힌트
HTML 파싱부터 화면에 픽셀이 그려지기까지의 단계를 생각해보세요.
정답 및 해설
브라우저의 Critical Rendering Path(CRP)를 설명해주세요.
Critical Rendering Path(CRP, 핵심 렌더링 경로)는 브라우저가 HTML, CSS, JavaScript를 받아서 화면에 픽셀을 그리기까지의 과정입니다. 이 과정을 이해하면 웹 페이지의 초기 로딩 성능을 최적화하는 데 핵심적인 인사이트를 얻을 수 있습니다. CRP 최적화는 사용자가 페이지를 처음 볼 때까지 걸리는 시간(FCP, LCP)을 단축하는 데 직결됩니다.
CRP 전체 흐름
네트워크에서 HTML 수신
↓
1. HTML 파싱 → DOM 생성
↓ (동시에)
2. CSS 파싱 → CSSOM 생성
↓
3. DOM + CSSOM → Render Tree 생성
↓
4. Layout (Reflow) - 위치/크기 계산
↓
5. Paint - 픽셀 채우기
↓
6. Composite - 레이어 합성
↓
화면 출력
1단계: HTML 파싱 → DOM 생성
<!-- 브라우저가 HTML을 파싱하여 DOM(Document Object Model) 트리 생성 -->
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="style.css"> <!-- CSS 파싱 시작 트리거 -->
<script src="app.js"></script> <!-- JS: DOM 파싱 차단! -->
</head>
<body>
<h1>안녕하세요</h1>
<p>본문입니다</p>
</body>
</html>
DOM 트리:
Document
└── html
├── head
│ ├── link (rel="stylesheet")
│ └── script (src="app.js")
└── body
├── h1 ("안녕하세요")
└── p ("본문입니다")
JavaScript는 DOM 파싱을 차단한다
<!-- ❌ 기본 script 태그: DOM 파싱을 중단하고 JS 실행 후 재개 -->
<head>
<script src="heavy-lib.js"></script> <!-- 이 스크립트 다운로드+실행 동안 파싱 중단 -->
</head>
<body>
<!-- heavy-lib.js 실행 완료 후 파싱 재개 -->
<h1>여기까지 파싱이 지연됨</h1>
</body>
<!-- ✅ defer: DOM 파싱 완료 후 실행, DOMContentLoaded 전 실행 -->
<script src="app.js" defer></script>
<!-- ✅ async: 다운로드는 병렬, 다운로드 완료 즉시 실행 (파싱 차단 가능) -->
<!-- 실행 순서 보장 안 됨 - 독립적인 스크립트에 적합 -->
<script src="analytics.js" async></script>
<!-- ✅ 모듈 스크립트: 기본적으로 defer 동작 -->
<script type="module" src="app.js"></script>
2단계: CSS 파싱 → CSSOM 생성
/* CSS 파싱 → CSSOM(CSS Object Model) 생성 */
body {
font-size: 16px;
color: #333;
}
h1 {
font-size: 2em; /* 상속: body의 font-size 기반 → 32px */
}
p.highlight {
color: blue;
}
CSSOM 트리:
body (font-size: 16px, color: #333)
├── h1 (font-size: 32px, color: #333 [상속])
└── p.highlight (color: blue)
CSS는 렌더링을 차단한다
<!-- CSS는 렌더링 차단 리소스 (Render-blocking resource) -->
<!-- CSSOM이 완성될 때까지 Render Tree 생성 불가 → 화면 출력 지연 -->
<!-- ❌ 느린 CSS 로드 -->
<link rel="stylesheet" href="large-styles.css">
<!-- ✅ 미디어 쿼리로 차단 조건 제한 -->
<!-- 프린트용 CSS는 화면 렌더링을 차단하지 않음 -->
<link rel="stylesheet" href="print.css" media="print">
<link rel="stylesheet" href="mobile.css" media="(max-width: 768px)">
<!-- ✅ Critical CSS 인라인화 -->
<head>
<style>
/* 위에 보이는 영역(above the fold)의 핵심 CSS만 인라인으로 포함 */
body { margin: 0; font-family: sans-serif; }
header { background: #fff; }
.hero { display: flex; ... }
</style>
<!-- 나머지 CSS는 비동기로 로드 -->
<link rel="preload" href="styles.css" as="style" onload="this.rel='stylesheet'">
</head>
3단계: Render Tree 생성
DOM과 CSSOM을 결합하여 실제로 화면에 그려질 요소만 포함하는 Render Tree를 생성합니다.
DOM 트리 → Render Tree 변환 규칙:
- display: none → Render Tree에서 제외 (공간 차지 없음)
- visibility: hidden → Render Tree에 포함 (공간은 차지, 보이지 않음)
- <head>, <script>, <style> → 화면에 표시 안 되므로 제외
- ::before, ::after 가상 요소 → 포함
DOM Render Tree
document body
├── head (제외) ├── h1 "안녕하세요" [font-size:32px, color:#333]
├── body └── p "본문" [color:#333]
│ ├── h1
│ ├── p
│ └── div [display:none] (제외!)
4단계: Layout (Reflow) - 위치와 크기 계산
Render Tree의 각 노드가 화면에서 차지할 정확한 위치와 크기를 계산합니다.
// Reflow를 유발하는 CSS 속성들 (비용이 큼)
// 변경 시 전체 또는 일부 레이아웃 재계산 필요
const reflowProperties = [
'width', 'height', 'margin', 'padding',
'border', 'position', 'top', 'left', 'right', 'bottom',
'display', 'float', 'overflow',
'font-size', 'font-weight', 'line-height'
];
// ❌ Reflow를 강제로 발생시키는 코드 (레이아웃 스래싱)
function badAnimation() {
const el = document.getElementById('box');
for (let i = 0; i < 100; i++) {
el.style.left = el.offsetLeft + 1 + 'px'; // 읽기(offsetLeft) + 쓰기(style.left) 반복
// → 매번 Reflow 강제 발생!
}
}
// ✅ Reflow 최소화
function goodAnimation() {
const el = document.getElementById('box');
let left = el.offsetLeft; // 한 번만 읽기
for (let i = 0; i < 100; i++) {
left += 1;
}
el.style.left = left + 'px'; // 한 번만 쓰기
}
// ✅ CSS transform 사용 (Layout 단계 건너뜀)
el.style.transform = 'translateX(100px)'; // Composite 단계만 실행
5단계: Paint - 픽셀 채우기
Layout 단계에서 계산된 위치에 실제 픽셀을 그립니다. 색상, 배경, 그림자, 텍스트 등을 그립니다.
// Repaint를 유발하는 CSS 속성 (Layout은 변경 안 하고 그리기만 다시)
const repaintProperties = [
'color', 'background-color', 'visibility',
'outline', 'border-radius', 'box-shadow',
'text-decoration'
];
// Repaint는 Reflow보다 비용이 적지만, 여전히 비쌈
// 가능하면 Composite 단계만 실행되도록 최적화
6단계: Composite - 레이어 합성
여러 레이어를 최종적으로 합성하여 화면에 출력합니다.
// GPU에서 처리 가능한 CSS 속성 (Composite 단계만 실행 → 가장 효율적)
const compositeOnlyProperties = [
'transform', // translateX, translateY, scale, rotate 등
'opacity', // 투명도
'filter', // blur 등 (일부)
];
// ✅ 애니메이션 최적화: transform과 opacity만 사용
.animated-element {
/* ❌ 느림: width 변경은 Layout → Paint → Composite 순서로 실행 */
transition: width 0.3s ease;
/* ✅ 빠름: transform은 Composite 단계만 실행 (GPU 가속) */
transition: transform 0.3s ease;
}
// will-change로 레이어 사전 생성 (과도한 사용 주의)
.panel {
will-change: transform; /* 이 요소가 변환될 것임을 브라우저에 힌트 */
}
렌더링 단계 트리거 비교
변경 속성 실행되는 단계
─────────────────────────────────────────
width, height Layout → Paint → Composite (가장 비쌈)
color Paint → Composite
transform Composite만 (가장 저렴)
opacity Composite만 (GPU 가속)
CRP 최적화 전략
1. 렌더 차단 리소스 최소화
<!-- Critical CSS 인라인화 -->
<head>
<style>
/* Above the fold 영역의 핵심 스타일만 */
body { margin: 0; }
.header { height: 60px; background: white; }
.hero { min-height: 400px; }
</style>
<!-- 나머지 CSS 비동기 로드 -->
<link rel="preload" href="full-styles.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="full-styles.css"></noscript>
</head>
<!-- JS defer/async 사용 -->
<script src="analytics.js" async></script>
<script src="app.js" defer></script>
2. 리소스 우선순위 힌트
<!-- 중요 리소스 미리 로드 -->
<link rel="preload" href="hero-image.jpg" as="image">
<link rel="preload" href="main-font.woff2" as="font" crossorigin>
<!-- 다음 페이지 리소스 미리 가져오기 (낮은 우선순위) -->
<link rel="prefetch" href="/next-page.html">
<!-- DNS 미리 조회 -->
<link rel="dns-prefetch" href="//api.example.com">
<!-- 연결 미리 맺기 -->
<link rel="preconnect" href="https://fonts.googleapis.com">
3. Reflow 최소화
// ✅ 여러 스타일 변경은 한 번에
const el = document.getElementById('box');
// ❌ 각각 Reflow 발생
el.style.width = '100px';
el.style.height = '100px';
el.style.margin = '10px';
// ✅ 클래스로 한 번에 변경
el.classList.add('resized');
// ✅ DocumentFragment로 DOM 조작 최소화
const fragment = document.createDocumentFragment();
for (let i = 0; i < 100; i++) {
const li = document.createElement('li');
li.textContent = `항목 ${i}`;
fragment.appendChild(li);
}
document.getElementById('list').appendChild(fragment); // DOM에 한 번만 추가
4. requestAnimationFrame 활용
// ✅ 애니메이션은 rAF로 처리 (브라우저 렌더링 주기에 맞춤)
function animate() {
element.style.transform = `translateX(${position}px)`;
position += 1;
if (position < 300) {
requestAnimationFrame(animate); // 다음 프레임에 실행
}
}
requestAnimationFrame(animate);
성능 측정 지표
| 지표 | 의미 | 목표 |
|---|---|---|
| FCP (First Contentful Paint) | 첫 번째 콘텐츠 표시 시간 | 1.8초 이하 |
| LCP (Largest Contentful Paint) | 가장 큰 콘텐츠 표시 시간 | 2.5초 이하 |
| CLS (Cumulative Layout Shift) | 레이아웃 이동 누적 점수 | 0.1 이하 |
| TTI (Time to Interactive) | 완전히 인터랙션 가능한 시간 | 3.8초 이하 |
정리
| 단계 | 역할 | 차단 요소 | 최적화 방법 |
|---|---|---|---|
| HTML 파싱 | DOM 트리 생성 | <script> 태그 | defer / async 속성 |
| CSS 파싱 | CSSOM 트리 생성 | CSS 파일 | Critical CSS 인라인화 |
| Render Tree | 표시할 요소 결정 | CSSOM 완성 전까지 대기 | 불필요한 노드 최소화 |
| Layout | 위치/크기 계산 | width, height 변경 | transform 사용, 배치 계산 최소화 |
| Paint | 픽셀 채우기 | color, background 변경 | 레이어 분리 |
| Composite | 레이어 합성 | - | GPU 가속 속성 (transform, opacity) 활용 |
핵심: CRP 최적화의 핵심은 "렌더 차단 리소스를 줄이고, Layout과 Paint 단계를 최소화하며, Composite 단계만 실행되는 CSS 속성(transform, opacity)으로 애니메이션을 구현하는 것"입니다.