HTML 깊이 보기: 파싱과 DOM, 접근성, 폼 검증, Shadow DOM

이 글의 핵심

문자열로 된 HTML이 어떻게 DOM 트리가 되는지(파싱·스크립트 차단), 시맨틱 마크업이 접근성·SEO에 주는 효과, 네이티브 폼 검증 API, 캡슐화된 UI를 위한 Shadow DOM·커스텀 엘리먼트, 배포 환경에서 자주 쓰는 HTML 패턴까지 한 번에 정리합니다.

들어가며

HTML은 태그를 나열하는 표기법이라기보다, 브라우저가 문서를 읽어 DOM을 만들고 그 위에서 스타일, 스크립트, 접근성 API가 동작하게 하는 계약에 가깝습니다. 프레임워크를 쓰더라도 최종 산출물은 HTML과 DOM이므로, 파싱 차단으로 인한 성능 문제, XSS와 CSP 같은 보안 문제, 보조 공학이 읽는 접근성 트리, 폼 검증과 오류 메시지의 품질은 마크업 설계에서 크게 갈립니다.

이 글은 입문용 태그 목록 대신, 브라우저 엔진이 HTML을 파싱해 DOM을 만드는 과정, 시맨틱 마크업과 접근성의 연결, Constraint Validation API, Shadow DOM과 커스텀 엘리먼트, 그리고 프로덕션에서 반복되는 HTML 패턴을 설명합니다.


DOM 구축과 HTML 파싱

바이트에서 문자, 토큰, 트리로

브라우저는 네트워크로 받은 바이트 스트림을 문서 인코딩(대개 Content-Type 헤더의 charset이나 <meta charset>)에 따라 문자로 디코딩합니다. 그다음 토큰화(tokenization) 단계에서 시작 태그, 끝 태그, 문자, 주석 같은 토큰으로 쪼개고, 트리 구성(tree construction) 규칙에 따라 DOM 노드를 붙입니다. <meta charset>은 문서의 처음 1024바이트 안에 있어야 인코딩 탐지에 쓰이므로 <head> 맨 앞에 둡니다.

HTML은 XML과 달리 오류에 관대한 문법이라, 닫히지 않은 태그나 잘못된 중첩이 있어도 스펙에 정의된 복구 알고리즘으로 트리를 만듭니다. 이 알고리즘이 표준화되면서 브라우저마다 다른 트리가 나오는 일은 줄었지만, 복구 결과가 작성자의 의도와 다를 수는 있습니다. 예를 들어 <p> 안에 <div>를 넣으면 파서가 <p>를 먼저 닫아 버려 빈 <p> 두 개와 형제 <div>가 생기고, 스타일과 이벤트 위임, 접근성 트리가 모두 예상과 달라집니다. 유효한 마크업에 가깝게 작성하는 것이 여전히 중요한 이유입니다.

스크립트와 파싱 차단

속성이 없는 클래식 <script>는 파서가 그 지점에 도달하면 HTML 파싱을 멈추고 스크립트를 내려받아 실행합니다. 스크립트가 document.write로 아직 파싱되지 않은 위치에 마크업을 끼워 넣을 수 있기 때문입니다. defer를 붙인 외부 스크립트는 파싱과 병행해 내려받고, 파싱이 끝난 뒤 문서에 나온 순서대로 실행됩니다. async는 다운로드가 끝나는 즉시 실행되므로 스크립트 간 실행 순서가 보장되지 않습니다. type="module" 스크립트는 속성 없이도 defer처럼 동작하고, async를 붙이면 준비되는 대로 실행됩니다.

세부 동작 가운데 실무에서 자주 헷갈리는 것이 세 가지 있습니다. 첫째, defer와 async는 src가 있는 외부 클래식 스크립트에만 적용됩니다. 인라인 <script defer>...</script>는 속성이 무시되고 즉시 실행되므로, 인라인 코드가 아직 파싱되지 않은 요소를 찾다가 null을 받습니다(인라인 모듈 스크립트는 예외로 지연 실행됩니다). 둘째, DOMContentLoaded는 defer 스크립트가 모두 실행된 뒤에 발생하므로 defer 스크립트 안에서 이 이벤트를 기다릴 필요가 없습니다. 셋째, 스타일시트 뒤에 오는 동기 스크립트는 CSSOM이 준비될 때까지 실행이 늦춰집니다. 스크립트가 getComputedStyle로 스타일을 읽을 수 있기 때문이며, 그래서 <head>의 큰 CSS 하나가 그 뒤의 스크립트 실행과 파싱까지 함께 지연시킬 수 있습니다.

실무에서는 동기 스크립트를 본문 중간에 두지 않고, defer나 모듈로 바꾸거나 초기 화면에 필요 없는 코드는 지연 로딩합니다.

<!-- 파싱을 멈추고 내려받아 실행 -->
<script src="/legacy.js"></script>

<!-- 파싱과 병행해 내려받고, 파싱 완료 후 순서대로 실행 -->
<script defer src="/app.js"></script>

innerHTML과 파서, 그리고 XSS

Element.innerHTML에 문자열을 넣으면 브라우저가 HTML 파서를 돌려 자식 트리를 통째로 교체합니다. 신뢰할 수 없는 문자열이 이 경로로 들어가면 곧바로 XSS가 됩니다. 반면 textContent는 문자열을 텍스트 노드로만 넣으므로 태그가 해석되지 않습니다.

흔한 오해는 innerHTML로 넣은 <script>는 실행되지 않으니 안전하다는 것입니다. 스펙상 innerHTML로 삽입된 <script>가 실행되지 않는 것은 맞지만, <img src=x onerror="...">처럼 이벤트 핸들러 속성은 그대로 살아 있어 이미지 로드가 실패하는 순간 실행됩니다. 따라서 사용자 입력을 innerHTML에 넣는 코드는 <script> 필터링 여부와 무관하게 위험합니다.

방어는 여러 겹으로 둡니다. 서버 템플릿에서 출력 시점에 이스케이프하고, CSP로 인라인 스크립트를 막고, 클라이언트에서는 textContent를 기본으로 씁니다. 서식 있는 사용자 콘텐츠처럼 HTML을 꼭 받아야 한다면 DOMPurify 같은 검증된 새니타이저를 거칩니다. Chromium 계열 브라우저에서는 Trusted Types CSP(require-trusted-types-for 'script')로 innerHTML에 평범한 문자열을 넣는 것 자체를 금지해, 새니타이저를 거치지 않은 대입을 코드 수준에서 막을 수 있습니다.

DOMContentLoaded와 load

DOMContentLoaded는 HTML 파싱이 끝나고 defer 스크립트까지 실행된 시점에 발생하고, load는 이미지, 스타일시트, iframe 같은 하위 리소스까지 모두 로드된 뒤 발생합니다. DOM을 다루는 초기화 코드는 defer나 모듈 스크립트에 두면 별도의 이벤트 대기 없이 파싱이 끝난 DOM에서 실행되고, 이미지 크기처럼 리소스 로드가 끝나야 알 수 있는 정보가 필요할 때만 load를 기다립니다.


시맨틱 HTML과 접근성

문서 구조와 랜드마크

<header>, <nav>, <main>, <aside>, <footer> 같은 요소는 스크린 리더 사용자에게 바로 이동할 수 있는 랜드마크(landmark)를 제공합니다. <div id="header">를 반복하는 것과 달리, 보조 기술이 “이 페이지의 본문은 어디인가”를 빠르게 찾을 수 있습니다. <main>은 페이지에 하나만 두고, 여러 <nav>가 있다면 aria-label로 “주 메뉴”, “페이지 내 목차”처럼 구분합니다.

제목 단계

<h1>부터 <h6>은 글자 크기가 아니라 문서의 개요입니다. 스크린 리더 사용자는 제목 목록을 띄워 문서를 훑어보므로, 단계를 건너뛰거나 시각적 크기를 맞추려고 단계를 뒤섞으면 탐색 구조가 깨집니다. 페이지의 주제를 나타내는 <h1> 하나를 두고, 그 아래를 논리적 깊이에 맞춰 <h2>, <h3>로 이어 가는 것이 안전합니다. 크기는 CSS로 따로 조정합니다.

한때 HTML5 스펙에는 <section>, <article> 안에서 모든 제목을 <h1>으로 써도 중첩 깊이에 따라 단계가 정해진다는 “문서 개요 알고리즘”이 있었습니다. 그러나 어떤 브라우저나 스크린 리더도 이를 구현하지 않았고, 2022년에 스펙에서 제거되었습니다. 섹션마다 <h1>을 쓰라는 옛 권고를 따르면 보조 기술에서는 모든 제목이 1단계로 평평하게 읽히므로, 제목 단계는 태그 숫자 그대로 해석된다고 보고 작성해야 합니다.

리스트, 버튼, 링크의 역할

<ul>과 <ol>은 항목의 집합임을 보조 기술에 알려 주며, 스크린 리더는 “목록, 항목 5개”처럼 읽습니다. 불릿 모양만 바꾸고 싶다면 리스트 요소를 유지한 채 CSS list-style로 조정합니다. Safari는 list-style: none인 목록의 리스트 의미를 VoiceOver에서 제거하므로, 이 경우 role="list"를 함께 붙이기도 합니다.

<button>은 키보드 포커스, Enter와 Space 키 활성화, disabled 상태가 모두 내장되어 있습니다. <div onclick>으로 같은 동작을 만들려면 tabindex, role="button", 키보드 이벤트 처리를 모두 직접 구현해야 하고, 대부분 어딘가 빠뜨립니다. <a href>는 다른 URL로 이동하는 내비게이션에 쓰고, 같은 페이지에서 무언가를 실행하는 동작에는 버튼을 씁니다. 모양은 CSS로 링크처럼 보이게 만들 수 있습니다.

폼 레이블

<label for="id">와 <input id="id">를 연결하거나 <label> 안에 입력 요소를 넣으면, 레이블을 클릭해도 필드에 포커스가 가고 스크린 리더가 필드 이름을 읽어 줍니다. placeholder는 입력을 시작하면 사라지고 대비가 낮은 경우가 많아, 레이블 대신 쓰면 저시력 사용자나 입력 도중 무슨 칸이었는지 잊은 사용자에게 불리합니다.

WAI-ARIA는 보조 수단

role과 aria-* 속성은 탭, 콤보박스, 트리처럼 시맨틱 태그로 표현할 수 없는 복합 위젯에 필요합니다. 하지만 ARIA는 보조 기술에 보이는 의미만 바꿀 뿐 동작을 추가하지 않습니다. role="button"을 붙여도 키보드로 눌리지 않으며, 잘못된 ARIA는 없는 것보다 접근성을 더 해칩니다. 네이티브 요소로 해결할 수 있으면 그쪽을 쓰고, ARIA가 필요하면 WAI-ARIA Authoring Practices의 해당 위젯 패턴을 따라 키보드 동작까지 구현합니다.


폼 검증과 Constraint Validation API

네이티브 제약 속성

폼 필드에는 required, min/max, minlength/maxlength, pattern, type="email" 같은 속성으로 브라우저 수준의 제약을 선언할 수 있습니다. 이 검증은 사용자에게 즉시 피드백을 주기 위한 것이고, 요청은 개발자 도구나 스크립트로 얼마든지 조작할 수 있으므로 서버 검증을 대체하지 않습니다.

Constraint Validation API

HTMLFormElement와 HTMLInputElement 등은 validity 객체와 checkValidity(), reportValidity(), setCustomValidity(message)를 제공합니다. 두 필드 값 일치처럼 속성으로 표현할 수 없는 규칙을 네이티브 검증 흐름에 태울 때 유용합니다.

<form id="signup">
  <label for="email">이메일</label>
  <input id="email" name="email" type="email" required />
  <label for="pw">비밀번호</label>
  <input id="pw" name="password" type="password" minlength="8" required
         aria-describedby="pw-hint" />
  <p id="pw-hint" aria-live="polite"></p>
  <button type="submit">가입</button>
</form>
<script>
  const form = document.getElementById('signup');
  const pw = document.getElementById('pw');
  const hint = document.getElementById('pw-hint');

  // 커스텀 규칙은 입력이 바뀔 때마다 다시 평가합니다
  pw.addEventListener('input', () => {
    pw.setCustomValidity(
      pw.value.includes(' ') ? '비밀번호에 공백을 포함할 수 없습니다.' : ''
    );
    hint.textContent = pw.value.length
      ? `현재 ${pw.value.length}자 (최소 8자)`
      : '';
  });
</script>

setCustomValidity에 빈 문자열이 아닌 값을 넣으면 그 필드는 다시 빈 문자열로 지울 때까지 계속 오류 상태로 남습니다. 그래서 규칙은 입력이 바뀔 때마다 다시 평가해야 합니다.

이 규칙을 submit 핸들러 안에서 설정하면 한 번 오류가 난 뒤 폼이 다시는 제출되지 않는 버그가 생깁니다. 사용자가 제출 버튼을 누르면 브라우저는 submit 이벤트를 발생시키기 전에 네이티브 검증을 먼저 수행하고, 실패하면 submit 이벤트 자체를 발생시키지 않습니다. 첫 제출에서 커스텀 오류가 설정되면, 사용자가 공백을 지우고 다시 눌러도 이전 오류가 남아 검증에서 막히고, 오류를 지워 줄 submit 핸들러는 실행되지 않습니다. 위처럼 input 이벤트에서 평가하면 이 문제가 없습니다. 비밀번호 확인란처럼 다른 필드의 값에 의존하는 규칙이라면, 두 필드 모두의 input 이벤트에서 확인란의 커스텀 오류를 다시 계산해야 합니다. 또 스크립트에서 form.submit()을 호출하면 검증을 건너뛰고 submit 이벤트도 발생하지 않으므로, 검증이 필요한 경우에는 form.requestSubmit()을 씁니다.

폼에 novalidate 속성을 붙이면 브라우저 기본 말풍선 대신 직접 만든 오류 UI를 쓸 수 있습니다. 이때도 validity.valueMissing, validity.typeMismatch, validity.tooShort 같은 플래그는 그대로 동작하므로, 판정은 브라우저에 맡기고 표시만 바꾸는 식으로 API를 계속 활용할 수 있습니다. 오류 메시지 요소에 aria-live를 두고 입력 필드와 aria-describedby로 연결하면 스크린 리더 사용자도 오류를 듣게 됩니다.

:user-invalid와 시각적 피드백

CSS :user-invalid 의사 클래스는 사용자가 필드와 상호작용한 뒤에만 일치합니다. 기존 :invalid는 페이지가 열리자마자 비어 있는 required 필드에 적용되어, 아직 아무것도 입력하지 않은 사용자에게 빨간 테두리부터 보여 주는 문제가 있었습니다. :user-invalid는 2023년 말부터 주요 브라우저에서 모두 지원되므로, 한 번 blur된 필드에 클래스를 붙이는 자바스크립트 없이도 적절한 시점에 오류 스타일을 줄 수 있습니다.


Shadow DOM과 Custom Elements

Shadow tree의 캡슐화

Shadow DOM은 요소에 별도의 트리 루트(shadow root)를 붙여 내부 마크업과 스타일을 바깥 문서와 분리합니다. 바깥 문서의 선택자는 섀도 트리 안의 요소와 일치하지 않고, 섀도 트리 안의 스타일도 바깥에 새지 않습니다. 다만 완전히 차단되는 것은 아닙니다. color나 font처럼 상속되는 속성은 호스트 요소에서 내부로 상속되고, CSS 사용자 정의 속성(--brand-color)도 경계를 넘어 전달되므로, 컴포넌트는 이 둘을 테마 지정 통로로 씁니다. 바깥에서 내부 특정 요소를 꾸미게 하려면 part 속성을 붙여 ::part() 선택자로 노출합니다.

Custom Elements 생명주기

customElements.define('my-widget', class extends HTMLElement { ... })로 태그를 등록합니다. 자주 쓰는 콜백은 DOM에 연결될 때 호출되는 connectedCallback(이벤트 구독, 데이터 로드), 제거될 때 호출되는 disconnectedCallback(구독 해제, 타이머 정리), 그리고 observedAttributes에 나열한 속성이 바뀔 때 호출되는 attributeChangedCallback입니다. 요소가 DOM에서 옮겨지면 disconnectedCallback과 connectedCallback이 차례로 다시 호출되므로, 연결 시 작업은 여러 번 실행되어도 안전하게 작성해야 합니다.

class PriceTag extends HTMLElement {
  static get observedAttributes() {
    return ['currency', 'value'];
  }

  constructor() {
    super();
    const root = this.attachShadow({ mode: 'open' });
    root.innerHTML = `
      <span part="label"></span>
      <strong part="amount"></strong>
      <style>
        :host { display: inline-flex; gap: 0.25rem; font: inherit; }
        strong { font-variant-numeric: tabular-nums; }
      </style>
    `;
  }

  attributeChangedCallback(name, _old, val) {
    const label = this.shadowRoot.querySelector('[part="label"]');
    const amount = this.shadowRoot.querySelector('[part="amount"]');
    if (name === 'currency') label.textContent = val || '';
    if (name === 'value') amount.textContent = val ?? '';
  }
}

customElements.define('price-tag', PriceTag);

mode: 'open'이면 바깥에서 element.shadowRoot로 내부에 접근할 수 있고, closed면 이 속성이 null을 반환합니다. closed는 실수로 내부를 건드리는 것을 막는 장치일 뿐, 같은 페이지의 스크립트를 상대로 한 보안 경계는 아닙니다.

생성자 안에서 섀도 루트를 만들고 내용을 채우는 것은 허용되는 패턴입니다. 반대로 생성자에서 자기 자신의 속성을 추가하거나 라이트 DOM 자식을 넣으면 안 됩니다. document.createElement('price-tag')로 만들 때 생성자가 끝난 뒤 요소에 속성이나 자식이 있으면 스펙상 NotSupportedError가 발생하고, 생성자 시점에는 HTML에 적힌 속성도 아직 반영되어 있지 않을 수 있습니다. 그래서 속성에 기반한 렌더링은 connectedCallback이나 attributeChangedCallback에서 합니다. 이 예제의 attributeChangedCallback은 파서가 속성을 처리할 때도 호출되므로 HTML에 <price-tag currency="KRW" value="1000">이라고 쓰면 정상적으로 채워집니다.

customElements.define 전에 HTML에 이미 <price-tag>가 있으면, 그 요소는 정의될 때까지 알 수 없는 요소로 남았다가 정의되는 순간 업그레이드됩니다. 그 사이 내용이 비어 보이거나 깜빡이는 것을 막으려면 price-tag:not(:defined) { visibility: hidden; } 같은 CSS를 둡니다. 서버 렌더링이 필요하다면 <template shadowrootmode="open">으로 섀도 트리를 HTML에 직접 쓰는 선언적 Shadow DOM을 써서, 자바스크립트가 로드되기 전에도 첫 화면을 그릴 수 있습니다.

슬롯과 라이트 DOM

<slot>은 사용자가 커스텀 엘리먼트 안에 쓴 자식 마크업(라이트 DOM)을 섀도 트리의 특정 위치에 보여 줍니다. 슬롯에 배치된 노드는 실제로는 여전히 라이트 DOM에 있습니다. 그래서 바깥 문서의 CSS가 그대로 적용되고, 섀도 트리 안의 스타일은 ::slotted() 선택자로 슬롯된 요소의 최상위만 꾸밀 수 있습니다.

반대로 섀도 트리 안에 직접 만든 <input>은 바깥 <form>에 속하지 않아 제출 데이터와 폼 검증에서 빠집니다. 폼 컨트롤을 감싸는 컴포넌트라면 input을 슬롯으로 받아 라이트 DOM에 두거나, static formAssociated = true와 attachInternals()로 커스텀 엘리먼트 자체를 폼에 참여하는 요소로 만들어야 합니다.

이벤트는 섀도 경계를 넘을 때 대상이 호스트 요소로 바뀌어(retargeting) 보입니다. 바깥에서 event.target을 보면 내부 버튼이 아니라 <price-tag>가 찍히며, 실제 경로는 event.composedPath()로 확인합니다. 컴포넌트가 직접 발생시키는 CustomEvent는 기본값이 composed: false라 바깥으로 전달되지 않으므로, 외부에 알리려면 { bubbles: true, composed: true }를 명시해야 합니다. 웹 컴포넌트를 처음 만들 때 “이벤트를 dispatch했는데 부모가 받지 못한다”는 문제의 대부분이 이 옵션 누락입니다.


프로덕션 HTML 패턴

문서 헤드와 메타

<html lang="ko">는 스크린 리더의 발음 엔진 선택, 브라우저 번역 제안, 하이픈 처리에 영향을 줍니다. 본문 중간에 다른 언어 구간이 있으면 그 요소에도 lang을 붙입니다. <meta name="viewport" content="width=device-width, initial-scale=1">은 모바일 반응형의 전제이며, 여기에 user-scalable=no나 maximum-scale=1을 넣어 확대를 막으면 저시력 사용자가 글자를 키울 수 없어 WCAG 위반이 됩니다. theme-color와 아이콘 링크는 모바일 브라우저 UI 색상과 홈 화면 아이콘에 쓰입니다.

리소스 힌트

<link rel="preconnect">는 곧 요청할 다른 오리진과의 연결(DNS, TCP, TLS)을 미리 맺고, dns-prefetch는 DNS 조회만 미리 합니다. preload는 현재 페이지에 곧 필요한 리소스를 높은 우선순위로 미리 받고, prefetch는 다음 탐색에 쓸 리소스를 낮은 우선순위로 받아 둡니다. preload를 남발하면 정말 중요한 리소스와 대역폭을 다투게 되므로, LCP 이미지나 첫 화면 웹폰트처럼 파서가 늦게 발견하는 핵심 리소스로 제한합니다.

CSP와 인라인 스크립트

Content-Security-Policy는 보통 인라인 스크립트와 eval을 금지하며, onclick="" 같은 인라인 이벤트 핸들러도 인라인 스크립트로 취급되어 함께 막힙니다. CSP를 도입하면 이런 핸들러를 외부 스크립트의 addEventListener로 옮기고, 꼭 필요한 인라인 스크립트에는 요청마다 바뀌는 nonce나 내용의 hash를 부여합니다. 도입 초기에는 Content-Security-Policy-Report-Only 헤더로 위반 보고만 받아 깨질 부분을 먼저 찾는 것이 안전합니다.

SEO와 소셜 메타

<title>, <meta name="description">, Open Graph와 Twitter 카드 메타는 검색 결과와 링크 공유 미리보기의 재료입니다. 링크 미리보기를 만드는 크롤러 상당수는 자바스크립트를 실행하지 않으므로, SPA라도 이 메타는 서버 렌더링이나 빌드 시점에 HTML에 넣어야 합니다.

성능과 마크업 습관

이미지에는 width와 height 속성을 넣어 브라우저가 로드 전에 자리를 잡게 하면 레이아웃 이동(CLS)이 줄어듭니다. loading="lazy"는 화면 밖 이미지에만 쓰고, 첫 화면의 LCP 이미지에 붙이면 오히려 로드가 늦어집니다. 서드파티 위젯 스크립트를 <head>에 동기로 쌓으면 파싱과 메인 스레드 모두를 막으므로, async나 defer로 바꾸거나 사용자 상호작용 이후에 불러옵니다.


같이 보면 좋은 글