남의 document 안에서 살아남기

15개 금융사 도메인에서 벌인 CSS 격리 전쟁
통제할 수 없는 호스트 페이지에 React SDK를 배포하면서 CSS 격리를 어떻게 설계했는지의 기록이다. 격리하면 오히려 깨질 수 있는 것을 먼저 따진 과정, CSS 덮어쓰기와 iframe을 진지하게 검토하고 접은 과정, 그리고 Shadow DOM을 선택하면서 치른 비용까지 다룬다.
KTX를 타고 가서 고치는 CSS
우리 SDK는 15개 금융사의 웹사이트 안에서 돌아갔다.
처음에는 이 숫자가 그렇게 크게 느껴지지 않았다. 화면이 몇 개 안 되는 SDK였고, Tailwind 기반 디자인시스템으로 필요한 속성은 컴포넌트마다 명시하고 있었으니 스타일 충돌은 적당히 방어할 수 있을 거라고 생각했다.
실제로는 정반대였다.
금융사 페이지에서 스타일이 깨졌다는 제보가 들어오면 가장 먼저 해야 하는 일은 그 금융사 페이지에 직접 들어가 보는 것이었다. 그런데 금융사 개발망은 보안상 외부에서 접근할 수 없는 경우가 많았고, 로컬에서 똑같은 환경을 만드는 것도 현실적으로 어려웠다.
결국 노트북을 들고 금융사 개발망에 들어가서 DevTools를 열어야 했다.
어느 날은 버튼 하나의 모양이 이상하다는 제보를 확인하러 갔는데, 원인이 우리 CSS가 아니었다. 금융사 페이지 어딘가에 있던 전역 button 스타일이었다.
문제는 이런 스타일이 우리 코드 안에 있지 않았다는 것이다. 금융사마다 달랐고, 페이지의 어느 위치에 선언되어 있는지도 달랐다. 처음부터 존재하는 것도 아니었다. 팝업 라이브러리나 레거시 스크립트가 런타임에 스타일을 추가하는 경우도 있었다.
버튼 하나의 border-radius를 확인하기 위해 왕복 몇 시간을 쓰는 일이 반복되기 시작하면, 그때부터는 이걸 CSS 문제라고 부르기 어려워진다. 우리가 만든 SDK가 다른 사람의 document 안에서 실행되는 구조 자체가 문제였다.
화면이 N개이고 금융사가 15개라면, 같은 화면이라도 금융사별로 확인해야 했다. 금융사가 하나 추가될 때마다 새로운 CSS 환경이 하나 추가되는 셈이었다.
flowchart LR
A[SDK 화면 N개] --> C[검증 조합 N × 15]
B[금융사 15개<br/>각기 다른 전역 CSS] --> C
C --> D[금융사 추가·화면 추가마다<br/>전체 재검증 + 폐쇄망 출장]
그래서 어느 순간부터 목표가 바뀌었다.
금융사 CSS를 잘 이기는 방법이 아니라, 애초에 금융사 CSS와 싸우지 않는 방법을 찾아야 했다.
격리하면 안 되는 것도 있었다
스타일 격리를 추진한 건 나였다. 리드에게 이걸 하고 싶다고 어필했고, 돌아온 건 우려였다.
이 프로젝트는 애초에 위험한 구조다. 남의 집에서 도는 코드이고, 우리는 플랫폼 회사지만 금융사와의 관계에서는 을에 가깝다. 게다가 금융사 사이트는 레거시가 두껍고 온갖 보안 솔루션이 얹혀 있다. 무엇이 어떻게 동작할지 예측하기 어렵다는 뜻이다.
당장 우리 화면 안에도 그런 게 있었다. 계좌 비밀번호나 주민등록번호를 입력할 때 금융사의 보안키보드 솔루션이 붙는다. 솔루션은 금융사마다 다르고, 공통점은 우리 입력 요소의 DOM을 직접 건드린다는 것이다.
격리는 이 지점과 정면으로 부딪힌다. JavaScript까지 갈라놓는 iframe은 말할 것도 없고, Shadow DOM도 바깥 스크립트가 경계 안의 요소를 찾아 들어와야 하므로 특성상 막힐 수 있다.
그래서 추진하기 전에 금융사가 쓰는 솔루션을 조사하고 직접 테스트해봤다. 다행히 Shadow DOM 환경에서도 동작했다.
다만 지금 되는 것이 앞으로도 된다는 보장은 아니다. 금융사가 솔루션을 바꿀 수도, 새로 들어오는 금융사가 지원하지 않는 솔루션을 쓸 수도 있다. 그래서 두 리스크를 저울에 올려야 했다.
스타일 격리를 하지 않을 경우
→ 이미 발생하고 있는 문제다
→ 금융사가 추가될 때마다 재발한다
→ 화면 전체가 깨질 수 있고 사용자 눈에 바로 보인다
외부 솔루션과 충돌할 경우
→ 아직 발생하지 않은 문제다
→ 특정 입력 필드에 국한된다
→ 대책을 미리 설계할 수 있다결론은 격리를 하지 않는 쪽이 더 큰 리스크라는 것이었다. 당장 눈에 보이는 문제이고 서비스 전체가 쉽게 깨지기 때문이다.
물론 외부 솔루션 쪽도 대책 없이 갈 수는 없었다. 필요해지면 해당 요소만 Portal로 shadow 경계 바깥에 꺼내서 처리하는 방법이 있었다. 복잡해지기는 해도 길이 막힌 것은 아니었다. 뒤에서 Portal의 도착지를 경계 안으로 옮기는 이야기가 나오는데, 그 방향을 거꾸로 쓰는 셈이다.
무엇이 깨졌나
금융사 페이지에는 저마다의 역사가 쌓여 있었다. 우리가 실제로 만났던 문제를 분류하면 대략 네 가지였다.
/* 유형 1: 전역 태그 셀렉터 */
button {
border-radius: 0;
background: #003478;
}
/* 유형 2: 브라우저 기본값을 지우고 가는 전역 reset */
* {
margin: 0;
padding: 0;
box-sizing: content-box;
}
/* 유형 3: html/body에서 상속되거나 기준값으로 사용되는 속성 */
body {
font-family: "은행전용서체";
font-size: 62.5%;
line-height: 1.2;
}
/* 유형 4: 런타임에 주입되는 스타일 */
body.modal-open {
overflow: hidden;
}실제로는 훨씬 다양했다. button에 border: 0을 걸어놓은 페이지가 있었고, 반대로 브라우저 기본 버튼 스타일을 되살리려는 스타일도 있었다. a 태그에 전역으로 text-decoration, color, cursor를 지정해 놓은 페이지도 있었다.
input, select, textarea 같은 폼 요소는 더 까다로웠다. 금융사 공통 CSS가 font-size, line-height, padding, border, appearance를 한꺼번에 건드리는 경우가 많았기 때문이다.
레이아웃도 마찬가지였다. html, body의 height나 overflow가 우리가 기대한 것과 다르면 풀페이지 SDK의 동작이 달라졌다. 특히 html { font-size: 62.5% } 같은 오래된 10px rem 트릭은 SDK 전체의 크기를 바꿔버리는 대표적인 원인이었다. 특정 wrapper에 transform이 들어가 있어서 position: fixed의 기준이 viewport가 아니라 wrapper가 되어버린 경우도 있었다.
우리는 Tailwind를 쓰고 있었다. 유틸리티 클래스로 필요한 속성을 선언하고 있었으니 어지간한 건 우리 값이 이길 거라고 봤다.
그런데도 깨졌다.
DevTools를 열어보면 우리 클래스는 정상적으로 붙어 있었다. 명시도로 보면 클래스가 태그 셀렉터를 이기니, 우리가 선언한 속성은 대체로 우리 값이 적용되고 있었다. 깨진 건 우리가 손대지 않은 속성들이었다.
왜 그런지 이해하려면 CSS가 한 document 안에서 어떻게 동작하는지부터 봐야 했다.
CSS에는 담장이 없다
하나의 document 안에 들어온 CSS는 결국 같은 스타일 계산 과정에 참여한다. <style>에서 왔는지 <link>로 가져왔는지, 우리 SDK에서 온 CSS인지 금융사에서 온 CSS인지는 CSS 격리 관점에서 별도의 담장이 되어주지 않는다.
브라우저는 각 요소에 대해 적용 가능한 규칙을 모으고 캐스케이드에 따라 최종 값을 결정한다. 그래서 우리가 클래스로 스타일링을 해도 금융사의 태그 셀렉터는 여전히 우리 요소를 선택한다.
예를 들어 우리 버튼이 이렇다고 해보자.
<button class="rounded-lg bg-neutral-900 px-4 py-3">
다음
</button>여기서 우리가 선언한 건 border-radius, background, padding뿐이다. 금융사 페이지에 다음 CSS가 있다면,
button {
border: 0;
font-family: "은행전용서체";
}border와 font-family는 우리가 한 번도 선언한 적이 없으므로 금융사 값이 그대로 적용된다. 명시도 경쟁이 일어나지도 않는다. 애초에 경쟁할 상대가 없기 때문이다.
여기에 상속과 !important가 더해진다. font-family, color, line-height 같은 속성은 부모에서 계산된 값이 자식으로 흘러내리므로 셀렉터 경쟁 자체가 성립하지 않는다. 그리고 금융사가 button { border-radius: 0 !important }를 선언하면 우리 클래스는 명시도와 무관하게 진다.
우리가 실제로 만난 침투 경로를 정리하면 세 가지였다.
| 침투 경로 | 원리 | 유틸리티 클래스로 막히나 |
|---|---|---|
| 상속 유입 | body에서 계산된 font, color, line-height가 흘러내림 | 안 막힘. 상속은 셀렉터 경쟁이 아님 |
| 빈 속성의 틈 | 우리가 선언하지 않은 속성은 금융사 태그 셀렉터 값이 그대로 적용 | 안 막힘 |
| !important 관통 | 중요도가 명시도보다 먼저 평가되므로 클래스의 명시도가 무의미 | 안 막힘 |
여기서 중요한 건 !important 자체보다 우리가 모든 CSS 속성을 통제하고 있지 않다는 사실이었다.
border-radius를 선언했다고 해서 font-family, line-height, appearance, box-sizing, border, color까지 자동으로 우리 것이 되지는 않는다. 선언하지 않은 속성은 여전히 document의 다른 규칙이 결정한다.
앞에서 본 유형 2의 전역 reset도 결국 같은 자리에서 터진다. *는 명시도가 가장 낮아서 우리가 선언한 속성은 못 이긴다. 다만 우리가 말한 적 없는 속성에서는 브라우저 기본값 대신 reset 값이 남는다. 늦게 로드돼서가 아니라 우리가 그 속성을 선언하지 않아서다.
방향은 반대로도 흘렀다. Tailwind Preflight가 금융사 document에 로드되어 있으면 우리 리셋이 금융사 페이지 전체를 건드린다. 우리가 당하기만 하는 구조가 아니라 양쪽이 서로를 오염시키는 구조였다.
1차 방어선: 전부 덮어쓰기, 그리고 실패
처음부터 Shadow DOM을 사용한 것은 아니다. 오히려 한동안은 우리 선언으로 전부 덮어쓰면 해결된다고 생각했다.
컨테이너 루트에서 리셋을 걸고, 주요 컴포넌트에는 필요한 속성을 최대한 명시했다. 명시도가 밀리는 곳에는 셀렉터를 조금 더 구체적으로 만들었고, 정말 안 되는 곳에는 !important도 썼다.
/* 우리가 운용하던 방어선의 축약판 */
.lsdk-root,
.lsdk-root * {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: Pretendard, sans-serif;
line-height: 1.5;
/* ...우리가 예상한 모든 속성... */
}
.lsdk-root .lsdk-button {
border-radius: 8px !important;
}처음에는 실제로 효과가 있었다. 몇 개 금융사에서 발생하던 문제를 빠르게 잡을 수 있었다.
금융사가 늘자 방어 규칙도 금융사별로 늘었다. 어느 금융사에서는 button의 border를 건드리고, 다른 금융사에서는 box-sizing을 건드리고, 또 다른 곳에서는 line-height를 건드렸다. 하나를 발견할 때마다 하나를 추가했다.
반대 방향 제약도 있었다. Tailwind Preflight를 금융사 document에 그대로 둘 수는 없었다. 그러면 우리 리셋이 금융사 페이지 전체를 건드린다. 그렇다고 리셋 범위를 우리 컨테이너 하위로 좁히면 브라우저 기본 스타일을 우리가 다시 채워 넣어야 했다.
그러다 어느 순간부터는 금융사 CSS를 방어하기 위해 브라우저 기본 스타일시트와 금융사 CSS를 다시 구현하고 있는 것 같은 상태가 됐다.
이 방식은 구조적으로 끝이 없었다. 우리는 우리가 알고 있는 충돌만 방어할 수 있었다. 금융사에서 새로운 전역 스타일을 하나 추가하면 새로운 구멍이 생겼고, 새 컴포넌트를 하나 만들면 그 컴포넌트가 금융사 CSS의 영향을 받지 않는지 다시 확인해야 했다.
!important도 해결책이 아니었다. 금융사에서 !important를 쓰면 우리도 써야 했고, 그러다 보면 SDK 내부의 스타일끼리도 !important로 싸우기 시작한다. 런타임에 스타일을 주입하는 라이브러리까지 등장하면 소스 순서를 미리 예측하기도 어려워진다.
무엇보다 이 방식은 금융사 수가 늘어날수록 유지비가 같이 증가한다. 15개에서 이미 부담스러웠고 금융사는 계속 늘어날 예정이었다.
덮어쓰기는 당장 발생한 장애를 막는 전술로는 쓸 만했지만 SDK의 구조를 보호하는 방법은 아니었다. 여기서부터 격리 기술을 제대로 검토하기 시작했다.
iframe을 세 번 검토하고 접었다
정답처럼 보였던 것: 별도 도메인 iframe
기술만 놓고 보면 답은 명확했다. 우리 도메인에서 제공하는 cross-origin iframe이다.
CSS가 격리되는 직접적인 이유는 iframe이 별도의 document를 갖기 때문이다. 거기에 cross-origin이면 JavaScript의 DOM 접근 제한까지 더해진다.
브라우저에는 Same-Origin Policy가 있다. 오리진은 스킴, 호스트, 포트의 조합이고 서로 다른 오리진의 문서는 기본적으로 서로의 DOM에 직접 접근할 수 없다.
금융사 페이지가 https://bank-a.com이고 우리 SDK가 https://sdk.our-company.com에서 제공된다면,
<iframe src="https://sdk.our-company.com"></iframe>안의 document는 금융사 document와 다른 오리진이 된다. 금융사 CSS가 iframe 내부 요소를 선택할 수 없고, 금융사 JavaScript도 내부 DOM을 마음대로 조작할 수 없다. CSS 격리만 놓고 보면 사실상 가장 깔끔한 답이었다.
실제 업계에서도 비슷한 구조를 많이 쓴다. Stripe Checkout 같은 결제 시스템은 민감한 입력 영역을 별도의 iframe 컨텍스트에서 다루는 방식으로 호스트 페이지와의 경계를 만든다.
우리 입장에서도 장점이 많았다. iframe 내부는 우리가 배포하는 애플리케이션이므로 금융사에 CSS 번들을 설치하게 할 필요가 없다. 장애를 수정하면 우리 서버에서 바로 반영할 수 있고, SDK 수정이 금융사의 배포 일정에 묶이지 않는다.
그런데 이 구조는 기술 검토에서 끝나지 않았다.
우리 서비스에서는 약정 화면을 금융사 도메인에서 제공해야 한다는 규제 조건이 있었다. 당시 법적 검토에서 약정 화면의 제공 주체와 도메인은 중요한 조건이었고, 우리 도메인에서 제공되는 화면을 금융사 사이트의 iframe 안에 넣는다고 해서 그 문제가 사라지지는 않았다.
CSS 격리만 보면 iframe이 가장 단순했다. 하지만 이 프로젝트에서는 규제 조건을 먼저 만족해야 했다. 그래서 cross-origin iframe은 여기서 접었다.
이 판단은 당시 우리 서비스에 대한 법적 검토 결과이고 일반적인 법률 해석이 아니라는 점은 밝혀둔다.

변형안: 서브도메인 CNAME
그렇다고 iframe을 바로 포기한 것은 아니었다. 한 단계 더 들어가서 금융사 서브도메인을 우리 인프라에 연결하는 방법도 검토했다.
loan-sdk.bank-a.com 같은 도메인을 금융사가 운영하고 DNS CNAME으로 우리 인프라에 연결하는 방식이다. 실제 응답은 우리 서버에서 내려주지만 도메인 자체는 금융사 소유로 유지된다. 화이트라벨 SaaS에서 고객사 커스텀 도메인을 지원할 때 흔히 쓰는 방식이다.
CNAME도 규제 해석과 운영 비용이라는 두 조건을 넘지 못했다. 도메인 명의만 금융사이고 실제 화면을 제공하는 주체는 우리라는 점에서 규제 문제가 완전히 해결되지 않았다. 그리고 15개 금융사 각각에 DNS 변경을 요청하고, 인증서를 처리하고, 보안 심사를 받고, 각 인프라 정책을 맞춰야 했다. 금융권에서 이 과정은 DNS 레코드 하나를 추가하는 일이 아니다.
아키텍처의 제약 조건에 기술 스택만 들어가는 게 아니라는 걸 이때 명확하게 체감했다.
마지막 카드: friendly iframe
iframe을 완전히 포기하기 전에 카드가 하나 더 있었다. src 없이 만든 iframe이다.
const frame = document.createElement('iframe');
// src를 지정하지 않으면 about:blank가 되고,
// about:blank는 생성한 문서의 오리진을 상속한다.
document.body.appendChild(frame);
frame.contentDocument.body.appendChild(ourRoot);about:blank iframe도 별도 document를 가지므로 금융사 페이지의 전역 CSS 셀렉터가 내부 요소를 선택할 수 없다. 다만 부모와 같은 오리진이라 JavaScript는 contentDocument로 접근할 수 있다. 광고나 임베드 시스템에서 오래전부터 써온 방식이기도 하다.
규제 요건을 충족하는지는 기술이 아니라 법무의 판단 영역이었기 때문에 별도로 검토하기로 하고, 기술적인 가능성부터 확인했다.
그런데 풀페이지 약정 플로우를 이 안에 넣으려고 하니 새로운 문제가 보였다.
가장 먼저 걸린 것은 history였다. 우리 SDK는 단순한 위젯이 아니라 여러 화면을 이동하고, 뒤로가기를 제어하고, 본인인증 같은 외부 플로우를 거쳤다가 다시 돌아와야 했다. iframe 안에서 내비게이션이 발생하면 부모 페이지와 iframe 내부의 session history가 얽히기 시작한다. 사용자가 브라우저 뒤로가기를 눌렀을 때 우리가 기대하는 “약정 화면 이전 단계”가 아니라 iframe의 이전 navigation 상태가 먼저 처리될 수 있다.
본인인증도 마찬가지였다. 외부 인증 페이지로 이동했다가 돌아오는 과정이 iframe 컨텍스트에서 어떻게 동작하는지 추가로 검증해야 했다.
모바일 웹뷰에서는 키보드와 포커스 문제도 있었다. iframe 안에 별도 document가 생기면 포커스, viewport, 키보드 resize 같은 모바일 특유의 문제를 또 하나의 환경에서 검증해야 한다.
React 애플리케이션 구조도 바뀌었다. 기존 앱은 하나의 document를 기준으로 만들어져 있었고 Portal도 document.body를 기준으로 쓰고 있었다. iframe을 쓰면 이걸 전부 별도 document에 맞춰야 한다.
그래서 friendly iframe은 현재 요구사항에서 비용이 너무 큰 기술이라고 판단했다. 당장 채택하지는 않았지만, JavaScript 실행 컨텍스트까지 분리해야 하는 시점이 오면 다시 검토하기로 했다.
Shadow DOM 채택
검토한 후보 중 현재 제약을 모두 만족한 것은 Shadow DOM이었다.
Shadow DOM을 사용하면 host 아래에 별도의 shadow tree가 만들어진다. document의 일반적인 스타일시트에서 정의한 셀렉터는 기본적으로 shadow tree 내부의 요소와 직접 매칭되지 않는다. 우리가 원했던 것이 바로 이 경계였다.
덮어쓰기처럼 금융사 CSS를 하나씩 이기는 게 아니라, 애초에 금융사 CSS와 우리 CSS가 같은 DOM 트리에서 경쟁하지 않도록 만드는 것.
요구사항이 처음부터 다 보였던 것은 아니다. 후보를 하나씩 검토하는 과정에서 규제, history, Portal, WebView 호환성 같은 평가 기준이 드러났고, 정리하고 나니 이렇게 됐다.
| 요구사항 | 덮어쓰기 | 별도 도메인 iframe | Friendly iframe | Shadow DOM |
|---|---|---|---|---|
| 금융사 CSS 셀렉터 격리 | ❌ | ✅ | ✅ | ✅ |
| 금융사 도메인에서 실행 (규제) | ✅ | ❌ | ✅* | ✅ |
| 현재 history 구조를 그대로 유지 | ✅ | 재설계 필요 | ⚠️ | ✅ |
| 기존 React 앱 수정량 | 낮음 | 높음 | 높음 | 중간 |
| React Portal 처리 | 그대로 | 별도 document | 별도 document | Portal 대상 변경 |
| 금융사 JS와의 격리 | ❌ | ✅ | ❌ | ❌ |
| 구현 복잡도 | 낮음 (유지비 무한) | 높음 | 높음 | 중간 |
| 결론 | 실패 | 규제 기각 | 보류 | 채택 |
- friendly iframe의 규제 충족 여부는 별도 법무 판단 필요
- history 항목은 기술적 가능 여부가 아니라 현재 구조를 유지할 수 있는가의 판단이다. cross-origin iframe도
postMessage로 history 계약을 새로 설계하면 가능하지만 앱을 크게 바꿔야 한다 - 금융사 JS와의 격리는 우리 요구사항이 아니었다. 앞에서 본 보안키보드처럼 오히려 JS가 경계를 넘어와야 하는 요구가 있었기 때문에, 이 축에서 Shadow DOM이 감점되지는 않았다
실행 환경이 자사 앱 웹뷰 중심이었기 때문에 브라우저 지원 문제를 크게 걱정하지 않아도 됐다는 점도 결정에 영향을 줬다.
다만 여기서 하나는 정확하게 짚고 넘어가야 했다. Shadow DOM이 모든 것을 격리해주지는 않는다.
| 항목 | 비고 | |
|---|---|---|
| 격리된 것 | 금융사 태그·클래스 셀렉터 | 경계 안과 매칭 자체가 안 됨 |
| 금융사 !important | 경쟁이 성립하지 않으므로 무의미 | |
| 캐스케이드 (명시도·소스 순서) | 경계 안은 우리 시트끼리의 경쟁 | |
| 바깥에서의 DOM 쿼리 | document.querySelector가 경계를 뚫지 않음* | |
| Tailwind Preflight의 오염 방향 | 우리 리셋이 금융사에 새지 않음 | |
| 격리되지 않은 것 | JavaScript, window, document | 같은 실행 컨텍스트 |
| host 요소 자체 | 금융사 document에 놓인 일반 요소 | |
| CSS custom property의 상속 | 내부 루트에서 SDK 토큰을 직접 선언 | |
| rem의 기준값 (html font-size) | 빌드 타임 px 치환으로 대응 | |
| font-family 이름 | LSDK- 접두어로 소유권 분리 |
|
| React Portal의 도착지 | 기본값이 경계 밖 document.body |
|
| z-index · 쌓임 맥락 | host 대역을 금융사와 협의 | |
| body 스크롤, viewport | 자체 스크롤 컨테이너 소유 |
- open 모드에서는
element.shadowRoot로 접근 자체는 가능하다. 이 격리는 사고 방지 수준이라는 뜻이고, 이 이야기는 글 뒤쪽에서 다시 한다.
여기서 덮어쓰기와의 차이가 갈린다. 덮어쓰기 시절에는 금융사가 전역 선언을 하나 추가할 때마다 방어 목록이 늘었다. 늘어나는 속도를 우리가 통제하지 못했다.
Shadow DOM을 적용한 뒤에는 금융사 셀렉터가 내부 요소를 직접 선택하지 못한다. 남은 문제는 host, 상속값, 리소스 로딩, Portal, 전역 실행 환경처럼 경계를 실제로 공유하는 지점으로 좁아졌다.
이게 모든 Shadow DOM 프로젝트에 공통인 완전한 목록이라는 뜻은 아니다. SDK 기능이 늘면 통합 지점도 늘어난다. 중요한 차이는 금융사마다 임의의 CSS 예외가 계속 생기는 구조에서 벗어났다는 것이고, 화면 수가 늘어도 금융사별 확인 항목이 함께 늘지 않는다는 것이다.
아래는 그 지점들을 세 갈래로 나눠 따라간 기록이다. host가 경계 바깥에 남는 문제, 경계를 넘어 들어오는 값의 문제, 그리고 같은 런타임을 계속 공유하는 문제다.

출처: https://developer.mozilla.org/ko/docs/Web/API/Web_components/Using_shadow_DOM
경계 바깥에 남은 host
shadow tree 안은 격리됐지만 host는 여전히 금융사 document의 요소다. 아래 세 가지는 전부 이 하나의 원인에서 나왔다.
마운트: body 직속, 그리고 보호받지 못하는 host
Shadow DOM을 적용하면서 가장 먼저 바꾼 것은 SDK를 어디에 붙일 것인가였다.
기존처럼 금융사가 지정한 wrapper 안에 마운트할 수도 있었지만, 그렇게 하면 wrapper의 CSS 환경을 그대로 물려받는다. 특히 문제였던 것이 position: fixed다.
조상 요소에 transform, filter, perspective 같은 속성이 들어가면 position: fixed의 기준이 viewport가 아니라 해당 조상으로 바뀔 수 있다. 실제 금융사 페이지에는 성능 최적화나 애니메이션을 위해 transform: translateZ(0) 같은 코드가 들어간 wrapper가 생각보다 많았다.
풀페이지 SDK를 그런 wrapper 아래에 넣으면 화면 전체를 덮어야 할 SDK가 wrapper 크기 안에 갇힌다. 그래서 기본 계약은 document.body 직속 마운트로 잡았다.
body 직속이 모든 문제를 없애주지는 않는다. html이나 body 자체에 transform, contain, will-change가 걸려 있을 수 있고, 금융사 통합 규칙상 지정 wrapper를 써야 하는 예외도 있었다. 그런 경우에는 containing block을 따로 확인했다.
Shadow tree 내부와 달리 host는 금융사 document의 요소다. 금융사에 이런 스타일이 있으면 host도 영향을 받는다.
div {
position: relative;
margin: 20px;
padding: 30px;
}shadow tree 안쪽은 안전하지만 문을 달아놓은 벽 자체는 금융사 마당에 서 있는 셈이다. 그래서 host는 최대한 단순하게 유지하고, 레이아웃에 꼭 필요한 값만 생성 시점에 인라인으로 지정했다.
const host = document.createElement('div');
Object.assign(host.style, {
position: 'fixed',
inset: '0',
display: 'block',
});
document.body.appendChild(host);
const shadowRoot = host.attachShadow({ mode: 'open' });인라인 선언이라고 해서 금융사의 !important까지 이기지는 못한다. host가 완전히 보호받지 못한다는 사실은 그대로 남는다. 중요한 건 금융사 document에 SDK 전체 CSS를 설치하는 방식으로 돌아가지 않는 것이었다. 경계 바깥의 요소 하나만 최소한으로 통제하고 실제 UI는 shadow tree 안에 넣는다.
z-index는 host에서 결정된다
Shadow DOM을 적용하고 나서도 z-index 문제는 남았다.
처음에는 이런 코드를 생각하기 쉽다.
.modal {
z-index: 999999999;
}그런데 shadow tree 안에서 아무리 큰 값을 줘도 금융사 document의 다른 요소와 직접 경쟁하는 것이 아니다. host가 stacking context를 만들면 shadow tree 안의 요소는 그 안에 갇힌다.
우리 host는 일반적인 block 요소였기 때문에 z-index 값만 추가해서는 기대한 레이어가 만들어지지 않았다. 앞 절에서 본 것처럼 host에는 position: fixed가 함께 들어가 있었고, 그래서 이 host가 document 수준의 stacking context로 동작했다.
이 상태에서 금융사 header가 z-index: 999999라면, shadow tree 안의 Modal에 z-index: 999999999를 줘도 header보다 위로 올라가지 않는다. 문서 레벨에서 비교되는 것은 host 쪽의 stacking context이기 때문이다.
금융사 header (z-index: 999999)
SDK host (z-index: ???) ← 승부는 여기서 난다
└── #shadow-root
└── modal (z-index: 999999999)
↑
host 내부에서만 유효그래서 이 문제의 해결 대상도 shadow 내부 Modal이 아니라 host였다. 금융사와 통합할 때 host가 사용할 z-index 대역을 정하고, SDK 화면이 떠 있는 동안 금융사 오버레이가 어떻게 동작할지 운영 규칙을 함께 맞췄다.
Shadow DOM이 CSS 격리를 제공한다고 해서 document 레벨의 stacking context까지 사라지지는 않았다.
body 스크롤은 신뢰하지 않는다
금융사 페이지의 팝업 라이브러리가 열릴 때 흔히 하는 일이 있다.
body {
overflow: hidden;
}팝업이 닫히면 다시 원래대로 돌린다. 금융사 입장에서는 정상 동작이다. 하지만 우리 SDK가 body의 스크롤을 그대로 쓰고 있다면 금융사 팝업 하나 때문에 SDK의 스크롤까지 영향을 받는다.
그래서 풀페이지 SDK에서는 body 스크롤을 애플리케이션의 전제로 삼지 않았다. host는 viewport에 고정하고, 실제 스크롤은 shadow tree 내부의 컨테이너가 소유하도록 했다.
금융사 body 스크롤
← 신뢰하지 않음
← 금융사 팝업이 수시로 잠갔다 푼다
SDK host
(position: fixed, inset: 0)
│
└── #shadow-root
│
└── 자체 스크롤 컨테이너
(overflow-y: auto)이렇게 하면 금융사가 body에 어떤 스크롤 정책을 적용하든 SDK 내부의 스크롤은 우리가 통제할 수 있다. 물론 viewport 자체와 모바일 웹뷰의 동작까지 격리한다는 뜻은 아니다. 다만 금융사 팝업 라이브러리가 body의 overflow를 바꾸는 것 때문에 SDK 전체가 같이 움직이는 문제는 피할 수 있었다.
경계를 넘어 들어오는 값
셀렉터는 차단됐지만 상속과 참조, 그리고 이름 해석은 별개의 통로다. 아래 세 가지는 이 원인에서 나왔다.
상속 초기화와 design token
Shadow DOM을 붙였다고 상속까지 자동으로 사라지지는 않는다. 셀렉터는 막혔어도 font-family, color, line-height 같은 값은 host를 통해 계속 흘러 들어온다.
처음에는 :host에서 초기화하면 될 거라고 봤다.
:host {
all: initial;
display: block;
}이건 절반만 맞다. :host는 shadow tree 안의 셀렉터지만 스타일을 먹이는 대상은 금융사 document에 놓인 host 요소다. 같은 요소를 두고 바깥의 div { margin: 20px }와 안쪽의 :host { all: initial }이 동시에 적용될 수 있다.
그리고 이 경쟁의 규칙은 명시도가 아니다. CSS 캐스케이드는 캡슐화 컨텍스트를 먼저 본다. 일반 선언은 바깥 컨텍스트가 이기고, !important 선언은 안쪽 컨텍스트가 이긴다. 웹 컴포넌트를 바깥에서 스타일링할 수 있게 하려는 설계다. 그 결과 :host { all: initial }은 금융사의 평범한 div 규칙조차 이기지 못한다.
그래서 역할을 나눴다. host의 레이아웃 필수값은 앞 절처럼 생성 시점에 인라인으로 지정하고, 상속값 초기화와 토큰 선언은 shadow tree 안쪽의 앱 루트에서 했다.
.sdk-root {
all: initial;
display: block;
font-family: "LSDK-Pretendard", sans-serif;
line-height: 1.5;
--lsdk-primary: #1a1a1a;
--lsdk-text: #333;
}all: initial도 만능이 아니다. 초기화 대상에서 빠지는 것들이 있다.
all: initial
│
├── 일반 상속 속성 (font, color, line-height ...)
│ └── 초기화된다. 이게 이 선언의 목적
│
├── CSS custom property (--변수)
│ └── 초기화되지 않는다. 상속을 타고 계속 들어온다
│
├── direction · unicode-bidi
│ └── 초기화되지 않는다. 필요하면 따로 명시해야 한다
│
└── rem의 기준값 (html의 font-size)
└── 애초에 상속이 아니라 '참조'라서 이 선언과 무관하다custom property는 실제 환경에서 특히 조심해야 했다. 금융사가 이렇게 변수를 정의해두고 있고 우리 SDK에서도 같은 이름을 쓴다면 기대하지 않은 값이 들어온다.
html {
--primary-color: #003478;
}그래서 SDK 내부에서 쓰는 custom property에는 일관되게 전용 namespace를 붙였다. 이름 충돌을 피하는 가장 단순하고 확실한 방법은 애초에 금융사가 쓸 가능성이 없는 이름을 쓰는 것이었다.
선언하는 자리도 함께 봐야 한다. 습관대로 :root에 토큰을 모으면 shadow tree 안에서는 아무 일도 일어나지 않는다. :root는 document의 root element를 가리키는데 shadow tree에는 그런 요소가 없기 때문이다. 경계 안에서는 :host나 내부 루트 요소에 선언해야 한다.
Tailwind를 쓴다면 이건 남의 이야기가 아니다. Tailwind v4는 초기에 테마 변수를 :root에만 출력해서 shadow tree 안에서 토큰이 잡히지 않았고, @theme 출력에 :host가 추가된 것은 2025년 1월이다. 그 뒤에도 @property 등록은 shadow root로 한정되지 않기 때문에 document 쪽 확인이 여전히 필요하다. 어느 버전을 쓰든 실제 빌드 결과를 열어보고 판단해야 한다.
rem: Shadow DOM이 끊지 못하는 참조
이번 작업에서 가장 기억에 남는 문제 중 하나가 rem이었다.
Shadow DOM을 적용하고 DevTools를 열어보면 금융사 전역 button 스타일은 더 이상 우리 버튼을 건드리지 않는다. 그런데 화면이 이상하게 작아졌다. 셀렉터 격리는 제대로 됐는데 크기가 틀렸다.
원인을 따라가 보니 금융사 페이지의 html에 이 스타일이 있었다.
html {
font-size: 62.5%;
}이른바 62.5% 트릭이다. 브라우저 기본 html font-size가 16px인 환경에서 1rem은 10px이 된다.
문제는 Tailwind의 spacing과 font-size 스케일이 기본적으로 rem을 쓴다는 것이었다. 우리가 padding: 1rem을 기대하고 있었다면 어떤 금융사 페이지에서는 16px이 아니라 10px이 된다. 결과적으로 SDK 전체가 작아졌다.
이 문제를 처음 보면 Shadow DOM이 제대로 격리되지 않은 것처럼 느껴진다. 하지만 Shadow DOM은 rem의 기준을 바꾸지 않는다.
rem은 shadow root를 기준으로 하는 단위가 아니라 document의 root element, 즉 html의 font-size를 기준으로 한다. 그래서 :host에서 font-size를 다시 선언해도 해결되지 않는다.
:host {
font-size: 16px;
}이렇게 해도 1rem이 host의 16px을 기준으로 계산되지 않는다.
px -> 자체적인 길이값
em -> 현재 요소의 font-size를 기준으로 계산
rem -> document의 html font-size를 기준으로 계산우리가 원한 것은 SDK의 크기가 금융사 document의 html 설정에 따라 변하지 않는 것이었다. 그래서 결국 빌드 단계에서 해결했다. SDK CSS에 사용된 rem을 px로 치환해서 금융사 document의 html font-size에 의존하지 않도록 했다.
이 선택에는 대가가 있다. 금융사 html의 기준값에서 SDK 크기를 떼어내는 대신, 브라우저 글자 크기 설정에 따른 상대적 확대도 함께 포기하게 된다.
이 문제를 겪으면서 Shadow DOM에 대한 생각도 조금 바뀌었다. 셀렉터와 캐스케이드는 경계 안으로 들어오지 못하게 만들 수 있지만, 어떤 값이 무엇을 참조하는지까지 바꿔주지는 않는다.
폰트 이름도 SDK가 소유한다
폰트는 실제 장애가 발생한 뒤에 고친 항목이 아니다. 통합 위험으로 미리 정리한 쪽에 가깝다.
우리도 Pretendard를 쓰고 금융사도 Pretendard를 쓴다면 어떻게 될까. 폰트 파일이 같은지 다른지만의 문제가 아니다. 금융사가 같은 이름으로 다른 버전이나 다른 subset을 사용하고 있다면 우리 SDK에서 의도하지 않은 폰트가 선택될 수 있고, 그 subset이 특정 한글 글리프를 빠뜨렸다면 우리 화면에서만 글자가 깨진다.
그래서 폰트도 custom property와 같은 방식으로 다뤘다. SDK가 소유한 font face라는 사실이 이름에서 드러나도록 접두어를 붙였다.
font-family: "LSDK-Pretendard", sans-serif;우리 폰트는 LSDK-Pretendard라는 이름으로 등록하고 SDK 내부에서는 이 이름만 쓴다. 폴백에 Pretendard를 남겨두면 우리 폰트가 로드되지 않았을 때 정확히 피하려던 그 폰트로 떨어지므로, 폴백은 시스템 폰트로만 뒀다.
같은 런타임을 공유한다
CSS 경계는 생겼지만 JavaScript 실행 컨텍스트는 하나다. 스타일을 경계 안에 넣는 일, Portal의 도착지, 전역 오염이 모두 여기서 나온다.
SDK의 CSS는 어디로 갔나
Shadow DOM을 도입하면서 금융사와의 CSS 전달 방식도 바뀌었다.
기존 SDK는 금융사가 CSS와 JS를 각각 설치해야 했다.
<!-- 기존 계약 -->
<link rel="stylesheet" href="/static/loan-sdk.css">
<script src="/static/loan-sdk.js"></script>이 구조에서 CSS는 금융사 document의 <head>에 존재했다. 그런데 Shadow DOM을 적용하고 나면 이 CSS는 shadow tree 내부에 적용되지 않는다. 오히려 Tailwind Preflight가 금융사 document 전체에 적용되기 때문에 그대로 남겨두는 것이 더 위험했다.
그래서 새 구조에서는 CSS 파일을 금융사 document에 설치하지 않는다. SDK가 실행될 때 shadow root 내부에 직접 CSS를 장착한다.
<!-- 새 계약: 스크립트 하나 -->
<script src="/static/loan-sdk.js"></script>금융사 document
│
├── loan-sdk.js <- 금융사가 삽입하는 유일한 태그
│
└── host
└── #shadow-root
├── SDK CSS <- 런타임에 SDK가 경계 안에 장착
└── React 앱금융사가 <head>에 넣는 태그는 하나로 줄었다. 다만 금융사 서버에 올라가는 파일까지 하나가 된 것은 아니다. 폰트는 여전히 SDK 배포본에 포함되어 금융사 서버로 함께 올라가고, 실행 시점에 거기서 내려온다. 우리 CDN을 타지 않는다.
CSS의 위치를 옮긴 것으로 끝나지 않았다. SDK의 배포 계약 자체가 바뀌었다.
Vite 기준으로 CSS를 shadow root 안으로 가져오는 방법은 크게 두 가지였다. CSS를 문자열로 번들에 포함시키는 방법과, 별도 CSS asset으로 두고 shadow root 안에서 로드하는 방법이다. 우리는 전자를 선택했다.
import css from './app.css?inline';JS와 CSS가 서로 다른 버전으로 어긋날 가능성을 줄이기 위해서였다. 금융사 배포 환경에서는 SDK 파일을 항상 동시에 교체할 수 있다는 보장이 없기 때문에, JS가 기대하는 CSS와 실제 로드된 CSS가 다른 버전이 되는 상황을 최대한 줄이는 것이 중요했다.
가져온 CSS를 shadow root에 장착하는 방법도 두 가지가 있었다.
// 방법 1: constructable stylesheet
const sheet = new CSSStyleSheet();
sheet.replaceSync(css);
shadowRoot.adoptedStyleSheets = [sheet];// 방법 2: style 태그
const style = document.createElement('style');
style.textContent = css;
shadowRoot.appendChild(style);우리 구조에서는 shadow root가 하나뿐이었다. adoptedStyleSheets의 대표적인 장점인 stylesheet 객체를 여러 shadow root에서 공유하는 효과는 여기서 의미가 없다. 성능으로 고를 문제가 아니었다.
기준은 다른 데 있었다. 어느 쪽 실패를 우리 손으로 고칠 수 있는가.
<style> 요소는 스크립트로 생성하더라도 style-src의 적용을 받는다. 'unsafe-inline'이 없다면 일치하는 nonce나 hash가 필요하다. nonce는 금융사 서버가 응답마다 생성해 우리 script 태그에 전달해야 하고, hash는 CSS가 바뀔 때마다 금융사 CSP도 함께 갱신해야 한다. 어느 쪽이든 15개 금융사와 새 배포 계약을 맺어야 한다. 앞에서 CNAME을 접었던 이유와 같은 비용이다.
CSSOM으로 시트를 붙이는 경로는 달랐다. 우리가 지원한 브라우저에서는 이 경로가 style-src 때문에 차단되지 않았다. 다만 CSP3 초안은 일부 CSSOM 파싱 경로를 'unsafe-eval'로 제어하는 방향을 담고 있고 표준의 연결 지점도 아직 정리 중이다. 영구적인 우회로 보지 않고 지원 브라우저에서 계속 확인해야 하는 동작으로 취급했다.
adoptedStyleSheets가 실패하는 조건은 구형 WebView인데, 그건 실행 시점에 우리가 감지할 수 있다.
if ('adoptedStyleSheets' in shadowRoot) {
const sheet = new CSSStyleSheet();
sheet.replaceSync(css);
shadowRoot.adoptedStyleSheets = [sheet];
} else {
const style = document.createElement('style');
style.textContent = css;
shadowRoot.appendChild(style);
}우리가 지원하던 WebView 범위에서는 구성 가능한 CSSStyleSheet 생성자와 replaceSync, adoptedStyleSheets가 함께 제공됐기 때문에 adoptedStyleSheets 하나만 확인했다. 지원 매트릭스가 다르다면 세 API를 각각 확인하는 편이 안전하다.
시트를 만드는 위치는 그냥 넘길 문제가 아니다. new CSSStyleSheet()를 분기 밖에서 먼저 호출하면 생성자가 없는 WebView에서 그 줄이 곧바로 예외를 던진다. else에 닿지 못하므로 폴백을 두고도 폴백이 실행되지 않는다.
당시 시점에서 폴백은 선택이 아니라 필수였다. 구성 가능한 스타일시트는 Safari와 iOS에서 16.4, 그러니까 2023년 3월부터 지원됐고 Baseline에서 널리 사용 가능으로 올라온 것은 2025년 9월이다. 자사 앱 웹뷰라도 OS 버전은 사용자 기기에 달려 있다.
폴백에도 사각지대는 남는다. 구형 WebView이면서 동시에 style-src를 조여둔 금융사가 있으면 두 경로가 모두 막힌다. 확률은 낮지만 폴백이 모든 조합을 덮어주지는 않는다.
replaceSync()에는 제약이 하나 더 있다. @import 규칙을 제거하고 콘솔 경고만 남긴다. 예외가 발생하지 않기 때문에 @import로 가져오려던 스타일만 빠진 채 나머지 CSS가 정상 적용된다. 화면이 절반쯤 멀쩡해 보여서 발견이 늦어질 수 있는 유형이다. 그래서 SDK CSS는 @font-face를 직접 선언하고 @import를 쓰지 않는다. Tailwind 엔트리가 @import 기반이라면 번들에 들어가기 전에 평탄화됐는지 확인해야 한다.
폰트 URL도 여기서 확인할 항목이다. constructed stylesheet의 url()은 SDK를 배포한 서버가 아니라 그 시트가 연결된 document, 그러니까 금융사 페이지를 기준으로 해석된다. 실제 최종 경로는 Vite가 url()을 어떤 문자열로 출력했는지에 달려 있으므로 배포 CSS를 직접 열어봐야 한다. 우리 구조에서는 폰트 파일도 SDK 배포본에 포함되어 금융사 서버에 함께 올라가기 때문에 이 해석이 오히려 맞았다. 대신 SDK를 새로 배포할 때마다 JS와 폰트가 같이 교체돼야 한다는 조건이 붙었고, 이 문제는 다음 편에서 다시 나온다.
얻기만 한 것은 아니다. constructed stylesheet는 Elements 패널에 <style> 노드로 나타나지 않고 대응하는 소스 파일도 없다. 폐쇄망에서 DevTools 하나로 원인을 좁혀야 할 때는 손해다. 이 글의 출발점이 바로 그 상황이었다는 걸 생각하면 얄궂은 맞바꿈이다.
경계 안의 스타일 구성은 이렇게 정리했다.
SDK
└── Shadow Root
├── custom reset <- Preflight 대신 최소한만
├── design tokens <- 내부 루트에 SDK 전용 이름으로
├── Tailwind utilities
└── React RootPreflight를 그대로 켜지는 않았다. 취향의 문제만은 아니었다. Preflight의 상당 부분이 html과 body 셀렉터인데 shadow tree에는 그 요소가 없다. * 규칙은 살아남지만 html에 걸린 line-height, font-family, -webkit-text-size-adjust 같은 기준값은 조용히 사라진다. Tailwind가 Preflight의 html 규칙에 :host를 더한 것은 2023년 9월이고, 그 이전 버전에서는 경계 안에서 리셋의 절반이 동작하지 않는다.
그래서 필요한 것만 직접 선언했다. 덮어쓰기 시절에는 어쩔 수 없이 넓혀야 했던 리셋을 여기서는 필요한 만큼만 남길 수 있었다.
React Portal: 오버레이의 도착지를 옮기다
이 문제부터는 CSS 자체보다 React 아키텍처의 문제였다.
모달, 토스트, 바텀시트 같은 오버레이 UI는 일반적으로 document.body에 Portal을 만든다. 우리 코드도 마찬가지였다.
// 기존: 오버레이의 도착지가 document.body
createPortal(<Modal />, document.body);Shadow DOM을 적용하고 이 코드를 그대로 두면 Modal은 React 트리에서는 SDK 안에 있는 것처럼 보이지만 실제 DOM은 shadow root 밖에 만들어진다. 결국 금융사 document에 있는 일반 DOM 요소가 된다.
그러면 두 가지가 동시에 발생한다. shadow root 안에 장착해둔 SDK CSS가 Modal에 적용되지 않고, Modal이 다시 금융사 전역 CSS의 영향을 받는다. CSS 격리를 하려고 Shadow DOM을 도입했는데 Portal 하나가 그 경계를 다시 넘어가 버린 것이다.
그래서 Portal의 도착지를 shadow root 안으로 옮겼다.
기존 변경 후
React App #shadow-root
└── Modal ├── App
↓ portal └── portal-root
document.body └── Modalshadow tree 안에 Portal 전용 컨테이너를 두고 React가 이 컨테이너를 바라보게 했다.
const portalRoot = shadowRoot.querySelector('#portal-root');
createPortal(<Modal />, portalRoot);코드만 보면 간단하지만 실제로는 Portal을 사용하는 모든 경로를 찾아야 했다. 직접 만든 모달만의 문제가 아니라, UI 라이브러리가 내부적으로 document.body에 Portal을 만들고 있다면 그것도 찾아서 처리해야 했다.
이 작업 이후 UI 라이브러리를 고를 때 기준이 하나 늘었다. Portal의 container를 외부에서 지정할 수 있는가. Radix의 container prop처럼 렌더링 위치를 주입할 수 있는 구조라면 Shadow DOM 환경에서 대응하기가 훨씬 쉬웠다.
앞에서 본 보안키보드 대책이 바로 이 구조의 반대편이다. 경계 안으로 들여온 Portal 컨테이너를, 필요한 요소에 한해 경계 밖으로 되돌리면 된다. 그때는 도착지를 바꾸는 코드가 이미 있다는 게 도움이 된다.
이벤트는 크게 손대지 않았다. React 17부터 이벤트 위임 방식이 바뀌면서 React 이벤트는 애플리케이션의 루트 컨테이너를 기준으로 처리된다. React 18과 우리가 사용한 이벤트 경로에서는 별도 처리가 필요하지 않았다. 모든 이벤트 종류와 모든 UI 라이브러리가 Shadow DOM에서 자동으로 동작한다는 뜻은 아니고, React 16 이하처럼 document에 이벤트를 위임하던 구조라면 Shadow DOM의 이벤트 retargeting과 맞물려 별도 확인이 필요하다.
전역 JavaScript는 오류 수집과 가이드로
여기까지 오면 이제 금융사 페이지와 완전히 독립된 것 아닌가 싶어진다. 그렇지는 않다.
Shadow DOM은 CSS와 DOM 구조를 격리하지만 JavaScript 실행 컨텍스트를 분리하지 않는다. 금융사 스크립트와 우리는 같은 window, 같은 document를 쓴다. 금융사 JavaScript가 document에 이벤트를 등록하거나, prototype을 수정하거나, 전역 객체를 변경하는 것은 Shadow DOM이 막아주지 않는다.
그리고 앞에서 봤듯 이건 순수한 손해도 아니다. 보안키보드 솔루션이 우리 입력 요소를 찾아 들어올 수 있는 것도 같은 이유다.
그래서 해결할 수 있는 문제와 그럴 수 없는 문제를 나눴다. CSS 셀렉터 충돌은 Shadow DOM으로 구조적으로 해결하고, 전역 JS 환경에서 발생하는 문제는 통합 가이드와 오류 수집으로 대응한다. Sentry로 SDK 런타임 예외를 수집할 때 금융사 식별자와 WebView 버전, 당시 SDK 단계를 함께 남겨서 어느 금융사 환경에서 터진 문제인지 좁힐 수 있게 했다. prototype 변경 같은 전역 오염을 자동으로 감지하는 장치는 아니었다.
무엇보다 중요한 것은 Shadow DOM을 보안 경계로 착각하지 않는 것이었다. open이든 closed든 Shadow DOM 자체가 악의적인 JavaScript 접근을 막아주는 보안 경계는 아니다.
우리에게 필요했던 것은 악성 코드로부터의 보안 격리가 아니라, 서로 다른 팀이 관리하는 CSS와 DOM이 실수로 서로를 망가뜨리지 않게 만드는 사고 방지용 경계였다. 그 목적에는 Shadow DOM이 충분했다. 이 경계조차 감당할 수 없는 문제가 생긴다면 앞에서 보류했던 friendly iframe을 다시 검토할 수 있도록 했다.
flowchart TB
subgraph DOC["금융사 document / window"]
BCSS["전역 CSS<br/>태그 셀렉터 · !important"]
BJS["전역 JS<br/>리스너 · 프로토타입 · 보안 솔루션"]
HTML["html font-size (rem 기준값)<br/>CSS 변수 · font-family 이름공간"]
end
BOUNDARY["shadow 경계<br/>(셀렉터·캐스케이드는 여기서 멈춘다)"]
subgraph SHADOW["#shadow-root (SDK)"]
SHEET["SDK 스타일시트<br/>(Tailwind · rem을 px로 치환)"]
APP["React 앱 + portal-root<br/>+ 자체 스크롤 컨테이너"]
end
BCSS -->|"차단됨"| BOUNDARY
HTML -->|"통과 · 치환과 네임스페이스로 대응"| APP
BJS -->|"통과 · 가이드 계약 + 오류 수집으로 대응"| APP
성능: 무엇이 비쌌나
Shadow DOM을 도입한다고 하면 자연스럽게 성능 이야기가 나온다. shadow root를 하나 더 만드는 게 느리지 않냐는 질문이다.
우리 구조에서 shadow root는 풀페이지 SDK를 감싸는 루트 하나였다. 컴포넌트마다 shadow root를 만드는 웹 컴포넌트 구조와 달라서 생성 횟수 자체가 많지 않았고, 초기 구동 비용에서 병목이 되지 않았다.
SDK 초기 구동 비용
├── JS 번들 다운로드·파싱 ← 크다 (다음 편에서 다룬다)
├── CSS 다운로드·파싱 ← 중간
├── React 초기 렌더링 ← 중간
└── shadow root 생성 ← 우리가 확인한 범위에서는 병목이 아니었다이론적으로는 Shadow DOM이 스타일 계산 범위를 제한하는 효과를 이야기할 수도 있지만 우리는 그 효과를 계측하지 않았다. 그래서 성능 개선 효과라고 주장하지는 않는다. 이 프로젝트에서 Shadow DOM은 필요한 CSS 격리를 얻으면서 성능상 감당할 수 있는 선택이었다.
결과

도입 전에는 금융사마다 서로 다른 CSS 환경을 가지고 있었고, 새로운 금융사가 추가될 때마다 기존 SDK 화면이 그 환경에서 깨지지 않는지 다시 확인해야 했다.
어떤 곳에서는 button의 border가 문제였고, 어떤 곳에서는 html의 font-size가, 또 어떤 곳에서는 html이나 body의 height와 overflow가 문제였다. 문제의 종류가 달랐기 때문에 덮어쓰기로 계속 대응하면 금융사별 예외가 SDK 코드에 쌓였다.
Shadow DOM을 적용한 뒤에는 적어도 금융사의 일반적인 전역 CSS 셀렉터와 우리 컴포넌트가 직접 경쟁하는 구조를 제거할 수 있었다. 그러면서 스타일 검증의 질문도 바뀌었다.
이전: 이 금융사 페이지에서 우리 CSS가 또 깨지지 않았는가?
이후: 우리 SDK 자체의 화면과 경계 바깥의 통합 지점이 정상적으로 동작하는가?
검증 조합의 모양도 바뀌었다. 도입 전에는 화면 N개 × 금융사 15개였다. 화면을 하나 추가하면 검증 대상이 15개 늘었다.
도입 후에는 남은 항목이 두 종류로 갈렸다.
한 번 고치면 금융사 수와 무관한 것
→ design token 네임스페이스
→ rem의 px 치환
→ 폰트 이름 네임스페이스
→ Portal의 도착지
금융사마다 확인해야 하는 것
→ host를 붙일 자리와 조상의 containing block 속성
→ host의 z-index 대역
→ CSP 정책
→ body 스크롤과 팝업 정책
→ 보안 솔루션을 포함한 금융사 전역 스크립트위쪽은 SDK 코드 안에서 한 번 해결하면 끝난다. 아래쪽만 금융사마다 남는다. 결국 금융사 15개 × 통합 지점이고, 통합 지점은 화면 수와 무관하다. 화면을 스무 개 더 만들어도 금융사별로 다시 확인할 것이 늘지 않는다.
15가 사라진 것은 아니다. 금융사가 추가되면 아래쪽 확인은 여전히 해야 한다. 사라진 건 거기 곱해지던 N이다.
효과는 QA 기간에서 가장 먼저 보였다. 같은 범위의 스타일 검증에 릴리스마다 일주일 가까이 잡던 일정을 이후에는 하루 단위로 계획할 수 있게 됐다. 노트북을 들고 금융사 개발망까지 들어가는 일도 눈에 띄게 줄었다.
물론 공짜는 아니었다. 경계를 얻은 대신 우리가 직접 책임져야 하는 것이 늘었다. host의 인라인 스타일과 z-index 대역, 내부 루트의 상속 초기화와 토큰 선언, 빌드 단계의 rem 치환, 폰트 이름의 소유권, Portal의 도착지, 자체 스크롤 컨테이너, 그리고 전역 JavaScript에 대한 통합 가이드까지 전부 우리 몫이 됐다.
그래서 Shadow DOM을 선택하면서 모든 문제가 사라졌다고 말하고 싶지는 않다. 경계가 무엇을 막아주는지만큼 무엇을 막아주지 않는지도 알아야 했다. 그 차이를 모르고 붙이면 금융사 button CSS는 사라졌는데 이번에는 rem이 깨지고, Portal이 경계를 넘어가고, host의 z-index에서 다시 막히는 식으로 문제가 계속 나타난다.
리드의 우려도 틀린 게 아니었다. 격리는 공짜로 얻는 안전이 아니라, 다른 종류의 위험과 맞바꾸는 선택이었다. 다만 그때 비교한 두 리스크 중 어느 쪽이 더 큰지는 지금도 같은 답이라고 생각한다.
다음 편은 로딩이다. script 태그 하나로 배포되는 SDK가 호출 타이밍 장애를 어떻게 구조적으로 없앴는지, 그리고 해시 파일명 때문에 금융사가 매번 재배포해야 했던 문제를 어떻게 풀었는지 다룬다.