전체 목록
브라우저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 TreeDOM + CSSOM 결합-불필요한 요소 display:none
Layout위치/크기 계산느린 단계transform 사용, Layout Thrashing 방지
Paint픽셀 채우기중간 비용will-change, GPU 가속 활용
Compositing레이어 합성빠른 단계transform, opacity로 직접 처리