Web Components로 프레임워크 독립 디자인 시스템 만들기: Custom Elements·Shadow DOM·Lit으로 React·Vue와 함께 쓰는 법

프레임워크가 바뀌어도 컴포넌트는 살아남아야 한다
우리 프론트엔드 개발자들은 매 2~3년 주기로 반복되는 피로감을 잘 압니다. 조직이 React에서 Vue로, Vue에서 다시 React로, 혹은 Next.js에서 SvelteKit으로 이동할 때마다 디자인 시스템 컴포넌트를 처음부터 다시 작성해야 하는 상황입니다.
우리 팀이 경험한 사례가 있습니다. 멀티 테넌트 B2B SaaS 제품에서 메인 앱은 React 18을 쓰고, 고객사 임베딩용 위젯은 Vue 3, 어드민 패널은 Svelte로 구성된 상황이었습니다. 세 곳에서 Button, Badge, Toast 컴포넌트를 각각 구현·유지보수하다 보니 디자인 토큰 변경 하나가 세 곳에 모두 반영되기까지 평균 2주가 걸렸습니다.
이 글은 Web Components 표준 스펙을 실용적인 수준에서 정리하고, Lit 라이브러리로 보일러플레이트를 최소화하며, React·Vue·Svelte에서 Custom Element를 실제로 어떻게 연결하는지 코드와 함께 설명합니다. 디자인 토큰과 명시도 관리를 함께 이해하고 싶다면 CSS @layer 실전 가이드도 함께 읽어보시길 권합니다.
1. Web Components 표준 스펙 정리: Custom Elements, Shadow DOM, HTML Templates
Web Components는 단일 API가 아니라 세 가지 독립적인 웹 표준의 묶음입니다. MDN Web Components 문서는 이 세 가지를 각각 Custom Elements API, Shadow DOM API, HTML Templates로 정의합니다.
Custom Elements는 브라우저가 인식하는 HTML 태그를 직접 정의하는 API입니다. <my-button>, <ds-input>, <app-modal> 같은 하이픈을 포함한 태그 이름을 customElements.define()으로 등록하면, 브라우저는 해당 태그를 만날 때마다 우리가 정의한 클래스를 인스턴스화합니다.
Shadow DOM은 컴포넌트 내부의 DOM 트리와 CSS를 외부로부터 캡슐화합니다. Shadow Root 안의 CSS는 외부로 새어 나가지 않고, 외부의 글로벌 CSS도 Shadow Root 안에 침투하지 못합니다.
HTML Templates는 <template> 태그와 <slot> 태그를 이용해 컴포넌트의 마크업 청사진을 정의합니다.
| 스펙 | 역할 | 핵심 API |
|---|---|---|
| Custom Elements v1 | 사용자 정의 HTML 태그 등록 | customElements.define() |
| Shadow DOM v1 | DOM·CSS 캡슐화 | attachShadow({ mode: 'open' }) |
| HTML Templates | 재사용 마크업 청사진 | <template>, <slot> |
| ES Modules | 컴포넌트 배포 단위 | import/export |
2026년 기준 이 세 가지 스펙은 모두 Baseline에 진입해 있습니다.
2. Custom Elements v1 라이프사이클
class DsBadge extends HTMLElement {
static get observedAttributes() {
return ['variant', 'label', 'count'];
}
constructor() {
super();
this._shadow = this.attachShadow({ mode: 'open' });
this._render();
}
connectedCallback() {
this.addEventListener('click', this._handleClick);
}
disconnectedCallback() {
this.removeEventListener('click', this._handleClick);
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return;
this._render();
}
_handleClick = () => {
this.dispatchEvent(
new CustomEvent('ds-badge-click', {
bubbles: true,
composed: true,
detail: { count: this.getAttribute('count') },
})
);
};
_render() {
const variant = this.getAttribute('variant') ?? 'default';
const label = this.getAttribute('label') ?? '';
this._shadow.innerHTML = `
<style>
:host { display: inline-flex; gap: 0.25rem; }
.dot { width: 8px; height: 8px; border-radius: 50%;
background-color: var(--ds-color-primary, #0070f3); }
</style>
<span class="dot"></span>
<span class="label">${label}</span>
`;
}
}
customElements.define('ds-badge', DsBadge);
constructor() 안에서 this.getAttribute()를 호출하면 항상 null이 반환됩니다. HTML 파서가 속성을 붙이기 전에 생성자가 실행되기 때문입니다. 속성 값에 의존하는 초기 렌더링 로직은 반드시 connectedCallback() 또는 attributeChangedCallback() 안에 두어야 합니다.
attributeChangedCallback이 동작하려면 static get observedAttributes()에 감시할 속성 이름을 배열로 명시해야 합니다.
3. Shadow DOM 캡슐화와 ::part(), ::slotted() 노출 패턴
Shadow DOM의 강력한 캡슐화는 양날의 검입니다. 외부 CSS가 침투하지 못한다는 것은 곧 소비자가 컴포넌트의 내부 요소를 직접 스타일링할 수 없다는 의미이기도 합니다.
/* 컴포넌트 내부 */
/* <button part="base trigger">클릭</button> */
/* 소비자 측 외부 CSS */
ds-button::part(base) {
border-radius: 0;
}
ds-button::part(trigger) {
font-weight: 800;
}
/* ::slotted: Shadow DOM 내부 스타일시트 */
::slotted(span) {
color: var(--ds-color-text-secondary);
}
::slotted([slot="icon"]) {
width: 1rem;
}
::part()와 ::slotted()는 노출 범위를 명시적으로 선언한다는 점에서 캡슐화 원칙에 위배되지 않습니다. ::part()는 한 단계 Shadow 경계만 넘습니다. CSS @scope와 Anchor Positioning 가이드에서 다룬 스코프 관리와 결합하면 디자인 시스템 일관성을 더 정밀하게 제어할 수 있습니다.
4. HTML Templates와 Slots: Light DOM ↔ Shadow DOM 합성
<template id="ds-card-template">
<style>
:host {
display: block;
border: 1px solid var(--ds-color-border, #e5e7eb);
border-radius: var(--ds-radius-lg, 0.5rem);
}
.header { padding: 1rem; }
.body { padding: 1.25rem; }
.footer { background: var(--ds-color-surface-subtle, #f9fafb); }
</style>
<div class="header"><slot name="header"></slot></div>
<div class="body"><slot></slot></div>
<div class="footer"><slot name="footer"></slot></div>
</template>
<ds-card>
<h2 slot="header">2026 Q1 리포트</h2>
<p>분기 매출이 전년 대비 증가했습니다.</p>
<button slot="footer">자세히 보기</button>
</ds-card>
슬롯 합성의 핵심 포인트: slot="header"로 지정된 <h2>는 Light DOM에 그대로 남아 있으면서 Shadow DOM의 <slot name="header"> 위치에 렌더링됩니다. 이 합성된 트리를 "Flattened Tree"라고 부릅니다.
5. Lit 라이브러리로 보일러플레이트 줄이기
Lit는 Google이 개발하는 경량 Web Components 빌딩 라이브러리로, 반복 작업을 선언적 템플릿과 리액티브 프로퍼티 시스템으로 대체합니다. Lit의 번들 크기는 minify+gzip 기준 약 5KB입니다.
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
@customElement('ds-button')
export class DsButton extends LitElement {
static styles = css`
:host { display: inline-block; }
:host([disabled]) { pointer-events: none; opacity: 0.4; }
button {
padding: 0.5rem 1.25rem;
background-color: var(--ds-color-primary, #0070f3);
color: #fff;
border: none;
border-radius: var(--ds-radius-md, 0.375rem);
cursor: pointer;
}
button:hover:not([disabled]) {
background-color: var(--ds-color-primary-hover, #005cc5);
}
`;
@property({ type: String })
variant: 'primary' | 'secondary' | 'ghost' = 'primary';
@property({ type: Boolean, reflect: true })
disabled = false;
@property({ type: Boolean })
loading = false;
private _handleClick() {
if (this.disabled || this.loading) return;
this.dispatchEvent(
new CustomEvent('ds-click', {
bubbles: true,
composed: true,
detail: { variant: this.variant },
})
);
}
render() {
return html`
<button
part="base"
?disabled="${this.disabled || this.loading}"
aria-busy="${this.loading}"
@click="${this._handleClick}"
>
<slot></slot>
</button>
`;
}
}
Lit의 html 태그 템플릿 리터럴은 변경된 부분만 DOM을 업데이트하는 최소 패치 렌더링을 수행합니다.
6. React에서 Custom Element 통합 시 주의사항
React 19에서 Custom Elements 지원이 대폭 개선됐지만, 아직 React 18 기반 프로젝트가 많습니다.
// React 18 방식: ref + addEventListener
import { useRef, useEffect, useCallback } from 'react';
function ProductCard({ product }) {
const buttonRef = useRef<HTMLElement>(null);
const handleDsClick = useCallback((event) => {
const customEvent = event as CustomEvent<{ variant: string }>;
console.log('ds-click received:', customEvent.detail);
}, []);
useEffect(() => {
const el = buttonRef.current;
if (!el) return;
el.addEventListener('ds-click', handleDsClick);
return () => el.removeEventListener('ds-click', handleDsClick);
}, [handleDsClick]);
return (
<ds-button ref={buttonRef} variant="primary">
구매하기
</ds-button>
);
}
// React 19 방식
function ProductCardV19({ product }) {
return (
<ds-button
variant="primary"
loading={product.isLoading}
onDs-click={(e: CustomEvent) => console.log(e.detail)}
>
구매하기
</ds-button>
);
}
주의: onClick 같은 React 합성 이벤트는 Custom Events를 감지하지 못합니다. composed: true로 설정된 Custom Event라도 React의 이벤트 시스템은 Shadow DOM 경계를 인식하지 못하므로, 항상 addEventListener로 직접 바인딩해야 합니다.
7. Vue·Svelte와의 호환성: custom-elements-everywhere 매트릭스
custom-elements-everywhere.com은 각 프레임워크가 Custom Elements를 얼마나 잘 지원하는지 표준화된 테스트 케이스로 측정해 공개합니다.
| 프레임워크 | 객체·배열 전달 | Custom Events | SSR 지원 |
|---|---|---|---|
| React 19 | 완전 지원 | 완전 지원 | use client 필요 |
| React 18 | ref 우회 필요 | ref 우회 필요 | use client 필요 |
| Vue 3 | :prop 바인딩 | @event 바인딩 | Nuxt 별도 설정 |
| Svelte 5 | 완전 지원 | on:event | SvelteKit 지원 |
| Angular 17+ | 완전 지원 | (event) | Angular Universal |
// Vue 3 + Vite 설정
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => tag.startsWith('ds-'),
},
},
}),
],
});
<template>
<ds-button
variant="primary"
:loading="product.isLoading"
@ds-click="handleClick"
>
구매하기
</ds-button>
</template>
8. 디자인 토큰 전달 전략: CSS 변수와 ::part
CSS 사용자 정의 속성은 Shadow DOM 경계를 상속으로 통과합니다. 이것이 Web Components 기반 디자인 시스템에서 테마를 구현하는 핵심 메커니즘입니다.
:root {
--ds-primitive-blue-500: #0070f3;
--ds-color-primary: var(--ds-primitive-blue-500);
--ds-color-primary-hover: #005cc5;
--ds-color-surface: #ffffff;
--ds-color-border: #e5e7eb;
--ds-radius-md: 0.375rem;
--ds-radius-lg: 0.5rem;
}
@media (prefers-color-scheme: dark) {
:root {
--ds-color-primary: #3b82f6;
--ds-color-surface: #1f2937;
--ds-color-border: #374151;
}
}
Primitive Token → Semantic Token → Component Token 3단계 계층을 유지하는 것이 중요합니다.
9. Storybook과 함께 문서화하기
open-wc.org는 Web Components 생태계의 모범 사례를 모아둔 커뮤니티 가이드입니다.
import type { Meta, StoryObj } from '@storybook/web-components';
import { html } from 'lit';
import './ds-button';
const meta: Meta = {
title: 'Design System/DsButton',
component: 'ds-button',
argTypes: {
variant: { control: 'select', options: ['primary', 'secondary', 'ghost'] },
disabled: { control: 'boolean' },
loading: { control: 'boolean' },
},
};
export default meta;
type Story = StoryObj;
export const Primary: Story = {
render: (args) => html`
<ds-button
variant="${args.variant}"
?disabled="${args.disabled}"
?loading="${args.loading}"
>
확인
</ds-button>
`,
};
export const AllVariants: Story = {
render: () => html`
<div style="display: flex; gap: 1rem;">
<ds-button variant="primary">Primary</ds-button>
<ds-button variant="secondary">Secondary</ds-button>
<ds-button variant="ghost">Ghost</ds-button>
</div>
`,
};
Custom Elements Manifest(CEM)는 Web Components의 퍼블릭 API를 JSON 형태로 기술하는 메타데이터 포맷입니다.
10. 번들 크기와 성능 트레이드오프
| 측면 | Web Components | 프레임워크별 재구현 |
|---|---|---|
| 초기 번들 크기 | Lit ~5KB | 각 프레임워크 런타임 포함 |
| 스타일 격리 | Shadow DOM 완전 격리 | CSS Modules |
| 글로벌 CSS 유틸리티 | 적용 불가 | 완전 적용 |
| 프레임워크 업그레이드 영향 | 없음 | 마이그레이션 필요 |
| 서버 사이드 렌더링 | Declarative Shadow DOM 필요 | 자연 지원 |
| 접근성 | ARIA 수동 관리 | 프레임워크 a11y 에코시스템 |
SSR 환경에서의 Declarative Shadow DOM(DSD)은 2026년 현재 Chrome·Safari 지원이 완료됐고, Firefox 125부터 지원됩니다.
결론
Web Components는 "React를 대체하는 기술"이 아닙니다. "프레임워크에 종속되지 않는 공유 컴포넌트 레이어를 만드는 표준"입니다.
도입이 적합한 상황은 세 가지입니다. 첫째, 여러 프레임워크가 공존하는 멀티 테넌트·마이크로 프론트엔드 환경. 둘째, 장기 유지가 필요한 외부 배포용 UI 라이브러리. 셋째, 팀별로 다른 스택을 쓰지만 브랜드 일관성을 강제해야 하는 조직.
- 프레임워크 지원 매트릭스 확인.
- CSS 변수 기반 토큰 체계 선설계.
::part()노출 API 명세 작성.- Custom Elements Manifest 생성 파이프라인 구축.
- SSR 전략 사전 결정.