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 내의 .card와 p가 외부 스타일을 무시하고 내부 스타일에 의해 색상이 입혀진 것을 확인 할 수 있다.

마무리

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

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

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

2개의 댓글

comment-user-thumbnail
2026년 4월 23일

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

1개의 답글