Shadow DOM 아키텍처 설계

Category
Engineering Deep Dive
Status
Published
Tags
shadow DOM
Emotion
스타일 격리
Chrome Extension
Trouble Shooting
Description
Published
Slug

Shadow DOM 아키텍처 설계

크롬 확장 프로그램의 CSS 격리

1. 문제 정의: 통제 불가능한 전장, 수만 개의 웹페이지

Criti.AI 크롬 확장 프로그램의 핵심 기능은, 사용자가 방문한 웹페이지 위에 직접 사이드바 UI를 띄워 분석 결과를 보여주는 것입니다.
초기 버전은 간단히 div를 페이지의 <body>에 주입했습니다.
하지만, 이 방식은 아래와 같은 문제를 야기하게 되었습니다.
  • 스타일 오염: Criti.AI 서비스에서 만든 깔끔한 button이, 방문한 사이트의 button { padding: 50px; } CSS에 오염되어 거대하게 변했습니다.
  • !important 전쟁: 이를 막기 위해 저희 CSS에 !important를 적용했고, 이는 곧 이곳 저곳에서 스타일 충돌이 발생해 유지보수의 문제를 야기했습니다.
  • 페이지 파괴 (DOM/Style Breaking): 반대로 저희의 z-index가 페이지의 중요 팝업을 가리거나, 저희 p 태그 스타일이 본문 전체를 망가뜨리기도 했습니다.
 
이러한 문제에 대한 근본적인 원인은 "우리의 확장 프로그램과 웹페이지가 하나의 전역 CSS 스코프(Global CSS Scope)를 공유한다"는 것이었고, 이 공유되는 문제를 근본적으로 분리할 필요가 있었습니다.
 

2. 해결책 탐색: iframe이 아닌 Shadow DOM을 선택한 이유

iframe을 사용하는 방법도 고려했지만, 다음과 같은 한계 때문에 Shadow DOM을 선택했습니다.
접근 방식
장점
단점 (포기한 이유)
iframe
완벽한 격리
치명적인 사용성 • iframe 내외 이벤트 처리(드래그 등)가 복잡함 • 동적인 높이 조절이 매우 까다로움 • 일부 사이트는 CSP(Content Security Policy)로 iframe 생성을 차단함
Shadow DOM
완벽한 CSS 격리 DOM 캡슐화
• CSS-in-JS 라이브러리와 통합이 까다로움
Shadow DOM은 외부 JS가 querySelector로 내부를 조회할 수 없고, 내부 CSS가 밖으로, 외부 CSS가 안으로 침투하지 못하는 구조를 가지기 때문에, 이 방법이 근본적인 해결책이라고 판단하였습니다.
 

3. 아키텍처 설계: Shadow DOM 파이프라인 구축

content/index.tsxmountApp 함수를 중심으로, 외부와 완벽히 격리된 React 앱 렌더링 파이프라인을 구축했습니다.
 

1. 격리의 시작, Shadow Host 생성

페이지에 영향을 주지 않는 최소한의 div를 생성하여 body에 부착합니다.
이 요소가 Shadow DOM을 품을 호스트(Host)가 됩니다.
// 1. Shadow Host 역할을 할 div 요소 생성 const shadowHost = document.createElement("div"); shadowHost.id = "criti-ai-shadow-host"; document.body.appendChild(shadowHost); // 2. Shadow Host에 Shadow Root 연결 // mode: 'closed'로 설정하여 외부로부터의 접근 차단 const shadowRoot = shadowHost.attachShadow({ mode: "closed" });
mode: "closed"로 설정하여, 페이지의 다른 스크립트가 shadowHost.shadowRoot로 저희 확장 프로그램의 내부 DOM에 접근하여 조작하는 것을 원천적으로 차단하여, 안정성을 향상시켰습니다
 
const shadowRoot = shadowHost.attachShadow({ mode: "closed" });
  • 비밀의 방(ShadowRoot)을 만들고, 문을 닫아 잠근 뒤,
    • 유일한 마스터 키shadowRoot라는 변수에 담아서 지금 이 스크립트에게 주는 역할을 합니다.
       
  • 그래서 유일한 마스터 키를 shadowRoot라는 지역 변수에 들고 있고
  • 이후 코드는 이 shadowRoot 변수를 직접 사용하여 스타일 태그를 넣고(shadowRoot.appendChild(...)), 리액트 앱을 렌더링합니다.
 
  • 다른 스크립트 (웹페이지)가 조작 못 하는 법
    • → 웹페이지의 다른 스크립트는 이 shadowRoot 변수(마스터 키)에 접근할 방법이 없음
    • 그들이 할 수 있는 유일한 시도는 shadowHost 엘리먼트(document.getElementById('criti-ai-shadow-host'))를 찾은 다음, 표준 속성인 .shadowRoot를 호출해보는 것
    • 하지만 mode'closed'이기 때문에, 브라우저는 "이 엘리먼트엔 Shadow DOM이 없습니다"라는 뜻으로 null을 반환
    • → 다른 스크립트들은 마스터 키가 없을 뿐만 아니라, 비밀의 방이 존재한다는 사실 자체를 숨김 당하게 됨
 
 

2. 완벽한 격리를 위한 CSS 환경 구축

Shadow DOM 내부에도 외부의 영향을 받지 않는 독립적인 스타일 환경을 구축해야 했습니다.

A. CSS Reset 주입

Shadow DOM은 외부 스타일을 막아주지만, font, color 등 상속(inherit)되는 CSS 속성은 막지 못합니다. 이를 원천 차단하기 위해 :host 선택자에 all: initial을 적용하여 모든 상속을 명시적으로 끊어냈습니다.
/* getShadowCSS() 함수 내부 */ /* :host는 Shadow DOM의 "루트" 요소를 의미 */ :host { all: initial; /*모든 상속된 스타일 초기화 */ display: block; /* 폰트 명시적으로 재선언하여 완벽히 격리 */ font-family: 'Pretendard', sans-serif; color: #111827; } * { /* 모든 하위 요소들도 격리된 환경 기준으로 box-sizing */ box-sizing: border-box !important; margin: 0; padding: 0; }
 

B. Emotion Cache 재설정

가장 큰 난관이었습니다. 이 프로젝트에서는 CSS-in-JS로 Emotion을 사용하고 있었는데, Emotion은 기본적으로 스타일 태그(<style>)를 문서의 <head>에 주입합니다.
하지만 저희의 React 앱은 Shadow DOM 내부에 렌더링되므로, <head>에 주입된 스타일은 적용되지 않습니다.
이 문제를 해결하기 위해, Emotion의 createCacheCacheProvider를 사용하여 Emotion의 동작 방식을 커스터마이징 했습니다
 
  • createCache는 Emotion의 '스타일 관리자'를 새로 생성하는 함수입니다. 기본적으로 Emotion은 <head>에 스타일을 주입하는 전역 관리자를 사용합니다. createCache를 사용하면, container 옵션을 통해 '스타일 태그가 삽입될 위치'를 <head>가 아닌 우리가 지정한 DOM 노드(Shadow DOM 내부의 emotionStyleContainer)로 강제할 수 있습니다.
  • CacheProvider는 React의 Context Provider 컴포넌트입니다. 이 컴포넌트로 React 앱을 감싸고, value prop으로 우리가 방금 생성한 emotionCache 객체를 전달합니다.
    • 이렇게 하면, CacheProvider의 모든 하위 컴포넌트들은 전역 관리자 대신 제공한 '로컬 스타일 관리자'를 사용하게 됩니다.
// ... (shadowRoot 생성 후) ... // 3. Emotion 스타일이 주입될 컨테이너 Shadow DOM 내부에 생성 const emotionStyleContainer = document.createElement("div"); emotionStyleContainer.id = "criti-ai-emotion-styles"; shadowRoot.appendChild(emotionStyleContainer); // 4. Emotion cache를 생성하며, 스타일 컨테이너를 지정 const emotionCache = createCache({ key: "criti-ai", // 스타일 태그의 prefix // * 스타일 태그가 삽입될 위치를 <head>가 아닌 // Shadow DOM 내부의 emotionStyleContainer로 강제 container: emotionStyleContainer, }); // 5. React 앱을 CacheProvider로 감싸서 렌더링 reactRoot.render( <CacheProvider value={emotionCache}> <ContentScriptApp {...props} /> </CacheProvider> );
이 설정을 통해, Emotion이 생성하는 모든 <style> 태그가 <head>가 아닌 Shadow DOM 내부의 emotionStyleContainer에 삽입되어, 프레임워크를 사용하면서도 완벽한 스타일 격리를 적용할 수 있었습니다
 

4. 결과 및 교훈

  • 결과
    • UI 일관성 확보: 어느 웹사이트를 방문하든 저희 사이드바는 항상 의도한 대로 동일한 스타일으로 렌더링됩니다.
    • !important 제거: CSS 코드베이스가 깨끗해지고 유지보수성이 향상되었습니다.
    • 안정성 향상: 외부 페이지의 스크립트나 스타일 변화로 인해 확장 프로그램이 오작동할 수 있는 문제가 방지되었습니다.
    •  
  • 배운 점
      1. 웹 표준의 힘: 프레임워크가 해결 못 하는 근본적인 문제는 결국 웹 표준에 대한 깊은 이해에서 해답을 찾을 수 있었습니다.
      1. 격리를 최우선으로 설계 크롬 확장 프로그램처럼 통제 불가능한 환경에서는, 처음부터 격리를 최우선으로 고려한 아키텍처 설계가 필수적이라는 점을 배울 수 있었습니다.
      1. 프레임워크 너머: React와 Emotion은 훌륭한 도구이지만, 그 동작 원리를 이해하고 특정 환경(Shadow DOM)에 맞게 커스터마이징(createCache)할 수 있는 능력이야말로 진정한 엔지니어링 역량임을 느낄 수 있었습니다.