개요

Web Components는 프레임워크 없이 재사용 가능한 커스텀 엘리먼트를 만드는 웹 표준입니다. Custom Elements, Shadow DOM, HTML Templates의 세 가지 핵심 기술로 구성되며, 모든 주요 브라우저에서 안정적으로 지원됩니다. React, Vue, Angular와 함께 사용할 수 있어 프레임워크 간 컴포넌트 공유가 가능합니다.

핵심 개념

Custom Elements는 직접 정의한 HTML 태그를 브라우저에 등록합니다. <my-button>처럼 하이픈을 포함한 이름으로 정의하며, HTMLElement를 상속받아 구현합니다.

Shadow DOM은 컴포넌트의 내부 구조를 캡슐화하여 외부 CSS와 JavaScript로부터 격리합니다. 스타일 충돌을 원천 차단하고 진정한 캡슐화를 제공합니다.

HTML Templates<slot> 요소는 재사용 가능한 마크업 조각과 컨텐츠 투영 메커니즘을 제공합니다.

실전 예제

기본적인 Custom Element를 생성합니다.

class MyButton extends HTMLElement {
  constructor() {
    super();
    // Shadow DOM 생성 (캡슐화)
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    // DOM에 삽입될 때 호출
    this.render();
    this.addEventListeners();
  }

  disconnectedCallback() {
    // DOM에서 제거될 때 호출
    this.removeEventListeners();
  }

  static get observedAttributes() {
    // 관찰할 속성 지정
    return ['variant', 'disabled'];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    // 속성 변경 시 호출
    if (oldValue !== newValue) {
      this.render();
    }
  }

  render() {
    const variant = this.getAttribute('variant') || 'primary';
    const disabled = this.hasAttribute('disabled');

    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: inline-block;
        }

        button {
          padding: 0.5rem 1rem;
          border: none;
          border-radius: 4px;
          font-size: 1rem;
          cursor: pointer;
          transition: all 0.2s;
        }

        button:hover:not(:disabled) {
          transform: translateY(-2px);
          box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
        }

        .primary {
          background: #2563eb;
          color: white;
        }

        .secondary {
          background: #6b7280;
          color: white;
        }

        button:disabled {
          opacity: 0.5;
          cursor: not-allowed;
        }
      </style>

      <button class="${variant}" ${disabled ? 'disabled' : ''}>
        <slot>Click me</slot>
      </button>
    `;
  }

  addEventListeners() {
    this.shadowRoot.querySelector('button').addEventListener('click', this.handleClick);
  }

  removeEventListeners() {
    this.shadowRoot.querySelector('button').removeEventListener('click', this.handleClick);
  }

  handleClick = (e) => {
    // 커스텀 이벤트 발행
    this.dispatchEvent(new CustomEvent('my-click', {
      bubbles: true,
      composed: true, // Shadow DOM 경계를 넘어 전파
      detail: { timestamp: Date.now() }
    }));
  }
}

// 컴포넌트 등록
customElements.define('my-button', MyButton);

사용 예시입니다.

<!-- 기본 사용 -->
<my-button variant="primary">저장</my-button>
<my-button variant="secondary" disabled>로딩 중...</my-button>

<script>
  document.querySelector('my-button').addEventListener('my-click', (e) => {
    console.log('Button clicked at:', e.detail.timestamp);
  });
</script>

더 복잡한 예제: 탭 컴포넌트를 구현합니다.

class TabGroup extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.activeTab = 0;
  }

  connectedCallback() {
    this.render();
    this.setupEventListeners();
  }

  render() {
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          font-family: system-ui, sans-serif;
        }

        .tab-list {
          display: flex;
          gap: 0.5rem;
          border-bottom: 2px solid #e5e7eb;
          margin-bottom: 1rem;
        }

        .tab-button {
          padding: 0.75rem 1.5rem;
          border: none;
          background: none;
          cursor: pointer;
          position: relative;
          color: #6b7280;
          font-weight: 500;
        }

        .tab-button.active {
          color: #2563eb;
        }

        .tab-button.active::after {
          content: '';
          position: absolute;
          bottom: -2px;
          left: 0;
          right: 0;
          height: 2px;
          background: #2563eb;
        }

        .tab-panels {
          display: block;
        }

        ::slotted([slot^="panel"]) {
          display: none;
        }

        ::slotted([slot="panel-0"]) {
          display: block;
        }
      </style>

      <div class="tab-list" role="tablist"></div>
      <div class="tab-panels">
        <slot name="panel-0"></slot>
        <slot name="panel-1"></slot>
        <slot name="panel-2"></slot>
      </div>
    `;

    this.updateTabs();
  }

  updateTabs() {
    const tabList = this.shadowRoot.querySelector('.tab-list');
    const tabs = this.querySelectorAll('[slot^="panel-"]');

    tabList.innerHTML = '';
    tabs.forEach((panel, index) => {
      const button = document.createElement('button');
      button.className = 'tab-button';
      if (index === this.activeTab) button.classList.add('active');
      button.textContent = panel.getAttribute('label') || `Tab ${index + 1}`;
      button.setAttribute('role', 'tab');
      button.dataset.index = index;
      tabList.appendChild(button);
    });
  }

  setupEventListeners() {
    this.shadowRoot.querySelector('.tab-list').addEventListener('click', (e) => {
      if (e.target.classList.contains('tab-button')) {
        this.switchTab(parseInt(e.target.dataset.index));
      }
    });
  }

  switchTab(index) {
    this.activeTab = index;

    // 버튼 활성화 상태 업데이트
    this.shadowRoot.querySelectorAll('.tab-button').forEach((btn, i) => {
      btn.classList.toggle('active', i === index);
    });

    // 패널 표시/숨김
    this.querySelectorAll('[slot^="panel-"]').forEach((panel, i) => {
      panel.style.display = i === index ? 'block' : 'none';
    });
  }
}

customElements.define('tab-group', TabGroup);

탭 컴포넌트 사용 예시입니다.

<tab-group>
  <div slot="panel-0" label="프로필">
    <h2>사용자 프로필</h2>
    <p>프로필 정보가 여기에 표시됩니다.</p>
  </div>

  <div slot="panel-1" label="설정">
    <h2>설정</h2>
    <p>설정 옵션이 여기에 표시됩니다.</p>
  </div>

  <div slot="panel-2" label="알림">
    <h2>알림</h2>
    <p>알림 내역이 여기에 표시됩니다.</p>
  </div>
</tab-group>

활용 팁

  • 프레임워크 연동: React에서는 ref를 사용하고, Vue에서는 v-bind로 속성을 전달하여 Web Components를 사용할 수 있습니다.
  • 스타일 격리: Shadow DOM은 완벽한 스타일 격리를 제공하지만, CSS 변수는 경계를 넘어 상속됩니다. 이를 활용하여 테마를 적용하세요.
  • 접근성: ARIA 속성을 적절히 사용하고, 키보드 내비게이션을 구현하세요.
  • 번들 크기: Lit, Stencil 같은 경량 라이브러리를 사용하면 보일러플레이트를 줄일 수 있습니다.
  • Form 연동: ElementInternals API를 사용하면 커스텀 엘리먼트가 네이티브 폼 컨트롤처럼 동작합니다.

마무리

Web Components는 프레임워크에 종속되지 않는 진정한 컴포넌트 재사용을 가능하게 합니다. Shadow DOM의 캡슐화는 대규모 애플리케이션에서 스타일 충돌을 원천 차단하고, Custom Elements는 HTML 표준의 확장성을 증명합니다. 디자인 시스템 구축이나 여러 프레임워크를 사용하는 환경에서 특히 유용합니다.