
DOM: 웹페이지의 설계도
HTML은 그저 글자일 뿐입니다. 브라우저가 이걸 이해하고 조작하려면 '트리 구조의 객체(DOM)'로 바꿔야 합니다.

HTML은 그저 글자일 뿐입니다. 브라우저가 이걸 이해하고 조작하려면 '트리 구조의 객체(DOM)'로 바꿔야 합니다.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

처음 웹 개발할 때, console.log(document)를 찍어봤습니다.
console.log(document);
// 출력:
#document
html
head
title
body
div
p
"어? 내가 쓴 HTML이 트리로 나온다?"
HTML 파일은 분명 이렇게 생겼는데:
<html><head>...</head><body>...</body></html>
브라우저는 이걸 가계도(트리)처럼 보여줬습니다. 아니, 왜? 내가 작성한 건 분명 꺾쇠로 시작하고 꺾쇠로 끝나는 텍스트였는데, 콘솔에는 마치 엑셀의 조직도처럼 계층 구조가 나타났습니다.
"뭐가 어떻게 된 거지?"JavaScript로 HTML을 수정하려고 했습니다:
document.getElementById("box").style.color = "red";
동작은 되는데, 무슨 원리인지 몰랐습니다. 그냥 구글링해서 복붙했던 코드가 되니까 그대로 써왔습니다.
document가 뭐고, getElementById는 어떤 방식으로 HTML을 찾는 거지? HTML 파일 안에서 grep 같은 걸 돌리는 건가? 아니면 메모리 어딘가에 HTML이 통째로 들어있는 건가?
시니어: "그건 DOM API야. HTML을 객체로 만들어서 조작하는 거지."
저: "객체? HTML이 객체로 바뀐다고요? HTML은 .html 파일인데 객체라니요?"
시니어는 웃으면서 말했습니다. "브라우저가 HTML 파일을 읽으면 메모리에 트리 구조로 만들어. 그게 DOM이야."
그때까지도 이해가 안 갔습니다. "아니, HTML에 <div>Hello</div>라고 써놨는데, 어떻게 이게 객체가 돼요? 그럼 이 객체가 파일 안에 들어있는 거예요?"
무엇보다 "그냥 HTML 수정하면 안 돼?"가 이해 안 갔습니다. HTML 파일 열어서 텍스트 에디터로 고치면 되는 거 아니야? 왜 JavaScript로 DOM을 조작해야 하지?
저는 처음에 HTML 파일 자체가 실시간으로 바뀌는 줄 알았습니다. document.getElementById("box").textContent = "World"를 실행하면, 서버에 있는 .html 파일이 자동으로 수정되는 줄 알았어요. 당연히 그게 아니었고, 몇 번의 삽질 끝에야 "아, HTML과 DOM은 다른 거구나"를 깨달았습니다.
시니어의 비유:
"아, HTML은 초기 설계이고, DOM은 실시간 상태구나!""HTML은 설계도(도면)입니다.
<div id="box">Hello</div>이건 그냥 글자예요. 종이에 쓴 것과 같습니다. 아니, 좀 더 정확하게는 CAD 파일이에요.
.dwg파일처럼 건축가가 그려놓은 도면. 그 자체로는 살 수 없죠.브라우저는 이 설계도를 보고 건물(DOM 트리)을 짓습니다.
{ tagName: 'div', id: 'box', textContent: 'Hello', style: { color: 'black' }, children: [] }JavaScript는 건물을 리모델링하는 겁니다. 설계도(HTML 파일)는 안 바뀌어요. 하지만 현재 건물(DOM)은 바뀝니다. 벽을 칠하고, 가구를 옮기고, 방을 추가하는 거죠."
이 비유를 듣자마자 머릿속에 확 들어왔습니다. HTML 파일은 그냥 .html 확장자의 텍스트 파일이고, 브라우저가 이걸 읽어서 메모리에 트리를 만드는구나. 그리고 JavaScript는 그 트리를 조작하는 거구나.
그래서 document.getElementById를 실행하면 파일을 여는 게 아니라 메모리 안의 트리를 검색하는 거였고, style.color = "red"를 실행하면 HTML 파일이 아니라 메모리 안의 객체 속성을 바꾸는 거였습니다.
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<title>Test</title>
</head>
<body>
<div id="app">Hello</div>
</body>
</html>
.html)HTML은 직렬화된 텍스트입니다. 태그와 꺾쇠 괄호로 이루어진 문자열. UTF-8 인코딩된 바이트 스트림. 네트워크로 전송되고, 디스크에 저장되고, Git으로 관리되는 건 바로 이 텍스트 파일입니다.
처음엔 이게 이해가 안 갔어요. "HTML이 화면에 보이는 거 아니야?" 라고 생각했거든요. 하지만 HTML 파일 자체는 화면에 보이지 않습니다. 브라우저가 이 파일을 읽어서 뭔가를 만들어야 비로소 화면에 나타납니다.
브라우저가 HTML을 읽고 메모리에 생성:
#document
└─ html
├─ head
│ └─ title ("Test")
└─ body
└─ div#app ("Hello")
DOM은 파싱된 트리 구조입니다. 브라우저가 HTML 텍스트를 한 글자씩 읽으면서 토큰으로 쪼개고, 토큰을 노드로 만들고, 노드를 트리로 조립합니다. 이 과정을 파싱(Parsing)이라고 하죠.
제가 처음 이걸 이해했을 때 든 생각: "아, 컴파일러랑 비슷하네." C 컴파일러가 소스코드를 읽어서 AST(Abstract Syntax Tree)를 만드는 것처럼, 브라우저도 HTML을 읽어서 DOM Tree를 만드는 거였습니다.
그래서 DOM은 메모리 안에만 존재합니다. 파일로 저장되지 않아요. 브라우저를 끄면 사라집니다. 새로고침하면 HTML 파일을 다시 읽어서 DOM을 새로 만듭니다.
<html>
<head>
<title>예제</title>
</head>
<body>
<div id="container">
<h1>제목</h1>
<p>내용</p>
</div>
</body>
</html>
DOM 트리:
document (Document 노드)
└─ html (Element 노드)
├─ head
│ └─ title
│ └─ "예제" (Text 노드)
└─ body
└─ div#container
├─ h1
│ └─ "제목"
└─ p
└─ "내용"
이 트리를 처음 봤을 때 든 생각: "어? 이거 자료구조 시간에 배운 Tree랑 똑같잖아?"
맞습니다. DOM은 트리 자료구조입니다. 루트 노드(document)가 있고, 각 노드는 부모와 자식을 가지고, 형제 관계도 있습니다. 그래서 DOM API에도 parentNode, childNodes, nextSibling 같은 메서드가 있습니다.
document)div, p, span)id="app", class="box")<!-- 주석 -->)저는 처음에 Text 노드가 따로 있다는 게 신기했습니다. <p>Hello</p>를 보면, p 태그는 Element 노드이고, Hello는 그 자식인 Text 노드입니다. 그래서 p.textContent를 바꾸면 사실은 Text 노드의 값을 바꾸는 거였어요.
Comment 노드도 처음엔 몰랐습니다. "주석은 코드에만 있는 거 아니야?"라고 생각했는데, DOM 트리에도 주석이 노드로 들어갑니다. 그래서 childNodes로 순회하면 주석도 나옵니다.
// ID로 선택
const box = document.getElementById("box");
// CSS 선택자
const boxes = document.querySelectorAll(".box");
// 태그명
const divs = document.getElementsByTagName("div");
// 클래스명
const buttons = document.getElementsByClassName("btn");
// 첫 번째 일치
const firstBox = document.querySelector(".box");
처음엔 getElementById와 querySelector의 차이를 몰랐습니다. "둘 다 요소 찾는 건데 왜 두 개야?" 나중에 알고 보니 성능 차이가 있더군요.
getElementById는 브라우저가 내부적으로 ID 해시맵을 가지고 있어서 O(1)에 찾습니다. 반면 querySelector는 CSS 선택자를 파싱해서 DOM 트리를 순회하므로 상대적으로 느립니다. 물론 현대 브라우저에서는 둘 다 충분히 빠르지만, ID로 찾을 수 있다면 getElementById를 쓰는 게 조금 더 효율적입니다.
const box = document.getElementById("box");
// 텍스트 변경
box.textContent = "New Text";
// HTML 변경
box.innerHTML = "<span>Bold</span>";
// 속성 변경
box.setAttribute("data-id", "123");
// 클래스 추가/제거
box.classList.add("active");
box.classList.remove("hidden");
box.classList.toggle("visible");
textContent와 innerHTML의 차이도 처음엔 헷갈렸습니다. 둘 다 내용을 바꾸는 건데 뭐가 다른지 몰랐어요.
나중에 알고 보니 textContent는 순수 텍스트만 다루고, innerHTML은 HTML 파싱을 합니다. 그래서 innerHTML에 <script>를 넣으면 XSS 공격이 가능하다는 걸 알았습니다. (물론 최신 브라우저는 innerHTML로 삽입한 스크립트는 실행 안 시키지만요.)
box.style.color = "red";
box.style.fontSize = "20px";
box.style.backgroundColor = "#f0f0f0";
// camelCase로 써야 함
// CSS: background-color
// JS: backgroundColor
CSS 속성명이 JavaScript에서 camelCase로 바뀌는 게 처음엔 불편했습니다. background-color를 backgroundColor로 써야 한다니. 왜 그냥 문자열로 style["background-color"] 이렇게 못 쓰나 싶었는데, 알고 보니 그것도 됩니다:
box.style["background-color"] = "red";
하지만 대부분의 코드는 camelCase를 씁니다. 왜냐하면 box.style.backgroundColor가 타이핑하기도 편하고, IDE 자동완성도 잘 되거든요.
// 생성
const newDiv = document.createElement("div");
newDiv.textContent = "I'm new!";
newDiv.className = "box";
// 추가
document.body.appendChild(newDiv);
// 특정 위치에 삽입
const parent = document.getElementById("container");
const firstChild = parent.firstChild;
parent.insertBefore(newDiv, firstChild);
// 삭제
box.remove();
// 또는
parent.removeChild(box);
appendChild와 insertBefore의 차이도 처음엔 헷갈렸습니다. appendChild는 맨 끝에 추가하고, insertBefore는 특정 노드 앞에 추가합니다.
그런데 insertAfter는 없습니다. 왜냐하면 nextSibling을 활용하면 되거든요:
// insertAfter 구현
function insertAfter(newNode, referenceNode) {
referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
}
처음 이 패턴을 봤을 때 "영리하네"라고 생각했습니다. nextSibling 앞에 넣으면 사실상 뒤에 넣는 거니까요.
제가 초보 시절 짠 코드:
// ❌ 나쁜 예
for (let i = 0; i < 1000; i++) {
const div = document.createElement("div");
div.textContent = i;
document.body.appendChild(div); // 1000번 DOM 조작!
}
결과: 브라우저가 버벅거림. 크롬 개발자 도구 Performance 탭을 보니 Rendering 시간이 80%를 차지했습니다.
시니어: "DOM 조작은 비싸. 1000번 하면 당연히 느려."
저: "왜요? 그냥 메모리에 객체 추가하는 건데?"
시니어: "DOM 조작하면 브라우저가 화면을 다시 그려야 돼. 레이아웃 계산하고, 픽셀 칠하고. 그게 비싼 거야."
매번 appendChild 할 때마다:
1000번 반복 = 1000번 레이아웃 재계산!
저는 처음에 "객체 추가하는 게 왜 느려?"라고 생각했는데, 알고 보니 DOM 조작은 단순히 메모리 조작이 아니었습니다. 브라우저는 DOM이 바뀔 때마다 렌더링 파이프라인을 다시 돌립니다.
렌더링 파이프라인:
매번 appendChild를 하면 4~6번 단계를 다시 실행합니다. 1000번 반복하면 당연히 느릴 수밖에요.
// ✅ 좋은 예
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
const div = document.createElement("div");
div.textContent = i;
fragment.appendChild(div); // Fragment에만 추가 (DOM 안 건드림)
}
document.body.appendChild(fragment); // 한 번에 DOM 추가!
결과: 엄청 빨라짐. Performance 탭을 다시 보니 Rendering 시간이 5% 미만으로 줄었습니다.
DocumentFragment는 메모리 안에만 존재하는 가상 컨테이너입니다. DOM 트리에 붙어있지 않아서 얼마든지 조작해도 Reflow가 발생하지 않습니다. 그리고 appendChild(fragment)를 하면 Fragment 자체는 사라지고 자식 노드들만 DOM에 추가됩니다.
이 패턴을 배우고 나서 "아, 그래서 React가 필요한 거구나"를 깨달았습니다. React는 Virtual DOM으로 변경사항을 모아뒀다가 한 번에 Real DOM에 반영하거든요. Fragment를 자동으로 해주는 셈이죠.
DOM의 구조나 크기가 바뀔 때:
// Reflow 발생
element.style.width = "100px";
element.style.height = "200px";
element.style.display = "none";
element.style.padding = "10px";
element.classList.add("big");
// 심지어 읽기만 해도 Reflow 발생
const height = element.offsetHeight; // Forced Synchronous Layout!
const width = element.getBoundingClientRect().width;
비용: 매우 비쌈 (전체 레이아웃 재계산)
Reflow가 발생하면 브라우저는 레이아웃 트리를 다시 계산합니다. 해당 요소뿐만 아니라 부모, 자식, 형제 요소의 위치와 크기도 다시 계산해야 할 수 있습니다.
예를 들어 부모 요소의 width를 바꾸면, 자식 요소들의 width도 영향을 받습니다. 특히 flex나 grid 레이아웃을 쓰면 하나의 요소 변경이 전체 레이아웃에 영향을 줍니다.
저는 처음에 offsetHeight를 읽는 것만으로도 Reflow가 발생한다는 게 신기했습니다. "읽기인데 왜 비싸?" 알고 보니 브라우저는 Lazy Evaluation을 합니다. DOM 조작을 여러 번 해도 바로 레이아웃을 계산하지 않고, 실제로 화면에 그려야 할 때 한 번에 계산합니다.
하지만 offsetHeight를 읽으려면 지금 당장 레이아웃을 계산해야 합니다. 그래서 브라우저가 즉시 Reflow를 실행하는 거죠. 이걸 Forced Synchronous Layout이라고 합니다.
색상이나 배경만 바뀔 때:
// Repaint만 발생 (Reflow X)
element.style.color = "red";
element.style.backgroundColor = "#fff";
element.style.visibility = "hidden"; // display: none과 다름!
element.style.outline = "1px solid blue";
비용: Reflow보다 싸지만 여전히 비쌈
Repaint는 레이아웃 계산은 건너뛰고 Paint 단계만 다시 실행합니다. 픽셀을 다시 칠하는 거죠.
visibility: hidden과 display: none의 차이도 여기서 나옵니다:
display: none: 요소가 레이아웃에서 사라짐 → Reflow 발생visibility: hidden: 요소는 자리를 차지하지만 보이지 않음 → Repaint만 발생그래서 요소를 숨길 때 성능을 생각한다면 visibility: hidden이 조금 더 효율적입니다. 물론 레이아웃을 유지해야 할 때만 쓸 수 있지만요.
DOM만 있으면 화면을 그릴 수 없습니다. 스타일 정보가 없으니까요. 브라우저는 CSS 파일도 파싱해서 CSSOM (CSS Object Model)을 만듭니다.
/* style.css */
body {
font-size: 16px;
}
.box {
width: 100px;
height: 100px;
background-color: red;
}
CSSOM 트리:
body
font-size: 16px
.box
width: 100px
height: 100px
background-color: red
그리고 DOM과 CSSOM을 합쳐서 Render Tree를 만듭니다. Render Tree는 실제로 화면에 그려질 요소만 포함합니다. 예를 들어 display: none인 요소는 Render Tree에 포함되지 않습니다.
저는 처음에 "DOM에 스타일 정보가 다 들어있는 줄" 알았는데, 알고 보니 DOM과 CSSOM이 분리되어 있었습니다. 그래서 JavaScript로 style.color = "red"를 하면 CSSOM이 아니라 요소의 인라인 스타일을 바꾸는 겁니다.
// ❌ 나쁜 예 (3번 Reflow)
element.style.width = "100px";
element.style.height = "100px";
element.style.margin = "10px";
// ✅ 좋은 예 (1번 Reflow)
element.style.cssText = "width: 100px; height: 100px; margin: 10px;";
// 또는
element.className = "box-large"; // CSS에서 한 번에 정의
// ✅ 더 좋은 예 (읽기/쓰기 분리)
const height = element1.offsetHeight; // 읽기
const width = element2.offsetWidth; // 읽기
element1.style.height = height + 10 + "px"; // 쓰기
element2.style.width = width + 10 + "px"; // 쓰기
읽기와 쓰기를 섞으면 매번 Forced Synchronous Layout이 발생합니다:
// ❌ 최악의 예 (2번 Forced Reflow)
element1.style.width = "100px"; // 쓰기
const height = element1.offsetHeight; // 읽기 → Forced Reflow!
element2.style.width = "200px"; // 쓰기
const width = element2.offsetWidth; // 읽기 → Forced Reflow!
브라우저는 "쓰기" 작업을 모아뒀다가 나중에 한 번에 처리하려고 하는데, 중간에 "읽기"가 들어오면 어쩔 수 없이 즉시 계산해야 합니다.
그래서 읽기는 먼저, 쓰기는 나중에 하는 게 성능상 유리합니다.
제가 React를 처음 배웠을 때 궁금했습니다:
"JavaScript로
setState하면 어떻게 빠르게 렌더링되지?"
// 1000개 아이템 업데이트
items.forEach(item => {
const element = document.getElementById(item.id);
element.textContent = item.name; // 1000번 DOM 조작!
});
이 코드는 1000번 Repaint를 유발합니다. 심지어 요소의 크기가 바뀌면 Reflow도 발생할 수 있습니다.
저는 처음에 "1000번이면 0.1초 걸리는 거 아니야? 괜찮은데?"라고 생각했는데, 실제로는 훨씬 느렸습니다. 각 DOM 조작마다 브라우저가 렌더링 파이프라인을 돌리니까요.
React가 하는 일:
Virtual DOM (가짜 DOM) 생성
// 메모리 안의 JavaScript 객체
{
type: 'div',
props: { id: 'app' },
children: ['Hello']
}
setState({ text: 'World' })
→ Virtual DOM에만 업데이트 (Real DOM 안 건드림)
Old Virtual DOM: { children: ['Hello'] }
New Virtual DOM: { children: ['World'] }
→ 차이: 텍스트만 바뀜
// 딱 1번만 Real DOM 수정
element.textContent = 'World';
결과: DOM 조작 횟수 최소화 → 빠름!
Virtual DOM은 Pure JavaScript Object입니다. DOM API를 전혀 안 씁니다. 그래서 조작이 엄청 빠릅니다. 객체 속성 바꾸는 건 나노초 단위거든요.
React는 Virtual DOM을 두 개 가지고 있습니다:
setState를 하면 Work-in-Progress Tree를 업데이트합니다. 그리고 Reconciliation 알고리즘으로 두 트리를 비교해서 차이를 찾습니다.
차이를 찾으면 그 부분만 Real DOM에 반영합니다. 이걸 Commit Phase라고 합니다.
저는 처음에 "Virtual DOM이 무조건 빠른 건 아니지 않나?"라고 생각했습니다. Virtual DOM도 결국 JavaScript 객체 비교를 하니까, 트리가 크면 느릴 수 있잖아요.
맞습니다. Virtual DOM이 항상 Real DOM보다 빠른 건 아닙니다. 하지만 대부분의 경우 빠릅니다. 왜냐하면:
const button = document.getElementById("btn");
button.addEventListener("click", () => {
console.log("Clicked!");
});
// 이벤트 제거
button.removeEventListener("click", handleClick);
이벤트 리스너를 처음 배웠을 때 든 생각: "HTML의 onclick 속성이랑 뭐가 다르지?"
<!-- HTML에서 -->
<button onclick="handleClick()">Click</button>
<!-- JavaScript에서 -->
<button id="btn">Click</button>
<script>
document.getElementById("btn").addEventListener("click", handleClick);
</script>
나중에 알고 보니 addEventListener가 훨씬 낫습니다:
onclick은 하나만 가능, addEventListener는 여러 개 가능removeEventListener로 제거 가능capture, once, passive 등 옵션 사용 가능특히 passive 옵션은 처음 알았을 때 신기했습니다:
element.addEventListener("touchstart", handleTouch, { passive: true });
passive: true를 설정하면 브라우저에게 "이 이벤트 핸들러는 preventDefault()를 안 쓸 거야"라고 알려줍니다. 그럼 브라우저는 스크롤을 즉시 시작할 수 있어서 성능이 좋아집니다.
<div id="outer">
<div id="inner">
<button id="btn">Click</button>
</div>
</div>
버튼 클릭 시 이벤트 전파:
button (click!) → inner → outer → document
이벤트 버블링을 처음 알았을 때 "왜 이런 게 필요하지?"라고 생각했습니다. 그냥 클릭한 요소에서만 이벤트가 발생하면 되는 거 아니야?
나중에 알고 보니 버블링 덕분에 이벤트 위임이 가능합니다. 그리고 여러 요소에 공통 처리를 할 때 편리합니다.
// 버블링 확인
document.getElementById("outer").addEventListener("click", () => {
console.log("Outer clicked");
});
document.getElementById("inner").addEventListener("click", () => {
console.log("Inner clicked");
});
document.getElementById("btn").addEventListener("click", () => {
console.log("Button clicked");
});
// 버튼 클릭 시 출력:
// Button clicked
// Inner clicked
// Outer clicked
버블링을 막으려면 stopPropagation()을 씁니다:
document.getElementById("btn").addEventListener("click", (e) => {
e.stopPropagation();
console.log("Button clicked");
});
// 이제 버튼 클릭 시 "Button clicked"만 출력됨
제가 실수한 코드:
// ❌ 나쁜 예 (1000개 버튼에 각각 리스너)
const buttons = document.querySelectorAll(".btn");
buttons.forEach(btn => {
btn.addEventListener("click", handleClick); // 1000개 리스너!
});
문제점:
개선:
// ✅ 좋은 예 (1개만!)
document.body.addEventListener("click", (e) => {
if (e.target.classList.contains("btn")) {
handleClick(e);
}
});
버블링 덕분에 가능! 버튼을 클릭하면 이벤트가 body까지 올라오니까, body에서 한 번만 잡으면 됩니다.
이 패턴을 처음 배웠을 때 "이거 엄청 영리한데?"라고 생각했습니다. jQuery 시절부터 쓰던 패턴인데, 지금도 여전히 유효합니다.
특히 동적으로 요소를 추가할 때 유용합니다:
// 이벤트 위임 사용
document.body.addEventListener("click", (e) => {
if (e.target.classList.contains("delete-btn")) {
e.target.parentElement.remove();
}
});
// 나중에 추가된 버튼도 자동으로 동작
const newBtn = document.createElement("button");
newBtn.className = "delete-btn";
document.body.appendChild(newBtn);
이벤트 위임이 없었다면 appendChild 할 때마다 이벤트 리스너를 다시 등록해야 했을 겁니다.
사실 이벤트는 두 단계로 전파됩니다:
document → outer → inner → buttonbutton → inner → outer → document기본적으로 addEventListener는 Bubble Phase에서 동작합니다. Capture Phase에서 동작하게 하려면:
element.addEventListener("click", handleClick, { capture: true });
// 또는
element.addEventListener("click", handleClick, true);
저는 처음에 "Capture는 언제 써?"라고 생각했는데, 실제로는 거의 안 씁니다. 특수한 경우에만 유용합니다. 예를 들어 부모 요소에서 자식 이벤트를 먼저 잡아서 막고 싶을 때:
// 모든 클릭을 막기
document.body.addEventListener("click", (e) => {
e.stopPropagation();
console.log("No clicks allowed!");
}, { capture: true });
Shadow DOM은 Web Components의 기술입니다. DOM 트리 안에 숨겨진 DOM 트리를 만드는 겁니다.
const host = document.getElementById("host");
const shadowRoot = host.attachShadow({ mode: "open" });
shadowRoot.innerHTML = `
<style>
p { color: red; }
</style>
<p>Shadow DOM 안의 텍스트</p>
`;
Shadow DOM 안의 스타일은 밖으로 새지 않습니다. 그리고 밖의 스타일도 안으로 안 들어옵니다. 완벽한 캡슐화.
저는 처음에 "이거 iframe이랑 뭐가 다르지?"라고 생각했는데, 알고 보니 완전히 다릅니다:
Shadow DOM은 브라우저 내장 요소에도 쓰입니다. 예를 들어 <video> 태그의 재생 버튼은 Shadow DOM으로 구현되어 있습니다. 개발자 도구에서 "Show user agent shadow DOM" 옵션을 켜면 볼 수 있습니다.
<video controls>
#shadow-root
<div class="controls">
<button class="play"></button>
<button class="pause"></button>
</div>
</video>
실제로 Shadow DOM을 직접 쓸 일은 많지 않지만, 라이브러리를 만들 때는 유용합니다. 스타일 충돌을 걱정할 필요가 없으니까요.
| 항목 | 설명 |
|---|---|
| HTML | 텍스트 파일 (초기 설계도) |
| DOM | 메모리 안의 객체 트리 (실시간 상태) |
| CSSOM | CSS를 파싱한 객체 트리 |
| Render Tree | DOM + CSSOM 합친 것 |
| 조작 | JavaScript가 DOM API로 수정 |
| Reflow | 레이아웃 재계산 (구조/크기 변경) |
| Repaint | 픽셀 다시 그리기 (색상 변경) |
| 최적화 | Fragment, Virtual DOM, 이벤트 위임 |
| Virtual DOM | React 등이 사용하는 JS 객체 트리 |
| Shadow DOM | 캡슐화된 DOM 서브트리 |
DOM을 이해하기 전:
DOM을 이해한 후:
이제 document.getElementById가 왜 빠른지 (해시맵),
innerHTML +=가 왜 느린지 (재파싱),
React가 왜 Virtual DOM을 쓰는지 (배치 업데이트) 이해됩니다.
그리고 CSSOM은 인테리어 설계도, Render Tree는 완성된 건물, Reflow는 구조 변경 공사, Repaint는 페인트칠.
이 비유가 머릿속에 확 들어왔습니다.