섀도 DOM 완벽 가이드: 진짜 캡슐화의 세계
섀도 DOM 완벽 가이드: 진짜 캡슐화의 세계
1. 서론: 스타일이 샌다
/* global.css */
.button {
background: blue;
color: white;
padding: 10px;
}
<!-- 서드파티 위젯 -->
<div class="widget">
<button class="button">위젯 버튼</button>
</div>
<!-- 내 버튼 -->
<button class="button">내 버튼</button>
"왜 위젯 버튼이 파란색이 됐지?"
전역 CSS의 저주다. 내가 작성한 스타일이 서드파티 위젯에 영향을 주고, 반대로 서드파티 스타일이 내 컴포넌트를 망가뜨린다.
BEM, CSS Modules, CSS-in-JS... 수많은 해결책이 나왔지만, 이들은 모두 "네이밍 컨벤션"이나 "빌드 타임 처리"에 의존한다.
그런데 브라우저가 제공하는 진짜 격리가 있다면? 바로 Shadow DOM이다.
2. Shadow DOM이란?
DOM의 평행 우주
// 일반 DOM
const div = document.createElement('div');
div.innerHTML = '<style>p { color: red; }</style><p>Hello</p>';
document.body.appendChild(div);
// 모든 p 태그가 빨간색이 된다!
// Shadow DOM
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = '<style>p { color: red; }</style><p>Hello</p>';
document.body.appendChild(host);
// Shadow DOM 내부의 p만 빨간색!
Shadow DOM은 DOM 트리의 "숨겨진 하위 트리"다. 마치 iframe처럼 독립적이지만, 같은 JavaScript 컨텍스트에서 동작한다.
핵심 개념들
// Shadow Host: Shadow DOM을 포함하는 일반 DOM 요소
const host = document.querySelector('#my-component');
// Shadow Root: Shadow DOM의 최상위 노드
const shadowRoot = host.attachShadow({ mode: 'open' });
// Shadow Tree: Shadow Root 아래의 DOM 구조
shadowRoot.innerHTML = `
<style>
:host { display: block; }
::slotted(*) { color: blue; }
</style>
<h1>Shadow DOM 내부</h1>
<slot></slot>
`;
3. 왜 Shadow DOM인가?
1. 진정한 스타일 캡슐화
<!-- 일반 DOM -->
<style>
button { background: red !important; }
</style>
<my-button>클릭</my-button>
<script>
class MyButton extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
button {
background: blue; /* !important도 못 뚫는다 */
color: white;
}
</style>
<button><slot></slot></button>
`;
}
}
customElements.define('my-button', MyButton);
</script>
2. DOM 구조 숨기기
// 일반 DOM
document.querySelector('.widget button'); // 찾을 수 있음
// Shadow DOM
document.querySelector('my-widget button'); // null! 찾을 수 없음
// Shadow DOM 내부는 querySelector로 접근 불가
3. 이름 충돌 방지
// 여러 컴포넌트가 같은 클래스명을 써도 OK
const template = `
<style>
.container { /* 이 .container는 이 컴포넌트만의 것 */ }
.title { /* 전역 .title과 충돌하지 않음 */ }
</style>
<div class="container">
<h1 class="title">안전한 제목</h1>
</div>
`;
4. Shadow DOM 실전 활용
기본 웹 컴포넌트 만들기
// video-player.js
class VideoPlayer extends HTMLElement {
constructor() {
super();
// Shadow DOM 생성
const shadow = this.attachShadow({ mode: 'open' });
// 템플릿 정의
shadow.innerHTML = `
<style>
:host {
display: block;
position: relative;
background: #000;
}
video {
width: 100%;
height: 100%;
}
.controls {
position: absolute;
bottom: 0;
width: 100%;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 10px;
}
button {
background: none;
border: none;
color: white;
cursor: pointer;
font-size: 20px;
}
button:hover {
color: #3498db;
}
</style>
<video id="video">
<slot name="source"></slot>
</video>
<div class="controls">
<button id="playBtn">▶️</button>
<button id="muteBtn">🔊</button>
<input type="range" id="volume" min="0" max="100" value="50">
<span id="time">0:00 / 0:00</span>
</div>
`;
// 내부 요소 참조
this.video = shadow.querySelector('#video');
this.playBtn = shadow.querySelector('#playBtn');
this.muteBtn = shadow.querySelector('#muteBtn');
// 이벤트 리스너
this.playBtn.addEventListener('click', () => this.togglePlay());
this.muteBtn.addEventListener('click', () => this.toggleMute());
}
togglePlay() {
if (this.video.paused) {
this.video.play();
this.playBtn.textContent = '⏸️';
} else {
this.video.pause();
this.playBtn.textContent = '▶️';
}
}
toggleMute() {
this.video.muted = !this.video.muted;
this.muteBtn.textContent = this.video.muted ? '🔇' : '🔊';
}
}
// 컴포넌트 등록
customElements.define('video-player', VideoPlayer);
사용:
<video-player>
<source slot="source" src="movie.mp4" type="video/mp4">
</video-player>
슬롯(Slot) 활용하기
// card-component.js
class CardComponent extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host {
display: block;
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
margin: 8px;
}
::slotted(h2) { /* 슬롯에 들어온 h2 스타일링 */
color: #333;
margin-top: 0;
}
.header {
border-bottom: 1px solid #eee;
padding-bottom: 8px;
margin-bottom: 16px;
}
.footer {
border-top: 1px solid #eee;
padding-top: 8px;
margin-top: 16px;
font-size: 0.9em;
color: #666;
}
</style>
<div class="header">
<slot name="header">기본 헤더</slot>
</div>
<div class="content">
<slot>기본 컨텐츠</slot>
</div>
<div class="footer">
<slot name="footer">기본 푸터</slot>
</div>
`;
}
}
customElements.define('card-component', CardComponent);
사용:
<card-component>
<h2 slot="header">제목</h2>
<p>본문 내용입니다.</p>
<span slot="footer">작성일: 2024-03-14</span>
</card-component>
5. 이벤트 처리와 버블링
Shadow DOM의 이벤트 재타겟팅
class ClickCounter extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<button id="internal">클릭하세요</button>
<span id="count">0</span>
`;
const button = shadow.querySelector('#internal');
const count = shadow.querySelector('#count');
let clicks = 0;
// Shadow DOM 내부 이벤트
button.addEventListener('click', (e) => {
clicks++;
count.textContent = clicks;
// 커스텀 이벤트 발생
this.dispatchEvent(new CustomEvent('count-changed', {
detail: { count: clicks },
bubbles: true,
composed: true // Shadow DOM 경계를 넘어 전파
}));
});
}
}
customElements.define('click-counter', ClickCounter);
// 외부에서 이벤트 리스닝
document.addEventListener('click', (e) => {
console.log('클릭된 요소:', e.target);
// Shadow DOM 내부 버튼을 클릭해도 e.target은 <click-counter>
});
document.addEventListener('count-changed', (e) => {
console.log('카운트 변경:', e.detail.count);
});
이벤트 옵션
// composed: false (기본값) - Shadow DOM 경계를 넘지 않음
shadow.querySelector('button').addEventListener('click', (e) => {
this.dispatchEvent(new CustomEvent('internal-click', {
bubbles: true,
composed: false // 외부로 전파되지 않음
}));
});
// composed: true - Shadow DOM 경계를 넘어 전파
this.dispatchEvent(new CustomEvent('public-click', {
bubbles: true,
composed: true // 외부로 전파됨
}));
6. React/Vue와의 비교
스타일 캡슐화 비교
React (CSS Modules)
// Button.module.css
.button {
background: blue;
}
// Button.jsx
import styles from './Button.module.css';
function Button() {
return <button className={styles.button}>클릭</button>;
// 실제 클래스명: button_a3f4d
}
Vue (Scoped CSS)
<template>
<button class="button">클릭</button>
</template>
<style scoped>
.button {
background: blue;
}
/* 실제: .button[data-v-f3f3eg9] */
</style>
Shadow DOM
shadow.innerHTML = `
<style>
.button { background: blue; }
</style>
<button class="button">클릭</button>
`;
// 진짜 .button, 변환 없음
차이점 정리
| 특성 | React/Vue | Shadow DOM | |------|-----------|------------| | 스타일 격리 | 빌드 타임 변환 | 브라우저 네이티브 | | 전역 스타일 차단 | 부분적 | 완전히 차단 | | DOM 접근 | 가능 | 불가능 | | 브라우저 지원 | 모든 브라우저 | 모던 브라우저 | | 번들 크기 | 프레임워크 필요 | 네이티브 | | 개발 경험 | 익숙함 | 학습 곡선 |
7. 장단점과 사용 시나리오
장점
- 완벽한 캡슐화: CSS와 DOM이 완전히 격리됨
- 네이티브 성능: 브라우저가 직접 처리
- 프레임워크 독립적: 어떤 환경에서도 동작
- 표준 기술: W3C 웹 표준
단점
- 스타일링 제한: 외부에서 스타일 커스터마이징 어려움
- 폼 요소 제한: form 참여, label 연결 등에 제약
- SEO 이슈: 검색 엔진이 내용을 못 볼 수 있음
- 디버깅 어려움: 개발자 도구에서 추가 단계 필요
언제 사용할까?
Shadow DOM이 적합한 경우:
- 독립적인 위젯 개발 (날짜 선택기, 비디오 플레이어)
- 서드파티 컴포넌트 (광고, 댓글 시스템)
- 디자인 시스템의 기본 컴포넌트
- 브라우저 확장 프로그램의 UI
Shadow DOM이 부적합한 경우:
- SEO가 중요한 콘텐츠
- 테마 커스터마이징이 자주 필요한 경우
- 폼 요소가 많은 경우
- 레거시 브라우저 지원 필요
8. 실전 예제: 토스트 알림 컴포넌트
// toast-notification.js
class ToastNotification extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.queue = [];
this.isShowing = false;
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
:host {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
}
.toast {
background: #333;
color: white;
padding: 16px 24px;
border-radius: 4px;
margin-bottom: 10px;
transform: translateX(400px);
transition: transform 0.3s ease;
display: flex;
align-items: center;
gap: 10px;
min-width: 250px;
}
.toast.show {
transform: translateX(0);
}
.toast.success { background: #4caf50; }
.toast.error { background: #f44336; }
.toast.warning { background: #ff9800; }
.close {
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s;
}
.close:hover {
opacity: 1;
}
@keyframes slideIn {
from { transform: translateX(400px); }
to { transform: translateX(0); }
}
</style>
<div id="container"></div>
`;
this.container = this.shadowRoot.querySelector('#container');
}
show(message, type = 'info', duration = 3000) {
this.queue.push({ message, type, duration });
if (!this.isShowing) {
this._showNext();
}
}
_showNext() {
if (this.queue.length === 0) {
this.isShowing = false;
return;
}
this.isShowing = true;
const { message, type, duration } = this.queue.shift();
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `
<span>${message}</span>
<span class="close">✕</span>
`;
this.container.appendChild(toast);
// 애니메이션
requestAnimationFrame(() => {
toast.classList.add('show');
});
// 닫기 버튼
toast.querySelector('.close').addEventListener('click', () => {
this._removeToast(toast);
});
// 자동 제거
setTimeout(() => {
this._removeToast(toast);
}, duration);
}
_removeToast(toast) {
toast.classList.remove('show');
setTimeout(() => {
toast.remove();
this._showNext();
}, 300);
}
}
customElements.define('toast-notification', ToastNotification);
// 전역 헬퍼 함수
window.toast = {
show: (message, type, duration) => {
let toastEl = document.querySelector('toast-notification');
if (!toastEl) {
toastEl = document.createElement('toast-notification');
document.body.appendChild(toastEl);
}
toastEl.show(message, type, duration);
},
success: (message) => window.toast.show(message, 'success'),
error: (message) => window.toast.show(message, 'error'),
warning: (message) => window.toast.show(message, 'warning')
};
사용:
// 어디서든 사용 가능
toast.success('저장되었습니다!');
toast.error('오류가 발생했습니다.');
toast.warning('주의하세요!');
9. 브라우저 호환성과 폴리필
브라우저 지원 현황 (2024년 기준)
- Chrome: 53+ ✅
- Firefox: 63+ ✅
- Safari: 10+ ✅
- Edge: 79+ ✅
- IE: ❌ (지원 안 함)
기능 감지
if ('attachShadow' in Element.prototype) {
// Shadow DOM 지원
} else {
// 폴백 로직
}
폴리필 사용
<!-- Webcomponents.js 폴리필 -->
<script src="https://unpkg.com/@webcomponents/webcomponentsjs@2.8.0/webcomponents-loader.js"></script>
<script>
window.WebComponents = window.WebComponents || {};
window.WebComponents.waitFor(() => {
// 웹 컴포넌트 사용
});
</script>
10. 결론: Shadow DOM의 미래
// 과거: 스타일 충돌과의 전쟁
.widget .button { /* 더 구체적으로... */ }
.widget > .container > .button { /* 더더 구체적으로... */ }
.widget[data-id="123"] > .container > .button:not(.disabled) { /* 😱 */ }
// 현재: Shadow DOM으로 평화
.button { /* 그냥 이거면 됨 */ }
Shadow DOM은 "진짜 컴포넌트"를 만들 수 있게 해준다. 외부 환경에 영향받지 않고, 외부에 영향을 주지도 않는 완벽한 캡슐화.
하지만 만능은 아니다. React나 Vue 같은 프레임워크가 제공하는 개발 경험과 생태계는 여전히 강력하다.
Shadow DOM을 고려해볼 만한 순간:
- 여러 환경에서 동작해야 하는 위젯
- 스타일 충돌이 절대 일어나면 안 되는 컴포넌트
- 프레임워크에 종속되지 않은 재사용 가능한 컴포넌트
웹 표준의 힘을 믿는다면, Shadow DOM은 훌륭한 선택이다.
"Shadow DOM은 웹 컴포넌트의 그림자가 아니라, 빛이다."