티스토리 뷰
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>태그만 영향을 받기때문
- 왜냐면 Shadow DOM은 자기 안에 있는
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 프레임워크 활용
- StencilJS:
- Lit SSR:
'FrontEnd > javascript' 카테고리의 다른 글
| [JavaScript] 자바스크립트 생성자 함수 (0) | 2025.03.26 |
|---|---|
| [JavaScript] IIFE(Immediately Invoked Function Expression) (0) | 2025.03.23 |
| [JavaScript] 자바스크립트 메모리 관리 - 스택, 힙, 변수 (0) | 2025.03.21 |
| Bitwise Operation (0) | 2025.03.21 |
| [JavaScript] CORS(Cross-Origin Resource Sharing) (0) | 2025.03.21 |
- Total
- Today
- Yesterday
- 모노레포 스크립트
- uselazyasyncdata
- refrerence
- JIT
- deep dive
- ViTE
- TypeScript
- npm ci
- useasyncdata
- prototype
- react
- interning
- vee-validate
- string table
- pnpm 명령어
- string
- premitive
- object literal
- bundler
- JavaScript
- primitive
- pakage-lock.json
- nuxt
- scoped slot
- vue
- 바이트 코드
- double-linked-list
- webpack
- library mode
- react-router
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
