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.tsx의 mountApp 함수를 중심으로, 외부와 완벽히 격리된 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의
createCache와 CacheProvider를 사용하여 Emotion의 동작 방식을 커스터마이징 했습니다createCache는 Emotion의 '스타일 관리자'를 새로 생성하는 함수입니다. 기본적으로 Emotion은<head>에 스타일을 주입하는 전역 관리자를 사용합니다.createCache를 사용하면,container옵션을 통해 '스타일 태그가 삽입될 위치'를<head>가 아닌 우리가 지정한 DOM 노드(Shadow DOM 내부의emotionStyleContainer)로 강제할 수 있습니다.
CacheProvider는 React의 Context Provider 컴포넌트입니다. 이 컴포넌트로 React 앱을 감싸고,valueprop으로 우리가 방금 생성한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 코드베이스가 깨끗해지고 유지보수성이 향상되었습니다.- 안정성 향상: 외부 페이지의 스크립트나 스타일 변화로 인해 확장 프로그램이 오작동할 수 있는 문제가 방지되었습니다.
- 배운 점
- 웹 표준의 힘: 프레임워크가 해결 못 하는 근본적인 문제는 결국 웹 표준에 대한 깊은 이해에서 해답을 찾을 수 있었습니다.
- 격리를 최우선으로 설계 크롬 확장 프로그램처럼 통제 불가능한 환경에서는, 처음부터 격리를 최우선으로 고려한 아키텍처 설계가 필수적이라는 점을 배울 수 있었습니다.
- 프레임워크 너머: React와 Emotion은 훌륭한 도구이지만, 그 동작 원리를 이해하고 특정 환경(Shadow DOM)에 맞게 커스터마이징(
createCache)할 수 있는 능력이야말로 진정한 엔지니어링 역량임을 느낄 수 있었습니다.