웹 컴포넌트 — 프레임워크 없는 재사용 가능한 컴포넌트
프론트엔드 개발 · 심화 주제 시리즈 #2
React도, Vue도, Angular도 아닌 — 순수 브라우저 표준으로 만드는 진정한 재사용 컴포넌트. Custom Elements, Shadow DOM, HTML Templates, ES Modules 네 가지 표준의 조합을 깊이 이해합니다.
1. 웹 컴포넌트란 무엇인가
웹 컴포넌트(Web Components) 는 브라우저 네이티브 표준으로 제공하는 재사용 가능한 캡슐화된 UI 컴포넌트 기술의 집합입니다. 세 가지 표준 기술로 구성됩니다.
Web Components
├── Custom Elements — 새로운 HTML 태그 정의
├── Shadow DOM — 캡슐화된 DOM 트리와 스타일
└── HTML Templates — 비활성 HTML 조각 (<template>, <slot>)
<!-- 웹 컴포넌트로 만든 커스텀 태그를 일반 HTML처럼 사용 -->
<user-avatar size="lg" src="/photo.jpg" name="홍길동"></user-avatar>
<my-modal open title="확인">삭제하시겠습니까?</my-modal>
<data-table sortable paginate rows-per-page="10"></data-table>
왜 웹 컴포넌트인가
프레임워크 독립성: React, Vue, Angular 어디서든 <my-button> 태그로 사용할 수 있습니다.
수명 주기 보장: 특정 프레임워크가 사라져도 브라우저 표준은 계속됩니다.
진정한 캡슐화: Shadow DOM으로 내부 구현이 외부 스타일/스크립트의 영향을 받지 않습니다.
현재 지원: 모든 현대 브라우저(Chrome, Firefox, Safari, Edge)에서 완전 지원됩니다.
2. Custom Elements
Custom Elements API는 새로운 HTML 태그를 JavaScript 클래스로 정의합니다.
Autonomous Custom Elements — 완전히 새로운 요소
// my-greeting.js
class MyGreeting extends HTMLElement {
constructor() {
super();
// constructor에서는 속성 접근 금지, 자식 DOM 조작 금지
// Shadow DOM 생성과 이벤트 리스너 등록만 허용
}
connectedCallback() {
// 요소가 DOM에 삽입될 때 호출 (componentDidMount에 해당)
const name = this.getAttribute('name') || '사용자';
this.innerHTML = `<p>안녕하세요, <strong>${name}</strong>님!</p>`;
}
disconnectedCallback() {
// 요소가 DOM에서 제거될 때 호출 (componentWillUnmount에 해당)
// 이벤트 리스너 정리, 타이머 해제 등
}
}
// 태그 이름 등록 (반드시 하이픈 포함)
customElements.define('my-greeting', MyGreeting);
<my-greeting name="홍길동"></my-greeting>
Customized Built-in Elements — 기존 요소 확장
// 기존 <button>을 확장
class FancyButton extends HTMLButtonElement {
connectedCallback() {
this.classList.add('fancy-btn');
this.addEventListener('click', this._handleClick);
}
_handleClick() {
this.animate(
[{ transform: 'scale(1)' }, { transform: 'scale(0.95)' }, { transform: 'scale(1)' }],
{ duration: 150 }
);
}
}
customElements.define('fancy-button', FancyButton, { extends: 'button' });
<!-- is 속성으로 사용 (Safari는 폴리필 필요) -->
<button is="fancy-button">클릭</button>
customElements.whenDefined — 정의 대기
// 커스텀 요소가 정의될 때까지 대기
await customElements.whenDefined('my-greeting');
console.log('my-greeting 준비 완료');
// 이미 정의되었는지 확인
const existingDef = customElements.get('my-greeting');
if (!existingDef) {
customElements.define('my-greeting', MyGreeting);
}
3. Shadow DOM
Shadow DOM은 컴포넌트 내부에 격리된 독립적인 DOM 트리를 만드는 기술입니다.
일반 DOM vs Shadow DOM
일반 DOM (Light DOM):
document
└── <body>
├── <div class="card">...</div>
└── <my-card> ← 커스텀 요소
└── (내부가 외부 스타일에 노출됨)
Shadow DOM:
document
└── <body>
└── <my-card> ← Shadow Host
└── #shadow-root ← Shadow Root (격리 경계)
├── <style>.title { color: blue; }</style>
└── <div class="title">내부 DOM</div>
Shadow DOM 생성
class MyCard extends HTMLElement {
constructor() {
super();
// Shadow Root 생성
// mode: 'open' → this.shadowRoot로 외부 접근 가능
// mode: 'closed'→ 외부 접근 완전 차단
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
/* 이 스타일은 Shadow DOM 안에서만 유효 */
:host {
display: block;
border-radius: 8px;
overflow: hidden;
}
:host([elevated]) {
box-shadow: 0 4px 16px rgba(0,0,0,.15);
}
.header {
background: #3498db;
color: white;
padding: 1rem;
}
.body {
padding: 1rem;
}
</style>
<div class="header">
<slot name="header">기본 제목</slot>
</div>
<div class="body">
<slot></slot>
</div>
`;
}
}
customElements.define('my-card', MyCard);
<!-- 외부 스타일이 Shadow DOM 내부에 영향 못 줌 -->
<style>
.header { background: red; } /* my-card 내부 .header에 영향 없음! */
</style>
<my-card elevated>
<span slot="header">카드 제목</span>
카드 내용입니다.
</my-card>
CSS 관통 선택자 — 외부에서 Shadow DOM 스타일 변경
Shadow DOM은 외부 CSS를 차단하지만, CSS Custom Properties(변수) 는 Shadow DOM 경계를 통과합니다.
/* Shadow DOM 내부 */
:host {
--card-bg: white; /* CSS 변수 기본값 */
--card-radius: 8px;
background: var(--card-bg);
border-radius: var(--card-radius);
}
/* 외부 CSS — CSS 변수로 Shadow DOM 스타일 제어 가능 */
my-card {
--card-bg: #f0f4f8;
--card-radius: 16px;
}
4. HTML Templates
<template> 태그는 렌더링되지 않는 HTML 조각을 정의합니다. JavaScript로 클론해서 사용합니다.
<!-- 페이지에 렌더링되지 않음 -->
<template id="user-card-template">
<style>
.card { border: 1px solid #ddd; border-radius: 8px; padding: 1rem; }
.name { font-weight: bold; font-size: 1.1rem; }
.email { color: #666; font-size: .9rem; }
</style>
<div class="card">
<img class="avatar" alt="프로필 사진" />
<p class="name"></p>
<p class="email"></p>
</div>
</template>
class UserCard extends HTMLElement {
connectedCallback() {
const template = document.getElementById('user-card-template');
// content를 cloneNode(true)로 복제 (원본 유지)
const clone = template.content.cloneNode(true);
// 데이터 채우기
clone.querySelector('.avatar').src = this.getAttribute('avatar');
clone.querySelector('.name').textContent = this.getAttribute('name');
clone.querySelector('.email').textContent = this.getAttribute('email');
this.attachShadow({ mode: 'open' }).appendChild(clone);
}
}
customElements.define('user-card', UserCard);
<slot> — 컨텐츠 투영
<!-- 컴포넌트 내부 정의 -->
<template id="dialog-template">
<style>
.dialog { background: white; border-radius: 12px; padding: 2rem; }
.title { font-size: 1.25rem; font-weight: bold; margin-bottom: 1rem; }
.actions { display: flex; gap: .5rem; justify-content: flex-end; margin-top: 1.5rem; }
</style>
<div class="dialog">
<div class="title">
<slot name="title">제목</slot> <!-- 이름 있는 슬롯 -->
</div>
<div class="content">
<slot></slot> <!-- 기본 슬롯 -->
</div>
<div class="actions">
<slot name="actions"> <!-- 이름 있는 슬롯 -->
<button>확인</button>
</slot>
</div>
</div>
</template>
<!-- 사용 — slot 속성으로 컨텐츠 투영 -->
<my-dialog>
<span slot="title">삭제 확인</span>
<p>정말 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.</p>
<div slot="actions">
<button>취소</button>
<button>삭제</button>
</div>
</my-dialog>
5. Life Cycle Callbacks
class MyComponent extends HTMLElement {
constructor() {
super();
// Shadow DOM 생성 가능
// 이벤트 리스너 등록 가능
// 자식 요소 접근 불가, getAttribute() 접근 권장하지 않음
this._shadow = this.attachShadow({ mode: 'open' });
this._shadow.innerHTML = `<p>컴포넌트</p>`;
}
// DOM에 삽입될 때
connectedCallback() {
// 속성 접근, 자식 DOM 조작, 이벤트 리스너 등록
this._render();
this._startTimer();
}
// DOM에서 제거될 때
disconnectedCallback() {
// 메모리 누수 방지: 정리 작업
this._stopTimer();
this._removeEventListeners();
}
// 다른 document로 이동될 때 (iframe 등)
adoptedCallback() {
console.log('다른 document로 이동됨');
}
// 감시할 속성 목록
static get observedAttributes() {
return ['name', 'color', 'size'];
}
// 감시 속성이 변경될 때
attributeChangedCallback(attributeName, oldValue, newValue) {
if (oldValue === newValue) return; // 실제 변경일 때만 처리
switch (attributeName) {
case 'name': this._updateName(newValue); break;
case 'color': this._updateColor(newValue); break;
}
}
}
6. 속성과 프로퍼티 반영
HTML 속성(attribute)과 DOM 프로퍼티(property)를 양방향으로 반영합니다.
class ProgressBar extends HTMLElement {
static get observedAttributes() { return ['value', 'max']; }
// 프로퍼티 getter/setter 정의
get value() {
return parseFloat(this.getAttribute('value') || '0');
}
set value(val) {
this.setAttribute('value', String(val)); // 속성도 동시 업데이트
}
get max() {
return parseFloat(this.getAttribute('max') || '100');
}
set max(val) {
this.setAttribute('max', String(val));
}
get percentage() {
return Math.min(100, Math.max(0, (this.value / this.max) * 100));
}
connectedCallback() {
this._shadow = this.attachShadow({ mode: 'open' });
this._render();
}
attributeChangedCallback() {
this._render();
}
_render() {
if (!this._shadow) return;
this._shadow.innerHTML = `
<style>
.bar-container {
width: 100%;
height: 8px;
background: #e0e0e0;
border-radius: 4px;
overflow: hidden;
}
.bar-fill {
height: 100%;
background: #3498db;
border-radius: 4px;
transition: width .3s ease;
}
</style>
<div class="bar-container">
<div class="bar-fill" style="width: ${this.percentage}%"></div>
</div>
`;
}
}
customElements.define('progress-bar', ProgressBar);
<progress-bar value="35" max="100"></progress-bar>
<script>
const bar = document.querySelector('progress-bar');
bar.value = 75; // 프로퍼티로 설정
console.log(bar.value); // 75
console.log(bar.getAttribute('value')); // "75" (속성 반영됨)
</script>
7. 이벤트와 통신
class SearchInput extends HTMLElement {
connectedCallback() {
this._shadow = this.attachShadow({ mode: 'open' });
this._shadow.innerHTML = `
<input type="search" placeholder="검색어 입력..." />
`;
const input = this._shadow.querySelector('input');
input.addEventListener('input', (e) => {
// composed: true → Shadow DOM 경계를 넘어 이벤트 전파
this.dispatchEvent(new CustomEvent('search-input', {
bubbles: true,
composed: true,
detail: { query: e.target.value },
}));
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
this.dispatchEvent(new CustomEvent('search-submit', {
bubbles: true, composed: true,
detail: { query: e.target.value },
}));
}
});
}
}
customElements.define('search-input', SearchInput);
<search-input></search-input>
<script>
document.querySelector('search-input').addEventListener('search-submit', (e) => {
console.log('검색:', e.detail.query);
});
</script>
8. 실전 컴포넌트 예제 — 커스텀 모달
class MyModal extends HTMLElement {
static get observedAttributes() { return ['open', 'title']; }
constructor() {
super();
this._shadow = this.attachShadow({ mode: 'open' });
this._shadow.innerHTML = `
<style>
:host { display: none; }
:host([open]) { display: block; }
.overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fade-in .2s ease;
}
.modal {
background: white;
border-radius: 12px;
padding: 2rem;
width: 90%;
max-width: 480px;
max-height: 80vh;
overflow-y: auto;
animation: slide-up .2s ease;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.title {
font-size: 1.25rem;
font-weight: 700;
margin: 0;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
line-height: 1;
color: #666;
}
.close-btn:hover { color: #000; }
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slide-up {
from { transform: translateY(16px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
</style>
<div class="overlay" part="overlay">
<div class="modal" part="modal" role="dialog" aria-modal="true">
<div class="header">
<h2 class="title" id="dialog-title"></h2>
<button class="close-btn" aria-label="닫기">✕</button>
</div>
<div class="body">
<slot></slot>
</div>
<div class="footer">
<slot name="footer"></slot>
</div>
</div>
</div>
`;
// 닫기 버튼
this._shadow.querySelector('.close-btn').addEventListener('click', () => {
this.close();
});
// 오버레이 클릭으로 닫기
this._shadow.querySelector('.overlay').addEventListener('click', (e) => {
if (e.target === e.currentTarget) this.close();
});
// ESC 키로 닫기
this._handleKeydown = (e) => {
if (e.key === 'Escape' && this.hasAttribute('open')) this.close();
};
}
connectedCallback() {
document.addEventListener('keydown', this._handleKeydown);
}
disconnectedCallback() {
document.removeEventListener('keydown', this._handleKeydown);
}
attributeChangedCallback(name, _, newVal) {
if (name === 'title') {
const titleEl = this._shadow.querySelector('.title');
if (titleEl) titleEl.textContent = newVal;
}
if (name === 'open') {
this._shadow.querySelector('[role="dialog"]')
?.setAttribute('aria-labelledby', 'dialog-title');
}
}
open() {
this.setAttribute('open', '');
this.dispatchEvent(new CustomEvent('modal-opened', { bubbles: true, composed: true }));
}
close() {
this.removeAttribute('open');
this.dispatchEvent(new CustomEvent('modal-closed', { bubbles: true, composed: true }));
}
}
customElements.define('my-modal', MyModal);
<my-modal id="confirm-modal" title="삭제 확인">
<p>정말 삭제하시겠습니까?</p>
<div slot="footer">
<button id="cancel-btn">취소</button>
<button id="confirm-btn">삭제</button>
</div>
</my-modal>
<button id="open-btn">모달 열기</button>
<script>
const modal = document.getElementById('confirm-modal');
document.getElementById('open-btn').addEventListener('click', () => modal.open());
document.getElementById('cancel-btn').addEventListener('click', () => modal.close());
document.getElementById('confirm-btn').addEventListener('click', () => {
console.log('삭제 확인됨');
modal.close();
});
</script>
9. React/Vue와의 통합
React에서 웹 컴포넌트 사용
// React 18 이하: 이벤트 핸들링이 불편함
// React 19+: 웹 컴포넌트 완전 지원
import { useRef, useEffect } from 'react';
import 'my-design-system/modal'; // 웹 컴포넌트 등록
function App() {
const modalRef = useRef(null);
useEffect(() => {
const modal = modalRef.current;
const handleClose = () => setOpen(false);
// React의 합성 이벤트가 커스텀 이벤트를 처리 못할 수 있음
// → ref로 직접 addEventListener 사용
modal.addEventListener('modal-closed', handleClose);
return () => modal.removeEventListener('modal-closed', handleClose);
}, []);
return (
<my-modal ref={modalRef} title="확인">
내용
</my-modal>
);
}
Vue에서 웹 컴포넌트 사용
<template>
<!-- Vue는 웹 컴포넌트를 자연스럽게 지원 -->
<my-modal
:open="isOpen || undefined"
:title="title"
@modal-closed="isOpen = false"
>
<p>내용</p>
</my-modal>
</template>
<script setup>
import { ref } from 'vue';
const isOpen = ref(false);
const title = ref('제목');
</script>
10. 결론
웹 컴포넌트는 "한 번 만들고, 어디서나 사용하는" 진정한 재사용 컴포넌트를 실현합니다.
언제 웹 컴포넌트를 선택하는가
- 디자인 시스템: 여러 프레임워크(React, Vue, Angular)를 사용하는 팀에 공통 UI 제공
- 마이크로 프론트엔드: 프레임워크 독립적인 통합 레이어
- 장기적 수명이 필요한 컴포넌트: 10년 뒤에도 동작해야 하는 UI
현재 한계
- React와의 이벤트 통합이 v18에서 불편함 (v19에서 개선)
- TypeScript 타입 정의 별도 작성 필요
- SSR(서버 사이드 렌더링)이 기본으로 복잡함
- 상태 관리, 리액티비티가 바닐라 JS로는 번거로움 → Lit 라이브러리로 보완
한 줄 요약: 웹 컴포넌트는 프레임워크의 나이를 먹지 않는 진정한 표준 컴포넌트입니다. 단일 프레임워크 프로젝트보다 다중 프레임워크 환경, 디자인 시스템, 장기 지원 컴포넌트에서 빛납니다.
댓글