티스토리 뷰

FrontEnd/javascript

[JavaScript] Shadow DOM

til-odin 2025. 3. 21. 20:42

00. TL;DR

  • Shadow DOM은 DOM을 캡슐화하여 외부 스타일과 스크립트의 간섭 없이 독립적으로 동작하도록 합니다.
  • React 또는 Vue 앱을 Shadow DOM 내부에 마운트하면, 모던 프레임워크를 캡슐화된 UI 컴포넌트로 만들 수 있습니다.
  • 외부와의 통신은 Custom Element 클래스의 메서드, 속성, 이벤트 등을 통해 안정적으로 설계할 수 있습니다.
  • slot, ::part, :host 등을 통해 외부에서 스타일 제어가 가능하며, 이벤트나 메서드로 내부 상태를 외부에서 제어할 수 있습니다.

01. Shadow DOM 기본 개념

01.01. Shadow DOM 이란?

Shadow DOM은 웹 컴포넌트(Web Components) 기술 중 하나로, 일반 DOM 트리와는 별개의 "캡슐화된 DOM 트리" 를 생성하여 외부와 독립적으로 동작하도록 하는 기능입니다.

이 기능은 주로 컴포넌트 기반 개발에서 구현 디테일을 숨기고, 스타일이나 구조가 외부로부터 영향을 받지 않게 하기 위해 사용됩니다.

01.02. Shadow DOM이 필요할까?

  • 스타일 충돌 방지
    • 전통적인 웹 페이지에서는 CSS 클래스나 ID가 전역적으로 적용되어 다른 컴포넌트의 스타일에 영향을 줄 수 있습니다.
    • Shadow DOM을 사용하면 내부 스타일은 그 컴포넌트에만 적용되며, 외부 스타일과 절대 충돌하지 않습니다.
  • 내부 구현 캡슈화
    • 컴포넌트 내부 구조나 동작을 외부에 노출하지 않음으로써, 외부에서 불필요한 접근이나 조작을 막을 수 있습니다.
  • 재사용 가능한 UI 컴포넌트 제작
    • 동일한 컴포넌트를 여러 곳에서 사용해도, 각각이 독립적으로 작동하므로 문제가 생기지 않습니다.

01.02.01. 간단한 사용 예시

아래 예시는 <my-component>라는 사용자 정의 태그를 만들어 Shadow DOM을 붙였습니다.

내부의 <p> 태그는 red 색상으로 표시되지만, 외부의 CSS가 적용되지 않으며, 외부의 <p> 스타일도 이 요소에 영향을 주지 못합니다.

class MyComponent extends HTMLElement {
  constructor() {
    super();

    // Shadow DOM을 생성합니다. mode는 'open'으로 설정합니다.
    const shadow = this.attachShadow({ mode: 'open' });

    // 내부 콘텐츠를 설정합니다.
    shadow.innerHTML = `
      <style>
        p {
          color: red;
          font-weight: bold;
        }
      </style>
      <p>이 문장은 Shadow DOM 안에 있습니다.</p>
    `;
  }
}

// 사용자 정의 요소를 등록합니다.
customElements.define('my-component', MyComponent);
<my-component></my-component>

02. mode 옵션

Shadow DOM을 생성할 때 attachShadow() 메서드를 호출할 때 mode 옵션을 전달할 수 있습니다.

모드 설명
open 외부 자바스크립트에서 .shadowRoot로 접근할 수 있습니다.
closed 외부 자바스크립트에서 .shadowRoot로 접근할 수 없으며, 보이지 않습니다.

예시 :

const shadow = this.attachShadow({ mode: 'closed' });
// null 반환
console.log(this.shadowRoot); 

03. 슬롯 기능

Shadow DOM 내부에서 <slot>을 사용하면 외부에서 전달된 콘텐츠를 원하는 위치에 주입할 수 있습니다.

03.01. 기본 슬롯 사용

shadow.innerHTML = `
  <style> ::slotted(*) { color: blue; } </style>
  <slot></slot>
`;
<my-component>
  <p>이 내용은 슬롯으로 전달됩니다.</p>
</my-component>

slot은 fallback content를 아래처럼 작성하면 제공 가능합니다.

<!-- 태그는 외부에서 콘텐츠가 전달되지 않으면 "기본 콘텐츠"를 렌더링합니다. -->
<slot>기본 콘텐츠</slot>

03.02. 여러 개의 슬롯 사용

여러 개의 <slot>을 정의하고 외부 콘텐츠를 slot="name"으로 주입할 수 있습니다.

예제 코드

class MultiSlotComponent extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <slot name="header"></slot>
      <slot name="content"></slot>
    `;
  }
}
customElements.define('multi-slot', MultiSlotComponent);
<multi-slot>
  <h1 slot="header">제목</h1>
  <p slot="content">내용</p>
</multi-slot>

04. 스타일링

기본적으로 외부 CSS는 Shadow DOM 내부에 적용되지 않습니다.

04.01. 컴포넌트에 외부 스타일 허용 방법

필요하다면 :host, ::part, :host-context() 를 사용하여 스타일링을 적용할 수 있습니다.

04.02. :host

:host는 Shadow DOM이 적용된 루트 컴포넌트 자체를 선택할 때 사용합니다.

04.02.01. :host 선택자

아래 코드는 <my-component> 자체(내부가 아니라 루트 요소)에 스타일을 지정합니다.

:host {
  display: block;
  padding: 1rem;
  background: #f0f0f0;
}

04.02.02. :host<class>

아래 코드는 컴포넌트에 특정 클래스가 주어졌을 때 스타일을 다르게 지정합니다.

:host(.danger) {
  border: 1px solid red;
}
<my-component class="danger"></my-component>

04.03. ::part

::part는 Shadow DOM 내부 요소에 대해 외부 스타일 적용을 허용하기 위한 방식입니다.

  • 외부에서는 Shadow DOM 내부의 .input 클래스는 절대 접근할 수 없지만, part를 부여하면 외부에서 지정한 스타일을 덮어씌울 수 있습니다.
  • 내부 요소에 part="이름"을 부여하면 외부에서 접근 가능합니다.

04.03.01. 컴포넌트 내부

<!-- shadow DOM 내부 -->
<style>
  .input {
    border: 1px solid lightgray;
  }
</style>

<input class="input" part="otp-input" />
my-component::part(button) {
  color: red;
  background: yellow;
}

04.03.02. 외부 스타일

my-component::part(otp-input) {
  border: 2px dashed blue;
}

04.04. :host-context()

:host-context()는 컴포넌트를 감싸고 있는 외부의 상위 요소가 어떤 조건을 만족할 때, Shadow DOM 내부 스타일을 조절하는 데 사용합니다.

  • :host-context()는 내부에서 외부 조건을 감지하는 방식이므로, 외부에서 내부 스타일을 조절할 수는 있어도 직접 조작하는 방식이 아닙니다.

04.04.01. 예시

/* Shadow DOM 내부에서 작성 - 외부에서 .dark-mode가 전달되면 다음 css 속성이 적용됩니다. */
:host-context(.dark-mode) {
  background-color: black;
  color: white;
}
<!-- 외부에서 특정 조건(class="dark-mode") 전달 -->
<div class="dark-mode">
  <my-component></my-component>
</div>

05. Shadow DOM 을 외부에서 자바스크립트로 제어 하려면

05.01. mode: 'open' 일 경우

  • Shadow DOM이 open 모드인 경우 .shadowRoot로 직접 접근해서 내부 DOM을 탐색, 조작할 수 있습니다.
  • 다만 추천하는 방식은 아니므로 외부 노출 메서드를 만드는 방식이 더 권장됩니다.
const comp = document.querySelector('mfa-component');
const shadow = comp.shadowRoot;

// 내부 input 값을 설정
const otpInput = shadow.querySelector('input');
otpInput.value = '123456';

05.02. mode: 'close' 일 경우

  • closed로 만든 경우, JavaScript로 접근이 불가능하게됩니다.
const comp = document.querySelector('mfa-component');
console.log(comp.shadowRoot); // null

05.03. 외부와 소통하기 위한 외부 노출 메서드 구현

05.03.01. 메서드 기반

커스텀 엘리먼트 정의:

class MfaComponent extends HTMLElement {
  setOtp(value) {
    this._otp = value;
  }
  getOtp() {
    return this._otp;
  }
  submit() {
    this.dispatchEvent(new CustomEvent('mfa-submit', {
      detail: { otp: this._otp },
      bubbles: true,
      composed: true
    }));
  }
}
customElements.define('mfa-component', MfaComponent);

사용 예시:

<mfa-component id="mfa"></mfa-component>
<script>
  const el = document.getElementById('mfa');
  el.setOtp('654321');
  console.log(el.getOtp()); // '654321'
  el.submit();
</script>

05.03.02. 속성 기반

커스텀 엘리먼트 정의:

class AttrComponent extends HTMLElement {
  static get observedAttributes() {
    return ['label'];
  }

  constructor() {
    super();
    this.shadow = this.attachShadow({ mode: 'open' });
    this._label = '';
    this._render();
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'label') {
      this._label = newValue;
      this._render();
    }
  }

  _render() {
    this.shadow.innerHTML = `<p>${this._label}</p>`;
  }
}
customElements.define('attr-component', AttrComponent);

사용 예시:

<attr-component id="attr" label="초기값"></attr-component>
<script>
  const el = document.getElementById('attr');
  el.setAttribute('label', '변경된 라벨');
</script>

05.03.03. 이벤트 기반

커스텀 컴포넌트 내부에서 이벤트 발송:

this.dispatchEvent(new CustomEvent('mfa-submit', {
  detail: { otp: value },
  bubbles: true,
  // `composed: true`가 없으면 Shadow DOM 내부에서 발생한 이벤트는 light DOM(외부 DOM)까지 도달하지 않습니다.
  composed: true
}));

사용 예시: 이벤트 수신 등록

<mfa-component id="mfa"></mfa-component>
<script>
  const el = document.getElementById('mfa');
  el.addEventListener('mfa-submit', e => {
    console.log('제출된 OTP:', e.detail.otp);
  });
  el.setOtp('999999');
  el.submit();
</script>

06. Shadow DOM과 프레임워크 연동

06.01. Shadow DOM + React

import { createRoot } from 'react-dom/client';
import App from './App';

class ShadowReact extends HTMLElement {
  constructor() {
    super();
    this.shadow = this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    const root = createRoot(this.shadow);
    root.render(<App />);
  }
}
customElements.define('shadow-react', ShadowReact);

06.01.01. React에서 CSS-in-JS 사용 주의사항

React에서는 styled-components나 emotion 사용 시, Shadow Root 내부에 스타일 주입이 제대로 안 될 수 있습니다.
이 경우 container 옵션을 사용해서 강제로 넣어줘야 합니다.

  • styled-components나 emotion 같은 CSS-in-JS 라이브러리는 스타일을 <style> 태그로 DOM에 삽입합니다.
  • 이때 기본적으로는 <head>에 삽입되기 때문에, Shadow DOM 안에 있는 컴포넌트에는 그 스타일이 적용되지 않습니다.
    • 왜냐면 Shadow DOM은 자기 안에 있는 <style> 태그만 영향을 받기때문

06.01.01.01. styled-components

styled-components를 사용한다면 <StyleSheetManager> 컴포넌트의 target 속성을 사용하여 스타일을 어디에 삽입할지 직접 지정해야 합니다.

import { StyleSheetManager } from 'styled-components';

root.render(
  <StyleSheetManager target={this.shadow}>
    <App />
  </StyleSheetManager>
);

06.01.01.02. emotion

emotion을 사용한다면 createCache 옵션으로 show root 참조를 container 속성으로 전달합니다.

import { CacheProvider } from '@emotion/react';
import createCache from '@emotion/cache';

const cache = createCache({
  key: 'shadow',
  container: this.shadow, // 실제 스타일을 삽입할 Shadow Root
});

root.render(
  <CacheProvider value={cache}>
    <App />
  </CacheProvider>
);

06.02. Shadow DOM + Vue

import { createApp } from 'vue';
import App from './App.vue';

class ShadowVue extends HTMLElement {
  constructor() {
    super();
    this.shadow = this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    const app = createApp(App);
    app.mount(this.shadow);
  }
}
customElements.define('shadow-vue', ShadowVue);

06.02.01. Vue scoped 사용 주의사항

  • scoped 스타일은 Shadow DOM 경계를 인식하지 않으므로 ::part를 사용하는 것이 더 적절합니다.
<template>
    <div part="text">{{ msg }}</div>
</template>
<script setup>
    const msg = 'Shadow DOM에 마운트됨';
</script>
my-vue-component::part(text) {
  color: red;
}

07. SSR, hydrate 관련 제한 사항

Shadow DOM은 서버사이드 렌더링과 호환이 좋지 않기 때문에, hydrateRoot 같은 기능은 사용할 수 없습니다.

서버에서 Shadow DOM을 완전히 구현할 수 없기 때문에, SSR 시에 미리 렌더링된 DOM과 클라이언트 측에서 Shadow DOM을 생성하는 과정 간의 불일치(hydration 문제)가 발생할 수 있습니다.

07.01. SSR 에 대한 몇 가지 접근 방법

07.01.01. 클라이언트에서 Shadow DOM 동적 생성

SSR로는 일반 DOM(라이트 DOM)만 렌더링하고, 클라이언트에서 JavaScript가 로드된 후 Shadow DOM을 동적으로 생성하는 방식입니다.

  • 장점:
    • 서버에서는 기존 SSR 기법을 그대로 사용하고, 클라이언트에서 캡슐화를 적용할 수 있습니다.
  • 단점:
    초기 렌더링과 클라이언트에서의 Shadow DOM 전환 사이에 잠깐의 일관성이 없고, 플리커링 현상이 발생할 수 있습니다.

07.01.02. 하이브리드 렌더링 전략 (Islands Architecture)

전체 페이지를 SSR하는 대신, 일부 인터랙티브 컴포넌트(“islands”)만 클라이언트에서 동적으로 렌더링하는 방식입니다.

  • 장점:
    • SSR로 정적 콘텐츠를 제공하고, 필요한 부분만 클라이언트에서 Shadow DOM을 사용해 캡슐화된 컴포넌트를 렌더링하여 두 기술의 장점을 모두 활용할 수 있습니다.
  • 단점:
    • 아키텍처 설계가 복잡해지고, 서버와 클라이언트 간 상태 동기화에 신경 써야 합니다.

07.01.03. 웹 컴포넌트용 SSR 프레임워크 활용

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/04   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30
글 보관함