Web Components로 프레임워크 독립 디자인 시스템 만들기: Custom Elements·Shadow DOM·Lit으로 React·Vue와 함께 쓰는 법
핵심 요약
이 글에서 확인할 내용
프레임워크가 바뀌어도 컴포넌트는 살아남아야 한다 우리 프론트엔드 개발자들은 매 2 3년 주기로 반복되는 피로감을 잘 압니다. 조직이 React에서 Vue로, Vue에서 다시 React로, 혹은 Next.js에서 SvelteKit으로 이동할 때마다 디자인 시스템 컴포넌트를 처음부터 다시 작성해야 하는 상황입니다. 담당 프로젝트가 경험한 사례가 있습니다.
- 1. Web Components 표준 스펙 정리: Custom Elements, Shadow DOM, HTML Templates
- 2. Custom Elements v1 라이프사이클
- 3. Shadow DOM 캡슐화와 ::part(), ::slotted() 노출 패턴
프레임워크가 바뀌어도 컴포넌트는 살아남아야 한다
우리 프론트엔드 개발자들은 매 2~3년 주기로 반복되는 피로감을 잘 압니다. 조직이 React에서 Vue로, Vue에서 다시 React로, 혹은 Next.js에서 SvelteKit으로 이동할 때마다 디자인 시스템 컴포넌트를 처음부터 다시 작성해야 하는 상황입니다.
담당 프로젝트가 경험한 사례가 있습니다. 멀티 테넌트 B2B SaaS 제품에서 메인 앱은 React 18을 쓰고, 고객사 임베딩용 위젯은 Vue 3, 어드민 패널은 Svelte로 구성된 상황이었습니다. 세 곳에서 Button, Badge, Toast 컴포넌트를 각각 구현·유지보수하다 보니 디자인 토큰 변경 하나가 세 곳에 모두 반영되기까지 평균 2주가 걸렸습니다.
처음 이 문제를 풀어보겠다고 나섰던 시도는 6개월짜리 '전면 재작성' 프로젝트였습니다. 세 스택의 컴포넌트를 하나의 React 라이브러리로 통합하고, Vue·Svelte 팀은 React를 어댑터로 감싸 쓰도록 강제하는 계획이었습니다. 결과는 절반의 실패였습니다. Vue 위젯 팀은 React 런타임을 번들에 추가로 얹는 것 자체를 거부했습니다. 고객사 페이지에 임베딩되는 위젯의 번들 크기가 300KB 가까이 늘어나는 부담을 감수할 수 없었기 때문입니다. Svelte로 짠 어드민 패널 팀의 반응도 다르지 않았습니다. 프로젝트는 결국 'React 팀만 새 컴포넌트를 쓰고, 나머지 두 팀은 예전 컴포넌트를 계속 유지보수하는' 어중간한 상태로 동결됐습니다. 이 실패에서 얻은 교훈은 명확했습니다. 프레임워크 하나를 표준으로 강제하는 접근은 조직 전체의 합의 없이는 작동하지 않으며, 우리에게 실제로 필요했던 것은 '더 나은 React 컴포넌트 라이브러리'가 아니라 '프레임워크라는 전제 자체가 없는 컴포넌트 레이어'였다는 것입니다.
이 글은 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에 진입해 있습니다.
이 세 표준을 조합하기 전에 먼저 답해야 할 질문이 있습니다. '우리 조직에 정말 프레임워크 독립 레이어가 필요한가'입니다. 단일 프레임워크만 쓰는 조직이라면 Web Components 도입은 오히려 손해일 수 있습니다. React 생태계 안에서만 산다면 컨텍스트, 훅 기반 상태 공유, 서버 컴포넌트 같은 프레임워크 고유의 합성 모델을 포기하고 굳이 더 낮은 수준의 DOM API로 내려갈 이유가 없기 때문입니다. Web Components 도입이 정당화되는 시점은 정확히 '두 개 이상의 프레임워크가 같은 조직 안에서 동시에 같은 컴포넌트를 소비해야 하는 순간'입니다.
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()에 감시할 속성 이름을 배열로 명시해야 합니다.
실무에서 이 라이프사이클 때문에 발생하는 버그 하나는 반드시 짚고 넘어가야 합니다. 드래그 앤 드롭으로 리스트 순서를 바꾸는 화면에서 정렬 라이브러리를 Custom Element 목록에 적용하면, 항목을 재정렬할 때마다 브라우저가 해당 엘리먼트를 DOM에서 떼어냈다가 다른 위치에 다시 붙입니다. 이 과정에서 disconnectedCallback과 connectedCallback이 아주 짧은 간격을 두고 연달아 호출되는데, disconnectedCallback에서 타이머·구독·WeakMap 참조 같은 내부 상태를 무조건 정리하도록 짜 두면 재정렬 직후 컴포넌트가 빈 상태로 리셋되는 문제가 생깁니다. 이 버그를 겪은 뒤 우리 팀이 택한 해법은, disconnectedCallback에서 상태를 즉시 정리하는 대신 queueMicrotask로 정리 작업을 한 틱 미루고, 그 사이에 connectedCallback이 다시 호출되면 예약된 정리를 취소하는 패턴이었습니다. DOM 재정렬은 '완전한 제거'가 아니라 '이동'이라는 사실을 라이프사이클 훅을 설계하는 단계에서부터 반드시 감안해야 합니다.
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 가이드에서 다룬 스코프 관리와 결합하면 디자인 시스템 일관성을 더 정밀하게 제어할 수 있습니다.
attachShadow()를 호출할 때 mode는 open 아니면 closed 둘 중 하나입니다. 실무에서는 거의 항상 open을 선택해야 합니다. closed 모드는 element.shadowRoot가 항상 null을 반환하도록 막아버리는데, 얻는 보안상 이득은 미미한 반면 잃는 것은 큽니다. Testing Library나 Playwright 같은 테스트 도구는 기본적으로 열린 Shadow Root 내부까지 쿼리가 통과하도록 설계되어 있지만, closed 모드에서는 이 경로가 완전히 차단됩니다. 우리 팀은 사내 위젯 하나에 '호스트 페이지의 스크립트로부터 내부 구조를 숨긴다'는 명분으로 closed 모드를 적용했다가, 정작 QA 팀의 자동화 테스트 스위트가 컴포넌트 내부를 검사하지 못해 테스트 커버리지의 절반가량이 무력화되는 부작용을 겪고 결국 원복했습니다. closed 모드가 정당화되는 경우는 서드파티에 배포하는 위젯이 호스트 페이지의 스크립트로부터 절대로 조작되면 안 되는 특수한 보안 요건이 있을 때 정도로 한정하는 편이 안전합니다.
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을 업데이트하는 최소 패치 렌더링을 수행합니다.
Lit의 업데이트 사이클을 이해해 두면 불필요한 리렌더를 피할 수 있습니다. 프로퍼티 여러 개가 동시에 바뀌어도 Lit은 즉시 렌더링하지 않고, 마이크로태스크 큐에 업데이트를 예약한 뒤 같은 틱 안에서 일어난 모든 변경을 모아 render()를 단 한 번만 호출합니다. 이 배치(batching) 동작 덕분에 this.variant, this.loading, this.disabled를 연달아 세 번 바꿔도 실제 DOM 갱신은 한 번만 일어납니다. Lit 공식 문서의 반응형 업데이트 사이클은 이 과정을 requestUpdate() → performUpdate() → updated()의 흐름으로 설명합니다. 업데이트가 실제로 DOM에 반영된 시점을 코드에서 확실히 확인해야 한다면 await this.updateComplete로 대기할 수 있습니다. 이 프로미스는 대기 중인 업데이트뿐 아니라 자식 컴포넌트의 업데이트까지 재귀적으로 기다려주므로, 통합 테스트에서 '렌더링이 끝난 뒤 어서션을 실행'하도록 보장하는 표준적인 방법입니다.

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로 직접 바인딩해야 합니다.
React 18에서 더 자주 마주치는 문제는 사실 이벤트보다 프로퍼티 전달 쪽입니다. JSX에서 <ds-button loading={product.isLoading}>처럼 불리언이나 객체를 속성으로 넘기면, React는 알려진 DOM 프로퍼티가 아닌 한 이를 문자열 HTML 어트리뷰트로 직렬화합니다. 그 결과 loading이 false일 때도 loading="false"라는 문자열이 그대로 붙어버리고, Custom Element 내부에서 Boolean('false')는 true이므로 의도와 정반대로 동작합니다. 배열이나 객체를 넘기면 상황은 더 나쁩니다. 직렬화 결과가 [object Object]라는 문자열이 되어버려 컴포넌트가 값을 파싱조차 할 수 없습니다. 우리가 실제로 겪은 사고도 이 패턴이었습니다. 상품 목록 위젯에 items 배열을 어트리뷰트로 넘기던 코드가 스테이징 환경에서는 배열이 비어 있어 문제가 드러나지 않다가, 실제 데이터가 들어간 프로덕션에서 화면 전체가 하얗게 죽어버린 사건이었습니다. 해법은 어트리뷰트가 아니라 프로퍼티로 값을 꽂는 것입니다. ref.current.items = product.items처럼 DOM 노드의 자바스크립트 프로퍼티에 직접 대입하면 문자열 직렬화 과정 자체를 건너뛰므로, 객체·배열·불리언 모두 원래 타입 그대로 전달됩니다. Lit 컴포넌트에서 @property({ type: Array })처럼 타입을 명시적으로 선언해 두는 것도, 소비자가 실수로 어트리뷰트 경로를 타더라도 최소한의 파싱 안전망이 되어줍니다.
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>
Vue 3는 :prop 바인딩을 쓰면 값의 원래 타입을 유지한 채로 전달하므로 React 18에서 겪는 문자열 직렬화 문제가 상대적으로 덜합니다. 다만 isCustomElement 설정을 빠뜨리면 Vue 컴파일러가 ds-button을 알 수 없는 컴포넌트로 간주해 콘솔에 경고를 계속 쏟아내면서도 렌더링 자체는 되는 애매한 상태가 되므로, 설정 누락은 배포 후에야 콘솔 로그로 뒤늦게 발견되는 경우가 많습니다. Svelte 5는 이벤트 핸들러를 속성으로 직접 전달하는 방식도 지원하지만, Custom Event의 detail 페이로드를 받으려면 여전히 event.detail을 명시적으로 꺼내야 하는 점은 React·Vue와 동일합니다.
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단계 계층을 유지하는 것이 중요합니다.
멀티 테넌트 환경에서는 여기에 한 단계가 더 필요합니다. 고객사별 화이트라벨 테마입니다. 우리는 <ds-root data-tenant="acme">처럼 테넌트를 식별하는 루트 요소를 두고, 그 하위 CSS 사용자 정의 속성 값만 테넌트별로 오버라이드하는 방식을 씁니다. Shadow DOM 내부의 컴포넌트는 자신이 어떤 테넌트 안에 있는지 전혀 몰라도 되고, 오직 상속된 CSS 변수 값만 읽으면 되므로 테넌트 분기 로직이 컴포넌트 구현 안으로 전혀 침투하지 않습니다. 토큰 계층을 조직 규모에서 어떻게 거버넌스할 것인지는 디자인 토큰 거버넌스: 색상·간격·타이포그래피를 제품 규모에서 유지하는 방법에서 더 깊이 다뤘습니다.
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 123부터 지원됩니다.
11. 디자인 시스템 마이그레이션 전략: 점진적 전환과 롤백 설계
기존 프레임워크 컴포넌트를 Web Components로 옮기는 작업에서 가장 큰 리스크는 기술이 아니라 조직입니다. 한 번에 모든 컴포넌트를 이관하는 빅뱅 방식은 검증해야 할 표면적이 너무 넓어서, 회귀 버그가 어디서 발생했는지 추적하기가 사실상 불가능해집니다. 우리가 실제로 채택한 접근은 마틴 파울러가 정리한 Strangler Fig 패턴을 컴포넌트 단위로 적용하는 것이었습니다.
첫 단계는 사용 빈도가 낮고 상태가 단순한 컴포넌트부터 이관하는 것입니다. Badge, Divider처럼 프로퍼티가 2~3개뿐이고 내부 상태가 없는 컴포넌트를 먼저 Web Components로 다시 만들고, 기존 React·Vue 코드베이스에는 이 컴포넌트를 감싸는 얇은 어댑터만 남깁니다. React 쪽에는 <DsBadge {...props} />라는 래퍼 컴포넌트가 내부적으로 <ds-badge>를 렌더링하도록 하고, 팀은 여전히 익숙한 React 컴포넌트 API를 그대로 사용합니다. 소비자 입장에서는 마이그레이션이 전혀 보이지 않습니다.
두 번째 단계는 버전 관리입니다. Custom Elements Manifest는 컴포넌트의 퍼블릭 API(속성, 이벤트, 슬롯)를 JSON으로 기술하므로, 이전 버전과 새 버전의 매니페스트를 diff하면 어떤 속성이 제거되거나 타입이 바뀌었는지 CI 단계에서 기계적으로 감지할 수 있습니다. 우리는 이 diff를 semver 규칙에 강제로 연결했습니다. 매니페스트에서 breaking change가 감지되면 CI가 major 버전 범프 없이는 머지를 막도록 설정했습니다. npm 패키지 버전 번호 자체가 '이 업데이트가 소비 팀의 코드를 깨뜨릴 수 있는지'를 정직하게 알려주는 신호가 되는 셈입니다. 여러 프레임워크 팀이 각자 다른 버전의 디자인 시스템 패키지를 소비하는 모노레포 환경에서 이 신호는 프론트엔드 모노레포 아키텍처에서 다룬 영향범위 분석과 맞물려 배포 파이프라인 전체의 신뢰도를 좌우합니다.
세 번째 단계는 롤백 계획입니다. 새 Web Components 버전을 배포한 뒤에도 이전 프레임워크 네이티브 컴포넌트를 최소 2개 마이너 버전 동안 deprecated 상태로 남겨 두고, 피처 플래그로 팀별 전환 시점을 개별 제어했습니다. 전환 도중 발견된 회귀는 Playwright 비주얼 회귀 테스트 파이프라인으로 구축한 스크린샷 diff가 가장 먼저 잡아냈습니다. 텍스트 기반 유닛 테스트는 통과했지만 실제로는 Shadow DOM 안의 ::part() 스타일이 미묘하게 어긋난 경우가 여러 번 있었는데, 이런 시각적 회귀는 픽셀 비교 없이는 코드 리뷰만으로 잡아내기 어렵습니다.
12. 실무자가 자주 묻는 질문
Q. Virtual DOM이 없는데 성능은 괜찮은가요?
Lit은 Virtual DOM diffing 대신 컴파일 타임에 가까운 방식으로 동작합니다. html 태그드 템플릿 리터럴을 파싱할 때 정적인 마크업 부분과 ${} 바인딩이 들어가는 동적 부분을 미리 분리해 두고, 값이 바뀌면 정적 부분은 건드리지 않은 채 바인딩된 DOM 노드만 직접 갱신합니다. Virtual DOM을 쓰는 프레임워크가 '이전 트리와 새 트리를 비교해 차이를 계산'하는 데 비용을 쓰는 반면, Lit에는 애초에 비교할 트리 자체가 없습니다. js-framework-benchmark에서 lit-html 기반 구현체는 라운드마다 React·Vue와 비슷하거나 더 나은 위치에서 측정됩니다. 다만 이 벤치마크는 단순 리스트 생성·삭제에 최적화되어 있으므로, 복잡한 상태 파생 로직이 많은 실제 앱에서는 상태 관리 설계 쪽이 성능을 더 크게 좌우한다는 점은 감안해야 합니다.
Q. SSR과 SEO는 어떻게 처리하나요?
Declarative Shadow DOM(DSD)을 쓰면 서버에서 <template shadowrootmode="open">을 포함한 완전한 HTML을 내려보낼 수 있고, 브라우저는 자바스크립트 실행 전에 Shadow Root를 파싱 즉시 구성합니다. Lit은 실험적인 @lit-labs/ssr 패키지로 이 흐름을 지원합니다. 검색엔진 크롤러 입장에서는 결국 서버가 내려준 최종 HTML을 읽으므로, DSD를 쓰는 한 SEO 관점의 불이익은 없습니다.
Q. Shadow DOM 안의 요소는 스크린 리더에서 잘 읽히나요?
기본적인 ARIA 속성(role, aria-label)은 문제없이 동작합니다. 문제는 aria-labelledby, aria-describedby처럼 다른 요소의 id를 참조하는 속성입니다. 참조 대상이 Shadow 경계 반대편에 있으면 접근성 트리가 연결을 만들지 못합니다. 이 한계를 보완하기 위해 나온 것이 attachInternals()가 반환하는 ElementInternals API입니다. ariaLabelledByElements 같은 프로퍼티로 Shadow 경계를 넘어 요소 참조를 직접 연결할 수 있고, 폼 요소로 동작해야 하는 커스텀 엘리먼트는 같은 API의 setFormValue()를 호출해 네이티브 <input>처럼 폼 제출·유효성 검사에 참여할 수 있습니다.
Q. 기존 React 컴포넌트 라이브러리를 전부 버려야 하나요?
아닙니다. 우리가 권장하는 방식은 완전 대체가 아니라 공존입니다. 팀 간 경계를 넘나드는 저수준 프리미티브(Button, Input, Badge, Icon)만 Web Components로 옮기고, 애플리케이션 도메인에 강하게 결합된 복합 컴포넌트(특정 페이지의 폼, 대시보드 위젯)는 각 프레임워크 안에 남겨 두는 편이 실용적입니다. 프리미티브는 재사용 빈도가 높고 변경 빈도는 낮아 마이그레이션 비용 대비 효과가 크지만, 도메인 컴포넌트는 반대로 재사용 빈도가 낮고 변경 빈도가 높아 굳이 프레임워크 독립성을 얻을 필요가 없습니다.
마치며
Web Components는 "React를 대체하는 기술"이 아닙니다. "프레임워크에 종속되지 않는 공유 컴포넌트 레이어를 만드는 표준"입니다.
도입이 적합한 상황은 세 가지입니다. 첫째, 여러 프레임워크가 공존하는 멀티 테넌트·마이크로 프론트엔드 환경. 둘째, 장기 유지가 필요한 외부 배포용 UI 라이브러리. 셋째, 팀별로 다른 스택을 쓰지만 브랜드 일관성을 강제해야 하는 조직.
반대로 도입을 재고해야 하는 상황도 분명히 있습니다. 팀 전체가 앞으로도 계속 단일 프레임워크만 쓸 것이 확실하다면, Shadow DOM 캡슐화와 프로퍼티-어트리뷰트 변환 비용을 감수할 이유가 없습니다. 초기 렌더링 속도와 Core Web Vitals가 절대적으로 중요한 마케팅 랜딩 페이지도 신중해야 합니다. Shadow Root 부착과 스타일 주입이 프레임워크 네이티브 컴포넌트보다 한 단계 더 필요한 작업이기 때문입니다. 이미 여러 프레임워크가 뒤섞인 마이크로 프론트엔드를 운영 중이라면 마이크로 프론트엔드 아키텍처 가이드에서 다룬 Module Federation 전략과 Web Components를 함께 검토하는 편이 좋습니다. 두 접근은 배타적이지 않습니다. Module Federation으로 애플리케이션 셸을 나누고, 그 안의 공유 UI 프리미티브만 Web Components로 통일하는 조합이 우리 조직에서는 가장 안정적이었습니다.
- 프레임워크 지원 매트릭스 확인 — 소비할 프레임워크의 메이저 버전까지 명시해 custom-elements-everywhere 결과를 재확인합니다.
- CSS 변수 기반 토큰 체계 선설계 — Primitive → Semantic → Component 3단계 계층과 테넌트별 오버라이드 지점을 먼저 정의합니다.
- ::part() 노출 API 명세 작성 — 어떤 내부 요소를 열어줄지 팀 합의를 문서로 남겨 무분별한 노출을 막습니다.
- Custom Elements Manifest 생성 파이프라인 구축 — 버전 간 API diff를 CI에서 자동 검출해 semver 규칙을 강제합니다.
- SSR 전략 사전 결정 — Declarative Shadow DOM 지원 브라우저 매트릭스와 폴리필 필요 여부를 미리 확인합니다.
- 점진적 마이그레이션과 롤백 창구 확보 — 최소 2개 마이너 버전 동안 이전 컴포넌트를 병행 운영할 계획을 세웁니다.