Web Components 2026: Custom Elements로 프레임워크 독립 컴포넌트 만들기

프론트엔드

Web ComponentsCustom ElementsShadow DOMLit프레임워크 독립

이 글은 누구를 위한 것인가

  • 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 래퍼를 만들면 이벤트 핸들러 연결도 자동화된다.