template/slot, Custom Elements, Shadow DOM으로 캡슐화된 재사용 컴포넌트를 설계하는 패턴을 익힌다.
템플릿·웹 컴포넌트로 재사용 구조 설계
입문편에서는 시맨틱 태그로 문서의 의미를 정확히 표현하는 방법을 배웠습니다. 그 다음 단계는 같은 UI 구조를 여러 곳에서 일관되게 재사용하는 것입니다. React나 Vue 같은 프레임워크 없이도, HTML 표준이 제공하는 Web Components — <template>, Custom Elements, Shadow DOM, Slot — 를 조합하면 프레임워크에 종속되지 않는 캡슐화된 컴포넌트를 만들 수 있습니다. 이 레슨은 각 메커니즘의 동작 원리와 실무에서 마주치는 함정을 중심으로 살펴봅니다.
학습 목표
<template>요소의 지연 파싱 특성과DocumentFragment활용 방법을 이해한다.- Custom Elements 의 정의 방법과
connectedCallback,attributeChangedCallback등 라이프사이클 콜백의 실행 시점을 파악한다. - Shadow DOM 이 제공하는 스타일·DOM 캡슐화 경계의 의미와 한계를 안다.
- slot / named slot 을 이용해 외부 콘텐츠를 컴포넌트 내부에 투영(projection)하는 패턴을 익힌다.
- Declarative Shadow DOM 과
form-associated커스텀 엘리먼트를 통해 서버 렌더링·폼 연동을 처리한다.
<template> 요소와 지연 파싱
<template> 태그의 내용은 파싱 단계에서 비활성 문서 프래그먼트(DocumentFragment) 에 저장되고, 렌더 트리에 포함되지 않습니다. 이미지 요청도, 스크립트 실행도, 스타일 적용도 일어나지 않습니다.
<template id="card-tpl">
<article class="card">
<img src="" alt="" />
<h2 class="title"></h2>
<p class="desc"></p>
</article>
</template>
<div id="container"></div>
<script>
const tpl = document.getElementById('card-tpl');
// tpl.content 는 DocumentFragment — 아직 DOM에 없음
const fragment = tpl.content.cloneNode(true); // ✅ 깊은 복사 필수
fragment.querySelector('.title').textContent = '카드 제목';
fragment.querySelector('.desc').textContent = '설명 텍스트';
fragment.querySelector('img').src = '/images/sample.jpg';
document.getElementById('container').appendChild(fragment);
</script>
⚠️ 주의
cloneNode(true)없이tpl.content를 직접appendChild하면 원본 프래그먼트가 DOM으로 이동되어, 두 번째 재사용 시 빈 프래그먼트만 남습니다. 반드시cloneNode(true)로 복사하세요.
template 안에서의 중첩 template
<template> 을 중첩하면 내부 <template> 역시 즉시 활성화되지 않습니다. 조건부·반복 렌더링 패턴에 유용합니다.
<template id="list-tpl">
<ul>
<template id="item-tpl">
<li class="item"></li>
</template>
</ul>
</template>
<script>
const listFrag = document.getElementById('list-tpl').content.cloneNode(true);
const itemTpl = listFrag.querySelector('#item-tpl');
const ul = listFrag.querySelector('ul');
['사과', '바나나', '딸기'].forEach(name => {
const item = itemTpl.content.cloneNode(true);
item.querySelector('.item').textContent = name;
ul.appendChild(item);
});
document.body.appendChild(listFrag);
</script>
Custom Elements 정의와 라이프사이클
Custom Elements API를 사용하면 HTMLElement 를 상속하는 클래스를 HTML 태그로 등록할 수 있습니다. 태그 이름에는 하이픈(-) 이 반드시 하나 이상 포함되어야 합니다.
class UserCard extends HTMLElement {
// 감시할 속성 목록 — 선언하지 않으면 attributeChangedCallback 미호출
static get observedAttributes() {
return ['name', 'avatar'];
}
constructor() {
super();
// constructor에서는 호스트 자신의 속성을 읽거나(getAttribute)
// 호스트에 속성/자식을 추가(setAttribute/appendChild)하는 것이 금지된다.
// 단, attachShadow로 Shadow Root를 만들고 내부 DOM을 구성하거나
// attachInternals를 호출하는 것은 허용된다.
this._initialized = false;
}
connectedCallback() {
// 요소가 DOM에 연결될 때마다 호출 (이동 시에도 재호출)
if (!this._initialized) {
this._render();
this._initialized = true;
}
}
disconnectedCallback() {
// DOM에서 제거될 때 — 이벤트 리스너, 타이머 정리
console.log('UserCard removed');
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return;
if (this._initialized) this._render();
}
_render() {
const name = this.getAttribute('name') ?? '익명';
const avatar = this.getAttribute('avatar') ?? '/default.png';
this.innerHTML = `
<div class="user-card">
<img src="${avatar}" alt="${name} 프로필">
<span>${name}</span>
</div>
`;
}
}
customElements.define('user-card', UserCard);
<user-card name="홍길동" avatar="/images/user1.png"></user-card>
💡 TIP
customElements.whenDefined('user-card')는 해당 요소가 등록될 때까지 기다리는 Promise를 반환합니다. 비동기 번들 로딩 환경에서 업그레이드 타이밍 문제를 방지할 때 사용합니다.
라이프사이클 실행 순서 정리
| 콜백 | 호출 시점 | 주의사항 |
|---|---|---|
constructor | 인스턴스 생성 시 | 호스트 속성/자식 조작 금지(shadow root 구성은 허용) |
connectedCallback | 문서 DOM에 연결될 때 | 이동하면 재호출됨 |
disconnectedCallback | 문서 DOM에서 제거될 때 | 정리(cleanup) 로직 위치 |
attributeChangedCallback | 감시 속성 값 변경 시 | observedAttributes 선언 필수 |
adoptedCallback | 다른 Document로 이동 시 | 드물게 사용 |
Shadow DOM과 캡슐화 경계
Shadow DOM은 커스텀 엘리먼트 내부의 DOM 서브트리를 호스트(host) 문서와 격리합니다. 외부 CSS 셀렉터가 내부에 침투하지 못하고, 내부 스타일도 외부로 누출되지 않습니다.
class TooltipButton extends HTMLElement {
connectedCallback() {
// mode: 'open' — JS로 shadowRoot 접근 가능
// mode: 'closed' — 외부에서 shadowRoot 접근 불가 (완전 폐쇄)
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
/* 이 스타일은 Shadow DOM 내부에만 적용 */
button {
background: #0055ff;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
.tip {
display: none;
position: absolute;
background: #333;
color: #fff;
padding: 4px 8px;
border-radius: 3px;
font-size: 0.8rem;
}
button:hover + .tip { display: block; }
</style>
<button type="button"><slot></slot></button>
<span class="tip">${this.getAttribute('tip') ?? ''}</span>
`;
}
}
customElements.define('tooltip-button', TooltipButton);
<tooltip-button tip="저장 단축키: Ctrl+S">저장</tooltip-button>
CSS 캡슐화 경계 조정
캡슐화가 완벽하더라도 외부에서 테마 색상 등을 주입해야 할 때가 있습니다. CSS 커스텀 프로퍼티(변수) 는 Shadow DOM 경계를 투과합니다.
/* 외부 (Light DOM) */
tooltip-button {
--btn-bg: #e44;
--btn-color: #fff;
}
/* Shadow DOM 내부 */
button {
background: var(--btn-bg, #0055ff);
color: var(--btn-color, white);
}
⚠️ 주의
::slotted()셀렉터는 슬롯에 배치된 Light DOM 요소의 최상위 노드에만 적용됩니다. 중첩 자손까지 스타일을 적용하려면 Light DOM 측의 전역 CSS를 활용해야 합니다.
Slot과 Named Slot — 콘텐츠 투영
<slot> 은 Shadow DOM 내부에서 외부(Light DOM)의 콘텐츠를 투영(projection) 하는 포털입니다. 실제 DOM 이동은 일어나지 않으며, 시각적으로만 배치됩니다.
class CardLayout extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host {
display: block;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
}
header { background: #f5f5f5; padding: 12px 16px; }
main { padding: 16px; }
footer { background: #f5f5f5; padding: 8px 16px; text-align: right; }
</style>
<header><slot name="header">기본 헤더</slot></header>
<main><slot></slot></main> <!-- 기본(unnamed) 슬롯 -->
<footer><slot name="footer"></slot></footer>
`;
}
}
customElements.define('card-layout', CardLayout);
<card-layout>
<h2 slot="header">상품 상세</h2>
<!-- 기본 슬롯에 투영 -->
<p>이 제품은 최고급 소재로 제작되었습니다.</p>
<img src="/product.jpg" alt="제품 이미지">
<!-- named slot -->
<div slot="footer">
<button>장바구니 담기</button>
<button>바로 구매</button>
</div>
</card-layout>
slotchange 이벤트로 투영 변화 감지
const shadow = this.attachShadow({ mode: 'open' });
// ...
shadow.querySelector('slot').addEventListener('slotchange', e => {
const assigned = e.target.assignedElements({ flatten: true });
console.log('슬롯 변경, 현재 요소:', assigned);
});
💡 TIP
assignedElements()는 슬롯에 실제로 배치된 Light DOM 요소 목록을 반환합니다.flatten: true옵션을 주면 중첩 슬롯까지 평탄화하여 반환합니다.
Declarative Shadow DOM과 서버 렌더링 호환
기존 Shadow DOM은 JavaScript가 실행되어야 생성되므로 서버사이드 렌더링(SSR) 환경에서 초기 HTML에 Shadow DOM 구조를 포함할 수 없었습니다. Declarative Shadow DOM(DSD) 은 이 문제를 HTML 마크업만으로 해결합니다.
<!-- 서버에서 렌더링된 HTML -->
<user-profile>
<template shadowrootmode="open">
<style>
:host { display: flex; align-items: center; gap: 12px; }
img { width: 48px; height: 48px; border-radius: 50%; }
</style>
<img src="/images/user.png" alt="프로필">
<slot></slot>
</template>
<!-- Light DOM 콘텐츠 -->
<span>홍길동</span>
</user-profile>
브라우저는 HTML 파서 단계에서 shadowrootmode 속성이 있는 <template> 을 만나면 자동으로 Shadow Root를 생성합니다. JavaScript 없이도 캡슐화된 구조가 초기 렌더링에 포함됩니다.
SSR 이후 JavaScript 하이드레이션
class UserProfile extends HTMLElement {
constructor() {
super();
// ElementInternals.shadowRoot 는 open/closed 상관없이
// 파서가 DSD로 자동 생성한 Shadow Root를 반환한다.
this._internals = this.attachInternals();
}
connectedCallback() {
// DSD로 이미 Shadow Root가 존재할 수 있음 — 중복 생성 방지
// closed DSD는 this.shadowRoot 가 null 이므로 internals.shadowRoot 로 감지
const root = this._internals.shadowRoot;
if (!root) {
// JS only 환경 — 수동으로 Shadow DOM 생성
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `<slot></slot>`;
}
// 이후 이벤트 리스너 등 동적 기능 추가
(this.shadowRoot ?? this._internals.shadowRoot)
.addEventListener('click', this._handleClick.bind(this));
}
_handleClick(e) {
console.log('클릭:', e.target);
}
}
customElements.define('user-profile', UserProfile);
⚠️ 주의
shadowrootmode="closed"를 DSD에서 사용하면 JavaScript의this.shadowRoot는null을 반환합니다. 다만 등록된 커스텀 엘리먼트라면 constructor에서this.attachInternals().shadowRoot로 파서가 자동 생성한 Shadow Root(open/closed 모두)에 접근할 수 있으므로 동적 기능 추가가 불가능한 것은 아닙니다. DSD 감지·하이드레이션에서는this.shadowRoot대신ElementInternals.shadowRoot를 확인하는 것이 권장 패턴입니다. 외부에서도 Shadow Root에 접근해야 하는 SSR + JS 하이드레이션 시나리오에서는 여전히open을 권장합니다.
is 속성·커스텀 빌트인과 form-associated 컴포넌트
커스텀 빌트인 엘리먼트 (Customized Built-in Elements)
is 속성을 사용하면 기존 HTML 요소를 확장할 수 있습니다. 네이티브 요소의 접근성·의미론을 그대로 상속하면서 기능을 추가할 때 유용합니다.
// HTMLButtonElement를 확장 — 기본 button 동작 유지
class LoadingButton extends HTMLButtonElement {
connectedCallback() {
this.addEventListener('click', async () => {
this.disabled = true;
this.textContent = '처리 중...';
try {
await this._onAction?.();
} finally {
this.disabled = false;
this.textContent = this.getAttribute('label') ?? '확인';
}
});
}
}
customElements.define('loading-button', LoadingButton, { extends: 'button' });
<button is="loading-button" label="제출">제출</button>
⚠️ 주의 Safari는 커스텀 빌트인(Customized Built-in)을 지원하지 않습니다. 폴리필 없이 프로덕션에 사용할 때는 반드시 지원 범위를 확인하세요. 순수 Autonomous Custom Elements(
<my-el>형태)는 전 브라우저에서 지원됩니다.
Form-associated Custom Elements
기본적으로 커스텀 엘리먼트는 <form> 의 제출·유효성 검사 메커니즘에 참여하지 못합니다. ElementInternals API와 formAssociated 플래그를 사용하면 네이티브 폼 요소처럼 동작하는 커스텀 컴포넌트를 만들 수 있습니다.
class StarRating extends HTMLElement {
// form-associated 활성화
static get formAssociated() { return true; }
constructor() {
super();
this._internals = this.attachInternals();
this._value = '0';
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
span { cursor: pointer; font-size: 1.5rem; color: #ccc; }
span.active { color: #f5a623; }
</style>
${[1,2,3,4,5].map(n =>
`<span data-value="${n}">★</span>`
).join('')}
`;
shadow.addEventListener('click', e => {
const star = e.target.closest('[data-value]');
if (!star) return;
this._setValue(star.dataset.value);
});
}
connectedCallback() {
// 초기 미선택('0') 상태부터 valueMissing 검증이 걸리도록 한 번 호출
// (HTML의 required 의도대로 미선택 제출을 invalid 로 처리)
this._setValue(this._value);
}
_setValue(val) {
this._value = val;
// 폼에 전달할 값 설정
this._internals.setFormValue(val);
// 유효성 검사 — 0점은 미선택 상태로 invalid 처리
if (val === '0') {
this._internals.setValidity(
{ valueMissing: true },
'별점을 선택해 주세요.',
this.shadowRoot.querySelector('span')
);
} else {
this._internals.setValidity({});
}
this._updateUI();
}
_updateUI() {
this.shadowRoot.querySelectorAll('[data-value]').forEach(star => {
star.classList.toggle('active', Number(star.dataset.value) <= Number(this._value));
});
}
// 폼 리셋 시 호출
formResetCallback() {
this._setValue('0');
}
// 폼 복원 시 호출 (뒤로 가기 등)
formStateRestoreCallback(state) {
this._setValue(state ?? '0');
}
get value() { return this._value; }
set value(v) { this._setValue(String(v)); }
get name() { return this.getAttribute('name'); }
get validity() { return this._internals.validity; }
get validationMessage() { return this._internals.validationMessage; }
checkValidity() { return this._internals.checkValidity(); }
reportValidity() { return this._internals.reportValidity(); }
}
customElements.define('star-rating', StarRating);
<form id="review-form">
<label>
별점
<star-rating name="rating" required></star-rating>
</label>
<button type="submit">제출</button>
</form>
<script>
document.getElementById('review-form').addEventListener('submit', e => {
e.preventDefault();
const data = new FormData(e.target);
console.log('rating:', data.get('rating')); // "3" 등 선택한 별점
});
</script>
💡 TIP
ElementInternals.setValidity()의 세 번째 인자는 유효성 검사 앵커 요소입니다. Shadow DOM 내부 요소를 지정하면 브라우저가 툴팁을 해당 위치에 표시합니다.
form-associated 라이프사이클 콜백 정리
| 콜백 | 호출 시점 |
|---|---|
formAssociatedCallback(form) | 폼에 연결·해제될 때 |
formDisabledCallback(disabled) | disabled 상태 변경 시 |
formResetCallback() | 폼 리셋 시 |
formStateRestoreCallback(state, mode) | 브라우저 히스토리 복원 시 |
요약
<template>요소의 내용은 지연 파싱되어 렌더 트리에 포함되지 않으며,cloneNode(true)로 복사해 재사용한다.- Custom Elements는
observedAttributes로 감시 속성을 선언해야attributeChangedCallback이 동작하고,constructor에서는 호스트 자신의 속성/자식 조작은 금지되지만attachShadow로 Shadow Root를 만들고 내부 DOM을 구성하는 것은 허용된다. - Shadow DOM 은 스타일·DOM 캡슐화 경계를 제공하며, CSS 커스텀 프로퍼티를 통해 외부 테마 주입을 허용할 수 있다.
- Named slot 으로 다중 콘텐츠 영역을 구분하고,
slotchange이벤트·assignedElements()로 투영 변화를 감지한다. - Declarative Shadow DOM (
shadowrootmode속성)으로 SSR 환경에서도 JavaScript 없이 Shadow DOM을 HTML에 포함할 수 있다. formAssociated+ElementInternals조합으로FormData, 유효성 검사, 폼 리셋에 참여하는 커스텀 폼 컴포넌트를 구현할 수 있다.
연습문제
-
<template>태그를 이용해<li>아이템 구조를 정의하고, JavaScript로 배열 데이터를 반복 렌더링하는 목록을 만드세요. 단, 각 아이템에는 제목과 날짜 정보가 포함되어야 합니다.힌트
cloneNode(true)로 복사한 뒤 fragment 내 요소를querySelector로 찾아 값을 채워 넣으세요. -
Shadow DOM을 사용하는
<badge-label>커스텀 엘리먼트를 만드세요.type속성값(success/warning/error)에 따라 배경색이 달라져야 하고, 배지 텍스트는 슬롯을 통해 주입받아야 합니다.힌트
attributeChangedCallback에서 Shadow DOM 내부의<span>클래스를 업데이트하거나 CSS 커스텀 프로퍼티를 변경하세요. -
Declarative Shadow DOM을 사용해 서버에서 렌더링된 것처럼 동작하는
<avatar-card>HTML 스니펫을 작성하세요. Shadow Root 내부에 프로필 이미지·이름 슬롯이 있어야 하고, JavaScript 없이도 스타일이 적용되어야 합니다.힌트
<template shadowrootmode="open">을<avatar-card>안에 직접 작성하세요. -
formAssociatedCustom Element로<toggle-switch>를 구현하세요. ON/OFF 상태를value프로퍼티("on"/"off")로 관리하고,<form>제출 시FormData에 포함되어야 하며, 폼 리셋 시 OFF 상태로 돌아가야 합니다.힌트
this.attachInternals()로ElementInternals인스턴스를 만들고,setFormValue()와formResetCallback()을 구현하세요.
💡 연습문제 풀이
불러오는 중…
댓글 0
“HTML 심화” 강좌에 대한 댓글입니다.