웹 컴포넌트 개발 입문: 재사용 가능한 UI 요소 만들기

웹 컴포넌트는 재사용 가능한 UI 요소를 구축하기 위한 표준 웹 기술 집합입니다. 이 기술을 통해 개발자는 사용자 정의 HTML 태그를 생성하고, 스타일과 동작을 캡슐화하여 다양한 프레임워크나 라이브러리 환경에서도 독립적으로 활용할 수 있는 컴포넌트를 만들 수 있습니다.

1. 웹 컴포넌트의 핵심 요소

웹 컴포넌트는 다음 세 가지 핵심 기술로 구성됩니다:

  • 사용자 정의 요소 (Custom Elements): 개발자가 새로운 HTML 태그를 정의하고 해당 태그의 동작을 프로그래밍 방식으로 제어할 수 있게 합니다.
  • 섀도 DOM (Shadow DOM): 컴포넌트의 DOM 구조, 스타일, 동작을 메인 문서와 격리하여 외부 CSS나 JavaScript가 컴포넌트 내부에 영향을 미치지 않도록 캡슐화합니다.
  • HTML 템플릿 (HTML Templates): <template><slot> 요소를 사용하여 재사용 가능한 마크업 구조를 정의하고, 필요할 때까지 렌더링되지 않도록 지연 로딩을 가능하게 합니다.

2. 웹 컴포넌트 구현하기

2.1. 기본적인 웹 컴포넌트 정의

사용자 정의 요소를 생성하려면 HTMLElement를 상속받는 클래스를 정의한 후 customElements.define() 메서드를 사용하여 등록합니다. 다음은 간단한 환영 메시지를 표시하는 컴포넌트 예시입니다.

class GreetingCardElement extends HTMLElement {
  constructor() {
    super();
    // 섀도 DOM을 생성하고 연결합니다. 'open' 모드는 외부 JavaScript 접근을 허용합니다.
    const shadowRoot = this.attachShadow({ mode: 'open' });

    // 메시지를 담을 요소를 생성합니다.
    const messageSpan = document.createElement('span');
    messageSpan.textContent = '안녕하세요, 웹 컴포넌트!';
    messageSpan.style.cssText = 'font-family: sans-serif; color: #333; padding: 10px; border: 1px solid #ccc; border-radius: 5px; display: inline-block;';

    // 섀도 DOM에 요소를 추가합니다.
    shadowRoot.appendChild(messageSpan);
  }
}

// 'greeting-card'라는 이름으로 사용자 정의 요소를 등록합니다.
// HTML에서 <greeting-card> 태그로 사용할 수 있습니다.
customElements.define('greeting-card', GreetingCardElement);

2.2. 컴포넌트 생명주기 훅

웹 컴포넌트는 특정 시점에 호출되는 여러 생명주기 훅(Lifecycle Hooks)을 제공하여 요소의 상태 변화에 따라 로직을 실행할 수 있습니다.

  • connectedCallback(): 사용자 정의 요소가 처음 문서 DOM에 삽입될 때 호출됩니다. (Vue의 mounted와 유사)
  • disconnectedCallback(): 사용자 정의 요소가 문서 DOM에서 제거될 때 호출됩니다. (Vue의 unmounted와 유사)
  • attributeChangedCallback(name, oldValue, newValue): static observedAttributes 배열에 정의된 속성이 추가, 수정, 제거 또는 대체될 때 호출됩니다.
class InteractiveWidget extends HTMLElement {
  // 변경을 감지할 속성들을 정의합니다.
  static observedAttributes = ['active'];

  constructor() {
    super();
    // 섀도 DOM 설정 (옵션)
    const shadowRoot = this.attachShadow({ mode: 'open' });
    const initialText = document.createElement('p');
    initialText.textContent = '위젯이 초기화되었습니다.';
    shadowRoot.appendChild(initialText);
  }

  // 요소가 DOM에 추가될 때 호출됩니다.
  connectedCallback() {
    console.log('InteractiveWidget: DOM에 연결됨');
    if (this.shadowRoot) {
        this.shadowRoot.querySelector('p').textContent = `위젯이 활성화되었습니다. 현재 상태: ${this.getAttribute('active') || 'false'}`;
    }
  }

  // 요소가 DOM에서 제거될 때 호출됩니다.
  disconnectedCallback() {
    console.log('InteractiveWidget: DOM에서 연결 해제됨');
  }

  // observedAttributes에 정의된 속성이 변경될 때 호출됩니다.
  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue !== newValue) {
      console.log(`InteractiveWidget: 속성 '${name}'이(가) '${oldValue}'에서 '${newValue}'로 변경됨`);
      if (name === 'active' && this.shadowRoot) {
        this.shadowRoot.querySelector('p').textContent = `위젯 상태: ${newValue === 'true' ? '활성' : '비활성'}`;
      }
    }
  }
}

customElements.define('interactive-widget', InteractiveWidget);

2.3. 사용자 정의 이벤트

웹 컴포넌트 내부에서 외부와의 통신을 위해 사용자 정의 이벤트를 발생시킬 수 있습니다. CustomEvent 클래스를 사용하여 이벤트를 생성하고 dispatchEvent() 메서드로 트리거합니다.

class DataInputComponent extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: 'open' });
    const button = document.createElement('button');
    button.textContent = '데이터 선택';
    button.style.cssText = 'padding: 8px 16px; background-color: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer;';
    button.onclick = () => this.triggerSelection();
    shadowRoot.appendChild(button);
  }

  // 사용자 정의 이벤트를 트리거하는 메서드
  triggerSelection() {
    // 특정 데이터를 담아 이벤트를 생성합니다.
    const selectionEvent = new CustomEvent('itemSelected', {
      detail: { itemId: 'ABC-123', itemName: '선택된 데이터 항목' },
      bubbles: true,   // 이벤트가 DOM 트리를 통해 버블링될지 여부
      composed: true,  // 섀도 DOM 경계를 넘어갈 수 있는지 여부
    });

    // 이벤트를 디스패치합니다.
    this.dispatchEvent(selectionEvent);
    console.log('\'itemSelected\' 이벤트가 발생했습니다.');
  }
}

customElements.define('data-input-component', DataInputComponent);

외부 코드에서 사용자 정의 이벤트 리스닝:

const dataInputEl = document.querySelector('data-input-component');

if (dataInputEl) {
  dataInputEl.addEventListener('itemSelected', (event) => {
    console.log('외부에서 \'itemSelected\' 이벤트를 수신했습니다:', event.detail);
    // 출력 예: { itemId: "ABC-123", itemName: "선택된 데이터 항목" }
  });
}

2.4. 속성 바인딩

static observedAttributes를 통해 컴포넌트가 관찰할 속성들을 정의하고, attributeChangedCallback을 통해 해당 속성의 변경을 감지하고 반응할 수 있습니다. 이를 통해 외부에서 전달된 데이터를 기반으로 컴포넌트의 상태를 업데이트할 수 있습니다.

class DynamicBox extends HTMLElement {
  // 관찰할 속성 목록을 정의합니다. HTML 속성 이름은 케밥 케이스(kebab-case)를 따릅니다.
  static observedAttributes = ['box-width', 'box-height', 'background-color', 'label-text'];

  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: 'open' });
    const container = document.createElement('div');
    container.style.cssText = 'display: flex; justify-content: center; align-items: center; border: 1px solid #ddd; border-radius: 4px; transition: all 0.3s ease; font-family: sans-serif;';
    const content = document.createElement('p');
    content.textContent = '초기 박스 내용';
    container.appendChild(content);
    shadowRoot.appendChild(container);

    this.renderContent(); // 초기 렌더링을 수행합니다.
  }

  // 관찰 대상 속성이 변경될 때 호출됩니다.
  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue !== newValue) {
      console.log(`DynamicBox: 속성 '${name}'이(가) '${oldValue}'에서 '${newValue}'로 변경됨.`);
      this.renderContent(); // 속성 변경 시 내용을 다시 렌더링합니다.
    }
  }

  renderContent() {
    const box = this.shadowRoot?.querySelector('div');
    const text = this.shadowRoot?.querySelector('p');
    if (box && text) {
      // 속성 값을 가져오고, 없으면 기본값을 사용합니다.
      const width = this.getAttribute('box-width') || '150';
      const height = this.getAttribute('box-height') || '100';
      const bgColor = this.getAttribute('background-color') || 'lightgray';
      const label = this.getAttribute('label-text') || '기본 텍스트';

      box.style.width = `${width}px`;
      box.style.height = `${height}px`;
      box.style.backgroundColor = bgColor;
      text.textContent = `${label} (가로: ${width}px, 세로: ${height}px)`;
    }
  }
}

customElements.define('dynamic-box', DynamicBox);

HTML 및 스크립트에서 사용 예시:

<dynamic-box id="myBox" box-width="200" box-height="150" background-color="lightcoral" label-text="첫 번째 박스"></dynamic-box>
<button id="updateBoxBtn">박스 속성 변경</button>

<script>
  const myBox = document.getElementById('myBox');
  const updateBtn = document.getElementById('updateBoxBtn');

  if (updateBtn) {
    updateBtn.onclick = function() {
      // 속성 변경을 통해 컴포넌트의 모습을 업데이트합니다.
      myBox.setAttribute('box-width', '280');
      myBox.setAttribute('box-height', '180');
      myBox.setAttribute('background-color', 'mediumseagreen');
      myBox.setAttribute('label-text', '변경됨!');
    };
  }
</script>

2.5. 웹 컴포넌트 스타일 적용

섀도 DOM은 컴포넌트의 스타일을 외부와 격리합니다. 스타일을 적용하는 두 가지 주요 방법이 있습니다.

2.5.1. 프로그래밍 방식 (adoptedStyleSheets)

CSSStyleSheet 객체를 생성하여 스타일을 정의하고 섀도 루트의 adoptedStyleSheets 속성에 할당합니다. 이는 재사용성이 뛰어나고 성능 면에서도 효율적입니다.

<div id="info-display-host"></div>

<script>
  // 1. 새로운 CSSStyleSheet 객체를 생성합니다.
  const styleSheet = new CSSStyleSheet();
  // 2. replaceSync() 또는 replace() 메서드로 스타일 규칙을 정의합니다.
  styleSheet.replaceSync('p { font-size: 18px; color: purple; border-bottom: 2px dashed blue; padding-bottom: 5px; font-family: sans-serif; }');

  // 3. 섀도 DOM을 생성하고 CSSStyleSheet를 adoptedStyleSheets에 할당합니다.
  const infoHost = document.getElementById('info-display-host');
  const shadowRootForInfo = infoHost.attachShadow({ mode: 'open' });
  shadowRootForInfo.adoptedStyleSheets = [styleSheet]; // 여기에 스타일시트를 적용합니다.

  // 4. 섀도 DOM 내부에 요소를 추가합니다.
  const messageParagraph = document.createElement('p');
  messageParagraph.textContent = "섀도 DOM 내부의 정보 메시지";
  shadowRootForInfo.appendChild(messageParagraph);

  // 섀도 DOM 외부에 있는 요소는 영향을 받지 않습니다. (예시)
  const externalParagraph = document.createElement('p');
  externalParagraph.textContent = "이것은 섀도 DOM 외부에 있습니다.";
  document.body.appendChild(externalParagraph);
</script>
2.5.2. 선언적 방식 (<template> 내부 <style>)

<template> 요소 내부에 <style> 태그를 포함하여 스타일을 정의할 수 있습니다. 이 스타일은 템플릿이 섀도 DOM에 추가될 때 해당 섀도 DOM에만 적용됩니다.

<template id="styled-component-template">
  <style>
    /* 이 스타일은 섀도 DOM 내부에만 적용됩니다. */
    div {
      background-color: lightblue;
      padding: 15px;
      border-radius: 8px;
      border: 1px solid #a0dfff;
      text-align: center;
      font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    }
    p {
      margin: 0;
      color: #333;
      font-weight: bold;
    }
  </style>
  <div>
    <p>템플릿으로 스타일이 적용된 콘텐츠</p>
  </div>
</template>

<div id="component-container"></div>
<p>템플릿 외부에 있는 일반 문단</p>

<script>
  const container = document.getElementById('component-container');
  const shadowRootForContainer = container.attachShadow({ mode: 'open' });
  const templateContent = document.getElementById('styled-component-template');

  // 템플릿의 내용을 복제하여 섀도 DOM에 추가합니다.
  if (templateContent && templateContent instanceof HTMLTemplateElement) {
    shadowRootForContainer.appendChild(templateContent.content.cloneNode(true));
  }
</script>

3. Vue를 사용하여 웹 컴포넌트 구축하기

Vue는 defineCustomElement 메서드를 제공하여 Vue 컴포넌트를 표준 웹 컴포넌트로 쉽게 변환할 수 있도록 지원합니다. 이 메서드는 일반적인 Vue 컴포넌트 옵션(props, emits, template 등)을 그대로 받아 웹 컴포넌트 인스턴스를 생성합니다.

import { defineCustomElement } from 'vue';

const VueComponentAsWebComponent = defineCustomElement({
  // Vue 컴포넌트와 동일한 옵션을 사용합니다.
  props: {
    message: String,
    count: {
      type: Number,
      default: 0
    }
  },
  emits: ['custom-action'], // Vue 이벤트는 웹 컴포넌트의 CustomEvent로 변환됩니다.
  template: `
    <div class="vue-wc-wrapper">
      <p>{{ message || '기본 메시지' }}</p>
      <button @click="incrementCount">카운트: {{ count }}</button>
    </div>
  `,
  // defineCustomElement의 고유한 옵션: 섀도 루트에 주입될 CSS
  styles: [
    `/* Vue 컴포넌트의 인라인 CSS */
    .vue-wc-wrapper {
      background-color: #f0f8ff;
      border: 1px solid #add8e6;
      padding: 10px;
      border-radius: 5px;
      text-align: center;
      font-family: 'Segoe UI', sans-serif;
      max-width: 250px;
      margin: 10px auto;
    }
    button {
      background-color: #4CAF50;
      color: white;
      border: none;
      padding: 8px 15px;
      text-align: center;
      text-decoration: none;
      display: inline-block;
      font-size: 14px;
      margin-top: 10px;
      cursor: pointer;
      border-radius: 4px;
    }`
  ],
  methods: {
    incrementCount() {
      // 'custom-action' 이벤트를 발생시키고 데이터를 전달합니다.
      // 이 데이터는 웹 컴포넌트 이벤트의 detail 속성에 담깁니다.
      this.$emit('custom-action', this.count + 1);
    }
  }
});

// 'vue-widget'이라는 이름으로 사용자 정의 요소를 등록합니다.
// 등록 이후, 해당 페이지의 <vue-widget> 태그는 이 컴포넌트로 업그레이드됩니다.
customElements.define('vue-widget', VueComponentAsWebComponent);

// 등록 후 프로그래밍 방식으로 인스턴스화할 수도 있습니다.
// document.body.appendChild(
//   new VueComponentAsWebComponent({
//     message: 'Hello from Vue Web Component!',
//     count: 5
//   })
// );

Vue의 defineCustomElement는 싱글 파일 컴포넌트(SFC, .vue 파일)와도 잘 통합됩니다. 특히, 컴포넌트 파일 이름이 .ce.vue로 끝나는 경우, 해당 SFC의 <style> 태그 내용은 CSS 문자열로 인라인 처리되어 컴포넌트의 styles 옵션으로 노출됩니다. defineCustomElement는 이 스타일을 추출하여 컴포넌트 초기화 시 섀도 DOM에 자동으로 주입합니다. 이 방식은 Vue 개발자가 익숙한 방식으로 컴포넌트를 작성하면서도 프레임워크에 독립적인 웹 컴포넌트를 쉽게 생성할 수 있도록 돕습니다.

태그: WebComponents CustomElements ShadowDOM HTMLElement JavaScript

7월 19일 18:54에 게시됨