
Cutom Elements의 문제점은 다음과 같다.
<user-card> 내부에 있는 div, button 같은 요소들이 외부 CSS 선택자에 그대로 잡힌다.Custom Element는 태그 이름과 생명주기만 커스터마이징해줄 뿐이고 그 안에 들어가는 DOM은 여전히 Light DOM, 즉 일반 DOM이다
Custom Elements는 "이름과 동작"만 컴포넌트화했고
경계는 만들지 않았다.
그래서 캡슐화가 되지 않는것이고, 이를 해결하기 위해 DOM 트리를 하나 더 도입한 것이 Shadow DOM이다.
DOM을 캡슐화해서, 외부로부터 격리된 "자신만의 DOM 트리"를 만드는 기능이다.
이를 통해 외부 DOM과 경계가 생긴다.
<user-card>
#shadow-root
<style>...</style>
<div class="name">민준</div>
</user-card>
구조로 본다면 위와 같이 생겼다. #shadow-root 내부는 바깥 CSS, JS가 침범 할 수 없다.
1) Host Element
<user-card><user-card> ← host
2) Shadow Root
<user-card>
#shadow-root ← shadow root
</user-card>
3) Shadow Tree

class UserCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
}
<user-card>왜
connectedCallback이 아니라
constructor에서 Shadow DOM을 붙일까?
attachShadow()는attributeChangedCallbackconnectedCallback어떤 콜백이 먼저 와도
this.shadowRoot는 항상 존재한다
mode: 'open'
class UserCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `<button>Click</button>`;
}
}
customElements.define('user-card', UserCard);
const card = document.querySelector('user-card');
card.shadowRoot.querySelector('button'); // 외부에서 접근 가능
mode: 'closed'
class UserCard extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'closed' });
shadow.innerHTML = `<button>Click</button>`;
}
}
customElements.define('user-card', UserCard);
const card = document.querySelector('user-card');
card.shadowRoot; // null
Shadow DOM은 존재하지만 공식 접근 경로가 차단됨
| 구분 | open | closed |
|---|---|---|
element.shadowRoot | 접근 가능 | 접근 불가 |
| 캡슐화 강도 | 동일 | 동일 |
| 보안 기능 | 아님 | 아님 |
| 차이점 | JS 접근 경로 제공 여부 | JS 접근 경로 차단 |
캡슐화는 둘 다 동일하다.
차이는 “외부에서 들여다볼 수 있느냐” 뿐이다.
디자인 시스템 컴포넌트나 사내 공용 UI 라이브러리
테스트(E2E, 단위 테스트), 디버깅, 확장 가능성이 필요한 경우 대부분 open을 사용한다.
아래는 블로그에 바로 올릴 수 있는 문서 형태로 정리한 내용이다.
설명은 핵심만 남기고, 이해를 돕는 최소한의 예시만 포함했다.
Shadow DOM은 일반 HTML 태그에도 붙일 수 있다.
하지만 모든 태그가 Shadow host가 될 수 있는 것은 아니다.
Shadow DOM은
“기본 동작이 없는 컨테이너 성격의 요소”에만 붙이는 것이 허용된다.
<div id="host"></div>
<script>
const host = document.getElementById('host');
const shadowRoot = host.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `
<style>
p { color: red; }
</style>
<p>Shadow DOM 내부 콘텐츠</p>
`;
</script>
이때 DOM 구조는 다음과 같다.
<div id="host">
#shadow-root
<p>Shadow DOM 내부 콘텐츠</p>
</div>
<div>는 기본 동작이 없기 때문에 Shadow DOM을 붙여도 기존 브라우저 동작과 충돌하지 않는다.
공통 특징은 기본 상호작용 동작이 없다는 것이다.
• div
• span
• section
• article
• main
• header
• footer
• nav
• aside
• 모든 Custom Element (예: <user-card>)
이러한 요소들은 클릭, 포커스, 키보드 동작에 대해 브라우저가 특별한 행동을 보장하지 않기 때문에 Shadow DOM을 붙여도 문제가 없다.
공통 특징은 브라우저가 기본 동작을 강하게 보장하는 상호작용 요소라는 점이다.
• a
• button
• input
• textarea
• select
• option
• label
• form
이 요소들에 attachShadow()를 호출하면 에러가 발생한다.
브라우저는 특정 요소에 대해 사용자와의 상호작용을 명확한 규칙으로 보장한다.
예를 들어 <a> 요소는 다음을 보장한다.
• 클릭 시 페이지 이동
• 키보드 Enter 입력 시 이동
• 포커스 가능
• 스크린 리더에서 “링크”로 인식
만약 <a> 요소가 Shadow host가 될 수 있다면, 실제 클릭은 Shadow DOM 내부의 다른 요소에서 발생하게 된다. 이 경우 브라우저는 다음 질문에 답해야 한다.
• 사용자가 클릭한 것은 링크인가 내부 요소인가
• 링크 이동을 해야 하는가
• 포커스는 어디로 가야 하는가
이러한 모호성은 브라우저의 기본 동작 보장을 깨뜨린다.
그래서 명세 차원에서 기본 동작이 있는 요소를 Shadow host로 사용하는 것을 금지했다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>Shadow DOM 예제</title>
</head>
<body>
<user-card>
<p>안녕하세요! 외부에서 넣은 콘텐츠입니다.</p>
</user-card>
<script>
class UserCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<div class="card">
내부 콘텐츠만 보여집니다.
</div>
`;
}
}
customElements.define('user-card', UserCard);
</script>
</body>
</html>
해당 코드를 브라우저에서 확인해보면, 분명 외부에서 넣은 <p>태그가 있는데, 화면에 보이지 않는다.

user-card
├─ shadowRoot (Shadow DOM)
│ └─ div.card
└─ p (Light DOM)
하지만, 개발자 도구에서는 외부에서 넣은 Light DOM에 있는 <p>태그가 보인다.
브라우저는 Shadow DOM 내부만 렌더링한다.
외부 Light DOM 자식인 <p> 는 Shadow DOM 내부에 자동으로 들어가지 않는다
그래서 화면에는 보이지 않는것이다.
그렇다면 어떻게 외부에서 넣은 요소를 화면에 렌더링 할 수 있을까?
<body>
<user-card>
<p>안녕하세요! 외부에서 넣은 콘텐츠입니다.</p>
</user-card>
<script>
class UserCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
// Shadow DOM 내부 구조에 <slot> 추가
this.shadowRoot.innerHTML = `
<div class="card">
<h3>내부 콘텐츠 (Shadow DOM)</h3>
<slot></slot> <!-- Light DOM 자식이 들어가는 자리 -->
<p>내부에서 미리 정의한 콘텐츠</p>
</div>
<style>
.card {
border: 2px solid #333;
padding: 12px;
border-radius: 6px;
background-color: #f9f9f9;
font-family: sans-serif;
}
::slotted(p) {
color: #0077cc;
font-weight: bold;
}
</style>
`;
}
}
customElements.define('user-card', UserCard);
</script>
</body>
코드 예시처럼<slot>을 활용하면 외부 요소를 화면에 표시할 수 있다.

사진을 보면 <slot></slot>을 사용한 자리에 외부 요소가 들어간 것을 확인 할 수 있다.
<user-card>
<p slot="header">헤더 콘텐츠</p>
<p slot="footer">푸터 콘텐츠</p>
</user-card>
<slot name="header"></slot>
<slot name="footer"></slot>
여러 slot이 필요한 경우, 위와 같이 Light DOM 자식을 특정 slot으로 보내기 가능하다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>Shadow DOM CSS 독립성 확인</title>
<style>
/* 외부 CSS */
user-card {
color: red;
font-size: 50px;
}
p {
color: green;
}
</style>
</head>
<body>
<user-card>
<p>외부 CSS 영향을 받는 Light DOM 콘텐츠</p>
</user-card>
<script>
class UserCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
// Shadow DOM 내부 스타일
this.shadowRoot.innerHTML = `
<div class="card">
<h3>Shadow DOM 내부 콘텐츠</h3>
<p>Shadow DOM 안의 p</p>
</div>
<style>
.card {
border: 2px solid #333;
padding: 12px;
background-color: #f0f0f0;
color: blue; /* 내부 h3 색상 */
font-size: 16px;
}
p {
color: #ff6600; /* Shadow DOM 내부 p 색상 */
}
</style>
`;
}
}
customElements.define('user-card', UserCard);
</script>
</body>
</html>
글의 서두에서 언급했듯이, Shadow DOM을 통해 스타일 캡슐화가 이루어질 수 있다.
위의 코드에서 외부 CSS가 있음에도, Shadow DOM 내부 스타일에 의해 Shadow DOM의 요소들이 영향을 받는다.

사진을 통해 Shadow DOM 내의 .card와 p가 외부 스타일을 무시하고 내부 스타일에 의해 색상이 입혀진 것을 확인 할 수 있다.
Shadow DOM을 활용하면 브라우저 수준에서 독립적인 UI 컴포넌트 라이브러리를 구축할 수 있다.
React나 Vue 같은 프레임워크 없이도 재사용 가능한 UI를 제공할 수 있으며, 버튼, 카드, 모달, 탭과 같은 다양한 컴포넌트를 독립적으로 설계할 수 있다.
이러한 컴포넌트는 내부 구조와 스타일이 외부에 영향받지 않고 유지되기 때문에, 일관된 디자인과 안정적인 동작을 보장하며, 현대 웹 개발에서 재사용성과 유지보수를 동시에 만족시키는 도구로 활용될 수 있다.
포스트 보고 왔습니다. 👍👍