dev.syw

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 DOMform-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>
HTML

⚠️ 주의 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>
HTML

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);
JavaScript
<user-card name="홍길동" avatar="/images/user1.png"></user-card>
HTML

💡 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);
JavaScript
<tooltip-button tip="저장 단축키: Ctrl+S">저장</tooltip-button>
HTML

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);
JavaScript
<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>
HTML

slotchange 이벤트로 투영 변화 감지

const shadow = this.attachShadow({ mode: 'open' });
// ...
shadow.querySelector('slot').addEventListener('slotchange', e => {
  const assigned = e.target.assignedElements({ flatten: true });
  console.log('슬롯 변경, 현재 요소:', assigned);
});
JavaScript

💡 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

브라우저는 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);
JavaScript

⚠️ 주의 shadowrootmode="closed" 를 DSD에서 사용하면 JavaScript의 this.shadowRootnull 을 반환합니다. 다만 등록된 커스텀 엘리먼트라면 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' });
JavaScript
<button is="loading-button" label="제출">제출</button>
HTML

⚠️ 주의 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);
JavaScript
<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>
HTML

💡 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, 유효성 검사, 폼 리셋에 참여하는 커스텀 폼 컴포넌트를 구현할 수 있다.

연습문제

  1. <template> 태그를 이용해 <li> 아이템 구조를 정의하고, JavaScript로 배열 데이터를 반복 렌더링하는 목록을 만드세요. 단, 각 아이템에는 제목과 날짜 정보가 포함되어야 합니다.

    힌트 cloneNode(true) 로 복사한 뒤 fragment 내 요소를 querySelector 로 찾아 값을 채워 넣으세요.

  2. Shadow DOM을 사용하는 <badge-label> 커스텀 엘리먼트를 만드세요. type 속성값(success / warning / error)에 따라 배경색이 달라져야 하고, 배지 텍스트는 슬롯을 통해 주입받아야 합니다.

    힌트 attributeChangedCallback 에서 Shadow DOM 내부의 <span> 클래스를 업데이트하거나 CSS 커스텀 프로퍼티를 변경하세요.

  3. Declarative Shadow DOM을 사용해 서버에서 렌더링된 것처럼 동작하는 <avatar-card> HTML 스니펫을 작성하세요. Shadow Root 내부에 프로필 이미지·이름 슬롯이 있어야 하고, JavaScript 없이도 스타일이 적용되어야 합니다.

    힌트 <template shadowrootmode="open"><avatar-card> 안에 직접 작성하세요.

  4. formAssociated Custom Element로 <toggle-switch> 를 구현하세요. ON/OFF 상태를 value 프로퍼티("on" / "off")로 관리하고, <form> 제출 시 FormData 에 포함되어야 하며, 폼 리셋 시 OFF 상태로 돌아가야 합니다.

    힌트 this.attachInternals()ElementInternals 인스턴스를 만들고, setFormValue()formResetCallback() 을 구현하세요.

💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

HTML 심화” 강좌에 대한 댓글입니다.

댓글을 작성하려면 로그인이 필요합니다.