웹 컴포넌트 튜토리얼

Nov 10, 2023·

8 min read

이 글은 MDN의 웹 컴포넌트 튜토리얼을 많은 부분 참고해 작성한 글이다. 다만 내 기준에서 조금 더 친절하고 이해하기 쉽도록, 그리고 바로 실행할 수 있는 예제를 통해 설명하도록 노력했다.

웹 컴포넌트에 대한 기본적인 개념과 사용법을 알아보고, 이를 이용해 커스텀 엘리먼트를 만들어 보는 것을 목표로 한다.

TL;DR

  • 웹 컴포넌트는 재사용할 수 있는 사용자 인터페이스 요소를 만드는 기술이다.

  • 웹 컴포넌트는 커스텀 엘리먼트Shadow DOM을 이용해 구현된다.

  • 커스텀 엘리먼트는 HTML 태그처럼 사용할 수 있는 커스텀 DOM 엘리먼트이다.

  • Shadow DOM은 DOM과 CSS를 캡슐화하는 기술이다.

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

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

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

  • name: 커스텀 엘리먼트의 이름이며, 커스텀 엘리먼트 이름 규칙을 따라야 한다. 가령 반드시 하이픈(-)을 포함해야 한다.

  • constructor: 커스텀 엘리먼트의 행동을 정의하는 클래스. 이 클래스는 HTMLElement를 상속받아야 한다.

  • options: 커스텀 엘리먼트의 기능을 확장하는 객체. 현재는 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)은 아래에서 다룬다. 여기서는 attachShadow 메서드를 통해 Shadow DOM을 생성하고, shadowRoot 프로퍼티를 통해 Shadow DOM에 접근할 수 있다는 것만 알아두자. CSS와 DOM을 캡슐화할 수 있는 하나의 독립된 DOM 트리라고 생각하면 된다.

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

스타일은 style 엘리먼트를 생성해 textContent에 CSS 문자열을 넣어준 뒤, 이를 Shadow DOM에 추가하는 방식을 이용했다. 이렇게 추가된 스타일은 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>

라이프사이클 콜백

커스텀 엘리먼트는 라이프사이클 콜백을 이용해 엘리먼트의 생성, 제거, 애트리뷰트 변경 등의 이벤트를 감지할 수 있다. 이를 통해 엘리먼트의 상태를 변경하거나, 엘리먼트가 DOM에 추가되거나, 혹은 제거될 때 필요한 작업을 수행할 수 있다. 커스텀 엘리먼트의 라이프사이클 콜백은 다음과 같다:

  • 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

복잡하게 구현된 컴포넌트 내부를 어떻게 잘 유지할 수 있을까? 다른 컴포넌트와의 충돌이나, 컴포넌트 내부의 DOM이 외부에서 접근되는 것을 어떻게 막을 수 있을까? 어떻게 해야 컴포넌트의 스타일이 외부에 영향을 주지 않을까? 그러면서도 어떻게 해야 확장성을 유지할 수 있을까?

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

Shadow DOM이란?

<!DOCTYPE html>

<html>
  <head>
    <meta charste="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 코드는 다음과 같이 트리 형태로 나타낼 수 있다는 것은 이미 알고 있을 것이다.

Shadow DOM 역시 크게 다르지 않다. 그저 DOM 트리의 하위 트리에 불과하다. 다만, Shadow DOM은 외부에서 접근할 수 없는 독립된 DOM 트리라는 점이 다른데, 이를 통해 DOM과 CSS를 캡슐화할 수 있게 된다.

  • 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 자체에 접근할 수 없다는 것은 아니다. 자세한 내용은 이어지는 예제에서 다루도록 한다.

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') ?? '';
  }
}

동작하는 예시 확인하기

Shadow DOM은 closed 모드로 생성했으나, innerHTML 프로퍼티를 이용해 Shadow DOM에 HTML을 추가했다. 이는 앞서 언급했듯이 closed 모드는 shadowRoot 프로퍼티를 이용해 Shadow DOM에 접근할 수 없다는 것을 의미하는 것이지, Shadow DOM 자체에 접근할 수 없다는 것은 아니기 때문이다. 이를 통해 Shadow DOM 내부의 HTML을 외부에서 쉽게 수정할 수 있었다.

참고로, 처음의 PopupInfo 예제에서도 언급했지만, Shadow DOM 내부에 정의된 스타일은 Shadow DOM 내부에서만 적용된다. 이를 통해 컴포넌트의 스타일이 외부에 영향을 주지 않도록 할 수 있다.

Template 그리고 Slot 태그

재사용할 수 있는 컴포넌트를 만드는 방법은 여러 가지가 있지만, 그중 하나는 <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>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의 특성 또한 매우 흥미로웠다. 이를 통해 웹 컴포넌트의 기본 개념을 이해하고, 그리고 직접 구현도 해 보았는데, 상당히 유익했던 경험이라 생각한다.