웹 컴포넌트
더 많은 작업
- Web Components; 웹 컴포넌트
- 프레임워크 없이 브라우저 표준만으로 재사용 가능한 사용자 정의 HTML 요소를 만드는 기술 모음
웹 컴포넌트(Web Components)는 Custom Elements(사용자 정의 요소), Shadow DOM(섀도 DOM), HTML 템플릿(<template>, <slot>) 같은 브라우저 표준 API를 묶어 부르는 이름이다. 이 API들로 <my-button> 같은 새 태그를 정의하고, 그 내부 DOM과 CSS를 바깥 문서와 격리해 캡슐화할 수 있다. 결과물은 브라우저가 직접 이해하는 HTML 요소이므로 React, Vue, Angular 등 어떤 프레임워크 안에서도, 또는 프레임워크 없이도 쓸 수 있다.
명세는 별도 문서 하나가 아니라 WHATWG의 HTML 표준(Custom Elements, template, slot, 선언적 Shadow DOM)과 DOM 표준(Shadow DOM)에 나뉘어 들어 있다.[1][2] 여러 팀이 한 페이지를 나눠 만드는 디자인 시스템, 마이크로 프런트엔드, CMS에 끼워 넣는 위젯 등에서 많이 쓰인다.
| 기술 | 하는 일 | 핵심 API |
|---|---|---|
| Custom Elements | 새 태그 이름을 자바스크립트 클래스와 연결하고 생명주기 콜백을 받는다 | customElements.define(), class extends HTMLElement
|
| Shadow DOM | 요소 안에 바깥과 분리된 DOM 트리를 붙여 스타일과 마크업을 격리한다 | attachShadow(), :host, ::part()
|
| HTML 템플릿 | 렌더링되지 않는 마크업 조각을 두었다가 복제해 쓴다 | <template>, content.cloneNode(true)
|
| 슬롯 | 요소 사용자가 넣은 자식 콘텐츠를 Shadow DOM 안 원하는 자리에 끼워 보여 준다 | <slot>, slot="name"
|
| (폐기) HTML Imports | HTML 파일을 다른 HTML 문서로 불러오던 방식 | <link rel="import">
|
사용자 정의 요소는 두 종류다.[3]
- 자율 사용자 정의 요소(autonomous custom element):
HTMLElement를 상속해 처음부터 만든다.<user-card></user-card>처럼 쓴다. - 내장 요소 확장(customized built-in element):
HTMLButtonElement같은 기존 요소를 상속해 기능을 더한다.<button is="fancy-button">처럼is속성으로 쓴다. 기존 요소의 접근성과 폼 동작을 그대로 물려받는 장점이 있지만 Safari(WebKit)는 구현하지 않겠다는 입장이라 실무에서는 잘 쓰지 않는다.
요소 이름은 소문자로 시작하고 하이픈(-)을 반드시 포함해야 한다. 하이픈이 없는 이름은 앞으로 표준 HTML이 쓸 자리로 남겨 두기 위해서다. 한 번 정의한 이름은 같은 레지스트리에서 다시 정의할 수 없다.
| 콜백 | 호출 시점 |
|---|---|
constructor() |
요소가 만들어지거나 정의 전에 있던 요소가 업그레이드될 때. super()를 먼저 부르고, 이 시점에는 속성이나 자식을 읽거나 추가하면 안 된다
|
connectedCallback() |
문서에 연결될 때마다(옮길 때 여러 번 불릴 수 있다). 렌더링, 이벤트 연결, 데이터 요청을 보통 여기서 한다 |
disconnectedCallback() |
문서에서 떨어질 때. 타이머, 전역 이벤트 리스너, 옵저버를 정리한다 |
connectedMoveCallback() |
Element.moveBefore()로 상태를 유지한 채 옮길 때 연결·분리 콜백 대신 불린다(새 기능)
|
adoptedCallback() |
document.adoptNode() 등으로 다른 문서(iframe 등)로 옮겨질 때
|
attributeChangedCallback(name, old, new) |
static observedAttributes에 적은 속성이 추가·변경·삭제될 때
|
요소에서 element.attachShadow({mode: 'open'})를 부르면 요소(섀도 호스트, shadow host)에 섀도 루트(shadow root)가 붙고, 그 아래가 섀도 트리가 된다. 섀도 트리 안의 요소는 바깥의 document.querySelector()에 잡히지 않고, 바깥 CSS 선택자도 안으로 들어오지 않으며, 안쪽 CSS도 밖으로 새지 않는다. id 충돌도 없다.
mode: 'open'이면 바깥에서element.shadowRoot로 접근할 수 있고,'closed'면null이 돌아온다. closed는 보안 경계가 아니라 캡슐화 수준의 차이일 뿐이다. 같은 페이지의 스크립트는 여러 방법으로 우회할 수 있다.- 상속되는 CSS 속성(
color,font-family등)과 CSS 사용자 정의 속성(--brand-color)은 경계를 넘어 들어간다. 그래서 테마는 보통 CSS 변수로 전달한다. - 컴포넌트 작성자는
:host,:host(.active),::slotted()로 호스트와 슬롯 내용을 꾸미고,part="label"로 노출한 부분은 사용하는 쪽에서my-el::part(label)로 꾸밀 수 있다. - 안에서 발생한 이벤트는 경계를 넘을 때 대상이 호스트 요소로 바뀌어(retargeting) 보인다. 직접 만든 이벤트를 밖으로 내보내려면
new CustomEvent('change', {bubbles: true, composed: true})처럼composed를 켜야 한다. adoptedStyleSheets로CSSStyleSheet객체 하나를 여러 섀도 루트가 공유할 수 있어, 인스턴스마다<style>을 복제하지 않아도 된다.
<video>의 재생 버튼, <input type="range">의 막대처럼 브라우저 내장 요소도 오래전부터 내부적으로 섀도 DOM을 써 왔다. 웹 컴포넌트는 이 기능을 개발자에게 연 것이다.
템플릿 요소 <template> 안의 내용은 파싱만 되고 렌더링되지 않는다. 템플릿 속 스크립트는 실행되지 않고 이미지도 받지 않다가, template.content를 복제해 문서에 넣을 때 비로소 활성화된다. <slot>은 섀도 트리 안의 "구멍"이다. 호스트의 자식(light DOM)은 실제로 옮겨지지 않고 슬롯 자리에 그려지기만 한다(분배, distribution). 이름 없는 슬롯은 기본 슬롯이고, slot="title" 속성을 가진 자식은 <slot name="title"> 자리로 간다. 슬롯에 아무것도 들어오지 않으면 <slot> 태그 사이의 기본 내용이 보인다.
<template id="user-card-tpl">
<style>
:host { display: block; border: 1px solid #ccc; border-radius: 8px; padding: 12px; }
:host([highlight]) { border-color: var(--brand-color, orange); }
h3 { margin: 0 0 4px; } /* 바깥 문서의 h3 스타일에 영향 없음 */
</style>
<h3 part="name"><slot name="name">이름 없음</slot></h3>
<slot></slot>
<button>팔로우</button>
</template>
<user-card highlight>
<span slot="name">홍길동</span>
<p>백엔드 개발자</p>
</user-card>
<script>
class UserCard extends HTMLElement {
static observedAttributes = ['highlight'];
constructor() {
super();
const tpl = document.getElementById('user-card-tpl');
this.attachShadow({ mode: 'open' }).append(tpl.content.cloneNode(true));
}
connectedCallback() {
this.shadowRoot.querySelector('button').addEventListener('click', this.#onClick);
}
disconnectedCallback() {
this.shadowRoot.querySelector('button').removeEventListener('click', this.#onClick);
}
attributeChangedCallback(name, oldValue, newValue) {
console.log(`${name}: ${oldValue} -> ${newValue}`);
}
#onClick = () => {
this.dispatchEvent(new CustomEvent('follow', { bubbles: true, composed: true }));
};
}
customElements.define('user-card', UserCard);
</script>
정의가 끝나는 시점은 customElements.whenDefined('user-card')가 돌려주는 프로미스로 알 수 있고, CSS의 :defined 가상 클래스로 정의 전 요소를 숨겨 스타일 깜빡임을 막을 수 있다.
원래 Shadow DOM은 자바스크립트의 attachShadow()로만 만들 수 있어서, 서버 측 렌더링(SSR)으로 만든 HTML에는 섀도 트리를 담을 수 없었다. 선언적 Shadow DOM(Declarative Shadow DOM)은 shadowrootmode 속성을 가진 <template>을 HTML 파서가 만나면 부모 요소에 섀도 루트를 붙이고 템플릿 내용을 그 안에 넣는 기능이다. 스크립트가 로드되기 전에도 캡슐화된 스타일과 마크업이 그려진다.[4]
<user-card>
<template shadowrootmode="open">
<style>:host { display:block; border:1px solid #ccc; }</style>
<h3><slot name="name"></slot></h3>
</template>
<span slot="name">홍길동</span>
</user-card>
Chrome 90은 비표준 shadowroot 속성으로 먼저 구현했고, 표준화된 shadowrootmode는 Chrome·Edge 111, Safari 16.4, Firefox 123에서 지원된다. 2024년 8월 Baseline(주요 브라우저 공통 지원)이 되었다. 이 기능은 처음 HTML을 파싱할 때만 작동한다. innerHTML로 넣으면 섀도 루트가 만들어지지 않고, setHTMLUnsafe()나 Document.parseHTMLUnsafe()를 써야 한다.[4]
사용자 정의 요소는 기본적으로 <form>의 일부가 아니라서, 직접 만든 입력 컴포넌트의 값이 폼 제출에 들어가지 않고 유효성 검사도 받지 못했다. 폼 연동 사용자 정의 요소(form-associated custom elements)는 static formAssociated = true를 선언하고 attachInternals()로 얻은 ElementInternals 객체로 이 문제를 푼다.
class RatingInput extends HTMLElement {
static formAssociated = true;
#internals = this.attachInternals();
#value = 0;
set value(v) {
this.#value = v;
this.#internals.setFormValue(String(v)); // 폼 제출 값
if (v < 1) this.#internals.setValidity({ valueMissing: true }, '별점을 고르세요');
else this.#internals.setValidity({});
}
formResetCallback() { this.value = 0; } // <form>.reset() 시
formDisabledCallback(disabled) { /* fieldset disabled 반영 */ }
}
customElements.define('rating-input', RatingInput);
이 객체는 폼 값과 유효성(setFormValue, setValidity, checkValidity) 외에도 internals.role, internals.ariaLabel 같은 기본 ARIA 의미를 속성 없이 지정하는 기능과, CSS :state(checked)로 꾸밀 수 있는 사용자 정의 상태(internals.states)를 제공한다.[3] formResetCallback, formDisabledCallback, formStateRestoreCallback, formAssociatedCallback 같은 폼 전용 생명주기 콜백도 생긴다.
표준 API만으로도 만들 수 있지만, 반응형 속성, 효율적인 다시 그리기, 템플릿 문법이 없어 대개 얇은 라이브러리를 쓴다.
| 이름 | 만든 곳 | 특징 |
|---|---|---|
| Polymer | Google(2013~) | 초창기 웹 컴포넌트 라이브러리. v0 API와 HTML Imports 위에서 출발했고 머티리얼 디자인 요소를 제공했다. 지금은 유지 보수 모드이고 Lit가 후계다 |
| Lit(LitElement, lit-html) | 태그된 템플릿 리터럴(html`...`)과 반응형 속성으로 바뀐 부분만 갱신한다. 런타임이 약 5KB 수준으로 작다
| |
| Stencil | Ionic 팀(2017~) | JSX와 TypeScript로 작성하면 표준 웹 컴포넌트를 출력하는 컴파일러. Ionic 프레임워크의 UI 요소가 이것으로 만들어진다 |
| FAST | Microsoft | 디자인 시스템용 웹 컴포넌트 라이브러리 |
| Salesforce Lightning Web Components | Salesforce | Salesforce 플랫폼의 UI 모델 |
| Angular Elements | Angular 6(2018)부터 Angular 컴포넌트를 사용자 정의 요소로 패키징 | |
Vue defineCustomElement, Svelte customElement |
각 프로젝트 | 프레임워크 컴포넌트를 사용자 정의 요소로 내보내는 기능 |
Lit로 쓴 같은 종류의 컴포넌트:
import { LitElement, html, css } from 'lit';
class CounterButton extends LitElement {
static properties = { count: { type: Number } };
static styles = css`button { font-size: 1.2rem; }`;
constructor() { super(); this.count = 0; }
render() {
return html`<button @click=${() => this.count++}>눌림: ${this.count}</button>`;
}
}
customElements.define('counter-button', CounterButton);
Adobe의 Spectrum Web Components(Lit 기반)는 웹판 포토샵 UI에 쓰였다.[5] GitHub, YouTube, Salesforce 등도 웹 컴포넌트를 쓴다고 알려져 있다.
| 항목 | 웹 컴포넌트 | React·Vue 같은 프레임워크 컴포넌트 |
|---|---|---|
| 정체 | 브라우저 표준 DOM 요소 | 프레임워크 런타임 안의 추상화(가상 DOM 등) |
| 상호 운용 | 어느 프레임워크에서나, 프레임워크 없이도 사용 | 같은 프레임워크 안에서만 |
| 스타일 격리 | Shadow DOM으로 브라우저가 보장 | CSS 모듈, CSS-in-JS 등 관례로 해결 |
| 상태 관리·데이터 흐름 | 표준에 없음(직접 또는 라이브러리) | 프레임워크가 제공(훅, 스토어 등) |
| 템플릿·반응성 | 표준에 없음(Lit 등이 채움) | 프레임워크가 제공 |
| SSR | 선언적 Shadow DOM으로 가능해졌지만 도구가 아직 적음 | 성숙한 SSR·하이드레이션 도구 |
| 수명 | 브라우저가 지원하는 한 유지 | 프레임워크 버전 교체의 영향을 받음 |
두 접근은 대립보다는 층위가 다르다. 흔한 조합은 공용 디자인 시스템의 버튼·입력 같은 말단 요소를 웹 컴포넌트로 만들고, 앱의 화면과 상태 관리는 프레임워크로 짜는 방식이다.
React는 오랫동안 사용자 정의 요소에 모든 prop을 문자열 속성(attribute)으로만 넘기고, 사용자 정의 이벤트를 JSX로 받지 못해 궁합이 나빴다. 여러 프레임워크의 호환성을 검사하는 Custom Elements Everywhere에서도 낮은 점수를 받았다. 2024년 12월 React 19가 이를 고쳐, 클라이언트에서는 요소에 같은 이름의 프로퍼티가 있으면 프로퍼티로, 없으면 속성으로 넘기고, 서버 렌더링에서는 원시값만 속성으로 출력한다. React 19는 Custom Elements Everywhere의 모든 검사를 통과한다.[6]
- 2011년: Google의 Alex Russell이 Fronteers 콘퍼런스에서 "Web Components and Model Driven Views"를 발표하며 개념을 처음 공개했다.[7]
- 2013~2014년: Chrome이 Custom Elements v0(
document.registerElement()), Shadow DOM v0(createShadowRoot(),<content>삽입점), HTML Imports를 구현하고, Google이 Polymer를 공개했다. 다른 브라우저는 v0를 구현하지 않았다. - 2014년 12월: Mozilla가 HTML Imports를 구현하지 않겠다고 밝혔다. 자바스크립트 모듈이 나오면 문제를 보는 방식이 달라질 것이라는 이유였다.[8]
- 2016년 무렵: 브라우저 벤더들이 합의한 v1 명세(
customElements.define(),attachShadow(),<slot>)가 나오고 Chrome과 Safari가 먼저 구현했다. - 2018년 10월: Firefox 63이 Custom Elements와 Shadow DOM을 기본 활성화했다.[9]
- 2020년: Chromium 기반으로 바뀐 Edge(79)가 v1을 지원하면서 주요 브라우저 모두가 지원하게 되었다. Chrome은 v0 API와 HTML Imports를 Chrome 73에서 제거하려다 일정을 늦춰 Chrome 80(2020년 2월 무렵)에서 제거했다.[10]
- 2023~2024년: 선언적 Shadow DOM, ElementInternals가 Safari 16.4와 Firefox까지 퍼지며 주요 브라우저 공통 기능이 되었다.
- 2024년 12월: React 19가 사용자 정의 요소를 완전히 지원했다.
최근 진행 중인 과제로는 섀도 루트마다 따로 요소를 등록하는 범위 지정 레지스트리(Scoped Custom Element Registries), 섀도 경계를 넘어 ARIA 관계를 잇는 참조 대상(Reference Target) 등이 있다.
- 접근성: 섀도 경계 때문에
aria-labelledby,<label for>같은 ID 참조가 경계를 넘지 못한다. 폼 요소를 섀도 DOM 안에 숨기면 바깥<label>과 연결되지 않으므로 ElementInternals나 슬롯으로 설계해야 한다. - 전역 이름 공간: 태그 이름은 페이지 전체에서 하나라, 같은 컴포넌트의 두 버전을 한 페이지에 둘 수 없다. 범위 지정 레지스트리가 이를 해결하려는 제안이다.
- SSR과 초기 표시: 선언적 Shadow DOM 이전에는 스크립트가 로드되기 전 요소가 비어 보였다(FOUC). 지금도 SSR 도구 지원은 프레임워크보다 적다.
- 스타일 격리가 양날의 칼: 전역 CSS 프레임워크(예: 유틸리티 클래스)가 안으로 들어가지 않는다. CSS 변수와
::part()로 노출할 부분을 미리 설계해야 한다. - closed 모드는 보안이 아니다: 같은 페이지 스크립트로부터 비밀을 지킬 수단이 아니다. 신뢰할 수 없는 콘텐츠를 격리하려면 샌드박스 iframe을 써야 한다.
- 생성자 제약: 생성자에서 속성을 읽거나 자식을 추가하면
document.createElement()경로에서 예외가 날 수 있다. 이런 작업은connectedCallback()으로 미룬다.
- ↑ WHATWG HTML Standard, Custom elements
- ↑ WHATWG DOM Standard, Shadow trees
- ↑ 3.0 3.1 MDN, Using custom elements
- ↑ 4.0 4.1 web.dev, Declarative Shadow DOM
- ↑ web.dev, Photoshop's journey to the web (2021)
- ↑ React, React v19 (2024-12-05)
- ↑ Fronteers 2011, Web Components and Model Driven Views (Alex Russell)
- ↑ Mozilla Hacks, Mozilla and Web Components: Update (2014-12-15)
- ↑ Mozilla Hacks, Firefox 63: Tricks and Treats! (2018)
- ↑ Chrome for Developers, Web Components update: more time to upgrade to v1 APIs (2019)