콘텐츠 모델(Content Model)이란?

HTML5 콘텐츠 모델의 개념과 요소 간 포함 관계 규칙을 이해하고, 브라우저가 마크업을 해석할 때 콘텐츠 모델이 어떤 역할을 하는지 학습합니다

입문 15분 콘텐츠 모델 포함 관계 HTML5 요소 규칙

HTML5는 단순히 태그를 나열하는 언어가 아닙니다. 각각의 HTML 요소는 어떤 내용을 담을 수 있고, 어떤 요소 안에 배치될 수 있는지에 대한 엄격한 규칙을 갖고 있으며, 이 규칙 체계를 콘텐츠 모델(Content Model)이라고 합니다. 콘텐츠 모델은 HTML 마크업의 유효성과 의미론적 정확성을 결정하는 근본 원리입니다. 이 개념을 이해하지 못하면 겉으로 보기엔 동작하지만 실제로는 잘못된 HTML을 작성하게 되고, 브라우저가 예상과 다르게 마크업을 해석하는 상황에 맞닥뜨리게 됩니다.

핵심 특징

  • HTML5는 요소를 7가지 콘텐츠 카테고리로 분류하며, 각 요소는 하나 이상의 카테고리에 속함
  • 모든 요소는 “어떤 카테고리에 속하는지(is)“와 “어떤 카테고리를 담을 수 있는지(accepts)“를 명세로 정의함
  • 인라인 요소 안에 블록 요소를 넣는 것처럼 콘텐츠 모델을 위반하면 브라우저가 마크업을 자동으로 수정해 버림
  • 💡 콘텐츠 모델은 시각적 표현(block/inline)이 아닌 콘텐츠의 의미와 역할을 기준으로 분류함
  • HTML 명세가 block/inline 구분을 버리고 콘텐츠 모델 체계로 전환한 이유는 접근성과 의미론적 정확성을 확보하기 위함

실무에서의 영향

콘텐츠 모델을 모르면 유효성 검사기에서 오류가 나거나 브라우저가 DOM을 조용히 수정하는 상황을 만나게 됩니다. 예를 들어 <p> 안에 <div>를 넣으면 브라우저는 이를 허용하지 않고 DOM 구조를 자동으로 재조정하며, 이는 JavaScript로 DOM을 다룰 때 예기치 않은 버그로 이어집니다. 반대로 콘텐츠 모델을 이해하면 시맨틱 마크업을 올바르게 작성할 수 있고, 스크린 리더와 검색 엔진이 페이지 구조를 정확히 해석하도록 도울 수 있습니다. 또한 React나 Vue 같은 프레임워크에서 컴포넌트를 설계할 때도 어떤 요소가 어떤 요소를 감쌀 수 있는지 판단하는 근거가 됩니다. 잘못된 포함 관계는 CSS 스타일링에도 영향을 주어 레이아웃이 의도와 다르게 무너지는 원인이 되기도 합니다. 프론트엔드 개발자가 HTML을 단순한 마크업 언어가 아닌 구조화된 규칙 체계로 이해하기 위해 반드시 거쳐야 하는 개념입니다.


핵심 개념

콘텐츠 카테고리 분류 체계

입문

HTML5는 모든 태그를 역할에 따라 그룹으로 나눠 놓았어요. 마치 학교에서 학생들을 반별로 나누는 것처럼요. 어떤 그룹이 있는지 알아볼까요!

📚 태그도 학교처럼 반이 있어요 학교에 1반, 2반, 3반이 있듯이, HTML 태그에도 여러 그룹이 있어요. HTML5는 태그들을 크게 7가지 그룹으로 나눠요. 이 그룹을 ‘콘텐츠 카테고리’라고 불러요.

📝 글쓰기 관련 태그 그룹은 뭔가요? 글쓰기와 관련된 태그들은 ‘플로우 콘텐츠(Flow Content)’ 그룹에 속해요. <p>, <div>, <h1> 같은 태그들이 여기 속하는데, 웹페이지의 본문 내용을 구성하는 거의 모든 태그가 이 그룹에 들어있어요. 학교로 치면 전교생 대부분이 속하는 가장 큰 반이에요.

✏️ 문장 안에서 쓰는 태그 그룹은요? 문장 중간에 쓰는 태그들은 ‘구문 콘텐츠(Phrasing Content)’ 그룹이에요. <span>, <strong>, <a> 같은 태그들인데, 마치 문장 안의 단어나 구절처럼 동작해요. 문장 안에 들어갈 수 있는 작은 부품 같은 거예요.

🏷️ 제목 역할을 하는 태그 그룹도 있나요? 네! <h1>부터 <h6>까지의 제목 태그들은 ‘제목 콘텐츠(Heading Content)’ 그룹에 속해요. 이 태그들은 문서의 목차를 만드는 역할을 하며, 스크린 리더(시각 장애인이 사용하는 도구)나 검색 엔진이 페이지 구조를 파악할 때 특히 중요해요.

🗂️ 태그 하나가 여러 그룹에 속할 수 있나요? 맞아요! 예를 들어 <a> 태그는 구문 콘텐츠이면서 동시에 인터랙티브 콘텐츠 그룹에도 속해요. 마치 한 학생이 공부도 잘하고 운동도 잘해서 학습 우수자 명단과 체육 우수자 명단에 동시에 이름이 올라가는 것과 같아요.

중급

HTML5 명세는 모든 요소를 7개의 콘텐츠 카테고리(Content Category)로 분류합니다. 각 요소는 하나 이상의 카테고리에 속할 수 있으며, 이 카테고리 정보가 포함 관계 규칙의 기반이 됩니다.

7가지 콘텐츠 카테고리

카테고리설명대표 요소
Flow Content문서 본문에 쓰이는 대부분의 요소<div>, <p>, <h1>, <section>
Phrasing Content텍스트와 인라인 요소<span>, <a>, <strong>, <em>
Heading Content섹션 제목을 정의하는 요소<h1> ~ <h6>, <hgroup>
Sectioning Content아웃라인(목차 구조)을 구성하는 요소<article>, <aside>, <nav>, <section>
Embedded Content외부 리소스를 삽입하는 요소<img>, <video>, <audio>, <iframe>
Interactive Content사용자와 상호작용하는 요소<a>, <button>, <input>, <select>
Metadata Content문서의 메타 정보를 설정하는 요소<link>, <meta>, <script>, <title>

한 요소가 여러 카테고리에 속하는 경우도 많습니다. 예를 들어 <a>는 Phrasing Content이자 Interactive Content이며, <video>는 Embedded Content이자 Flow Content에 속합니다.

<!-- <section>: Sectioning Content + Flow Content -->
<section>
  <!-- <h2>: Heading Content + Flow Content + Phrasing Content -->
  <h2>제목</h2>

  <!-- <p>: Flow Content -->
  <p>
    <!-- <strong>: Phrasing Content + Flow Content -->
    <strong>강조 텍스트</strong>
    <!-- <a>: Phrasing Content + Interactive Content + Flow Content -->
    <a href="#">링크</a>
  </p>
</section>

심화

HTML5 콘텐츠 카테고리는 HTML Living Standard(WHATWG)의 3.2.5절 “Kinds of content”에 정의된 형식 분류 체계입니다. 이 체계는 HTML4의 이진(block/inline) 분류를 대체하며, 요소의 의미론적 역할과 허용 컨텍스트를 정밀하게 모델링합니다.

WHATWG 명세의 카테고리 정의 방식 각 요소의 명세는 두 가지 핵심 정보를 포함합니다.

  • Categories: 해당 요소가 속하는 카테고리 목록 (요소가 “무엇인지”)
  • Contexts in which this element can be used: 이 요소를 허용하는 부모 컨텍스트 (요소가 “어디에 놓일 수 있는지”)
  • Content model: 이 요소가 자식으로 허용하는 카테고리 (요소가 “무엇을 담을 수 있는지”)

예를 들어 <p> 요소의 명세는 다음과 같이 정의됩니다. Categories: Flow Content. Content model: Phrasing Content. 이 정의에 따라 <p>는 Flow Content를 받는 컨텍스트에 배치 가능하고, 자식으로는 Phrasing Content만 허용합니다.

카테고리 체계의 설계 철학 HTML4의 block/inline 이분법은 시각적 표현(렌더링 방식)에 기반한 분류였습니다. CSS가 이 표현을 완전히 제어할 수 있게 되면서, 렌더링 방식에 근거한 분류는 의미론적으로 불완전해졌습니다. HTML5의 카테고리 체계는 표현이 아닌 콘텐츠의 의미와 역할을 기준으로 삼아, 동일한 요소가 컨텍스트에 따라 다른 역할을 수행할 수 있음을 명세 수준에서 지원합니다.

투명 콘텐츠 모델(Transparent Content Model) <a>, <ins>, <del>, <map> 등의 요소는 “transparent”라는 특수 콘텐츠 모델을 가집니다. 이 경우 해당 요소의 허용 자식은 부모 요소의 콘텐츠 모델을 그대로 상속합니다. 예를 들어 <div> 안의 <a>는 Flow Content를 자식으로 허용하고, <span> 안의 <a>는 Phrasing Content만 허용합니다. 이 메커니즘은 <a> 요소의 유연한 사용(텍스트 링크, 블록 링크 모두 허용)을 가능하게 합니다.

포함 관계 규칙

입문

HTML 태그는 아무 태그나 마음대로 안에 넣을 수 없어요. 규칙에 맞는 태그만 안에 넣을 수 있어요. 마치 도시락통에 뚜껑이 맞아야 닫히는 것처럼요!

📦 태그 안에 아무 태그나 넣을 수 있나요? 아니에요! 태그마다 안에 넣을 수 있는 태그의 종류가 정해져 있어요. 예를 들어 음식을 담는 그릇(태그)은 음식(어울리는 태그)만 담을 수 있고, 그릇 안에 또 다른 큰 그릇을 넣을 수는 없어요.

🚫 어떤 경우가 잘못된 건가요? 글 문단을 나타내는 <p> 태그는 안에 짧은 글자 요소들만 넣을 수 있어요. 마치 하나의 문장 안에는 단어들만 들어가야 하는 것처럼요. 그런데 여기에 큰 구역을 나타내는 <div> 태그를 억지로 넣으면 규칙 위반이 돼요.

✅ 어떤 경우가 올바른 건가요? <div> 태그는 다양한 종류의 태그를 안에 넣을 수 있는 넉넉한 그릇이에요. 안에 <p>, <h1>, <ul> 같은 태그들을 자유롭게 담을 수 있어요. 큰 상자에 여러 물건을 넣는 것처럼요.

🎯 왜 이런 규칙이 필요한가요? 이 규칙이 있어야 웹페이지의 내용이 제대로 된 의미를 갖게 되고, 스크린 리더나 검색 엔진이 내용을 올바르게 이해할 수 있어요. 규칙 없이 마구 태그를 넣으면 읽는 기계들이 혼란스러워해요.

💡 중첩이라는 개념이 뭔가요? 태그 안에 태그를 넣는 것을 ‘중첩(Nesting)‘이라고 해요. 마치 러시아 인형처럼 인형 안에 또 작은 인형이 있고, 그 안에 또 더 작은 인형이 있는 구조예요. HTML도 이런 방식으로 구조를 만들지만, 어떤 인형이 안에 들어갈 수 있는지 규칙이 있어요.

중급

각 HTML 요소는 명세에서 “Content model”을 통해 자식으로 허용되는 카테고리(또는 특정 요소)를 명시합니다. 부모 요소의 Content model에 해당하지 않는 카테고리의 요소를 자식으로 배치하면 포함 관계 위반이 됩니다.

주요 요소의 Content model

  • <div>: Flow Content (대부분의 요소를 담을 수 있음)
  • <p>: Phrasing Content (<div>, <section> 같은 Flow Content 전용 요소는 담을 수 없음)
  • <ul> / <ol>: <li> 요소만 직접 자식으로 허용
  • <table>: <thead>, <tbody>, <tr>, <caption> 등 테이블 관련 요소만 허용
  • <span>: Phrasing Content
<!-- 올바른 포함 관계 -->
<div>
  <p>문단 안에 <strong>강조</strong> 텍스트</p>
</div>

<!-- 잘못된 포함 관계 - <p> 안에 <div> 불가 -->
<p>
  <div>이 구조는 잘못됨</div>
</p>

<!-- 올바른 방법: div로 감싸기 -->
<div>
  <p>첫 번째 문단</p>
  <div>블록 요소</div>
  <p>두 번째 문단</p>
</div>
<!-- 올바른 ul 구조: 직접 자식은 반드시 li -->
<ul>
  <li>항목 1</li>
  <li>항목 2</li>
</ul>

<!-- 잘못된 구조: ul 직접 자식으로 div 불가 -->
<!-- <ul>
  <div>이건 안 됨</div>
</ul> -->

심화

포함 관계 규칙은 WHATWG HTML Living Standard의 각 요소 정의에서 “Content model” 항목으로 형식화됩니다. 이 규칙은 파서(Parser) 수준에서 강제되며, 위반 시 브라우저의 에러 복구(Error Recovery) 알고리즘이 작동합니다.

파서 수준의 포함 관계 강제 HTML 파싱은 WHATWG HTML Living Standard 8절 “Parsing HTML documents”에 정의된 상태 기계(State Machine) 기반 토크나이저와 트리 생성기(Tree Construction)로 이루어집니다. 트리 생성기는 삽입 모드(Insertion Mode)에 따라 현재 허용 가능한 토큰(요소)을 결정합니다.

<p> 요소 파싱 중 Flow Content 요소(<div>, <p>, <h1> 등)를 만나면, 파서는 자동으로 현재 <p>를 닫는 IMPLIED END TAG를 삽입한 뒤 새 요소를 처리합니다. 이는 명시적 닫는 태그 없이도 <p>가 닫힐 수 있는 이유이며, DOM 구조가 소스 코드와 달라지는 원인입니다.

Content model과 Permitted content의 차이 명세에서 Content model은 요소가 허용하는 자식의 카테고리를 정의합니다. 반면 Permitted content는 실제로 유효한 자식을 추가 제약과 함께 기술합니다. 예를 들어 <figure> 요소의 Content model은 Flow Content이지만, <figcaption>이 있다면 첫 번째 또는 마지막 자식이어야 한다는 위치 제약도 Permitted content에 명시됩니다.

선택적 태그(Optional Tags) 규칙과의 상호작용 WHATWG 명세 13.1.2.4절은 생략 가능한 태그를 정의합니다. <li>, <dt>, <dd>, <td>, <th>, <tr> 등의 닫는 태그는 특정 조건에서 생략 가능한데, 이 조건은 바로 콘텐츠 모델에 근거합니다. 파서는 이 요소들의 포함 관계 컨텍스트를 추적하여 생략된 태그를 자동으로 복원합니다.

콘텐츠 모델 위반과 브라우저 자동 수정

입문

규칙에 맞지 않는 HTML을 써도 브라우저는 그냥 화면을 보여줘요. 하지만 조용히 여러분의 코드를 몰래 바꿔버려요! 이게 왜 위험한지 알아볼게요.

🤫 브라우저가 몰래 코드를 바꾼다고요? 네! 여러분이 규칙을 어긴 HTML을 쓰면 브라우저는 “아, 이건 잘못됐구나” 하고 스스로 고쳐서 화면을 보여줘요. 마치 선생님이 여러분의 틀린 답을 조용히 고쳐서 채점하는 것처럼요. 그래서 화면엔 멀쩡해 보여도 실제 코드 구조는 여러분이 쓴 것과 달라져 있어요.

😱 어떻게 바뀌는 건가요? 예를 들어 문단 태그(<p>) 안에 구역 태그(<div>)를 넣으면, 브라우저는 “문단 안에 구역이 들어올 수 없어” 하면서 문단을 자기가 먼저 닫아버려요. 그러면 여러분이 하나의 문단으로 만들고 싶었던 내용이 두 개의 서로 다른 부분으로 쪼개져요.

🐛 왜 버그가 생기나요? 여러분이 JavaScript로 “이 문단의 내용을 가져와!”라고 명령할 때, 브라우저가 바꿔버린 구조 때문에 원하는 내용을 못 찾게 돼요. 마치 책상 서랍에 넣어뒀다고 생각했는데 청소하는 사람이 다른 곳에 정리해 버린 상황과 같아요. 화면은 같아 보여도 실제 위치가 달라진 거예요.

🔍 어떻게 확인할 수 있나요? 브라우저의 개발자 도구(F12)를 열면 “Elements” 탭에서 브라우저가 실제로 만든 구조를 볼 수 있어요. 여러분이 쓴 HTML 코드와 다를 수 있으니, 뭔가 이상하다 싶을 때 꼭 확인해 보세요.

💡 그럼 어떻게 해야 하나요? HTML 요소를 쓸 때 어떤 태그 안에 어떤 태그를 넣을 수 있는지 미리 알고 사용하면 돼요. 모를 때는 MDN Web Docs에서 그 태그의 사용법을 찾아보면 ‘허용되는 콘텐츠’가 나와 있어요.

중급

HTML 파서는 콘텐츠 모델을 위반한 마크업을 만나면 에러를 표시하는 대신 에러 복구(Error Recovery) 알고리즘을 실행하여 DOM을 자동으로 수정합니다. 이 동작은 표준화되어 있어 모든 브라우저가 동일하게 처리하지만, 개발자가 의도한 DOM 구조와 실제 DOM 구조가 달라지는 근본 원인이 됩니다.

대표적인 위반 패턴과 브라우저 처리 결과

  1. <p> 안에 <div> 삽입: 파서가 <p>를 즉시 닫고 <div><p> 밖에 배치
  2. <ul> 안에 <div> 직접 삽입: 파서가 <div><ul> 밖으로 이동
  3. <table> 안에 일반 텍스트 직접 삽입: 텍스트가 <table> 바깥으로 추출됨 (foster parenting)
<!-- 작성한 코드 -->
<p>
  <div>블록 요소</div>
  텍스트
</p>

<!-- 브라우저가 실제로 만드는 DOM -->
<!-- <p></p>              ← p가 먼저 닫힘 -->
<!-- <div>블록 요소</div> ← div는 p 밖에 위치 -->
<!-- <p>텍스트</p>        ← 텍스트는 새 p 안에 -->

이 차이는 document.querySelector('p').textContent로 텍스트를 읽거나, parentElement로 부모를 탐색할 때 예상과 다른 결과를 내는 원인이 됩니다. 또한 CSS 선택자가 예상대로 동작하지 않는 레이아웃 버그의 원인이 되기도 합니다.

// HTML: <p><div id="target">내용</div></p>
// 실제 DOM: <p></p><div id="target">내용</div><p></p>

const p = document.querySelector('p');
const target = document.getElementById('target');

// 예상: target은 p의 자식
// 실제: target은 p의 형제 요소
console.log(p.contains(target)); // false (예상: true)

심화

HTML 파서의 에러 복구 알고리즘은 WHATWG HTML Living Standard 8.2절 “Parsing HTML documents”에 완전히 명세화되어 있습니다. 이 알고리즘은 모든 브라우저(Blink, Gecko, WebKit)가 동일하게 구현해야 하며, 덕분에 잘못된 마크업에서도 일관된 DOM이 생성됩니다.

Implied End Tag와 자동 닫기 메커니즘 트리 생성기(Tree Construction Stage)는 “open elements 스택(Stack of Open Elements)“을 관리합니다. <p> 파싱 중 Flow Content 요소 토큰을 만나면, 8.2.6절의 “generate implied end tags” 알고리즘이 실행되어 <p>의 IMPLIED END TAG를 삽입합니다. 대상 요소: <dd>, <dt>, <li>, <optgroup>, <option>, <p>, <rb>, <rp>, <rt>, <rtc>.

이 메커니즘은 <p> 태그의 닫는 태그(</p>)가 선택적(optional)인 이유와도 연결됩니다. 파서는 이 컨텍스트 정보를 기반으로 자동 복원하기 때문입니다.

Foster Parenting 알고리즘 <table>, <tbody>, <tr> 등의 테이블 요소 안에 허용되지 않는 콘텐츠(예: 일반 텍스트, <div>)가 들어오면 “foster parenting” 알고리즘이 실행됩니다. 이 알고리즘은 해당 노드를 테이블의 바로 앞 형제 위치(이전 형제 또는 부모의 부모)에 삽입합니다. 이는 테이블 레이아웃 버그의 원인이자, 동적으로 테이블에 콘텐츠를 삽입할 때 innerHTMLinsertRow() 방식의 결과가 달라지는 이유입니다.

실무적 함의: 서버 사이드 렌더링과의 상호작용 React, Vue의 서버 사이드 렌더링(SSR)에서 Hydration 불일치(Hydration Mismatch)의 주요 원인 중 하나가 콘텐츠 모델 위반입니다. 서버에서 생성된 HTML 문자열이 클라이언트의 파서를 통해 다른 DOM으로 변환되면, 가상 DOM과 실제 DOM 비교 단계에서 불일치가 발생하여 전체 컴포넌트를 클라이언트에서 재렌더링하게 됩니다. 이는 SSR의 성능 이점을 무력화하고, console.error로 경고를 출력합니다.