dev.syw

토크나이저부터 DOM/CSSOM 트리 생성까지, 브라우저가 HTML을 해석하는 내부 동작을 들여다본다.

브라우저 파싱과 DOM 구축 원리

웹 개발자라면 HTML 파일을 작성하고 브라우저에서 결과를 확인하는 과정에 익숙할 것입니다. 그런데 브라우저가 서버로부터 바이트 스트림을 받아 화면에 픽셀을 그리기까지, 내부에서는 놀랍도록 정교한 파이프라인이 작동합니다. 이 파이프라인을 이해하면 성능 병목의 원인을 정확히 짚어내고, 렌더링 지연을 예방하는 코드를 의식적으로 작성할 수 있게 됩니다.

이 강에서는 입문편에서 "태그를 어떻게 쓰는가"보다 한 단계 깊이 내려가, "브라우저는 그 태그를 어떻게 해석하는가"라는 질문에 답합니다. 바이트를 문자로 바꾸는 디코딩 단계에서 시작해, 토크나이저가 어떻게 토큰을 만들고 파서가 DOM 트리를 구축하는지, 그리고 CSS까지 합쳐져 최종 렌더 트리가 완성되는 흐름을 살펴봅니다.

학습 목표

  • 바이트 스트림 디코딩토크나이저(tokenizer) 동작 원리를 설명할 수 있다.
  • HTML 파싱 상태 머신insertion mode의 역할을 이해한다.
  • DOM 트리, CSSOM 트리, 렌더 트리가 어떻게 결합되는지 설명할 수 있다.
  • preload scannerspeculative parsing이 성능에 미치는 영향을 이해한다.
  • <script> 태그의 파서 차단(blocking) 메커니즘과 document.write의 위험성을 안다.
  • quirks modestandards mode의 차이를 렌더링 관점에서 설명할 수 있다.

바이트 스트림 디코딩과 토크나이저

브라우저가 서버로부터 HTML을 받을 때 가장 먼저 처리하는 것은 원시 바이트(raw bytes)입니다. 네트워크 계층에서 넘어온 바이트들은 곧바로 파서로 들어가지 않고, 먼저 문자 인코딩 감지(charset detection) 단계를 거칩니다.

인코딩 감지 순서

브라우저는 다음 우선순위로 인코딩을 결정합니다.

우선순위출처예시
1BOM (Byte Order Mark)파일 앞 EF BB BF (UTF-8 BOM)
2HTTP 응답 헤더Content-Type: text/html; charset=UTF-8
3<meta charset> 또는 <meta http-equiv> (prescan)<meta charset="UTF-8">
4브라우저 휴리스틱 / 초기 1024바이트 prescan초기 1024바이트를 분석
5브라우저 기본값보통 UTF-8

이 순서는 WHATWG의 인코딩 스니핑 알고리즘(encoding sniffing algorithm) 을 따릅니다(사용자 강제 override는 그보다도 우선). 핵심은 BOM이 HTTP 응답 헤더의 charset보다 우선한다는 점입니다. 즉 파일 앞에 UTF-8 BOM(EF BB BF)이 존재하면, HTTP 헤더가 charset=EUC-KR 같은 다른 인코딩을 선언하더라도 브라우저는 이를 무시하고 UTF-8로 강제합니다.

<meta charset> 선언이 HTML 앞부분에 있어야 하는 이유가 여기에 있습니다. 브라우저는 스트리밍 파싱 도중 인코딩을 발견하면 파싱을 재시작(reparse) 할 수 있으며, 이 재시작 비용을 막으려면 <head> 최상단에 선언해야 합니다.

<!-- ✅ head 최상단에 배치 — 재파싱 방지 -->
<head>
  <meta charset="UTF-8">
  <title>페이지 제목</title>
</head>

<!-- ❌ 늦은 위치 — 이미 일부 파싱 후 발견 시 재파싱 발생 가능 -->
<head>
  <title>페이지 제목</title>
  <meta charset="UTF-8">
</head>
HTML

바이트가 문자(character)로 변환되면 토크나이저(tokenizer) 가 가동됩니다. 토크나이저는 문자 스트림을 읽으며 아래 종류의 토큰을 생성합니다.

  • DOCTYPE 토큰: <!DOCTYPE html> 파싱
  • 시작 태그 토큰: <div class="box"> — 태그명과 속성 목록 포함
  • 종료 태그 토큰: </div>
  • 문자 토큰: 태그 사이의 텍스트 내용
  • 주석 토큰: <!-- ... -->
  • EOF 토큰: 입력 종료

파싱 상태 머신과 insertion mode

HTML 파서의 핵심은 WHATWG 명세에 정의된 80개 이상의 상태를 가진 유한 상태 머신(FSM, Finite State Machine) 입니다. 토크나이저는 현재 상태에 따라 다음 문자를 해석하는 방식을 바꿉니다.

예를 들어 < 문자를 만나면 상태가 "Tag open state"로 전환되고, 이어지는 ! 는 "Markup declaration open state"로, / 는 "End tag open state"로, 알파벳은 "Tag name state"로 이어집니다.

초기 상태(Data state)
    │
    └─ '<' 문자 감지 → Tag open state
          │
          ├─ '!' → Markup declaration open state  (DOCTYPE, 주석 등)
          ├─ '/' → End tag open state              (종료 태그)
          └─ [a-zA-Z] → Tag name state             (시작 태그)

토크나이저가 토큰을 생성하면 트리 생성기(tree constructor) 가 이를 받아 DOM 트리를 만듭니다. 트리 생성기는 insertion mode라는 컨텍스트 상태를 관리하는데, 현재 어느 요소 안에 있는지에 따라 같은 토큰도 다르게 처리됩니다.

Insertion Mode활성화 조건특이 동작
"in head"<head> 파싱 중<script>, <style>, <meta> 처리
"in body"<body> 파싱 중대부분의 콘텐츠 요소 처리
"in table"<table> 파싱 중허용되지 않는 요소는 "foster parenting"으로 테이블 밖에 삽입
"in select"<select> 파싱 중주로 <option>, <optgroup>을 처리하며 그 외 대부분의 시작 태그는 무시되거나 select를 닫는다
<!-- 테이블 내 잘못된 텍스트: foster parenting 발생 -->
<table>
  직접 텍스트   <!-- ❌ 이 텍스트는 테이블 앞에 삽입됨(foster parented) -->
  <tr><td></td></tr>
</table>
HTML

자동 태그 보정 (Error Recovery)

HTML 파서는 오류가 발생해도 예외를 던지지 않습니다. WHATWG 명세는 "tree construction error"를 정의하고 각각에 대한 복구 알고리즘을 규정합니다.

<!-- 닫는 태그 없음 — 파서가 자동으로 닫아줌 -->
<p>첫 번째 단락
<p>두 번째 단락
<!-- 파서 결과: <p>첫 번째 단락</p><p>두 번째 단락</p> -->

<!-- 잘못된 중첩 — 파서가 재구조화 -->
<b><i>굵고 기울임</b></i>
<!-- 파서 결과: <b><i>굵고 기울임</i></b><i></i> -->
HTML

⚠️ 주의: 자동 보정 결과는 브라우저마다 약간씩 다를 수 있습니다. 의도한 구조를 보장하려면 명시적으로 올바른 태그를 닫아야 합니다. 에러 복구에 의존하는 코드는 유지보수 함정이 됩니다.

DOM 트리와 CSSOM 트리

파서가 HTML 문서를 처리하면 DOM(Document Object Model) 트리가 메모리에 구축됩니다. DOM 트리의 각 노드는 명세의 인터페이스(Element, Text, Comment 등)를 구현하며, JavaScript에서 document.querySelector 같은 API로 접근할 수 있는 그 객체들입니다.

// DOM 트리 탐색 — 파서가 만든 구조를 그대로 반영
const body = document.body;
console.log(body.nodeType);       // 1 (ELEMENT_NODE)
console.log(body.childNodes);     // NodeList: 실제 DOM 자식들
console.log(body.firstChild);     // 첫 번째 자식 (텍스트 노드 포함)
console.log(body.firstElementChild); // 첫 번째 요소 자식만
JavaScript

HTML 파싱이 진행되는 동안 브라우저는 CSS도 함께 처리합니다. <link rel="stylesheet"><style> 태그를 만나면 CSS 파서가 가동되어 CSSOM(CSS Object Model) 트리를 구축합니다.

/* 아래 CSS는 CSSOM 트리로 변환됨 */
body { font-size: 16px; }
h1   { font-size: 2em; color: #333; }
p    { margin: 0 0 1rem; }

CSSOM 트리는 상속과 캐스케이드가 이미 계산된 스타일 구조입니다. JavaScript에서 getComputedStyle로 접근하면 이 계산 결과를 볼 수 있습니다.

const h1 = document.querySelector('h1');
const style = window.getComputedStyle(h1);
console.log(style.fontSize);  // "32px" (2em × 16px)
console.log(style.color);     // "rgb(51, 51, 51)"
JavaScript

💡 TIP: CSSOM은 DOM과 달리 렌더 차단(render-blocking) 리소스입니다. 브라우저는 CSSOM이 완성될 때까지 렌더 트리 생성을 지연합니다. CSS 파일을 최대한 빨리 로드해야 하는 이유입니다.

렌더 트리로의 결합

DOM과 CSSOM이 준비되면 브라우저는 두 트리를 결합해 렌더 트리(Render Tree) 를 만듭니다. 렌더 트리에는 화면에 실제로 표시되는 노드만 포함됩니다.

DOM 트리          CSSOM 트리
  html              html { ... }
  ├─ head            body { ... }
  └─ body            ├─ h1 { display: block; ... }
      ├─ h1          └─ p  { display: block; ... }
      ├─ p               └─ span { display: none; }
      └─ script

렌더 트리 (display:none, <script>, <head> 등 제외)
  body
  ├─ h1
  └─ p  (span은 display:none이므로 렌더 트리에서 제외)
/* 렌더 트리에서 제외되는 노드 */
.hidden { display: none; }       /* ❌ 렌더 트리에 없음 */

/* 렌더 트리에 포함되지만 공간 차지 안 함 */
.invisible { visibility: hidden; } /* ✅ 렌더 트리에 있으나 투명 */

렌더 트리가 완성되면 레이아웃(Layout) 단계에서 각 노드의 위치와 크기를 계산하고, 이어 페인트(Paint) 단계에서 실제 픽셀을 그립니다.

preload scanner와 투기적 파싱

HTML 파서는 기본적으로 단일 스레드에서 순차적으로 동작합니다. 그런데 <script> 태그를 만나 파서가 차단되면, 그 동안 나머지 HTML에 있는 <img>, <link> 같은 리소스들은 아직 발견되지 않은 상태로 대기합니다. 이 비효율을 해소하기 위해 브라우저는 preload scanner(투기적 파서, speculative parser) 를 병렬로 실행합니다.

메인 파서: [HTML 파싱 → <script> 발견 → 차단됨 ...]
                                                      ↓
Preload Scanner: [차단 중에도 앞쪽 HTML을 미리 스캔]
                  → <img src="hero.jpg"> 발견 → 선제적 다운로드 시작
                  → <link rel="stylesheet" href="main.css"> → 선제적 다운로드

preload scanner가 효과를 발휘하려면 HTML에서 리소스 URL이 정적으로 선언되어 있어야 합니다. JavaScript로 동적으로 삽입되는 리소스는 preload scanner가 감지할 수 없습니다.

<!-- ✅ preload scanner가 감지 가능 — 조기 다운로드 -->
<img src="/images/hero.jpg" alt="히어로 이미지">
<link rel="stylesheet" href="/css/main.css">
<script src="/js/app.js"></script>

<!-- ❌ preload scanner 감지 불가 — JS 실행 후에야 다운로드 시작 -->
<script>
  const img = new Image();
  img.src = '/images/hero.jpg'; // 동적 생성 — 조기 감지 안 됨
</script>
HTML

<link rel="preload"> 를 사용하면 preload scanner에 힌트를 추가로 줄 수 있습니다.

<head>
  <meta charset="UTF-8">
  <!-- 폰트는 CSS 파싱 후에야 발견되므로, 직접 preload 선언 -->
  <link rel="preload" href="/fonts/MyFont.woff2" as="font" type="font/woff2" crossorigin>
  <!-- LCP 이미지도 선제적으로 로드 -->
  <link rel="preload" href="/images/hero.jpg" as="image">
  <link rel="stylesheet" href="/css/main.css">
</head>
HTML

script의 파서 차단과 document.write

<script> 태그는 HTML 파서에서 특별 대우를 받습니다. 인라인 스크립트든 외부 스크립트든, 기본적으로 파서를 차단(block) 합니다. 이유는 스크립트가 document.write를 통해 HTML 스트림에 내용을 삽입할 수 있기 때문입니다.

HTML: ... <script src="heavy.js"></script> <div>콘텐츠</div> ...
                    ↑
              파서 여기서 중단
              1. heavy.js 다운로드 대기
              2. 실행 완료
              3. 파싱 재개 → <div>콘텐츠</div> 처리

async와 defer로 차단 해제

<!-- 기본: 파서 차단 -->
<script src="app.js"></script>

<!-- async: 다운로드는 병렬, 실행 시 파서 차단, 순서 보장 없음 -->
<script async src="analytics.js"></script>

<!-- defer: 다운로드는 병렬, 실행은 파싱 완료 후, 순서 보장 -->
<script defer src="app.js"></script>
<script defer src="plugin.js"></script> <!-- app.js 다음에 실행 보장 -->

<!-- type="module": 기본적으로 defer처럼 동작 -->
<script type="module" src="main.js"></script>
HTML
속성다운로드실행 시점실행 순서파서 차단
없음(기본)순차즉시선언 순서O
async병렬다운로드 완료 즉시보장 없음O (실행 시)
defer병렬DOMContentLoaded 직전선언 순서X
type="module"병렬DOMContentLoaded 직전선언 순서X

document.write의 위험성

document.write는 파서가 열려 있는 스트림에 직접 텍스트를 삽입합니다. 이는 파싱 파이프라인을 심각하게 교란할 수 있습니다.

// ❌ document.write — 절대 사용하지 말 것
document.write('<script src="another.js"><\/script>');
// 파서가 document.write로 삽입된 스크립트를 처리하기 위해
// 추가 네트워크 요청이 발생하고 파싱이 다시 차단됨

// 페이지 로드 완료 후 document.write를 호출하면
// 기존 DOM 전체가 삭제되는 더 치명적인 문제 발생
window.onload = function() {
  document.write('새 내용'); // ❌ 페이지 전체가 사라짐!
};
JavaScript

⚠️ 주의: Chrome은 느린 네트워크에서 document.write로 삽입된 파서 차단 스크립트를 차단합니다. Lighthouse 감사에서도 document.write 사용은 성능 경고 항목입니다. DOM 조작이 필요하다면 innerHTML, appendChild, insertAdjacentHTML 등을 사용하세요.

// ✅ 올바른 동적 콘텐츠 삽입
const container = document.getElementById('target');
container.insertAdjacentHTML('beforeend', '<p>동적으로 추가된 내용</p>');

// ✅ 또는 DOM API 직접 사용
const p = document.createElement('p');
p.textContent = '동적으로 추가된 내용';
container.appendChild(p);
JavaScript

quirks mode vs standards mode

DOCTYPE 선언이 파싱에 미치는 가장 중요한 영향은 렌더링 모드 결정입니다. 브라우저는 DOCTYPE에 따라 세 가지 모드 중 하나로 동작합니다.

모드활성화 조건박스 모델 기본값IE 호환 여부
Standards mode<!DOCTYPE html> 또는 현대적 DOCTYPEW3C 표준낮음
Almost standards mode구형 HTML4 Transitional DOCTYPE (일부)표준 + 일부 예외중간
Quirks modeDOCTYPE 없음 또는 매우 오래된 DOCTYPE구형 IE 호환높음
<!-- ✅ Standards mode 활성화 — 항상 이것을 사용 -->
<!DOCTYPE html>
<html lang="ko">...</html>

<!-- ❌ DOCTYPE 없음 — Quirks mode 활성화 -->
<html lang="ko">...</html>
HTML

quirks mode에서는 CSS 박스 모델 계산이 표준과 다르게 동작합니다.

/* Standards mode: width = 콘텐츠 너비만 (box-sizing: content-box 기본값) */
/* Quirks mode:    width = border + padding + 콘텐츠 너비 포함 */

.box {
  width: 200px;
  padding: 20px;
  border: 5px solid black;
}
/* Standards: 실제 점유 너비 = 200 + 40 + 10 = 250px */
/* Quirks:    실제 점유 너비 = 200px (width에 padding, border 포함) */

JavaScript로 현재 모드를 확인할 수 있습니다.

console.log(document.compatMode);
// "CSS1Compat"  → Standards mode (또는 almost standards)
// "BackCompat"  → Quirks mode
JavaScript

💡 TIP: quirks mode는 1990년대 IE와의 하위 호환성을 위해 존재합니다. 현대 웹에서는 <!DOCTYPE html> 한 줄로 이 모드를 완전히 피할 수 있습니다. DOCTYPE을 빠뜨리는 것은 단순한 실수가 아니라 예측 불가능한 렌더링 버그의 원인이 됩니다.

요약

  • HTML 파싱은 바이트 → 문자 → 토큰 → DOM 노드 순서의 파이프라인으로 진행된다. 인코딩 선언은 <head> 최상단에 두어 불필요한 재파싱을 막아야 한다.
  • 토크나이저는 80개 이상의 상태를 가진 FSM이며, 트리 생성기는 insertion mode 컨텍스트를 보며 DOM 트리를 구성한다. 오류 HTML에는 명세에 정의된 자동 보정이 적용된다.
  • DOM 트리 + CSSOM 트리 → 렌더 트리 결합 이후 레이아웃·페인트가 진행된다. CSSOM은 렌더 차단 리소스이므로 CSS를 빠르게 전달하는 것이 중요하다.
  • preload scanner는 메인 파서가 차단된 동안에도 앞쪽 HTML을 스캔해 리소스를 미리 요청한다. 동적으로 삽입되는 리소스는 이 혜택을 받지 못한다.
  • <script> 는 기본적으로 파서를 차단한다. deferasync 속성, type="module" 을 활용해 차단을 최소화해야 한다. document.write는 파싱 파이프라인을 교란하므로 사용하면 안 된다.
  • <!DOCTYPE html> 선언은 standards mode를 활성화해 예측 가능한 CSS 렌더링을 보장한다. DOCTYPE 없이 quirks mode로 동작하면 박스 모델 등 핵심 레이아웃 계산이 달라진다.

연습문제

  1. 아래 HTML 스니펫을 브라우저가 파싱할 때 발생하는 문제를 설명하고, 올바른 코드로 수정하세요.
<html>
<head>
  <title>테스트</title>
  <meta charset="UTF-8">
</head>
<body>
  <table>
    주목할 내용
    <tr><td>데이터</td></tr>
  </table>
</body>
</html>
HTML

힌트: 인코딩 선언 위치와 테이블 내 텍스트 노드의 foster parenting 동작을 생각해 보세요.

  1. 다음 세 가지 script 태그 배치 시나리오에서 실행 순서와 파서 차단 여부를 설명하세요.
<script src="a.js"></script>
<script async src="b.js"></script>
<script defer src="c.js"></script>
HTML

힌트: 각 속성이 다운로드와 실행 시점에 어떤 영향을 주는지, 그리고 실행 순서가 보장되는지 여부를 고려하세요.

  1. 아래 JavaScript 코드의 문제점을 지적하고 올바른 대안 코드를 작성하세요.
function loadAd() {
  document.write('<script src="https://ads.example.com/ad.js"><\/script>');
}
window.addEventListener('load', loadAd);
JavaScript

힌트: document.writeload 이벤트 이후에 호출하면 어떤 일이 발생하는지, 그리고 외부 스크립트를 동적으로 삽입하는 안전한 방법은 무엇인지 생각해 보세요.

  1. 두 개의 HTML 파일이 있습니다. 하나는 <!DOCTYPE html> 선언이 있고 다른 하나는 없습니다. 아래 CSS를 적용했을 때 두 파일에서 .box의 렌더링이 어떻게 달라지는지 설명하세요.
.box {
  width: 300px;
  padding: 30px;
  border: 10px solid black;
}

힌트: document.compatMode 값과 함께 박스 모델 너비 계산 공식을 비교해 보세요.

💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

HTML 심화” 강좌에 대한 댓글입니다.

댓글을 작성하려면 로그인이 필요합니다.