섀도 DOM 완벽 가이드: 진짜 캡슐화의 세계

2024년 12월 3일
shadow-domweb-componentscss-encapsulationdomfrontend

섀도 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. 장단점과 사용 시나리오

장점

  1. 완벽한 캡슐화: CSS와 DOM이 완전히 격리됨
  2. 네이티브 성능: 브라우저가 직접 처리
  3. 프레임워크 독립적: 어떤 환경에서도 동작
  4. 표준 기술: W3C 웹 표준

단점

  1. 스타일링 제한: 외부에서 스타일 커스터마이징 어려움
  2. 폼 요소 제한: form 참여, label 연결 등에 제약
  3. SEO 이슈: 검색 엔진이 내용을 못 볼 수 있음
  4. 디버깅 어려움: 개발자 도구에서 추가 단계 필요

언제 사용할까?

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은 웹 컴포넌트의 그림자가 아니라, 빛이다."