이 글은 누구를 위한 것인가
- React/Vue 종속 없이 어디서나 쓸 수 있는 컴포넌트를 만들려는 팀
- 디자인 시스템을 Web Components로 구축해 여러 프레임워크에 배포하려는 팀
- Lit으로 간결하게 Web Components를 작성하려는 개발자
들어가며
"이 버튼 컴포넌트를 React 앱과 Vue 앱 둘 다에서 쓰고 싶다." Web Components는 이 요구를 순수 표준 기술로 해결한다. 2026년 현재 모든 주요 브라우저에서 완전 지원된다.
이 글은 bluefoxdev.kr의 Web Components 실전 가이드 를 참고하여 작성했습니다.
1. Web Components 구성 요소
[Web Components = 3가지 표준]
1. Custom Elements:
- HTML에 새 태그 추가
- <my-button>, <user-avatar> 등
- 생명주기 콜백: connectedCallback, disconnectedCallback,
attributeChangedCallback, adoptedCallback
2. Shadow DOM:
- 캡슐화된 DOM 트리
- 내부 스타일이 외부에 영향 없음
- 외부 스타일이 내부에 영향 없음
- ::slotted(), :host 선택자로 접근
3. HTML Templates:
- <template>: 렌더링 안 되는 HTML 조각
- <slot>: 콘텐츠 삽입 위치 지정
[Lit - 경량 Web Components 프레임워크]
React 같은 선언적 템플릿
태그드 템플릿 리터럴 (html``)
반응형 프로퍼티 (@property)
번들 크기: ~5KB (gzip)
성능: 최적화된 DOM 업데이트
[브라우저 지원]
Chrome 67+, Firefox 63+, Safari 12+, Edge 79+
모든 현대 브라우저 완전 지원
2. Web Components 구현
// 순수 Custom Elements (Lit 없이)
class MyButton extends HTMLElement {
static observedAttributes = ['variant', 'disabled', 'loading'];
private _loading = false;
constructor() {
super();
// Shadow DOM 생성
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
this.shadowRoot!.querySelector('button')?.addEventListener('click', this._handleClick);
}
disconnectedCallback() {
this.shadowRoot!.querySelector('button')?.removeEventListener('click', this._handleClick);
}
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
if (oldValue !== newValue) this.render();
}
get loading() { return this._loading; }
set loading(value: boolean) {
this._loading = value;
this.render();
}
private _handleClick = () => {
if (!this.hasAttribute('disabled') && !this._loading) {
this.dispatchEvent(new CustomEvent('my-click', { bubbles: true, composed: true }));
}
};
private render() {
const variant = this.getAttribute('variant') ?? 'primary';
const disabled = this.hasAttribute('disabled') || this._loading;
this.shadowRoot!.innerHTML = `
<style>
:host { display: inline-block; }
button {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border: none;
border-radius: 8px;
cursor: pointer;
font-family: inherit;
font-size: 14px;
font-weight: 500;
transition: background-color 0.2s;
}
button[data-variant="primary"] {
background: var(--my-button-bg, #3b82f6);
color: var(--my-button-color, white);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.spinner {
width: 14px;
height: 14px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
<button
data-variant="${variant}"
${disabled ? 'disabled' : ''}
aria-busy="${this._loading}"
>
${this._loading ? '<span class="spinner"></span>' : ''}
<slot></slot>
</button>
`;
}
}
customElements.define('my-button', MyButton);
// Lit으로 간결하게 구현
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
@customElement('my-card')
export class MyCard extends LitElement {
static styles = css`
:host {
display: block;
border-radius: 12px;
overflow: hidden;
box-shadow: var(--card-shadow, 0 4px 20px rgba(0,0,0,0.1));
}
.header {
padding: 16px;
background: var(--card-header-bg, #f8fafc);
border-bottom: 1px solid #e2e8f0;
}
.body { padding: 16px; }
::slotted([slot="footer"]) {
padding: 16px;
border-top: 1px solid #e2e8f0;
}
`;
@property({ type: String }) title = '';
@property({ type: Boolean }) elevated = false;
@state() private _expanded = false;
render() {
return html`
<div class=${classMap({ elevated: this.elevated })}>
<div class="header">
<h3>${this.title}</h3>
<button @click=${() => this._expanded = !this._expanded}>
${this._expanded ? '접기' : '펼치기'}
</button>
</div>
${this._expanded ? html`
<div class="body">
<slot></slot>
</div>
` : ''}
<slot name="footer"></slot>
</div>
`;
}
}
// 타입 선언 (TypeScript)
declare global {
interface HTMLElementTagNameMap {
'my-card': MyCard;
'my-button': MyButton;
}
}
// React에서 Web Components 사용
// React 19에서는 Web Components 지원 개선됨
import { createComponent } from '@lit/react';
import { MyCard } from './my-card';
import { MyButton } from './my-button';
// React 래퍼 생성 (이벤트 매핑 포함)
const MyCardReact = createComponent({
tagName: 'my-card',
elementClass: MyCard,
react: React,
events: {
onMyClick: 'my-click',
},
});
const MyButtonReact = createComponent({
tagName: 'my-button',
elementClass: MyButton,
react: React,
events: {
onMyClick: 'my-click',
},
});
// React에서 사용
function App() {
return (
<MyCardReact title="상품 정보" elevated>
<p>상품 상세 내용</p>
<div slot="footer">
<MyButtonReact variant="primary" onMyClick={() => console.log('클릭!')}>
구매하기
</MyButtonReact>
</div>
</MyCardReact>
);
}
// Vue에서 사용 (설정 불필요)
// <my-card title="상품 정보" :elevated="true" @my-click="handleClick">
// <p>상품 내용</p>
// </my-card>
import React from 'react';
마무리
Web Components는 "한 번 만들어서 어디서나 사용"이라는 약속을 2026년에는 충분히 실현한다. Lit은 순수 Web Components의 보일러플레이트를 대폭 줄여 React 수준의 개발 경험을 제공한다. 디자인 시스템을 Web Components로 만들면 React, Vue, Svelte, Angular 팀 모두가 동일한 컴포넌트를 사용할 수 있다. @lit/react로 React 래퍼를 만들면 이벤트 핸들러 연결도 자동화된다.