웹 컴포넌트 튜토리얼

웹 컴포넌트의 기본 개념과 사용법을 알아보고, 이를 이용해 커스텀 엘리먼트를 만들어 보자

Table of Contents

이 글은 MDN 웹 컴포넌트 튜토리얼을 많은 부분 참고해 작성한 글이다. 바로 실행할 수 있는 예제와 함께 조금 더 친절하고 이해하기 쉽도록 설명하고자 했다.

TL;DR

  • 웹 컴포넌트는 재사용할 수 있는 사용자 인터페이스 요소를 만드는 기술이다.
  • 웹 컴포넌트는 커스텀 엘리먼트Shadow DOM을 이용해 구현된다.
  • 커스텀 엘리먼트는 HTML 태그처럼 사용할 수 있는 커스텀 DOM 엘리먼트이다.
  • Shadow DOM은 DOM과 CSS를 캡슐화하는 기술이다.

커스텀 엘리먼트 만들어 보기

웹 컴포넌트는 재사용할 수 있는 커스텀 엘리먼트를 만들 수 있게 해준다. 커스텀 엘리먼트는 일반적인 HTML 태그처럼, 또는 JavaScript을 이용해 사용할 수 있다. 이는 CustomElementRegistry.define 메서드를 이용하는데, window.customElements 객체를 통해 접근할 수 있다.

// customElements.define() 으로도 접근이 가능
window.customElements.define(/* ... */);

이 메서드는 인자 세 개를 전달받아 커스텀 엘리먼트를 등록(Registry)한다:

  • name: 커스텀 엘리먼트 이름이며, 커스텀 엘리먼트 이름 규칙을 따라야 한다. 가령 반드시 하이픈(-)을 포함해야 한다.
  • constructor: 커스텀 엘리먼트 행동을 정의하는 클래스. 이 클래스는 HTMLElement를 상속받아야 한다.
  • options: 커스텀 엘리먼트 기능을 확장하는 객체. 현재(2024.03)는 extends 옵션만을 지원한다. 커스텀 엘리먼트가 상속받을 빌트인 HTML 엘리먼트를 확장할 수 있다.

가령 다음과 같이 WordCount 라는 커스텀 엘리먼트 정의가 가능하다.

class WordCount extends HTMLParagraphElement {
  constructor() {
    super(); // 반드시 호출

    // 커스텀 엘리먼트 기능
  }
}

customElements.define('word-count', WordCount, { extends: 'p' });

등록 이후에는 WordCount 커스텀 엘리먼트를 사용할 수 있다.

<word-count></word-count>

<!-- or -->

<p is="word-count"></p>
document.createElement('word-count');

// or

document.createElement('p', { is: 'word-count' });

이 외에도 커스텀 엘리먼트 생성, 제거, 애트리뷰트 변경 등 이벤트를 감지할 수 있는 커스텀 엘리먼트 라이프사이클 콜백이 있다. 자세한 내용은 아래에서 다루도록 한다.

예제를 통해 알아보기

여기서는 다음 기능을 수행하는 커스텀 엘리먼트를 만들어 보도록 한다. 이름은 popup-info로 하자.

  • 이미지 아이콘과 텍스트로 구성
  • 텍스트는 기본적으로 숨겨져 있고, 아이콘은 항상 보임
  • 아이콘에 마우스를 올리면 텍스트가 보임

어렵지 않다. HTMLElement를 상속받는 PopupInfo 클래스를 만들고, constructor에 필요한 기능을 구현하면 된다. 항상 super()를 호출해야 한다는 점을 잊지 말자.

class PopupInfo extends HTMLElement {
  constructor() {
    super();

    // Shadow DOM 생성
    this.attachShadow({ mode: 'open' });

    // 아이콘 및 텍스트 엘리먼트 생성
    const wrapper = document.createElement('div');
    wrapper.classList.add('wrapper');

    const icon = wrapper.appendChild(document.createElement('span'));
    icon.classList.add('icon');
    icon.addEventListener('click', () => wrapper.classList.toggle('popup'));

    const img = icon.appendChild(document.createElement('img'));
    img.src = this.getAttribute('img') ?? 'https://placeholder.com/100x100';

    const info = wrapper.appendChild(document.createElement('span'));
    info.classList.add('info');
    info.textContent = this.getAttribute('data-info') ?? '';

    const style = document.createElement('style');
    style.textContent = `
      .wrapper.popup .info { display: block; }
      .info { display: none; }
    `;

    // 엘리먼트를 Shadow DOM에 추가
    this.shadowRoot.append(style, wrapper);
  }
}

// 커스텀 엘리먼트 등록
customElements.define('popup-info', PopupInfo);

💡 Shadow DOM(Shadow Root)은 CSS와 DOM을 캡슐화할 수 있는 독립된 DOM 트리라고 생각하면 된다. 여기서는 attachShadow 메서드를 통해 Shadow DOM을 생성하고, shadowRoot 프로퍼티를 통해 Shadow DOM에 접근할 수 있다는 점만 알아두자. 자세한 내용은 아래에서 다루도록 한다.

constructor 내부에서 this.getAttribute를 통해 애트리뷰트 값을 가져올 수 있다. this.hasAttribute를 통해 애트리뷰트 존재 여부를 확인할 수도 있다. 이를 통해 img 애트리뷰트가 존재하지 않을 경우 기본 이미지를 사용하도록 했다.

스타일은 style 엘리먼트를 생성해 textContent에 CSS 문자열을 넣어준 뒤 Shadow DOM에 추가하는 방식을 이용했다. 스타일은 Shadow DOM 내부에서만 적용되며, 외부에는 영향을 주지 않는다.

이제 popup-info 커스텀 엘리먼트를 사용할 수 있다.

<popup-info
  img="https://placeholder.com/300x300"
  data-info="Popup info 1"
></popup-info>

동작하는 예시 확인하기

extends 옵션을 이용한 빌트인 엘리먼트 확장

extends 옵션을 이용하면 빌트인 HTML 엘리먼트를 확장할 수 있다. 가령 extends: 'ul' 옵션을 사용해 만들어진 커스텀 엘리먼트는 <ul> 엘리먼트 기능을 상속받는다.

// <ul> 엘리먼트를 확장하는 클래스는 HTMLUListElement를 상속받아야 한다.
class ExpandingList extends HTMLUListElement {
  constructor() {
    super();

    // nothing
  }
}

customElements.define('expanding-list', ExpandingList, { extends: 'ul' });

ExpandingList 커스텀 엘리먼트에 대한 상세 기능을 작성하지 않았지만, <ul> 엘리먼트와 동일하게 동작한다.

<expanding-list>
  <li>Item 1</li>
  <li>Item 2</li>
  <li>Item 3</li>
</expanding-list>

라이프사이클 콜백

커스텀 엘리먼트는 라이프사이클 콜백을 이용해 엘리먼트 생성, 제거, 애트리뷰트 변경 등에 대한 이벤트를 감지할 수 있다:

  • connectedCallback: 커스텀 엘리먼트가 DOM에 추가(연결)될 때 호출
  • disconnectedCallback: 커스텀 엘리먼트가 DOM에서 제거(해제)될 때 호출
  • adoptedCallback: 커스텀 엘리먼트가 다른 DOM으로 이동될 때 호출
  • attributeChangedCallback: 커스텀 엘리먼트 애트리뷰트가 추가, 제거, 변경될 때 호출

connectedCallback은 몇 가지 추가 사항이 존재한다:

  • 모든 DOM이 파싱되기 전에 호출될 수 있음
  • DOM 노드가 이동될 때도 호출됨
  • DOM에서 제거될 때도 호출될 수 있으며, 이는 isConnected 프로퍼티를 이용해 판별이 가능

attributeChangedCallback을 위해서는 문자열 배열을 반환하는 static get observedAttributes() 메서드를 통해 감지할 애트리뷰트를 지정해야 한다.

라이프사이클 콜백을 구현해 보면 다음과 같다.

class CustomSquare extends HTMLElement {
  constructor() {
    super();

    this.shadow = this.attachShadow({ mode: 'open' });

    const div = document.createElement('div');
    this.shadow.appendChild(div);

    console.log('Create Custom square element');
  }

  // DOM에 추가
  connectedCallback() {
    console.log(
      'Custom square element added to page',
      this.parentElement.tagName,
    );
  }

  // DOM에서 제거
  disconnectedCallback() {
    console.log('Custom square element removed from page', {
      isConnected: this.isConnected,
    });
  }

  // DOM 이동
  adoptedCallback() {
    console.log(
      'Custom square element moved to new page',
      this.parentElement.tagName,
    );
  }

  // 애트리뷰트 변경
  attributeChangedCallback(name, oldValue, newValue) {
    console.log('Custom square element attributes changed', name);
  }

  static get observedAttributes() {
    // 애트리뷰트 이름을 담은 배열을 반환
    return ['c', 'l']; // 'c'와 'l' 애트리뷰트 변경을 감지
  }
}

동작하는 예시 확인하기

Shadow DOM

중요한 웹 컴포넌트 개념 중 하나는 캡슐화(Encapsulation)다. Shadow DOM API는 이에 초점을 맞추어 DOM과 CSS를 캡슐화하는 방법을 제공한다. 여기서는 Shadow DOM 기본 개념과 사용법을 알아보도록 한다.

Shadow DOM이란?

<!doctype html>

<html>
  <head>
    <meta charset="utf-8" />
    <title>Simple DOM</title>
  </head>

  <body>
    <section>
      <img src="dinosaur.png" alt="T-Rex" />
      <p>
        Here we will add a link to the
        <a href="https://www.mozilla.org/">Mozilla</a>
      </p>
    </section>
  </body>
</html>

위 HTML 코드는 다음과 같이 트리 형태로 나타낼 수 있다.

html tree example HTML DOM 트리 예시 (출처: MDN)

Shadow DOM 역시 다르지 않다. 그저 하위 DOM 트리에 불과하다. 다만 Shadow DOM은 외부에서 접근할 수 없는 독립된 트리라는 점이 다르다. 즉, DOM과 CSS가 캡슐화된다.

shadow dom 그림으로 표현한 Shadow DOM (출처: MDN)

  • Shadow host: 일반적인 DOM 노드처럼 보이는, Shadow DOM 연결 지점
  • Shadow tree: Shadow DOM 내부 DOM 트리
  • Shadow root: Shadow DOM 루트 노드
  • Shadow boundary: Shadow DOM 시작 노드부터 끝 노드까지 경계

Shadow DOM 사용해 보기

앞서 attachShadow 메서드를 통해 Shadow DOM을 생성해 보았다. 이 메서드는 mode 옵션을 통해 Shadow DOM 접근 가능 여부를 제어할 수 있다:

  • open: Shadow DOM 참조를 외부에서 접근 가능
  • closed: Shadow DOM 참조는 외부에서 접근 불가능

Shadow DOM 참조를 얻을 때는 shadowRoot 프로퍼티를 이용하는데, 모드에 따라 서로 다른 값을 갖는다.

elementOpen.attachShadow({ mode: 'open' });
console.log(elementOpen.shadowRoot); // #shadow-root (open)

elementClosed.attachShadow({ mode: 'closed' });
console.log(elementClosed.shadowRoot); // null

이러한 특성으로 인해 PopupInfo 커스텀 엘리먼트에서 attachShadow 메서드 호출 시 mode 옵션을 open으로 설정했었다. 만약 closed로 설정했다면, shadowRoot 프로퍼티를 통해 Shadow DOM에 접근할 수 없어 에러가 발생한다.

class PopupInfo extends HTMLElement {
  constructor() {
    super();

    // closed 모드로 Shadow DOM 생성
    this.attachShadow({ mode: 'closed' });

    // ...

    this.shadowRoot.append(style, wrapper);
  }
}
customElements.define('popup-info', PopupInfo);

document.createElement('popup-info'); // TypeError: Cannot read properties of null (reading 'append')

동작하는 예시 확인하기

closed 모드 동작에 유의하자. shadowRoot 프로퍼티를 이용해 Shadow DOM 접근이 불가능할 뿐이다. 자세한 내용은 이어지는 예제를 통해 확인해보자.

Shadow DOM을 이용해 캡슐화된 컴포넌트 만들어 보기

innerHTML 프로퍼티를 이용해 Shadow DOM에 HTML을 추가할 수 있다. 이를 이용해 PopupInfo 커스텀 엘리먼트를 다음과 같이 구현할 수 있다:

class PopupInfo extends HTMLElement {
  #html = `
    <div class="wrapper">
      <div class="icon">
        <img src="${this.img}" alt="info icon">
      </div>
      <span class="info">
        ${this.info}
      </span>
    </div>

    <style>
    .wrapper.popup .info { display: block; }
    .info { display: none; }
    </style>
  `;

  constructor() {
    super();

    const shadow = this.attachShadow({ mode: 'closed' });
    shadow.innerHTML = this.#html;

    const wrapper = shadow.querySelector('.wrapper');
    wrapper.addEventListener('click', () => wrapper.classList.toggle('popup'));
  }

  get img() {
    return this.getAttribute('img') ?? 'https://placeholder.com/100x100';
  }

  get info() {
    return this.getAttribute('data-info') ?? '';
  }
}

동작하는 예시 확인하기

여기서는 closed 모드로 생성했으나 innerHTML 프로퍼티를 이용해 Shadow DOM에 HTML을 추가했다. 어떻게 가능했을까? 앞서 언급했듯이 closed 모드는 shadowRoot 프로퍼티를 이용해 Shadow DOM에 접근할 수 없을 뿐이지, Shadow DOM 접근 자체가 불가능하지는 않기 때문이다. 따라서 Shadow DOM 내부 HTML을 외부에서 수정할 수 있었다.

Template 그리고 Slot 태그

재사용할 수 있는 컴포넌트를 만드는 방법은 여러 가지가 있다. 여기서는 그들 중 <template><slot>을 소개한다.

Template 태그

<template>은 HTML 요소 일부를 정의해두고 재사용할 수 있게 해준다.

<template id="my-paragraph">
  <p>My Paragraph</p>

  <style>
    p {
      font-size: 30px;
    }
  </style>
</template>

💡 Template 내부에서 작성된 스타일은 전역 스타일로 적용됨을 유의하자.

템플릿은 content 프로퍼티를 통해 접근할 수 있다. 이 프로퍼티는 DocumentFragment를 반환하는데, 이를 통해 템플릿 내부 DOM에 접근할 수 있다.

const template = document.querySelector('#my-paragraph');
const templateContent = template.content;

console.log(templateContent); // #document-fragment
document.body.appendChild(templateContent);

동작하는 예시 확인하기

웹 컴포넌트와 함께 사용할 때는 content 프로퍼티를 이용해 템플릿 내부 DOM에 접근한 뒤, 이를 Shadow DOM에 추가하면 된다.

class MyParagraph extends HTMLElement {
  constructor() {
    super();

    const template = document.getElementById('my-paragraph');

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

    this.shadowRoot.appendChild(template.content.cloneNode(true));
  }
}

customElements.define('my-paragraph', MyParagraph);

동작하는 예시 확인하기

참고로 template 태그를 여러 곳에서 사용할 때는 cloneNode 메서드를 이용해야 하는데, 이는 appendChild 메서드가 DOM을 이동시키는 동작을 수행하기 때문이다. 자세한 내용은 MDN appendChild 문서를 참고.

Slot 태그

<slot>은 템플릿 내 특정 위치에 콘텐츠를 삽입할 수 있게 해준다.

<template id="my-paragraph">
  <p><slot></slot></p>

  <style>
    p {
      font-size: 30px;
    }
  </style>
</template>

my-paragraph 템플릿은 다음과 같이 사용할 수 있다.

<my-paragraph>Hello from slots!</my-paragraph>

슬롯 여럿 필요하다면 name 애트리뷰트를 이용하자.

<template id="my-paragraph">
  <p class="title"><slot name="title"></slot></p>
  <p><slot></slot></p>

  <style>
    p {
      font-size: 30px;
    }
    p.title {
      font-size: 50px;
    }
  </style>
</template>
<my-paragraph>
  <span slot="title">Title</span>
  Hello from slots!
</my-paragraph>

슬롯에는 기본값을 지정할 수도 있다. <slot> 태그 내부에 기본값을 작성하면 된다.

<template>
  <p><slot>default slot contents</slot></p>
</template>

동작하는 예시 확인하기

마치며

이 글에서는 웹 컴포넌트 기본 개념과 사용법을 알아보았다. 이를 통해 재사용할 수 있는 컴포넌트를 만들 수 있게 되었고, 코드 재사용성과 유지보수성을 높일 수 있었다. 또한 Shadow DOM은 DOM과 CSS를 캡슐화를 통해 컴포넌트 독립성을 보장해주는데, 이를 통해 컴포넌트 간 충돌을 방지할 수 있다.

개인적으로는 Vue나 React를 통해 무의식적으로 사용하던 컴포넌트를 외부 라이브러리 없이 JavaScript 만으로도 구현이 가능하다는 점이 놀라웠다. Shadow DOM 특성 또한 흥미로웠다. Lit이나 Stencil 같은 라이브러리를 이용하면 더욱 쉽게 웹 컴포넌트를 구현할 수 있다고 한다. 기회가 된다면 한 번 사용해보고 싶다.