브라우저Medium#43
브라우저의 렌더링 과정을 Critical Rendering Path 관점에서 설명해주세요.
#브라우저#렌더링#CRP#성능
힌트
HTML 파싱 → CSSOM → DOM → Render Tree → Layout → Paint → Composite 순서를 떠올려보세요.
정답 및 해설
브라우저의 렌더링 과정을 Critical Rendering Path 관점에서 설명해주세요.
Critical Rendering Path(CRP)는 브라우저가 HTML, CSS, JavaScript를 화면에 픽셀로 변환하는 일련의 단계를 의미합니다. 이 과정을 이해하면 페이지 로딩 성능을 최적화하고 First Contentful Paint(FCP), Largest Contentful Paint(LCP) 같은 Web Vitals 지표를 개선할 수 있습니다. CRP를 최적화하면 사용자가 콘텐츠를 더 빠르게 볼 수 있게 됩니다.
렌더링 과정 전체 흐름
HTML 수신
↓
HTML 파싱 → DOM 트리 생성
↓ (CSS 발견 시 병렬)
CSS 파싱 → CSSOM 트리 생성
↓
DOM + CSSOM → Render Tree 생성
↓
Layout (Reflow): 위치/크기 계산
↓
Paint: 픽셀 그리기
↓
Compositing: 레이어 합성
↓
화면 출력
1단계: HTML 파싱과 DOM 트리 생성
브라우저는 HTML 바이트를 문자로 변환하고, 토큰화 과정을 거쳐 DOM(Document Object Model) 트리를 생성합니다.
Bytes → Characters → Tokens → Nodes → DOM Tree
예시:
<html>
<head><title>페이지</title></head>
<body>
<h1 class="title">안녕하세요</h1>
<p>본문 내용</p>
</body>
</html>
DOM 트리:
Document
└── html
├── head
│ └── title
│ └── "페이지"
└── body
├── h1 (class="title")
│ └── "안녕하세요"
└── p
└── "본문 내용"
HTML 파싱 중 JS/CSS 처리
<!-- CSS는 렌더링 차단 리소스 (CSSOM 완성될 때까지 렌더링 차단) -->
<link rel="stylesheet" href="style.css" />
<!-- JS는 DOM 파싱 차단 (기본적으로) -->
<script src="app.js"></script>
<!-- defer: DOM 파싱 후 실행 (비차단) -->
<script src="app.js" defer></script>
<!-- async: 다운로드 완료 즉시 실행 (DOM 파싱 차단 가능) -->
<script src="analytics.js" async></script>
<!-- 인라인 스크립트: HTML 파싱 즉시 차단 -->
<script>
document.getElementById('app') // 이 시점에 아직 없을 수 있음
</script>
2단계: CSS 파싱과 CSSOM 트리 생성
CSS를 파싱하여 CSSOM(CSS Object Model) 트리를 생성합니다. CSSOM이 완성되기 전까지 렌더링이 차단됩니다.
Bytes → Characters → Tokens → Nodes → CSSOM Tree
예시 CSS:
body { font-size: 16px; }
h1 { color: blue; font-size: 2em; }
h1.title { color: red; }
p { color: gray; }
CSSOM 트리 (상속 포함 최종 값):
body
├── font-size: 16px
└── h1 (font-size: 32px, color: blue)
├── .title (color: red 오버라이드)
└── (기타 h1)
CSS가 렌더링을 차단하는 이유
// 브라우저가 렌더링을 차단하는 이유:
// CSS는 캐스케이딩 규칙에 의해 나중 스타일이 앞 스타일을 덮어쓸 수 있음
// CSSOM이 완전히 구성되어야 최종 스타일을 알 수 있음
// 불완전한 CSSOM으로 렌더링하면 화면이 깜빡이는 FOUC(Flash of Unstyled Content) 발생
// FOUC 방지 - CSS가 로드될 때까지 렌더링 차단
// <link rel="stylesheet"> 는 항상 렌더링 차단
3단계: Render Tree 생성
DOM과 CSSOM을 결합하여 실제로 화면에 표시될 요소들만 포함하는 Render Tree를 만듭니다.
DOM Tree + CSSOM Tree → Render Tree
규칙:
- display: none 요소 제외 (공간 차지 않음)
- visibility: hidden 요소 포함 (공간은 차지하지만 안 보임)
- <head>, <script>, <meta> 등 비시각 요소 제외
- ::before, ::after 등 가상 요소 포함
예시:
DOM: CSSOM: Render Tree:
html h1 { color: red } html
├── head (제외) p { display:none } └── body
└── body ├── h1 (color:red)
├── h1 │ └── "제목"
│ └── "제목" └── (p는 display:none이므로 제외)
└── p
└── "본문"
4단계: Layout (Reflow)
Render Tree의 각 노드가 화면에서 차지하는 정확한 위치와 크기를 계산합니다.
// Layout을 유발하는 CSS 속성들 (비용이 큰 작업)
// width, height, margin, padding, border
// top, left, right, bottom (position)
// font-size, line-height
// display, float
// JavaScript에서 Layout을 강제 발생시키는 작업 (피해야 함)
const element = document.getElementById('box')
// 이런 속성 읽기는 최신 Layout 결과를 요구함
const width = element.offsetWidth
const height = element.offsetHeight
const rect = element.getBoundingClientRect()
// ❌ Layout Thrashing (성능 저하)
for (let i = 0; i < 100; i++) {
element.style.width = (element.offsetWidth + 1) + 'px' // 매 반복마다 Layout
}
// ✅ Layout Thrashing 방지 - 읽기와 쓰기를 분리
const currentWidth = element.offsetWidth // 한 번에 읽기
for (let i = 0; i < 100; i++) {
element.style.width = (currentWidth + i) + 'px' // 쓰기만
}
5단계: Paint
Layout에서 계산된 위치/크기를 기반으로 실제 픽셀을 채웁니다. 텍스트, 색상, 이미지, 테두리, 그림자 등을 그립니다.
// Paint를 유발하는 CSS 속성들 (Layout보다는 저렴)
// color, background-color, background-image
// border-color, border-radius
// box-shadow, text-shadow
// outline
// GPU 가속을 활용하는 속성들 (Paint 없이 Compositing만 발생 - 가장 빠름)
// transform: translate(), scale(), rotate()
// opacity
// will-change: transform (레이어 분리 힌트)
// ✅ 애니메이션 최적화 - transform 사용 (Paint 없이 Compositing만)
.animated-element {
transform: translateX(0);
transition: transform 0.3s ease;
}
.animated-element:hover {
transform: translateX(100px); /* Reflow, Repaint 없이 Compositing만 */
}
/* ❌ 느린 애니메이션 - left 사용 (Reflow + Repaint 발생) */
.slow-element {
left: 0;
transition: left 0.3s ease;
}
.slow-element:hover {
left: 100px; /* Layout + Paint 재실행 */
}
6단계: Compositing (컴포지팅)
여러 레이어를 합쳐 최종 화면을 구성합니다. 특정 CSS 속성은 별도 레이어를 생성하여 GPU에서 처리됩니다.
/* 레이어 생성을 유발하는 속성들 */
.new-layer {
/* 아래 속성들은 브라우저가 새 합성 레이어를 생성 */
transform: translateZ(0); /* 또는 translate3d(0, 0, 0) */
will-change: transform, opacity;
position: fixed; /* fixed 요소는 별도 레이어 */
}
/* 레이어를 미리 생성하여 애니메이션 준비 */
.hero-image {
will-change: transform; /* 브라우저에게 "이 요소는 변환될 것임" 힌트 */
}
렌더링 차단 리소스 최적화
CSS 최적화
<!-- ✅ 미디어 쿼리로 불필요한 CSS 차단 방지 -->
<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="print.css" media="print" /> <!-- 인쇄 시에만 차단 -->
<link rel="stylesheet" href="mobile.css" media="(max-width: 768px)" />
<!-- ✅ Critical CSS 인라인화 -->
<style>
/* Above-the-fold에 필요한 최소한의 CSS만 인라인 */
body { margin: 0; font-family: sans-serif; }
.hero { background: #000; color: #fff; height: 100vh; }
</style>
<!-- 나머지 CSS는 비동기로 로드 -->
<link rel="preload" href="main.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
JavaScript 최적화
<!-- ❌ 렌더링 차단 스크립트 -->
<head>
<script src="large-library.js"></script> <!-- DOM 파싱 차단! -->
</head>
<!-- ✅ defer: DOM 파싱 완료 후, DOMContentLoaded 전 실행 -->
<script src="app.js" defer></script>
<!-- ✅ async: 다운로드 완료 즉시 실행 (순서 보장 없음, 독립적 스크립트에 사용) -->
<script src="analytics.js" async></script>
<!-- ✅ 모듈은 기본적으로 defer -->
<script type="module" src="app.mjs"></script>
<!-- ✅ 동적 import로 코드 분할 -->
<script>
// 필요할 때만 로드
button.addEventListener('click', async () => {
const { heavyFeature } = await import('./heavy-feature.js')
heavyFeature()
})
</script>
리소스 힌트
<head>
<!-- preconnect: DNS 조회 + TCP 연결 + TLS 핸드셰이크 미리 수행 -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<!-- dns-prefetch: DNS 조회만 미리 수행 (preconnect보다 저비용) -->
<link rel="dns-prefetch" href="https://cdn.example.com" />
<!-- preload: 현재 페이지에서 곧 필요한 리소스 미리 로드 -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/hero.jpg" as="image" />
<!-- prefetch: 다음 페이지에서 필요할 리소스 미리 로드 -->
<link rel="prefetch" href="/next-page.js" />
</head>
Reflow와 Repaint 최소화
// ❌ 여러 번 DOM 조작 (매번 Reflow 유발)
const el = document.getElementById('box')
el.style.width = '100px' // Reflow
el.style.height = '100px' // Reflow
el.style.margin = '10px' // Reflow
// ✅ 한 번에 스타일 적용
el.style.cssText = 'width: 100px; height: 100px; margin: 10px;'
// ✅ 또는 클래스 변경으로 한 번에 처리
el.classList.add('sized-box')
// ✅ DocumentFragment 사용 - DOM 조작을 메모리에서 처리
const fragment = document.createDocumentFragment()
for (let i = 0; i < 100; i++) {
const li = document.createElement('li')
li.textContent = `아이템 ${i}`
fragment.appendChild(li) // 메모리에서만 조작 (Reflow 없음)
}
document.getElementById('list').appendChild(fragment) // 한 번만 DOM에 추가
// ✅ 요소를 숨기고 조작한 후 다시 표시
const el = document.getElementById('complex-element')
el.style.display = 'none' // Reflow (한 번)
// ... 많은 DOM 조작 ...
el.style.display = 'block' // Reflow (한 번)
// 중간 조작들은 화면에 반영 안 됨
브라우저 DevTools로 CRP 분석
Chrome DevTools Performance 탭 사용:
1. F12 → Performance 탭
2. Record 버튼 클릭 후 페이지 로드
3. 분석 항목:
- Parsing HTML (파란색): DOM/CSSOM 생성
- Layout (보라색): Reflow 발생
- Paint (초록색): Repaint 발생
- Composite Layers: 컴포지팅
Lighthouse로 CRP 분석:
- Render-blocking resources 항목 확인
- Critical Request Chains 확인
- Unused CSS/JS 확인
정리 표
| 단계 | 작업 | 렌더링 차단 여부 | 최적화 방법 |
|---|---|---|---|
| HTML 파싱 | DOM 트리 생성 | JS에 의해 차단 | defer, async 사용 |
| CSS 파싱 | CSSOM 트리 생성 | 렌더링 차단 | Critical CSS 인라인화, 미디어 쿼리 |
| Render Tree | DOM + CSSOM 결합 | - | 불필요한 요소 display:none |
| Layout | 위치/크기 계산 | 느린 단계 | transform 사용, Layout Thrashing 방지 |
| Paint | 픽셀 채우기 | 중간 비용 | will-change, GPU 가속 활용 |
| Compositing | 레이어 합성 | 빠른 단계 | transform, opacity로 직접 처리 |