Skip to content

Shadow DOM을 활용한 스타일 격리

Shadow DOM API를 이해하고, React 어플리케이션에서 마이크로앱 간 CSS 오염을 원천 차단하는 스타일 격리 기법을 구현한다.

학습 목표

  1. Shadow DOM API의 개념과 동작 원리를 설명할 수 있다.
  2. open 모드와 closed 모드의 차이를 이해하고 적절한 모드를 선택할 수 있다.
  3. React에서 Shadow DOM을 활용하는 커스텀 훅을 직접 구현할 수 있다.
  4. react-shadow 라이브러리를 활용하여 간편하게 스타일 격리를 적용할 수 있다.
  5. Shadow DOM 기반 스타일 격리의 한계와 주의점을 파악할 수 있다.

1. Shadow DOM API 개념과 원리

웹 브라우저는 HTML 문서를 파싱하여 DOM 트리를 구성하고, CSS를 파싱하여 CSSOM 트리를 만든다. 이 두 트리를 결합하여 렌더 트리를 생성하고 화면에 그린다.

Shadow DOM은 DOM의 캡슐화된 하위 트리를 생성하는 웹 표준 API다. Shadow DOM 내부의 스타일은 외부 DOM에 영향을 주지 않으며, 외부 DOM의 스타일도 Shadow DOM 내부에 영향을 주지 않는다.

1.1 기본 사용법

Shadow DOM은 Element.attachShadow() 메서드로 생성한다.

javascript
// Shadow DOM 호스트 요소 선택
const host = document.getElementById('shadow-host');

// Shadow Root 생성
const shadowRoot = host.attachShadow({ mode: 'open' });

// Shadow DOM 내부에 콘텐츠 추가
const style = document.createElement('style');
style.textContent = `
  div { background-color: blue; width: 300px; height: 300px; }
`;

const content = document.createElement('div');
content.textContent = 'Shadow DOM 내부 콘텐츠';

shadowRoot.appendChild(style);
shadowRoot.appendChild(content);

이렇게 추가된 스타일은 Shadow DOM 내부에만 적용된다. 외부 DOM에 동일한 div 셀렉터가 있더라도 서로 영향을 주지 않는다.

1.2 Shadow DOM을 지원하는 요소

모든 HTML 요소가 Shadow DOM 호스트가 될 수 있는 것은 아니다. 다음 요소들이 attachShadow()를 지원한다:

  • article, aside, blockquote, body, div, footer, h1~h6, header, main, nav, p, section, span
  • 커스텀 요소 (Custom Elements)

2. open 모드 vs closed 모드

attachShadow()mode 옵션은 외부에서 Shadow Root에 접근할 수 있는지를 결정한다.

항목openclosed
element.shadowRoot 반환값ShadowRoot 객체null
외부 JS 접근가능불가능
사용 시나리오일반적인 컴포넌트 캡슐화보안이 중요한 위젯(결제 폼 등)
디버깅 용이성높음 (DevTools에서 직접 접근)낮음
javascript
// open 모드
const openRoot = host.attachShadow({ mode: 'open' });
console.log(host.shadowRoot);  // ShadowRoot 객체

// closed 모드
const closedRoot = host.attachShadow({ mode: 'closed' });
console.log(host.shadowRoot);  // null

MFA에서의 권장 모드: 대부분의 마이크로 프론트엔드 시나리오에서는 open 모드를 사용한다. 디버깅이 용이하고 호스트 앱과의 제한적 상호작용이 가능하기 때문이다. closed 모드는 결제 위젯처럼 외부 접근을 완전히 차단해야 하는 경우에만 사용한다.

3. React에서 Shadow DOM 사용하기

3.1 커스텀 ShadowDOM 컴포넌트 구현

React에서 Shadow DOM을 사용하려면 useRef, useState, useEffect를 조합하여 Shadow Root 안에 React 컴포넌트를 렌더링하는 래퍼를 만들어야 한다. 핵심 구현 흐름은 다음과 같다:

  1. useRef로 호스트 <div> 요소의 참조를 확보한다.
  2. useEffect에서 attachShadow({ mode: 'open' })으로 Shadow Root를 생성한다.
  3. createPortal로 React children을 Shadow Root 내부에 렌더링한다.
  4. createContext로 Shadow Root 참조를 하위 컴포넌트에 전달한다.
tsx
// 핵심 구조 (간략화)
const ShadowRootContext = createContext<ShadowRoot | null>(null);

export function useShadowRoot() {
  return useContext(ShadowRootContext);
}

export function ShadowDOM({ children, styles }: ShadowDOMProps) {
  const hostRef = useRef<HTMLDivElement>(null);
  const [shadowRoot, setShadowRoot] = useState<ShadowRoot | null>(null);

  useEffect(() => {
    if (!hostRef.current) return;
    const root = hostRef.current.shadowRoot
      ?? hostRef.current.attachShadow({ mode: 'open' });
    setShadowRoot(root);
  }, []);

  return (
    <div ref={hostRef}>
      {shadowRoot && createPortal(
        <ShadowRootContext.Provider value={shadowRoot}>
          {children}
        </ShadowRootContext.Provider>,
        shadowRoot as unknown as Element
      )}
    </div>
  );
}

3.2 사용 예시

tsx
function App() {
  return (
    <div>
      <h1>호스트 어플리케이션</h1>
      <ShadowDOM styles="li { padding: 8px; border-bottom: 1px solid #eee; }">
        <MailList />
      </ShadowDOM>
    </div>
  );
}

MailList 컴포넌트의 스타일은 Shadow DOM 내부에만 적용되며, 호스트 어플리케이션의 <h1> 등 외부 요소에는 영향을 주지 않는다.

4. react-shadow 라이브러리 활용

직접 구현하는 대신 react-shadow 라이브러리를 사용하면 더 간편하게 Shadow DOM을 적용할 수 있다.

4.1 설치 및 기본 사용법

bash
npm install react-shadow
tsx
import root from 'react-shadow';

function App() {
  return (
    <div>
      <h1>호스트 어플리케이션 (외부)</h1>
      <root.div>
        <style>{`
          h2 { color: blue; font-size: 24px; }
          p { color: green; }
        `}</style>
        <h2>Shadow DOM 내부 제목</h2>
        <p>이 스타일은 외부에 영향을 주지 않습니다.</p>
      </root.div>
    </div>
  );
}

react-shadow는 내부적으로 attachShadow({ mode: 'open' })을 호출하고 createPortal을 사용하여 children을 Shadow Root에 렌더링한다.

4.2 직접 구현 vs 라이브러리 비교

항목직접 구현react-shadow
제어 수준높음 (세밀한 커스터마이징 가능)중간 (API로 제공되는 범위 내)
코드량많음 (50줄 이상)적음 (import 후 바로 사용)
유지보수직접 관리 필요커뮤니티에서 관리
Context 전달직접 구현 필요내장 훅 제공
적합한 상황특수한 요구사항이 있는 경우일반적인 스타일 격리

5. 스타일 격리의 한계와 주의점

Shadow DOM이 강력한 격리 도구이지만 만능은 아니다. 다음과 같은 한계와 주의점을 반드시 인지해야 한다.

5.1 상속 가능한 CSS 속성

font-family, color, line-height, font-size 등 상속 가능한 CSS 속성은 Shadow DOM 경계를 넘어 전파된다.

css
/* 호스트 앱의 전역 스타일 */
body {
  font-family: 'Arial', sans-serif;
  color: #333;
}

이 스타일은 Shadow DOM 내부에도 상속된다. 완전한 격리가 필요하면 Shadow DOM 내부에서 all: initial 또는 명시적으로 속성을 재설정해야 한다.

css
/* Shadow DOM 내부 스타일 */
:host {
  all: initial;         /* 모든 상속 차단 */
  display: block;       /* 필요한 속성만 재설정 */
  font-family: 'Noto Sans KR', sans-serif;
}

5.2 CSS 커스텀 속성(변수)

CSS 커스텀 속성(--variable)은 Shadow DOM 경계를 관통한다. 이는 의도적인 설계로, 테마 적용에 활용할 수 있다.

css
/* 호스트 앱 */
:root { --primary-color: #007bff; }

/* Shadow DOM 내부에서도 사용 가능 */
.button { background: var(--primary-color); }

5.3 이벤트 버블링

Shadow DOM 내부에서 발생한 이벤트는 기본적으로 composed: true인 이벤트(click, focus 등)만 Shadow 경계를 넘어 버블링된다. 커스텀 이벤트는 명시적으로 composed: true를 설정해야 한다.

5.4 서드파티 라이브러리 호환성

일부 서드파티 라이브러리(모달, 토스트, 드롭다운 등)는 document.body에 직접 요소를 추가하는 패턴을 사용한다. Shadow DOM 내부에서 이런 라이브러리를 사용하면 스타일이 깨질 수 있다.

대응 전략:

  • 포탈(Portal)을 Shadow Root 내부로 리다이렉트
  • 해당 라이브러리의 컨테이너 옵션을 Shadow Root로 지정
  • 라이브러리 교체 또는 포크

핵심 정리

항목내용
Shadow DOMDOM의 캡슐화된 하위 트리를 생성하는 웹 표준 API
open vs closedopen은 외부 JS 접근 가능, closed는 불가. MFA에서는 주로 open 사용
React 통합useRef + attachShadow + createPortal 조합으로 구현
react-shadow간편한 Shadow DOM 래퍼 라이브러리, root.div로 즉시 사용
상속 속성 주의font-family, color 등은 Shadow 경계를 넘어 상속됨
CSS 변수 관통--custom-property는 Shadow DOM을 관통하여 테마 적용에 활용 가능

다음 단계

Shadow DOM으로 스타일 격리가 가능해졌으니, 이제 마이크로앱을 UMD 형식으로 빌드하여 런타임에 인젝션하는 방법을 학습한다. Webpack 설정과 react-app-rewired를 활용한다.

다음: 03-Webpack-UMD-빌드-설정