웹 컴포넌트 알아보기
웹 컴포넌트를 공부해 보자
Web Component API
SPA 프레임워크를 사용해 본 분들은 귀에 딱지가 앉도록 들어본 그 컴포넌트가 맞습니다. MDN 문서를 보면 “웹 컴포넌트는 그 기능을 나머지 코드로부터 캡슐화하여 재사용 가능한 커스텀 엘리먼트를 생성하고 웹 앱에서 활용할 수 있도록 해주는 다양한 기술들의 모음”이라고 쓰여 있습니다. SPA 라이브러리/프레임워크 없이 커스텀한 마크업을 재사용하는 것은 꽤나 불편한 일이고요. 웹 컴포넌트는 아래 세 가지 API로 이 문제를 해결합니다.
Shadow DOM API
CustomElement API
html
<template>
<slot>
엘리먼트
사실 Shadow DOM과 template/slot의 경우 웹 컴포넌트를 사용함에 있어 필수는 아니지만, 코드 재사용성과 유지보수의 측면에서는 사실상 필수인 것 같습니다. 왜 그런지 하나씩 알아보도록 하겠습니다.
Shadow DOM
Shadow DOM은 우리가 아는 통상적인 DOM 트리에 부착되는 숨겨진 DOM 트리입니다.

가운데의 Shadow DOM Tree는 DOM 노드의 일부인 schadow host에 부착됩니다.
Shadow boundary는 Shadow DOM Tree의 경계입니다.
Shadow DOM Tree의 root 노드를 Shadow root이라고 합니다.
이 트리를 통상적으로는 개발자 도구에서 이렇게 확인할 수 있습니다.

여기서는 body가 shadow host가 됩니다.#shadow-root
는 실제 HTML 태그는 아니고, shadow boundary를 형성해 외부 DOM으로부터 shadow dom을 캡슐화하는 역할을 합니다. 이 캡슐화 때문에 shadow dom 내부에는 스코프가 형성됩니다. 외부의 스크립트와 전역 스타일이 shadow dom 내부의 태그에 영향을 주지 못하게 되고, 내부의 스타일과 스크립트도 외부에 영향을 미치지 못하게 됩니다.

파란 색으로 강조된 라인의 style.css
에는 p 엘리먼트의 기본 여백을 없애는 전역 스타일이 작성되어 있습니다. 옅은 회색으로 강조된 라인은 현재 선택된 노드로, shadow dom 아래에 있는 <p> 엘리먼트입니다. 브라우저의 기본 여백이 그대로 남아있는 걸 보실 수 있어요.
구체적인 사용법과 API는 MDN에 한국어로 잘 번역되어 있습니다.
shadow DOM 사용하기 - Web API | MDN
https://developer.mozilla.org/ko/docs/Web/API/Web_components/Using_shadow_DOM
CustomElement API
CustomElement API는 개발자가 자신만의 HTML 요소를 커스텀할 수 있도록 하는 인터페이스입니다. 몇 가지 주요 사양이 있는데요,
customElements.define(tag-name, elementClass)
메서드를 사용해 정의tag-name는 케밥 케이스여야 하며, elementClass는 HTMLElement를 확장하는 자바스크립트 클래스
elementClass 내의 LifeCycle Callbacks
constructor()
- 인스턴스 생성 시connectedCallback()
- 엘리먼트가 DOM에 추가될 때disConnectedCallback()
- 엘리먼트가 DOM에서 제거될 때attributeChangedCallback(attrName, oldVal, newVal)
- 지정된 속성값이 변경될 때adoptedCallback()
- 엘리먼트가 다른 문서로 이동될 때
아래 코드는 제가 문서를 보고 작성해 본 커스텀 HTML 요소입니다.
customElements.define('custom-paragraph', class CustomParagraph extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({ mode: 'open' });
//Shadow Root 생성
const p = document.createElement('p');
p.setAttribute('class', 'paragraph');
p.textContent = this.textContent || '';
const style = document.createElement('style');
style.textContent = `
.paragraph {
color: red;
}
`;
shadowRoot.appendChild(style);
shadowRoot.appendChild(p);
this.textContent = '';
}
connectedCallback() {
console.log('paragraph mounted');
}
disconnectedCallback() {
console.log('paragraph unmounted');
}
});
저는 HTMLElement를 extend해서 작성했는데, 특정 HTMLElement를 extend할 수도 있습니다. 예를 들어 HTMLParagraphElement를 extend한다면 <p>
엘리먼트를 확장한 요소가 됩니다. 이렇게 만든 웹 컴포넌트는 기존처럼
<custom-paragraph>내용</custom-paragraph>
와 같이 사용하게 되는 것이 아니라,
<p is="custom-paragraph>내용</p>
처럼 is
attribute와 함께 사용하게 됩니다. 좀더 원형 마크업의 형태를 띠게 되는데요, 다만 이렇게 만든 웹 컴포넌트의 경우 is
attribute를 지원하지 않는 브라우저인 safari에서는 동작하지 않습니다.

세부 기능들은 MDN에 한글로 정말 잘 나와 있습니다.
사용자 정의 요소 사용하기 - Web API | MDN
https://developer.mozilla.org/ko/docs/Web/API/Web_components/Using_custom_elements
<template>, <slot>
Template
HTML에는 <template>
이라는 엘리먼트가 있습니다. 말 그대로 템플릿이라, 템플릿과 템플릿 내부에 있는 요소는 JS단에서 따로 컨트롤하기 전에는 화면에 렌더링되지 않습니다. Shadow DOM이 스타일에 대한 스코프를 형성한다는 점을 이용해 유용하게 사용할 수 있는데요, 아까 작성한 custom-paragraph에서 마크업과 스타일을 분리해 보겠습니다.
<template id="custom-paragraph">
<style>
p {
margin: 0;
padding: 0;
}
</style>
<p class="paragraph"></p>
</template>
이렇게 JS에서 마크업과 스타일만을 선언적으로 분리해
...
customElements.define(
'custom-paragraph',
class CustomParagraph extends HTMLElement {
constructor() {
super()
const template = document.getElementById(
'custom-paragraph'
) as HTMLTemplateElement
const contents = template.content.cloneNode(true)
const shadowRoot = this.attachShadow({ mode: 'open' })
shadowRoot.appendChild(contents)
}
connectedCallback() {
const paragraph = this.shadowRoot!.querySelector('.paragraph')
if (paragraph) {
paragraph.textContent = this.textContent || ''
}
this.textContent = ''
}
...
}
)
Shadow DOM 내부에서 template id만을 호출해 컴포넌트 정의와 생명주기에 따른 동작만을 작성하고,
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script src="./paragraph.js" defer></script>
</head>
<body>
<custom-paragraph>우와</custom-paragraph>
</body>
</html>
html에서 호출할 수 있습니다.

관심사를 분리할 수 있다는 점이 강력합니다. 굳이 document.createElement…
로 모든 엘리먼트를 JavaScript에서 만들지 않아도 되기 때문입니다. 웹 컴포넌트를 구성하기 위해 꼭 필요하지는 않지만 깔끔하게 코드를 작성하려면 필수적입니다.
Slot
커스텀 버튼 하나를 정의해 봅시다.
<template id="my-btn">
<button>
<slot name="icon"></slot>
<slot>Default Text</slot>
</button>
</template>
customElements.define(
'my-btn',
class MyBtn extends HTMLElement {
constructor() {
super()
const template = document.getElementById(
'my-button'
) as HTMLTemplateElement
const contents = template.content.cloneNode(true)
const shadowRoot = this.attachShadow({ mode: 'open' })
shadowRoot.appendChild(contents)
}
}
)
이렇게 정의된 버튼을 아래처럼 사용할 수 있는데요,
<html>
<!-- ... -->
<my-btn>
<span slot="icon">🚀</span>
<span>보내기!</span>
</my-btn>
<!-- ... -->
</html>


이런 식으로 브라우저에 렌더링됩니다. 개발자 도구에서는 이렇게 확인하실 수 있어요. 슬롯을 만들어 부모 노드에서 내려 준 값이나 요소를 간단하게 사용할 수 있게 되고, 없는 경우에는 기본 요소를 제공할 수 있습니다. 예시에서 보실 수 있듯 name
attribute가 없는 slot 엘리먼트는 slot
어트리뷰트가 없는 자식 노드에 대응되어 채워지게 됩니다. (텍스트 노드를 포함합니다) slot이 없었다면 귀찮은 attribute 규칙을 많이 채용하게 되거나, 내부적으로 귀찮은 자바스크립트 코드를 정말 많이 작성해야 했을 것입니다.
웹 컴포넌트의 의의
그러면 SPA 프레임워크가 참 많이 발전한 시대에 웹 컴포넌트가 가지는 의의는 뭘까요? 표준이나 프레임워크 독립성(framework agnostic)같은 말들도 있지만 저에게는 가벼움이 가장 큰 의의입니다. 빠른 인터넷 속도 덕분에 잊고 사는 것이지만 가벼운 자기소개 페이지를 만들 때 프레임워크나 라이브러리들은 배보다 배꼽이 큰 경우가 많습니다(react의 번들 사이즈는 6kb에 달합니다). 물론 SPA 프레임워크의 엄청난 생산성을 배제할 수는 없지만, 수많은 라이브러리나 번들에 의존하지 않으면서 재사용 가능한 마크업을 가지고 가벼운 웹 페이지를 구성할 수 있다는 점은 충분히 매력적입니다. 그런 점 때문에 학습을 시작한 만큼, 빠른 시일 내에 한 번은 사용해 보려고 합니다. 지금은 주류인 React 생태계에서 벗어나서 작업해야 하거나 웹 컴포넌트를 사용할 날이 올 수도 있지 않을까요?
혹시 여기 사용된 코드들을 보시고 싶으시다면 이 링크로 가시면 됩니다.
혹시나 해서 유튜브 웹 사이트를 살펴보니 웹 컴포넌트로 구현되어 있는 것 같습니다. 특별한 프레임워크를 사용하지 않은 것 같기도…?