본문 바로가기
카테고리 없음

웹 컴포넌트 (Web Components)

by SuldenLion 2026. 6. 1.
반응형

웹 컴포넌트 — 프레임워크 없는 재사용 가능한 컴포넌트

프론트엔드 개발 · 심화 주제 시리즈 #2
React도, Vue도, Angular도 아닌 — 순수 브라우저 표준으로 만드는 진정한 재사용 컴포넌트. Custom Elements, Shadow DOM, HTML Templates, ES Modules 네 가지 표준의 조합을 깊이 이해합니다.


1. 웹 컴포넌트란 무엇인가

웹 컴포넌트(Web Components) 는 브라우저 네이티브 표준으로 제공하는 재사용 가능한 캡슐화된 UI 컴포넌트 기술의 집합입니다. 세 가지 표준 기술로 구성됩니다.

Web Components
├── Custom Elements    — 새로운 HTML 태그 정의
├── Shadow DOM        — 캡슐화된 DOM 트리와 스타일
└── HTML Templates    — 비활성 HTML 조각 (<template>, <slot>)
<!-- 웹 컴포넌트로 만든 커스텀 태그를 일반 HTML처럼 사용 -->
<user-avatar size="lg" src="/photo.jpg" name="홍길동"></user-avatar>
<my-modal open title="확인">삭제하시겠습니까?</my-modal>
<data-table sortable paginate rows-per-page="10"></data-table>

왜 웹 컴포넌트인가

프레임워크 독립성: React, Vue, Angular 어디서든 <my-button> 태그로 사용할 수 있습니다.

수명 주기 보장: 특정 프레임워크가 사라져도 브라우저 표준은 계속됩니다.

진정한 캡슐화: Shadow DOM으로 내부 구현이 외부 스타일/스크립트의 영향을 받지 않습니다.

현재 지원: 모든 현대 브라우저(Chrome, Firefox, Safari, Edge)에서 완전 지원됩니다.


2. Custom Elements

Custom Elements API는 새로운 HTML 태그를 JavaScript 클래스로 정의합니다.

Autonomous Custom Elements — 완전히 새로운 요소

// my-greeting.js
class MyGreeting extends HTMLElement {
  constructor() {
    super();
    // constructor에서는 속성 접근 금지, 자식 DOM 조작 금지
    // Shadow DOM 생성과 이벤트 리스너 등록만 허용
  }

  connectedCallback() {
    // 요소가 DOM에 삽입될 때 호출 (componentDidMount에 해당)
    const name = this.getAttribute('name') || '사용자';
    this.innerHTML = `<p>안녕하세요, <strong>${name}</strong>님!</p>`;
  }

  disconnectedCallback() {
    // 요소가 DOM에서 제거될 때 호출 (componentWillUnmount에 해당)
    // 이벤트 리스너 정리, 타이머 해제 등
  }
}

// 태그 이름 등록 (반드시 하이픈 포함)
customElements.define('my-greeting', MyGreeting);
<my-greeting name="홍길동"></my-greeting>

Customized Built-in Elements — 기존 요소 확장

// 기존 <button>을 확장
class FancyButton extends HTMLButtonElement {
  connectedCallback() {
    this.classList.add('fancy-btn');
    this.addEventListener('click', this._handleClick);
  }

  _handleClick() {
    this.animate(
      [{ transform: 'scale(1)' }, { transform: 'scale(0.95)' }, { transform: 'scale(1)' }],
      { duration: 150 }
    );
  }
}

customElements.define('fancy-button', FancyButton, { extends: 'button' });
<!-- is 속성으로 사용 (Safari는 폴리필 필요) -->
<button is="fancy-button">클릭</button>

customElements.whenDefined — 정의 대기

// 커스텀 요소가 정의될 때까지 대기
await customElements.whenDefined('my-greeting');
console.log('my-greeting 준비 완료');

// 이미 정의되었는지 확인
const existingDef = customElements.get('my-greeting');
if (!existingDef) {
  customElements.define('my-greeting', MyGreeting);
}

3. Shadow DOM

Shadow DOM은 컴포넌트 내부에 격리된 독립적인 DOM 트리를 만드는 기술입니다.

일반 DOM vs Shadow DOM

일반 DOM (Light DOM):
document
  └── <body>
        ├── <div class="card">...</div>
        └── <my-card>  ← 커스텀 요소
              └── (내부가 외부 스타일에 노출됨)

Shadow DOM:
document
  └── <body>
        └── <my-card>  ← Shadow Host
              └── #shadow-root  ← Shadow Root (격리 경계)
                    ├── <style>.title { color: blue; }</style>
                    └── <div class="title">내부 DOM</div>

Shadow DOM 생성

class MyCard extends HTMLElement {
  constructor() {
    super();
    // Shadow Root 생성
    // mode: 'open'  → this.shadowRoot로 외부 접근 가능
    // mode: 'closed'→ 외부 접근 완전 차단
    const shadow = this.attachShadow({ mode: 'open' });

    shadow.innerHTML = `
      <style>
        /* 이 스타일은 Shadow DOM 안에서만 유효 */
        :host {
          display: block;
          border-radius: 8px;
          overflow: hidden;
        }
        :host([elevated]) {
          box-shadow: 0 4px 16px rgba(0,0,0,.15);
        }
        .header {
          background: #3498db;
          color: white;
          padding: 1rem;
        }
        .body {
          padding: 1rem;
        }
      </style>
      <div class="header">
        <slot name="header">기본 제목</slot>
      </div>
      <div class="body">
        <slot></slot>
      </div>
    `;
  }
}

customElements.define('my-card', MyCard);
<!-- 외부 스타일이 Shadow DOM 내부에 영향 못 줌 -->
<style>
  .header { background: red; } /* my-card 내부 .header에 영향 없음! */
</style>

<my-card elevated>
  <span slot="header">카드 제목</span>
  카드 내용입니다.
</my-card>

CSS 관통 선택자 — 외부에서 Shadow DOM 스타일 변경

Shadow DOM은 외부 CSS를 차단하지만, CSS Custom Properties(변수) 는 Shadow DOM 경계를 통과합니다.

/* Shadow DOM 내부 */
:host {
  --card-bg: white;         /* CSS 변수 기본값 */
  --card-radius: 8px;
  background: var(--card-bg);
  border-radius: var(--card-radius);
}
/* 외부 CSS — CSS 변수로 Shadow DOM 스타일 제어 가능 */
my-card {
  --card-bg: #f0f4f8;
  --card-radius: 16px;
}

4. HTML Templates

<template> 태그는 렌더링되지 않는 HTML 조각을 정의합니다. JavaScript로 클론해서 사용합니다.

<!-- 페이지에 렌더링되지 않음 -->
<template id="user-card-template">
  <style>
    .card { border: 1px solid #ddd; border-radius: 8px; padding: 1rem; }
    .name { font-weight: bold; font-size: 1.1rem; }
    .email { color: #666; font-size: .9rem; }
  </style>
  <div class="card">
    <img class="avatar" alt="프로필 사진" />
    <p class="name"></p>
    <p class="email"></p>
  </div>
</template>
class UserCard extends HTMLElement {
  connectedCallback() {
    const template = document.getElementById('user-card-template');
    // content를 cloneNode(true)로 복제 (원본 유지)
    const clone = template.content.cloneNode(true);

    // 데이터 채우기
    clone.querySelector('.avatar').src  = this.getAttribute('avatar');
    clone.querySelector('.name').textContent  = this.getAttribute('name');
    clone.querySelector('.email').textContent = this.getAttribute('email');

    this.attachShadow({ mode: 'open' }).appendChild(clone);
  }
}

customElements.define('user-card', UserCard);

<slot> — 컨텐츠 투영

<!-- 컴포넌트 내부 정의 -->
<template id="dialog-template">
  <style>
    .dialog { background: white; border-radius: 12px; padding: 2rem; }
    .title { font-size: 1.25rem; font-weight: bold; margin-bottom: 1rem; }
    .actions { display: flex; gap: .5rem; justify-content: flex-end; margin-top: 1.5rem; }
  </style>
  <div class="dialog">
    <div class="title">
      <slot name="title">제목</slot>      <!-- 이름 있는 슬롯 -->
    </div>
    <div class="content">
      <slot></slot>                        <!-- 기본 슬롯 -->
    </div>
    <div class="actions">
      <slot name="actions">               <!-- 이름 있는 슬롯 -->
        <button>확인</button>
      </slot>
    </div>
  </div>
</template>
<!-- 사용 — slot 속성으로 컨텐츠 투영 -->
<my-dialog>
  <span slot="title">삭제 확인</span>
  <p>정말 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.</p>
  <div slot="actions">
    <button>취소</button>
    <button>삭제</button>
  </div>
</my-dialog>

5. Life Cycle Callbacks

class MyComponent extends HTMLElement {
  constructor() {
    super();
    // Shadow DOM 생성 가능
    // 이벤트 리스너 등록 가능
    // 자식 요소 접근 불가, getAttribute() 접근 권장하지 않음
    this._shadow = this.attachShadow({ mode: 'open' });
    this._shadow.innerHTML = `<p>컴포넌트</p>`;
  }

  // DOM에 삽입될 때
  connectedCallback() {
    // 속성 접근, 자식 DOM 조작, 이벤트 리스너 등록
    this._render();
    this._startTimer();
  }

  // DOM에서 제거될 때
  disconnectedCallback() {
    // 메모리 누수 방지: 정리 작업
    this._stopTimer();
    this._removeEventListeners();
  }

  // 다른 document로 이동될 때 (iframe 등)
  adoptedCallback() {
    console.log('다른 document로 이동됨');
  }

  // 감시할 속성 목록
  static get observedAttributes() {
    return ['name', 'color', 'size'];
  }

  // 감시 속성이 변경될 때
  attributeChangedCallback(attributeName, oldValue, newValue) {
    if (oldValue === newValue) return;  // 실제 변경일 때만 처리
    switch (attributeName) {
      case 'name':  this._updateName(newValue);  break;
      case 'color': this._updateColor(newValue); break;
    }
  }
}

6. 속성과 프로퍼티 반영

HTML 속성(attribute)과 DOM 프로퍼티(property)를 양방향으로 반영합니다.

class ProgressBar extends HTMLElement {
  static get observedAttributes() { return ['value', 'max']; }

  // 프로퍼티 getter/setter 정의
  get value() {
    return parseFloat(this.getAttribute('value') || '0');
  }
  set value(val) {
    this.setAttribute('value', String(val));  // 속성도 동시 업데이트
  }

  get max() {
    return parseFloat(this.getAttribute('max') || '100');
  }
  set max(val) {
    this.setAttribute('max', String(val));
  }

  get percentage() {
    return Math.min(100, Math.max(0, (this.value / this.max) * 100));
  }

  connectedCallback() {
    this._shadow = this.attachShadow({ mode: 'open' });
    this._render();
  }

  attributeChangedCallback() {
    this._render();
  }

  _render() {
    if (!this._shadow) return;
    this._shadow.innerHTML = `
      <style>
        .bar-container {
          width: 100%;
          height: 8px;
          background: #e0e0e0;
          border-radius: 4px;
          overflow: hidden;
        }
        .bar-fill {
          height: 100%;
          background: #3498db;
          border-radius: 4px;
          transition: width .3s ease;
        }
      </style>
      <div class="bar-container">
        <div class="bar-fill" style="width: ${this.percentage}%"></div>
      </div>
    `;
  }
}

customElements.define('progress-bar', ProgressBar);
<progress-bar value="35" max="100"></progress-bar>

<script>
  const bar = document.querySelector('progress-bar');
  bar.value = 75;          // 프로퍼티로 설정
  console.log(bar.value);  // 75
  console.log(bar.getAttribute('value'));  // "75" (속성 반영됨)
</script>

7. 이벤트와 통신

class SearchInput extends HTMLElement {
  connectedCallback() {
    this._shadow = this.attachShadow({ mode: 'open' });
    this._shadow.innerHTML = `
      <input type="search" placeholder="검색어 입력..." />
    `;

    const input = this._shadow.querySelector('input');

    input.addEventListener('input', (e) => {
      // composed: true → Shadow DOM 경계를 넘어 이벤트 전파
      this.dispatchEvent(new CustomEvent('search-input', {
        bubbles:  true,
        composed: true,
        detail: { query: e.target.value },
      }));
    });

    input.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') {
        this.dispatchEvent(new CustomEvent('search-submit', {
          bubbles: true, composed: true,
          detail: { query: e.target.value },
        }));
      }
    });
  }
}

customElements.define('search-input', SearchInput);
<search-input></search-input>

<script>
  document.querySelector('search-input').addEventListener('search-submit', (e) => {
    console.log('검색:', e.detail.query);
  });
</script>

8. 실전 컴포넌트 예제 — 커스텀 모달

class MyModal extends HTMLElement {
  static get observedAttributes() { return ['open', 'title']; }

  constructor() {
    super();
    this._shadow = this.attachShadow({ mode: 'open' });
    this._shadow.innerHTML = `
      <style>
        :host { display: none; }
        :host([open]) { display: block; }

        .overlay {
          position: fixed;
          inset: 0;
          background: rgba(0,0,0,.5);
          display: flex;
          align-items: center;
          justify-content: center;
          z-index: 1000;
          animation: fade-in .2s ease;
        }

        .modal {
          background: white;
          border-radius: 12px;
          padding: 2rem;
          width: 90%;
          max-width: 480px;
          max-height: 80vh;
          overflow-y: auto;
          animation: slide-up .2s ease;
        }

        .header {
          display: flex;
          align-items: center;
          justify-content: space-between;
          margin-bottom: 1rem;
        }

        .title {
          font-size: 1.25rem;
          font-weight: 700;
          margin: 0;
        }

        .close-btn {
          background: none;
          border: none;
          font-size: 1.5rem;
          cursor: pointer;
          line-height: 1;
          color: #666;
        }

        .close-btn:hover { color: #000; }

        @keyframes fade-in {
          from { opacity: 0; }
          to   { opacity: 1; }
        }
        @keyframes slide-up {
          from { transform: translateY(16px); opacity: 0; }
          to   { transform: translateY(0); opacity: 1; }
        }
      </style>

      <div class="overlay" part="overlay">
        <div class="modal" part="modal" role="dialog" aria-modal="true">
          <div class="header">
            <h2 class="title" id="dialog-title"></h2>
            <button class="close-btn" aria-label="닫기">✕</button>
          </div>
          <div class="body">
            <slot></slot>
          </div>
          <div class="footer">
            <slot name="footer"></slot>
          </div>
        </div>
      </div>
    `;

    // 닫기 버튼
    this._shadow.querySelector('.close-btn').addEventListener('click', () => {
      this.close();
    });

    // 오버레이 클릭으로 닫기
    this._shadow.querySelector('.overlay').addEventListener('click', (e) => {
      if (e.target === e.currentTarget) this.close();
    });

    // ESC 키로 닫기
    this._handleKeydown = (e) => {
      if (e.key === 'Escape' && this.hasAttribute('open')) this.close();
    };
  }

  connectedCallback() {
    document.addEventListener('keydown', this._handleKeydown);
  }

  disconnectedCallback() {
    document.removeEventListener('keydown', this._handleKeydown);
  }

  attributeChangedCallback(name, _, newVal) {
    if (name === 'title') {
      const titleEl = this._shadow.querySelector('.title');
      if (titleEl) titleEl.textContent = newVal;
    }
    if (name === 'open') {
      this._shadow.querySelector('[role="dialog"]')
        ?.setAttribute('aria-labelledby', 'dialog-title');
    }
  }

  open() {
    this.setAttribute('open', '');
    this.dispatchEvent(new CustomEvent('modal-opened', { bubbles: true, composed: true }));
  }

  close() {
    this.removeAttribute('open');
    this.dispatchEvent(new CustomEvent('modal-closed', { bubbles: true, composed: true }));
  }
}

customElements.define('my-modal', MyModal);
<my-modal id="confirm-modal" title="삭제 확인">
  <p>정말 삭제하시겠습니까?</p>
  <div slot="footer">
    <button id="cancel-btn">취소</button>
    <button id="confirm-btn">삭제</button>
  </div>
</my-modal>

<button id="open-btn">모달 열기</button>

<script>
  const modal = document.getElementById('confirm-modal');
  document.getElementById('open-btn').addEventListener('click', () => modal.open());
  document.getElementById('cancel-btn').addEventListener('click', () => modal.close());
  document.getElementById('confirm-btn').addEventListener('click', () => {
    console.log('삭제 확인됨');
    modal.close();
  });
</script>

9. React/Vue와의 통합

React에서 웹 컴포넌트 사용

// React 18 이하: 이벤트 핸들링이 불편함
// React 19+: 웹 컴포넌트 완전 지원

import { useRef, useEffect } from 'react';
import 'my-design-system/modal';  // 웹 컴포넌트 등록

function App() {
  const modalRef = useRef(null);

  useEffect(() => {
    const modal = modalRef.current;
    const handleClose = () => setOpen(false);

    // React의 합성 이벤트가 커스텀 이벤트를 처리 못할 수 있음
    // → ref로 직접 addEventListener 사용
    modal.addEventListener('modal-closed', handleClose);
    return () => modal.removeEventListener('modal-closed', handleClose);
  }, []);

  return (
    <my-modal ref={modalRef} title="확인">
      내용
    </my-modal>
  );
}

Vue에서 웹 컴포넌트 사용

<template>
  <!-- Vue는 웹 컴포넌트를 자연스럽게 지원 -->
  <my-modal
    :open="isOpen || undefined"
    :title="title"
    @modal-closed="isOpen = false"
  >
    <p>내용</p>
  </my-modal>
</template>

<script setup>
import { ref } from 'vue';
const isOpen = ref(false);
const title  = ref('제목');
</script>

10. 결론

웹 컴포넌트는 "한 번 만들고, 어디서나 사용하는" 진정한 재사용 컴포넌트를 실현합니다.

언제 웹 컴포넌트를 선택하는가

  • 디자인 시스템: 여러 프레임워크(React, Vue, Angular)를 사용하는 팀에 공통 UI 제공
  • 마이크로 프론트엔드: 프레임워크 독립적인 통합 레이어
  • 장기적 수명이 필요한 컴포넌트: 10년 뒤에도 동작해야 하는 UI

현재 한계

  • React와의 이벤트 통합이 v18에서 불편함 (v19에서 개선)
  • TypeScript 타입 정의 별도 작성 필요
  • SSR(서버 사이드 렌더링)이 기본으로 복잡함
  • 상태 관리, 리액티비티가 바닐라 JS로는 번거로움 → Lit 라이브러리로 보완

한 줄 요약: 웹 컴포넌트는 프레임워크의 나이를 먹지 않는 진정한 표준 컴포넌트입니다. 단일 프레임워크 프로젝트보다 다중 프레임워크 환경, 디자인 시스템, 장기 지원 컴포넌트에서 빛납니다.


 

반응형

댓글