이 글은 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);
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>
이렇게 정의한 템플릿은 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의 특성 또한 매우 흥미로웠다. 이를 통해 웹 컴포넌트의 기본 개념을 이해하고, 그리고 직접 구현도 해 보았는데, 상당히 유익했던 경험이라 생각한다.