브라우저가 직접 지원하는 네이티브 컴포넌트 아키텍처 외부 프레임워크 의존성을 완전히 제거하는 웹 표준 기술을 알아봅니다.
현대 웹 개발은 React, Vue, Svelte 같은 거대한 프레임워크를 중심으로 이루어지고 있습니다. 하지만 이러한 도구들은 버전 업그레이드, 빌드 시스템의 복잡성, 그리고 런타임 오버헤드를 동반합니다. Web Components는 브라우저 엔진 레벨에서 재사용 가능한 커스텀 요소를 만들 수 있도록 W3C에서 제정한 표준 기술 모음입니다. 프레임워크 생태계가 급변하더라도 Web Components로 작성된 컴포넌트는 수 년간 소스 코드 수정 없이 브라우저에서 네이티브하게 작동한다는 강력한 장점이 있습니다.
Web Components는 다음 세 가지 핵심 기술로 구성됩니다.
window.customElements)attachShadow())<template>과 <slot>아래 표는 주요 프레임워크 컴포넌트 방식과 Web Components의 아키텍처 차이를 비교한 것입니다.
| 비교 항목 | React 컴포넌트 | Vue 컴포넌트 (.vue) | Web Components (네이티브) |
|---|---|---|---|
| 런타임 의존성 | React 렌더링 엔진 필요 | Vue 런타임 필요 | 없음 (브라우저 자체 지원) |
| 스타일 격리 | CSS Modules / Styled-components | Scoped CSS | Shadow DOM (완벽한 네이티브 격리) |
| 번들 크기 | 대형 (프레임워크 라이브러리 포함) | 중간 (Vue 코어 포함) | 매우 작음 (순수 자바스크립트/HTML) |
| 표준 호환성 | 라이브러리 종속적 | 프레임워크 종속적 | W3C / WHATWG 웹 표준 |
Custom Elements를 생성하려면 HTMLElement를 상속받는 자바스크립트 클래스를 작성하고, customElements.define 메서드를 통해 브라우저에 등록해야 합니다. 태그 이름에는 반드시 하이픈(-)이 포함되어야 HTML 표준 엘리먼트와 충돌하지 않습니다. (예: <user-card>는 유효하지만 <user>는 예약된 표준 태그명과 충돌하여 오류가 발생합니다.)
실무에서 자주 사용되는 자율형 커스텀 엘리먼트(Autonomous Custom Element)의 완성된 구현 예시는 다음과 같습니다.
class UserCard extends HTMLElement {
constructor() {
super();
// 캡슐화를 위해 Open 모드로 Shadow DOM 부착
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
max-width: 320px;
border: 1px solid #E2E8F0;
border-radius: 12px;
padding: 20px;
background: #FFFFFF;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
p {
color: #1E3A5F;
font-weight: 600;
font-size: 1.1rem;
margin: 0 0 12px 0;
}
.badge {
display: inline-block;
background: #EBF8FF;
color: #3182CE;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.85rem;
}
</style>
<p>사용자 카드 컴포넌트</p>
<span class="badge">Active Member</span>
`;
}
}
// 브라우저 레지스트리에 컴포넌트 등록
customElements.define('user-card', UserCard);HTML 문서에서는 아래와 같이 일반 태그처럼 즉시 사용할 수 있으며, 브라우저 파서가 태그를 인식하는 즉시 인스턴스화됩니다.
<user-card></user-card>Shadow DOM은 Web Components의 핵심입니다. 일반적인 DOM 트리의 하위에 별도의 닫힌 DOM 트리를 부착하여, 외부의 CSS 스타일이 내부로 침투하지 못하도록 막고 내부 스타일이 외부로 새어나가지 않도록 보호합니다.
여기서 mode: 'open'으로 설정하면 외부 자바스크립트에서 element.shadowRoot를 통해 내부 DOM에 접근할 수 있지만, mode: 'closed'로 설정하면 외부에서 내부 Shadow DOM 접근이 완전히 차단되어 보안상 더 엄격한 캡슐화를 제공합니다.
다만 외부 스타일이 완전히 차단되면 디자인 시스템의 일관성을 유지하기 어렵기 때문에, **CSS Custom Properties (CSS 변수)**를 활용해 외부에서 내부 스타일의 특정 값을 제어할 수 있는 통로를 열어둘 수 있습니다.
class StyledBox extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
div {
/* 외부에서 전달된 CSS 변수가 없으면 기본값 #D6E8FB 사용 */
background: var(--box-bg, #D6E8FB);
border: 1px solid var(--box-border, #5B8FD4);
color: var(--box-color, #1A365D);
padding: 16px;
border-radius: 8px;
font-family: inherit;
}
</style>
<div>격리된 스타일 영역입니다. (외부 CSS 변수 연동 가능)</div>
`;
}
}
customElements.define('styled-box', StyledBox);외부 HTML 문서에서는 다음과 같이 CSS 변수를 주입하여 내부 스타일을 안전하게 커스텀할 수 있습니다.
<style>
styled-box {
--box-bg: #FEFCBF;
--box-border: #D69E2E;
--box-color: #744210;
}
</style>
<styled-box></styled-box>매번 자바스크립트 문자열 내부에 HTML을 작성하는 것은 복잡한 UI에서 유지보수성을 떨어뜨립니다. <template> 태그를 사용하면 브라우저가 파싱하지만 렌더링하지 않는 정적 마크업 조각을 선언할 수 있으며, <slot>을 통해 컴포넌트 내부로 외부 콘텐츠를 동적으로 주입(Content Projection)할 수 있습니다.
이름이 있는 슬롯(Named Slot)을 활용하면 복잡한 레이아웃 컴포넌트도 깔끔하게 설계할 수 있습니다.
<template id="app-modal-template">
<style>
.modal-backdrop {
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-container {
background: white;
padding: 24px;
border-radius: 12px;
width: 400px;
box-shadow: 0 10px 25px rgba(0,0,0,0.2);
}
.modal-header { font-size: 1.25rem; font-weight: bold; margin-bottom: 12px; }
.modal-footer { margin-top: 20px; display: flex; justify-content: flex-end; gap: 8px; }
</style>
<div class="modal-backdrop">
<div class="modal-container">
<div class="modal-header">
<slot name="header">기본 제목</slot>
</div>
<div class="modal-body">
<slot name="body">기본 본문 내용</slot>
</div>
<div class="modal-footer">
<slot name="footer">
<button id="close-btn">닫기</button>
</slot>
</div>
</div>
</div>
</template>
<script>
class AppModal extends HTMLElement {
constructor() {
super();
const template = document.getElementById('app-modal-template');
this.attachShadow({ mode: 'open' }).appendChild(template.content.cloneNode(true));
}
}
customElements.define('app-modal', AppModal);
</script>사용 시에는 아래와 같이 슬롯 이름을 지정하여 콘텐츠를 채워 넣습니다.
<app-modal>
<span slot="header">시스템 경고</span>
<p slot="body">작업을 계속 진행하시겠습니까? 데이터가 유실될 수 있습니다.</p>
<div slot="footer">
<button onclick="alert('취소됨')">취소</button>
<button style="background: red; color: white;" onclick="alert('확인됨')">확인</button>
</div>
</app-modal>Custom Elements는 리액트 등의 컴포넌트 생명주기와 유사하게, 브라우저가 상태를 변경할 때 호출하는 고유한 라이프사이클 콜백 메서드를 제공합니다.
constructor(): 엘리먼트가 생성될 때 호출 (초기 상태 설정 및 Shadow DOM 생성 용도)connectedCallback(): 엘리먼트가 문서의 DOM에 처음 추가(마운트)될 때 호출disconnectedCallback(): 엘리먼트가 문서의 DOM에서 제거(언마운트)될 때 호출 (이벤트 리스너 해제 및 타이머 정리 용도)adoptedCallback(): 엘리먼트가 다른 문서(document)로 이동될 때 호출attributeChangedCallback(name, oldValue, newValue): 관찰 중인 어트리뷰트가 추가, 제거, 수정될 때 호출class LifeCycleElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
console.log('[1] constructor 호출됨');
}
// 감시할 어트리뷰트 지정
static get observedAttributes() {
return ['status', 'theme'];
}
connectedCallback() {
console.log('[2] connectedCallback 호출됨: DOM 마운트 완료');
this.render();
}
disconnectedCallback() {
console.log('[3] disconnectedCallback 호출됨: DOM 언마운트 완료');
// 메모리 누수 방지를 위한 이벤트 리스너 제거 등 정리 작업 수행
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
console.log(`[4] attributeChangedCallback: ${name} 속성이 [${oldValue}] -> [${newValue}]로 변경됨`);
this.render();
}
}
render() {
const status = this.getAttribute('status') || 'idle';
const theme = this.getAttribute('theme') || 'light';
this.shadowRoot.innerHTML = `
<style>
div { padding: 12px; border-radius: 6px; }
.light { background: #F7FAFC; color: #2D3748; }
.dark { background: #1A202C; color: #EDF2F7; }
</style>
<div class="${theme}">
현재 컴포넌트 상태: <strong>${status}</strong> (테마: ${theme})
</div>
`;
}
}
customElements.define('lifecycle-element', LifeCycleElement);순수 Web Components에서 상태 관리는 주로 프로퍼티(properties)와 커스텀 이벤트(CustomEvent)를 조합하여 구현합니다. 부모 컴포넌트는 자식에게 속성(Attribute)이나 프로퍼티로 데이터를 전달하고, 자식은 이벤트로 부모에게 상태 변화를 알립니다.
복잡한 폼 컨트롤이나 위젯을 만들 때 표준 FormData 및 ElementInternals API를 활용하면 네이티브 <form> 태그와 완벽하게 연동되는 컴포넌트를 작성할 수 있습니다.
class CounterComponent extends HTMLElement {
// Form 연동을 위한 설정
static formAssociated = true;
constructor() {
super();
this._count = 0;
this.attachShadow({ mode: 'open' });
this.internals = this.attachInternals();
this.render();
}
get count() {
return this._count;
}
set count(val) {
this._count = val;
this.render();
// 폼 내부에서 값으로 인식되도록 내부 상태 설정
this.internals.setFormValue(String(this._count));
}
increment() {
this.count++;
// 외부로 커스텀 이벤트 디스패치 (bubbles와 composed를 true로 설정해야 Shadow DOM을 뚫고 올라감)
this.dispatchEvent(new CustomEvent('count-changed', {
detail: { count: this.count },
bubbles: true,
composed: true
}));
}
render() {
this.shadowRoot.innerHTML = `
<style>
button {
background-color: #3182CE;
color: white;
padding: 8px 16px;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
}
button:hover { background-color: #2B6CB0; }
</style>
<button id="btn">카운트: ${this._count}</button>
`;
this.shadowRoot.getElementById('btn').addEventListener('click', () => this.increment());
}
}
customElements.define('counter-component', CounterComponent);Web Components를 대규모 엔터프라이즈 서비스에 도입할 때 고려해야 할 핵심 성능 최적화 기법과 권장 수치는 다음과 같습니다.
customElements.whenDefined('tag-name')을 활용해 정의 완료 시점을 안전하게 보장할 수 있습니다.innerHTML을 파싱하는 것은 메인 스레드에 큰 부하를 줍니다. <template>의 cloneNode(true)를 사용하여 메모리 및 파싱 비용을 최대 70% 이상 절감할 수 있습니다.click 리스너를 붙이지 않고, Shadow Root 최상단 루트 노드에 단일 이벤트 리스너를 배치하여 메모리 사용량을 최소화합니다.아래는 성능 최적화가 적용된 템플릿 복제 패턴의 예시입니다.
// 전역 템플릿 캐싱 활용
const cachedTemplate = document.createElement('template');
cachedTemplate.innerHTML = `
<style>
.item { padding: 8px; border-bottom: 1px solid #EDF2F7; }
</style>
<div class="item"><slot>아이템</slot></div>
`;
class OptimizedItem extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
// 이미 파싱된 템플릿 노드를 클론하여 DOM 삽입 속도 극대화
shadow.appendChild(cachedTemplate.content.cloneNode(true));
}
}
customElements.define('optimized-item', OptimizedItem);div, .class, #id)가 전혀 적용되지 않습니다. 디자인 시스템의 일관성을 맞추기 위해 일반 CSS를 상속받으려 하지 말고, 반드시 **CSS Custom Properties (--*)**를 통해 값을 주입해야 합니다.constructor() 내부에서 아직 DOM에 완전히 마운트되지 않은 상태의 어트리뷰트나 속성에 접근하면 null 또는 빈 값이 반환됩니다. 속성 값 초기화 및 읽기는 반드시 connectedCallback() 내부에서 수행해야 안전합니다.✨ 한 줄 요약 프레임워크 종속성을 끊어내는 가장 확실한 웹 표준 아키텍처, Web Components와 Shadow DOM
