Ray Book
브라우저 렌더링 파이프라인

HTML/CSS 파싱과 DOM/CSSOM

바이트 스트림이 DOM·CSSOM 트리가 되는 과정을 시각화합니다

browserrenderingdomcssomparsing

네트워크에서 도착한 바이트

브라우저에 URL을 입력하면, 서버는 HTML 문서를 바이트 스트림으로 응답합니다. 이 바이트는 아직 의미 없는 숫자의 나열입니다.

HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8

브라우저는 HTTP 헤더의 charset 값 (또는 HTML 내부의 <meta charset>)을 참고해 바이트를 문자열로 디코딩합니다. 대부분의 웹 문서는 UTF-8 인코딩을 사용합니다.

바이트: 3C 68 74 6D 6C 3E ...
         ↓ UTF-8 디코딩
문자열: <html> ...

문자열이 확보되면 본격적인 파싱 (parsing)이 시작됩니다. 파싱의 목표는 텍스트를 프로그램이 다룰 수 있는 구조화된 트리로 변환하는 것입니다.

HTML 토크나이저

HTML 파싱의 첫 단계는 토큰화 (tokenization)입니다. HTML 토크나이저는 HTML Living Standard에 정의된 상태 머신(state machine)으로 구현됩니다.

토크나이저는 문자를 하나씩 읽으며 다음과 같은 토큰을 생성합니다.

  • DOCTYPE 토큰 : <!DOCTYPE html>
  • 시작 태그 토큰 : <html>, <head>, <body>
  • 종료 태그 토큰 : </html>, </head>, </body>
  • 문자 토큰 : 태그 사이의 텍스트
  • 주석 토큰 : <!-- 주석 -->
<h1 class="title">Hello</h1>

위 코드는 아래 토큰들로 분해됩니다.

StartTag: { tag: "h1", attrs: [{ name: "class", value: "title" }] }
Character: "Hello"
EndTag: { tag: "h1" }

토크나이저의 핵심은 상태 전이입니다. < 문자를 만나면 "태그 열기 상태"로, > 문자를 만나면 "데이터 상태"로 돌아갑니다. 이 상태 머신은 HTML 스펙에 80개 이상의 상태로 정의되어 있습니다.

DOM 트리 구축

토큰이 생성되면 트리 빌더 (tree builder)가 이를 소비하며 DOM 트리를 구축합니다. 토큰화와 트리 구축은 파이프라인으로 연결되어, 토큰이 하나 만들어질 때마다 즉시 트리에 반영됩니다.

트리 구축 규칙은 단순합니다.

  1. 시작 태그 토큰 → 새 Element 노드를 생성하고 현재 노드의 자식으로 추가. 이 노드가 새로운 "현재 노드"가 됨
  2. 종료 태그 토큰 → 현재 노드를 부모로 이동 (트리를 한 단계 올라감)
  3. 문자 토큰Text 노드를 생성하고 현재 노드의 자식으로 추가

아래 시각화로 바이트에서 DOM/CSSOM 트리가 완성되기까지의 전체 과정을 확인하세요.

HTML 소스대기
1<!DOCTYPE html>
2<html lang="ko">
3<head>
4 <link rel="stylesheet" href="style.css">
5 <script src="app.js"></script>
6</head>
7<body>
8 <h1>Hello</h1>
9 <p>World</p>
10</body>
11</html>
DOM Tree
아직 노드 없음
네트워크에서 HTML 바이트 스트림이 도착합니다. 브라우저는 Content-Type 헤더의 charset(보통 UTF-8)으로 바이트를 문자열로 디코딩합니다. 아직 파싱은 시작되지 않았습니다.

결과적으로 DOM은 HTML 문서의 프로그래밍 인터페이스입니다. JavaScript의 document.querySelector()element.appendChild() 같은 API는 모두 이 DOM 트리를 조작합니다.

CSS 파싱과 CSSOM

HTML 파서가 <link rel="stylesheet"> 또는 <style> 태그를 만나면, CSS 파싱이 시작됩니다. CSS 파싱도 HTML과 유사하게 바이트 → 토큰 → 트리 과정을 거칩니다.

h1 { color: navy; font-size: 24px; }
p  { font-size: 16px; line-height: 1.6; }

위 CSS는 다음과 같은 CSSOM 트리로 변환됩니다.

StyleSheet
  Rule: h1
    color: navy
    font-size: 24px
  Rule: p
    font-size: 16px
    line-height: 1.6

CSSOM은 단순히 CSS 규칙을 나열한 것이 아닙니다. 캐스케이드 (cascade)와 특이도 (specificity) 규칙을 적용한 최종 계산 결과를 담고 있습니다.

특이도 계산 우선순위 (Selectors Level 4 기준, 3칸 모델):

선택자 유형특이도 (A-B-C)예시
ID1-0-0#header
클래스, 속성, 의사클래스0-1-0.title, [type="text"], :hover
요소, 의사요소0-0-1h1, ::before

인라인 스타일 (style="...") 은 특이도 계산이 아닌 캐스케이드 우선순위에 의해 모든 선택자보다 우선합니다.

중요한 특성: CSS는 렌더 블로킹 리소스입니다. CSSOM이 완성되지 않으면 렌더 트리를 만들 수 없으므로, 브라우저는 CSS가 로드될 때까지 화면 렌더링을 멈춥니다. 하지만 CSS는 DOM 파싱을 블로킹하지 않습니다. DOM 파싱과 CSS 파싱은 병렬로 진행됩니다.

파서 블로킹

HTML 파서가 <script> 태그를 만나면 상황이 달라집니다. 파서 블로킹이 발생합니다.

<head>
  <link rel="stylesheet" href="style.css">
  <!-- HTML 파서가 여기서 멈춤 -->
  <script src="app.js"></script>
  <!-- 스크립트 실행 완료 후 파싱 재개 -->
</head>

파서가 멈추는 이유는 스크립트가 document.write()로 DOM을 직접 수정할 수 있기 때문입니다. 파서는 스크립트의 영향을 예측할 수 없으므로, 안전하게 실행 완료를 기다립니다.

deferasync 속성으로 이 블로킹을 제어할 수 있습니다.

<!-- 1. 일반 script: 다운로드 + 실행 동안 파서 블로킹 -->
<script src="app.js"></script>

<!-- 2. async: 다운로드는 병렬, 다운로드 완료 시점에 실행 (실행 중 블로킹) -->
<script async src="analytics.js"></script>

<!-- 3. defer: 다운로드는 병렬, DOMContentLoaded 직전에 순서대로 실행 -->
<script defer src="app.js"></script>

세 방식의 타이밍 차이를 정리하면:

<script>          : HTML 파싱 중단 -> 다운로드 -> 실행 -> 파싱 재개
<script defer>    : HTML 파싱과 병렬 다운로드 -> 파싱 완료 후 실행
<script async>    : HTML 파싱과 병렬 다운로드 -> 다운로드 완료 즉시 실행

실무 권장 : 대부분의 스크립트는 defer로 로드하세요. 실행 순서가 보장되며, DOM이 완전히 파싱된 후에 실행됩니다. async는 Google Analytics처럼 독립적이고 순서가 중요하지 않은 스크립트에 적합합니다.

프리로드 스캐너

파서 블로킹은 성능에 치명적일 수 있습니다. 스크립트를 다운로드하는 동안 파서가 멈추면, 나머지 HTML에 있는 이미지나 CSS 파일을 발견하지 못해 추가 지연이 생깁니다.

이 문제를 해결하기 위해 모든 주요 브라우저는 프리로드 스캐너 (preload scanner)를 구현합니다. HTML Living Standard에서 "speculative parsing"으로 언급되는 이 메커니즘은 다음과 같이 동작합니다.

  1. 메인 HTML 파서가 스크립트 실행을 기다리며 블로킹됨
  2. 프리로드 스캐너가 나머지 HTML을 경량 파싱하며 리소스 URL을 추출
  3. <img>, <link>, <script> 등의 리소스를 미리 네트워크 요청
  4. 메인 파서가 재개되면 이미 다운로드 중이거나 완료된 리소스를 즉시 사용
메인 파서:     파싱 중 ... [script 블로킹] ... 파싱 재개
프리로드 스캐너: (블로킹 중에도) 나머지 HTML 스캔 -> 리소스 사전 요청

프리로드 스캐너 덕분에 파서 블로킹의 성능 영향이 크게 줄어듭니다. 하지만 프리로드 스캐너가 발견할 수 없는 리소스도 있습니다. JavaScript로 동적 생성되는 요소나 CSS background-image가 그 예입니다.

실무 사례

Chrome의 프리로드 스캐너

Chrome (Blink 엔진)의 프리로드 스캐너는 HTMLPreloadScanner라는 이름으로 구현되어 있습니다. 이 스캐너는 메인 파서와 별도로 토큰화만 수행하며, DOM 트리를 구축하지 않습니다. 발견한 리소스 URL을 PreloadRequest로 변환해 네트워크 스택에 전달합니다.

Chrome DevTools의 Performance 탭에서 파서 블로킹과 프리로드의 효과를 직접 확인할 수 있습니다.

  1. DevTools → Performance → Record
  2. 페이지 새로고침
  3. "Parse HTML" 이벤트와 "Send Request" 이벤트의 타이밍을 비교
  4. 프리로드된 리소스는 파서 블로킹 중에도 다운로드가 시작된 것을 확인 가능

defer/async 실제 로딩 타이밍

<!DOCTYPE html>
<html>
<head>
  <link rel="stylesheet" href="critical.css">
  <!-- defer: DOM 파싱과 병렬 다운로드, DOMContentLoaded 전 실행 -->
  <script defer src="app.js"></script>
  <!-- async: 병렬 다운로드, 완료 즉시 실행 -->
  <script async src="analytics.js"></script>
</head>
<body>
  <h1>Hello</h1>
  <img src="hero.jpg" alt="Hero">
  <!-- body 끝에 놓아도 defer와 유사하지만, 프리로드 스캐너가 더 늦게 발견 -->
  <script src="late.js"></script>
</body>
</html>

<script defer><head>에 두는 것이 <body> 끝에 일반 <script>를 두는 것보다 유리합니다. defer 스크립트는 프리로드 스캐너가 즉시 발견해 다운로드를 시작하지만, <body> 끝의 스크립트는 파서가 해당 지점에 도달해야 발견됩니다.

렌더 블로킹 CSS 최적화

CSS는 렌더 블로킹이므로, 크리티컬 CSS를 인라인하면 첫 렌더링이 빨라집니다.

<head>
  <!-- 크리티컬 CSS 인라인: 네트워크 왕복 없이 즉시 CSSOM 구축 -->
  <style>
    h1 { color: navy; } body { margin: 0; font-family: sans-serif; }
  </style>
  <!-- 비크리티컬 CSS: 비동기 로드 -->
  <link rel="preload" href="full.css" as="style" onload="this.rel='stylesheet'">
</head>

다음 단계

DOM과 CSSOM 트리가 완성되면, 브라우저는 이 두 트리를 결합해 렌더 트리 (Render Tree)를 만듭니다. 렌더 트리는 실제로 화면에 표시될 노드만 포함하며, 각 노드에 최종 계산된 스타일이 적용되어 있습니다.

다음 글에서는 렌더 트리 구축과 레이아웃 (Layout) 단계를 살펴봅니다. 각 요소의 정확한 위치와 크기를 계산하는 과정입니다.