DOM을 숨기는 기술, Shadow DOM

아더에러·2026년 1월 6일

프론트엔드

목록 보기
2/15
post-thumbnail

Custom Elements의 문제점

Cutom Elements의 문제점은 다음과 같다.

  • <user-card> 내부에 있는 div, button 같은 요소들이 외부 CSS 선택자에 그대로 잡힌다.
  • 내부 DOM 구조가 개발자도구에서 전부 보이고 구조 변경 시 외부 코드와 쉽게 엮인다.

Custom Element는 태그 이름과 생명주기만 커스터마이징해줄 뿐이고 그 안에 들어가는 DOM은 여전히 Light DOM, 즉 일반 DOM이다

Custom Elements는 "이름과 동작"만 컴포넌트화했고
경계는 만들지 않았다.

그래서 캡슐화가 되지 않는것이고, 이를 해결하기 위해 DOM 트리를 하나 더 도입한 것이 Shadow DOM이다.

Shadow DOM이란?

DOM을 캡슐화해서, 외부로부터 격리된 "자신만의 DOM 트리"를 만드는 기능이다.

이를 통해 외부 DOM과 경계가 생긴다.

<user-card>
  #shadow-root
    <style>...</style>
    <div class="name">민준</div>
</user-card>

구조로 본다면 위와 같이 생겼다. #shadow-root 내부는 바깥 CSS, JS가 침범 할 수 없다.

Shadow DOM 특징

  1. 구조 캡슐화
  • 외부에서 querySelector('user-card div') → 안 잡힌다.
  • 내부 DOM은 컴포넌트의 “구현 상세”가 된다.
  1. 스타일 캡슐화
  • 외부 CSS는 Shadow DOM 내부에 적용되지 않는다.
  • Shadow DOM 내부 스타일도 밖으로 새지 않는다.

Shadow DOM 이해에 필요한 최소 용어

1) Host Element

  • Shadow DOM을 소유하는 실제 DOM 요소
  • 예: <user-card>
<user-card>   ← host

2) Shadow Root

  • Host Element에 붙는 독립된 DOM 트리의 루트
  • DOM 트리이긴 한데, 일반 DOM과는 경계가 있다.
<user-card>
  #shadow-root  ← shadow root
</user-card>

3) Shadow Tree

  • Shadow Root 아래에 있는 전체 DOM 구조
  • 이 안이 우리가 말하는 "캡슐화된 내부 구현"이다.

Shadow DOM 사용 방법

class UserCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }
}
  • this: 현재 Custom Element 인스턴스
    → 즉, 실제로 DOM에 생성된 <user-card>
  • attachShadow: 이 요소를 host element로 삼아 shadow root를 하나 생성한다

connectedCallback이 아니라
constructor에서 Shadow DOM을 붙일까?

constructor에서 Shadow DOM을 붙이는 이유

  1. constructor는 DOM과의 관계가 없는 상태가 보장된다
  • 이 시점의 요소는
    • 부모 요소 없음
    • document에 속해 있지 않음
  • 즉, DOM 트리에 들어가기 전 상태다
  1. Shadow DOM은 DOM과의 관계가 아니라 "자기 자신 내부 구조"다
  • attachShadow()
    • document, parentNode에 의존하지 않고
    • host element(this) 내부에 shadow root를 만든다
  • 그래서 DOM에 붙기 전에도 안전하다
  1. 이후 생명주기에서 항상 준비된 상태를 보장한다
  • attributeChangedCallback
  • connectedCallback

어떤 콜백이 먼저 와도
this.shadowRoot는 항상 존재한다

Shadow DOM의 mode

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은 존재하지만 공식 접근 경로가 차단됨

구분openclosed
element.shadowRoot접근 가능접근 불가
캡슐화 강도동일동일
보안 기능아님아님
차이점JS 접근 경로 제공 여부JS 접근 경로 차단

캡슐화는 둘 다 동일하다.
차이는 “외부에서 들여다볼 수 있느냐” 뿐이다.

디자인 시스템 컴포넌트나 사내 공용 UI 라이브러리
테스트(E2E, 단위 테스트), 디버깅, 확장 가능성이 필요한 경우 대부분 open을 사용한다.

아래는 블로그에 바로 올릴 수 있는 문서 형태로 정리한 내용이다.
설명은 핵심만 남기고, 이해를 돕는 최소한의 예시만 포함했다.

Shadow DOM을 일반 HTML 태그에 붙일 수 있을까?

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을 붙여도 기존 브라우저 동작과 충돌하지 않는다.

Shadow host가 될 수 있는 태그

공통 특징은 기본 상호작용 동작이 없다는 것이다.
• div
• span
• section
• article
• main
• header
• footer
• nav
• aside
• 모든 Custom Element (예: <user-card>)

이러한 요소들은 클릭, 포커스, 키보드 동작에 대해 브라우저가 특별한 행동을 보장하지 않기 때문에 Shadow DOM을 붙여도 문제가 없다.

Shadow host가 될 수 없는 태그

공통 특징은 브라우저가 기본 동작을 강하게 보장하는 상호작용 요소라는 점이다.
• a
• button
• input
• textarea
• select
• option
• label
• form

이 요소들에 attachShadow()를 호출하면 에러가 발생한다.

왜 일부 태그는 Shadow host가 될 수 없을까?

브라우저는 특정 요소에 대해 사용자와의 상호작용을 명확한 규칙으로 보장한다.

예를 들어 <a> 요소는 다음을 보장한다.
• 클릭 시 페이지 이동
• 키보드 Enter 입력 시 이동
• 포커스 가능
• 스크린 리더에서 “링크”로 인식

만약 <a> 요소가 Shadow host가 될 수 있다면, 실제 클릭은 Shadow DOM 내부의 다른 요소에서 발생하게 된다. 이 경우 브라우저는 다음 질문에 답해야 한다.
• 사용자가 클릭한 것은 링크인가 내부 요소인가
• 링크 이동을 해야 하는가
• 포커스는 어디로 가야 하는가

이러한 모호성은 브라우저의 기본 동작 보장을 깨뜨린다.
그래서 명세 차원에서 기본 동작이 있는 요소를 Shadow host로 사용하는 것을 금지했다.

Shadow DOM slot

<!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>을 활용하면 외부 요소를 화면에 표시할 수 있다.

  • Light DOM 콘텐츠를 Shadow DOM 안에 넣는 공식 통로
  • Shadow DOM은 캡슐화되어 있어서 외부 콘텐츠를 자동으로 렌더링하지 않음
  • 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 내의 .cardp가 외부 스타일을 무시하고 내부 스타일에 의해 색상이 입혀진 것을 확인 할 수 있다.

마무리

Shadow DOM을 활용하면 브라우저 수준에서 독립적인 UI 컴포넌트 라이브러리를 구축할 수 있다.

React나 Vue 같은 프레임워크 없이도 재사용 가능한 UI를 제공할 수 있으며, 버튼, 카드, 모달, 탭과 같은 다양한 컴포넌트를 독립적으로 설계할 수 있다.

이러한 컴포넌트는 내부 구조와 스타일이 외부에 영향받지 않고 유지되기 때문에, 일관된 디자인과 안정적인 동작을 보장하며, 현대 웹 개발에서 재사용성과 유지보수를 동시에 만족시키는 도구로 활용될 수 있다.

2개의 댓글

comment-user-thumbnail
2026년 4월 23일

포스트 보고 왔습니다. 👍👍

1개의 답글