Shadow DOM — CSS가 절대 새어나가지 않는 벽
이 토픽을 마치면
Shadow DOM이 무엇인지, CSS 캡슐화가 왜 필요한지 이해하고, 실제로 Shadow DOM을 만들어 스타일이 격리되는 것을 확인할 수 있습니다.
남의 CSS가 내 컴포넌트를 망가뜨리는 문제
팀에서 버튼 컴포넌트를 만들었습니다. 파란 배경, 흰 글씨. 잘 작동합니다.
/* 내 컴포넌트 스타일 */
button { background: blue; color: white; }그런데 다른 팀원이 전역 CSS에 이걸 추가합니다:
/* 전역 스타일 */
button { background: red; color: black; }내 버튼이 빨간색으로 변했습니다. CSS는 기본적으로 전역이라, 같은 선택자가 있으면 충돌합니다. 프로젝트가 커질수록 이 문제가 심해집니다.
해결책:
- BEM (
.block__element--modifier) → 네이밍으로 회피. 규칙을 어기면 망가짐 - CSS Modules → 빌드 타임에 클래스명 해시화. 프레임워크 의존
- Shadow DOM → 브라우저 레벨에서 물리적으로 격리
Shadow DOM이란
Shadow DOM은 DOM 안에 격리된 작은 DOM 트리를 만드는 브라우저 기능입니다. Shadow DOM 안의 CSS는 밖으로 새지 않고, 밖의 CSS는 안으로 들어오지 않습니다.
// Shadow DOM 만들기
const host = document.getElementById("my-widget");
const shadow = host.attachShadow({ mode: "open" });
shadow.innerHTML = `
<style>
button { background: blue; color: white; padding: 12px 24px; }
</style>
<button>Shadow 버튼</button>
`;이 버튼은 페이지의 어떤 CSS에도 영향받지 않습니다. button { background: red; }가 전역에 있어도 이 버튼은 파란색입니다.
이미 쓰고 있었다
브라우저의 <input type="range"> 슬라이더, <video> 플레이어의 재생 버튼 — 이것들은 전부 Shadow DOM입니다.
Chrome DevTools에서 Settings → "Show user agent shadow DOM"을 켜면 볼 수 있습니다:
<input type="range">
#shadow-root (user-agent)
<div id="track">
<div id="thumb"></div>
</div>브라우저가 내부적으로 Shadow DOM을 사용해서 복잡한 UI를 캡슐화합니다. 우리가 input 태그에 CSS를 줘도 내부 track이나 thumb이 예상치 않게 바뀌지 않는 이유입니다.
Web Components — Custom Elements + Shadow DOM
Shadow DOM의 실제 활용은 Web Components에서 빛납니다:
class MyCard extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });
shadow.innerHTML = `
<style>
.card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
max-width: 300px;
}
h2 { margin: 0 0 8px; color: #333; }
p { margin: 0; color: #666; }
</style>
<div class="card">
<h2><slot name="title">제목</slot></h2>
<p><slot>내용</slot></p>
</div>
`;
}
}
customElements.define("my-card", MyCard);<my-card>
<span slot="title">세포 배양</span>
세포 배양은 생물학 실험의 기초입니다.
</my-card>이 <my-card> 태그는 어떤 프레임워크에서든 작동합니다. React, Vue, Angular, 순수 HTML — 전부 됩니다. 프레임워크에 종속되지 않는 컴포넌트가 Web Components의 핵심 가치입니다.
open vs closed
// open — 외부에서 shadow DOM에 접근 가능
const shadow = el.attachShadow({ mode: "open" });
console.log(el.shadowRoot); // shadow DOM 객체
// closed — 외부 접근 차단
const shadow = el.attachShadow({ mode: "closed" });
console.log(el.shadowRoot); // nullclosed는 보안 목적이 아닙니다. 우회 방법이 있기 때문입니다. "이 내부는 건드리지 마세요"라는 의도 표현에 가깝습니다. 대부분의 경우 open을 씁니다.
스타일 관통 방법
완전한 격리가 항상 좋은 것은 아닙니다. 테마(다크모드 등)를 적용하려면 외부에서 스타일을 주입할 방법이 필요합니다:
// CSS 커스텀 속성(변수)은 Shadow DOM을 관통합니다
shadow.innerHTML = `
<style>
button {
background: var(--theme-primary, blue);
color: var(--theme-text, white);
}
</style>
<button>테마 버튼</button>
`;/* 외부에서 테마 설정 */
:root {
--theme-primary: #6200ea;
--theme-text: #ffffff;
}CSS 커스텀 속성(--변수명)은 Shadow DOM 경계를 넘어 상속됩니다. 이것이 의도된 "구멍"입니다. 컴포넌트 제작자가 어떤 부분을 커스터마이징 가능하게 할지 결정합니다.
React/Vue와의 관계
| Shadow DOM | React | Vue (Scoped) | |
|---|---|---|---|
| 격리 수준 | 브라우저 레벨 (물리적) | 빌드 타임 (논리적) | 빌드 타임 (attribute) |
| 프레임워크 의존 | 없음 (표준 API) | React 필수 | Vue 필수 |
| SSR | 어려움 | 지원 | 지원 |
| 생태계 | 작음 | 거대 | 거대 |
React의 CSS-in-JS나 Vue의 <style scoped>도 스타일 격리를 합니다. 하지만 빌드 타임에 클래스명을 변환하는 방식이라, 런타임에 주입된 CSS에는 무력합니다. Shadow DOM은 브라우저가 강제하는 진짜 격리입니다.
실제로 Shadow DOM과 React를 함께 쓰는 경우도 있습니다. 위젯을 다른 사이트에 임베드할 때(채팅 위젯, 분석 도구 등) Shadow DOM으로 감싸면 호스트 사이트의 CSS 간섭을 완벽히 차단합니다.
핵심 한 줄: Shadow DOM은 DOM 안에 CSS가 새어나가지 않는 격리된 서브트리를 만드는 브라우저 표준 기능입니다.
<video>태그의 재생 버튼도 Shadow DOM이고, Web Components의 핵심 기술입니다.