Shadow Dom : 중요한 건 깨지지 않는 스타일
하나의 서비스에서 다양한 플랫폼의 이력서를 깨지지 않고 보여주는 방법
2025년 7월 사람인의 공고/후보자 서비스의 대대적인 개편이 있었습니다.🎉
후보자 서비스에서 다양한 연계 플랫폼(점핏, 코메이트, 원더풀 시니어 등)과의 연동이 가능해졌습니다.
문제는 여기서 시작됐는데요. 각 플랫폼이 제공하는 이력서는 PDF, HTML 등 형식도 다르고 HTML 기반이라 해도 CSS, 클래스명, 레이아웃방식이 모두 제각각입니다.
이 말은 후보자 상세 페이지의 기존 스타일과 충돌해 깨질 수 있다는 것을 의미합니다.
후보자 상세 페이지는 인사담당자가 후보자를 평가하기 위해 제일 많이 접근하는 핵심화면입니다.
이력서가 깨지거나 읽기 어려워지면 회사는 인재를 놓치고 후보자는 합격의 기회를 놓치는 등의 악영향이 있을 수 있습니다. 중요한 건 깨지지 않는 스타일이기에 이 문제를 해결하기 위해 단단하고 안전한 캡슐화가 필요했습니다.
그리고 그 답은, Shadow Dom이었습니다.
이 글에서는 “Shadow DOM으로 하나의 서비스에서 다양한 플랫폼의 이력서를 깨지지 않고 보여주는 방법”에 대하여 정리했습니다.
이 글로 얻을 수 있는 정보
- Shadow DOM이란?
- Shadow DOM의 장단점
- Shadow DOM 내 HTML/CSS 데이터를 렌더링 하는 방법
- Shadow DOM 내 내/외부 스크립트를 실행시키는 방법 및 주의 사항
- Shadow DOM 내 스타일 적용 시 스타일 미적용 데이터 노출 현상 해결방법
1. Shadow DOM
연동 플랫폼이 다양해지면서 다음과 같은 문제가 예상되는 상황이었습니다.
- CSS/DOM 스코프 충돌
- 재사용성 저하
- 예측 불가능한 Side Effect 발생 가능성
이를 근본적으로 차단하기 위해 Shadow Dom을 도입하게되었습니다. Shadow DOM은 CSS/DOM을 완전히 독립된 공간으로 격리해주는 기술입니다.
1-1. Shadow DOM의 구조
Shadow host: Shadow DOM이 연결된 일반 DOM 노드Shadow tree: Shadow DOM 내의 DOM 트리Shadow boundary: Shadow DOM이 끝나고 일반 DOM이 시작되는 곳Shadow root: Shadow 트리의 root 노드
Shadow Dom은 Shadow host에 Shadow Tree를 만들어 Shadow root를 통하여 DOM 내부를 제어할 수 있습니다.
즉 Shadow root가 Shadow DOM 내에서 document 같은 역할을 하게 됩니다. (이 부분은 Shadow DOM 내 제어에 큰 역할을 하게 되니 꼭 기억해주세요!)
1-2. Shadow root mode (open vs closed)
1
2
3
4
5
6
7
8
const host = document.querySelector("#shadow-host"); // host 선택
const shadow = host.attachShadow({ mode: "open" }); // shadow root 객체 생성(open)
// const shadow = host.attachShadow({ mode: "closed" }); // shadow root 객체 생성(closed)
// Shadow DOM 내 element 추가
const span = document.createElement("span");
span.textContent = "I'm in the shadow DOM";
shadow.appendChild(span);
Shadow Dom은 attachShadow 메서드를 이용해 Shadow root 객체를 생성하여 사용할 수 있습니다.
이 때, attachShadow 메서드를 사용할 때 위과 같이 { mode: "open" | "closed" }를 사용할 수 있는데요! 해당 옵션으로 외부에서 Shadow root 접근 가능 여부를 정할 수 있습니다.
<template>을 이용해 Shadow DOM을 구성하는 방법도 있으니, 관심이 있으시다면 아래 글을 참고해보시면 좋을 것 같습니다.
2. Shadow DOM 내 이력서 렌더링 플로우
이력서 데이터는 HTML 문자열로 전달되기 때문에 Shadow DOM에서 렌더링하기 위한 가공 과정이 필요했습니다.
전체적인 플로우는 다음과 같습니다.
- String -> HTML 데이터로 변경
- meta, link, style, body content 태그 추출 후 HTML 구조 생성
- 내/외부 스크립트 추출
- Shadow DOM 생성
- 추출한 외부 스크립트 로드 후 Shadow DOM 내 삽입
- HTML 데이터 Shadow DOM 내 삽입
- Shadow DOM 내 스타일 변경 감지 후 Render 성공 여부 처리 (스타일 미적용 데이터 노출 현상 - FOUC)
- Render 성공 후 내부 스크립트 Shadow DOM 내 삽입
플로우 순서대로 간략한 예시 코드와 함께 하나씩 알아보겠습니다! 각 단계 별로 주의할 점도 함께 기재해두었으니 참고해보면 좋을 것 같습니다👍
2-1. String -> HTML 데이터로 변경
먼저 DOMParser의 parseFromString 메서드를 이용해 string 데이터를 HTML로 변경해줍니다.
1
2
3
// HTML 파싱
const parser = new DOMParser();
const doc = parser.parseFromString(resumeHtml, "text/html");
2-2. meta, link, style, body content 태그 추출 후 HTML 구조 생성
Shadow DOM 내 script를 실행시키려면 script태그를 만들어 넣어주어야하기 때문에 script를 제외한 meta, link, style, body content를 추출 후 HTML을 재구성해줍니다.
- 이력서 데이터에서
base태그로baseURI를 지정하는데, base 태그는 document 기준으로 작동하기 때문에 Shadow DOM 내부에서는상대경로 -> 절대경로변환이 필요합니다.- 만약 Shadow DOM 내 적용될 Global Style이 있다면
:host로 정의해주면 됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// base url 추출
const baseElement = doc.querySelector("base");
const baseUrl = baseElement?.getAttribute("href") ?? "https://baseurl.com" ;
// 메타 태그 추출
const metaTags = Array.from(doc.querySelectorAll("meta"))
.map((meta) => meta.outerHTML)
.join("\n");
// CSS link 태그 생성 (모든 상대 경로를 절대 경로로 변환)
const links = doc.querySelectorAll("link[href]");
const linkTags = Array.from(links)
.map((link) => {
(상대경로 -> 절대경로 변환 로직)
})
.join("\n");
// 기존 스타일 태그 내용 추출
let styles = "";
const styleElements = doc.querySelectorAll("style");
styleElements.forEach((styleElement) => {
styles += styleElement.textContent;
});
// body 내용에서 script 태그 제거
const bodyElement = doc.body;
if (bodyElement) {
// body에서 모든 script 태그 선택
const scriptTags = bodyElement.querySelectorAll("script");
// 각 script 태그 제거 (내부 스크립트는 따로 추출)
scriptTags.forEach((script) => {
script.remove();
});
// 상대경로 a tag를 찾아, baseUrl을 붙인 절대 경로로 변경
const aTags = doc.querySelectorAll("a[href]");
aTags.forEach((a) => {
(상대경로 -> 절대경로 변환 로직)
});
}
// 파싱된 HTML 이력서 데이터
const parsedHtmlResumeData = `
${metaTags}
${linkTags}
<style>
${saraminGlobalStyle}
${styles}
</style>
${bodyContent}
`;
2-3. 내/외부 스크립트 추출
내/외부 스크립트도 추출 시 상대경로 -> 절대경로 변환이 필요합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
// 외부 스크립트 처리
const externalScripts = doc.querySelectorAll("script[src]");
const externalScriptSrcList = Array.from(externalScripts).map((script) => {
(상대경로 -> 절대경로 변환 로직)
});
// 내부 스크립트 태그 처리 (src 속성이 없는 스크립트)
const inlineScripts = doc.querySelectorAll("script:not([src])");
const inlineScriptContents = Array.from(inlineScripts)
.map((script) => {
(상대경로 -> 절대경로 변환 로직)
});
2-4. Shadow DOM 생성
Shadow host에 Shadow DOM을 생성합니다.
(후보자 관리 페이지는 Next.js로 구성되어 있어 실제로는 Ref를 참고하고 있는데, 기본적으로 document.querySelector를 사용하시면 됩니다.)
1
2
3
const shadowRoot =
shadowDomRef.current.shadowRoot ||
shadowDomRef.current.attachShadow({ mode: "closed" });
- String -> HTML 데이터로 변경 ✅
- meta, link, style, body content 태그 추출 후 HTML 구조 생성 ✅
- 내/외부 스크립트 추출 ✅
- Shadow DOM 생성 ✅
- 추출한 외부 스크립트 로드 후 Shadow DOM 내 삽입
- HTML 데이터 Shadow DOM 내 삽입
- Shadow DOM 내 스타일 변경 감지 후 Render 성공 여부 처리 (스타일 미적용 데이터 노출 현상 - FOUC)
- Render 성공 후 내부 스크립트 Shadow DOM 내 삽입
여기까지가 렌더링 플로우의 반 정도 왔는데요. 대부분의 Side Effect가 내/외부 스크립트에 발생할 확률이 높아 앞으로가 더 중요하니 집중해서 봐주세요!
2-5. 추출한 외부 스크립트 로드 후 Shadow DOM 내 삽입
이력서 내에 외부 스크립트를 사용하는 컴포넌트가 존재할 수도 있습니다. 따라서 재구성한 HTML 데이터를 Shadow DOM에 넣기 전, 외부스크립트를 먼저 불러옵니다.
이 때, Promise.all 대신 Promise.allSettled를 사용했습니다.
Promise.all: 하나의 스크립트가 실패하면 전체 렌더링 실패Promise.allSettled: 실패한 스크립트가 어도 나머지 결과를 모두 받아올 수 있어 렌더링 안정성에 적합
아래 예시 코드와 같이 외부 스크립트와 프로젝트 내 라이브러리 버전 충돌이 있는 경우, 버전 동기화 작업이 필요할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 외부 스크립트 로드 함수
const loadExternalScript = (shadowRoot: ShadowRoot, src: string): Promise<void> => {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
let convertedSrc = src;
// react-pdf와 pdfjs 충돌로 인해 shadowDom 내 pdf.js/pdf.worker.js를 react-pdf와 같은 패키지를 사용하도록 변경
if (src.includes("pdf.worker.js")) {
// PDF 워커 파일
convertedSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
).toString();
script.type = "module";
} else if (src.includes("pdf.js")) {
// PDF 메인 파일
convertedSrc = new URL(
"pdfjs-dist/build/pdf.min.mjs",
import.meta.url,
).toString();
script.type = "module";
}
script.src = convertedSrc;
script.onload = () => {
resolve();
};
script.onerror = (error) => {
console.error(`Script error: ${convertedSrc}`, error);
reject(error);
};
shadowRoot.appendChild(script);
});
};
// 스크립트 처리 (HTML 콘텐츠는 스크립트 로드 후 설정)
// 모든 외부 스크립트를 병렬로 로드(외부 스크립트 중 하나가 실패해도 모든 promise의 결과를 받을 수 있게 allSettled를 사용
await Promise.allSettled(
externalScriptSrcList.map((src) => loadExternalScript(shadowRoot, src)),
);
2-6. HTML 데이터 Shadow DOM 내 삽입
2-2번에서 만든 HTML 데이터(parsedHtmlResumeData)는 구성한 모양 그대로 Shadow DOM에 들어가야하기 때문에 Fragment로 넣어줍니다.
template.content는Document Fragment를 반환합니다.
1
2
3
4
// 외부 스크립트 처리 후 파싱된 이력서 데이터 Fragment로 추가
const template = document.createElement("template");
template.innerHTML = parsedHtmlResumeData;
shadowRoot.appendChild(template.content);
2-7. Shadow DOM 내 스타일 변경 감지 후 Render 성공 여부 처리 (스타일 미적용 데이터 노출 현상 - FOUC)
2-6번에서 Shadow DOM에 삽입하면 FOUC(Flash of Unstyled Content)가 발생하게 됩니다.
- ShadowRoot.adoptedStyleSheets
- HTML 데이터보다 style태그 우선 삽입
위와 같은 Shadow DOM 내 FOUC 해결 방법으로 해결되지 않아 DOM 트리에서 이루어지는 변경 사항을 감지할 수 있는 MutationObserver을 사용하였습니다.
FOUC(Flash of Unstyled Content)란?
웹 페이지가 로드될 때, 스타일이 적용되지 않은 컨텐츠가 잠시 나타났다가 이후 스타일이 적용되는 현상을 말합니다.
adoptedStyleSheets란?
Shadow root 인터페이스의 속성으로 Shadow DOM 하위 트리에서 사용될 구성된 스타일시트의 배열을 설정합니다.
MutationObserver를 이용한 방법을 간단히 설명하면 아래와 같습니다.
- Boolean 성공 여부 상태 값에 따라 Shadow DOM display block/none 처리 (초기에는 none)
- MutationObserver로 스타일/클래스 변경을 감지
- 0.3초 동안 스타일 변경이 없으면 스타일이 안정화되었다고 간주하여 성공 상태로 변경 (최대 5초 후에는 무조건 성공으로 간주)
- 성공 여부 상태 값이 true가 되어 Shadow DOM display block으로 변경
저희 팀에서는 no-use-before-define lint rule을 적용하기 때문에 예시 코드는 밑에서부터 번호를 따라 보시는 게 더 보기에 편할거에요!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
// 성공 여부 상태 값
const [isStyleSuccess, setIsStyleSuccess] = useState(false);
// observer, 스타일 안정화 타이머 참조 값
const observerRef = useRef<MutationObserver>(null);
const styleStabilityTimerRef = useRef<NodeJS.Timeout>(null);
/**
* 2. MutationObserver로 스타일/클래스 변경을 감지
* shadow dom에 스타일 적용 시 스타일 적용되지 않은 데이터가 잠깐 노출되는 현상을 해결하기 위해 감지함.
*/
const setupStyleObserver = (shadowRoot: ShadowRoot) => {
// 이미 존재하는 Observer 정리
if (observerRef.current) {
observerRef.current.disconnect();
}
// 스타일 안정화 타이머 초기화
if (styleStabilityTimerRef.current) {
clearTimeout(styleStabilityTimerRef.current);
styleStabilityTimerRef.current = null;
}
// MutationObserver 구성
observerRef.current = new MutationObserver((mutations) => {
// 스타일 변경 여부
let isStyleChange = false;
mutations.forEach(({ type, addedNodes, attributeName }) => {
// 스타일 요소 추가/제거 감지
if (type === "childList") {
// 추가된 스타일 리스트
const addedStyles = [...addedNodes].filter(
(node) => node.nodeName === "STYLE" || node.nodeName === "LINK",
);
/**
* 추가된 스타일이 있으면 변경 여부 true 처리
* isStyleChange = addedStyles.length > 0 와 같이 처리하면 된다고 생각할 수 있으나
* mutations.forEach로 순회 시 하나라도 변경된 게 있으면 스타일 타이머를 설정해야 하기 때문에 아래와 같이 처리해야 함.
*/
if (addedStyles.length > 0) {
isStyleChange = true;
}
}
// 스타일/클래스 속성 변경 감지
if (
type === "attributes" &&
(attributeName === "style" || attributeName === "class")
) {
isStyleChange = true;
}
});
// 스타일이 변경되지 않았으면 early return
if (!isStyleChange) return;
// 스타일 안정화 타이머 재설정
if (styleStabilityTimerRef.current) {
clearTimeout(styleStabilityTimerRef.current);
}
// 3. 0.3초 동안 스타일 변경이 없으면 스타일이 안정화되었다고 간주하여 성공 상태로 변경 (아래에 최대 5초 후에는 무조건 성공으로 간주)
styleStabilityTimerRef.current = setTimeout(() => {
// 4. 성공 여부 상태 값이 true가 되어 Shadow DOM display block으로 변경
setIsStyleSuccess(true);
// Observer 정리
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
}, 300);
}); // MutationObserver 구성 끝
// Shadow DOM 전체와 하위 요소들의 변경 감시
observerRef.current.observe(shadowRoot, {
childList: true, // 자식 요소 추가/제거 감지
attributes: true, // 속성 변경 감지
subtree: true, // 하위 요소의 변경도 감지
attributeFilter: ["style", "class"], // 스타일, 클래스 속성만 감시
});
// 최대 5초 후에는 무조건 성공으로 간주
setTimeout(() => {
if (!isStyleSuccess && observerRef.current) {
setIsStyleSuccess(true);
observerRef.current.disconnect();
observerRef.current = null;
}
}, 5000);
};
// Shadow root의 스타일 변경 감지 후 Render 성공 여부 처리
useLayoutEffect(() => {
const shadowRoot = shadowRootRef.current;
if (shadowRoot) {
setupStyleObserver(shadowRoot);
return () => {
// Observer 정리
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
// 타이머 정리
if (styleStabilityTimerRef.current) {
clearTimeout(styleStabilityTimerRef.current);
styleStabilityTimerRef.current = null;
}
};
}
}, [shadowRootRef.current]);
// 1. Boolean 성공 여부 상태 값에 따라 Shadow DOM display block/none 처리 (초기에는 none)
useLayoutEffect(() => {
const shadowDom = shadowDomRef.current;
if (shadowDom) {
shadowDom.style.display = isStyleSuccess ? "block" : "none";
}
}, [shadowDomRef.current, isStyleSuccess]);
2-8. 스타일 적용 후 내부 스크립트 Shadow DOM 내 삽입
이제 드디어 스타일 적용에 성공했습니다! 마지막으로 Shadow DOM 내부 스크립트만 적용시켜주면 되는데요.
혹시 아까 Shadow root가 Shadow DOM 내에서 document 같은 역할을 하게 된다는 말 기억하시나요?
때문에 내부 스크립트에 document로 동작하는 로직이 있다면 document -> Shadow root로 변환해주어야합니다.
이 때 다음과 같은 3가지 이슈를 주의해야합니다.
1. Shadow root에는 body 대신 host를 사용
2. document의 생성 메서드는 Shadow root로 변환 불가
document.createElement() 같은 생성 메서드는 Shadow root에서 사용할 수 없습니다. 따라서 탐색 메서드만 골라 변경하면 되는데, 이력서 페이지에서는 아래 탐색 메서드만 사용됩니다.
- querySelector()
- querySelectorAll()
- getElementById()
- body
3. attachShadow closed 모드에서는 Shadow root 접근 불가
attachShadow에서 closed mode로 Shadow DOM을 구성했기 때문에 script tag를 만들어 넣어버리면 Shadow root에 접근할 수 없습니다.
따라서 스크립트 실행 함수(new Function)를 만들어 Shadow DOM을 만들 때 받은 객체를 넣어 실행하였으니 참고해서 봐주세요.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 인라인 스크립트 처리 함수
const processInlineScripts = (
shadowRoot: ShadowRoot,
inlineScriptContents: string[],
): void => {
// 모든 인라인 스크립트 콘텐츠 변환 및 결합
const scriptContents = inlineScriptContents
.filter((content) => !!content)
.map((content) => {
// 선택자 메서드 변환
const convertedContent = content
.replace(/document\.querySelector/g, "shadowRoot.querySelector")
.replace(/document\.querySelectorAll/g, "shadowRoot.querySelectorAll")
.replace(/document\.getElementById/g, "shadowRoot.getElementById")
// body 참조 변환
.replace(/document\.body/g, "shadowRoot.host”);
return convertedContent;
});
if (scriptContents.length > 0) {
// 스크립트 실행 함수 생성
const executeScripts = new Function("shadowRoot", scriptContents.join("\n"));
// 접근자를 통해 안전하게 스크립트 실행
try {
executeScripts(shadowRoot);
} catch (error) {
console.error("Error executing scripts:", error);
}
}
};
// 스타일 적용에 성공하면 inlineScript 붙여넣음
useLayoutEffect(() => {
const shadowRoot = shadowRootRef.current;
if (isStyleSuccess && shadowRoot) {
processInlineScripts(shadowRoot, inlineScripts);
}
}, [isStyleSuccess]);
혹시 아래 예시코드와 같이 외부스크립트 내부 로직에 document가 포함되어 작동하지 않는 로직이 있다면 외부 스크립트 추출 시 걸러내어 내부 스크립트에 같이 넣어주는 것도 좋습니다!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const loadExternalScript = (shadowRoot: ShadowRoot, src: string): Promise<void> => {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
let convertedSrc = src;
…
/**
* document 로직이 포함된 외부 스크립트를 조회하여 내부 스크립트에 삽입
* (해당 모듈에서 사용하는 document를 shadow root로 바꾸기 위함)
*/
if (src.includes("/external/script")) {
fetch(src, { credentials: "include" })
.then(async (res) => {
const scriptText = await res.text();
setInlineScripts((prev) => [...prev, scriptText]);
resolve();
})
.catch((error) => {
console.error(`Script error: ${src}`, error);
reject(error);
});
return;
}
…
});
};
3. Shadow DOM, 분리한 건 좋은데 만능은 아니다
- String -> HTML 데이터로 변경 ✅
- meta, link, style, body content 태그 추출 후 HTML 구조 생성 ✅
- 내/외부 스크립트 추출 ✅
- Shadow DOM 생성 ✅
- 추출한 외부 스크립트 로드 후 Shadow DOM 내 삽입 ✅
- HTML 데이터 Shadow DOM 내 삽입 ✅
- Shadow DOM 내 스타일 변경 감지 후 Render 성공 여부 처리 (스타일 미적용 데이터 노출 현상 - FOUC) ✅
- Render 성공 후 내부 스크립트 Shadow DOM 내 삽입 ✅
Shadow DOM 내 이력서 렌더링 플로우을 함께 알아봤는데요. Shadow DOM의 캡슐화로 인해 스타일 충돌 가능성을 제거할 수는 있었지만, 항상 완벽하게 각 플랫폼 별 이력서를 보여줄 수 있는 건 아닙니다.
3-1. 각 플랫폼 이력서 변경 시 Side Effect 발생 가능
사람인 서비스에서 다른 플랫폼의 이력서를 볼 수 있게 되면서 사용자 관점에서는 편리해졌지만, 개발적인 관점으로 봤을 때에는 플랫폼 별 이력서 수정 시 사람인 서비스도 확인해야 하는 과정이 추가됩니다. 여기에는 코드는 물론이고 라이브러리 변경도 포함됩니다.
실제로 기존 이력서 라이브러리 중 내부에 document를 사용하는 로직이 있어 변경하는 작업이 이루어지기도 했어요.
그리고 Shadow DOM은 스타일이 격리되어 있기 때문에 전역 CSS로 내부 변경은 어렵기에 외부에서 변경하고 싶다면 아래의 psedo-class나 전용 API를 사용하는 것도 좋겠네요.
- :host
- :host()
- ::slotted()
- ::part()
하지만 서비스 배포 시에 Side Effect 체크는 필연적이라 어찌보면 당연하다고 생각할 수도 있겠습니다.
3-2. 디버깅의 어려움
Shadow DOM은 일반 DOM과 별도의 트리를 형성합니다. Chrome DevTools에서 Elements 탭을 보면 #shadow-root 노드가 표시되고, 그 안에 별도 트리가 펼쳐지는데요. 만약 attachShadow가 closed라면 Shadow root에 접근하지 못해 디버깅의 어려움이 있을 수 있습니다.
Chrome의 경우
Settings > Preferences > Elements에서Show user agent shadow DOM을 체크하면 브라우저 내장 Shadow DOM(<video>, <input type="date">등)도 볼 수 있습니다.
3-3. 러닝 커브
만약 Shadow DOM 구성 시 template/slot이나 part를 사용한다면, 코드의 복잡성이 증가하고 Shadow DOM 뿐만 아니라 template/slot이나 part에 대해 학습해야 하기 때문에 러닝 커브가 높아질 수도 있습니다.
3-4. SEO Shadow DOM 인덱싱 지연
이력서 서비스는 기업 서비스이기에 SEO에 노출되지 않지만, SEO 노출이 필요한 컨텐츠를 Shadow DOM으로 삽입하게 되면 다음 예시와 같이 초기에 컨텐츠가 없기 때문에 크롤러 인덱싱 처리가 지연될 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<body>
<div id="product"></div>
<script>
const product = document.getElementById('product');
const shadow = product.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<h1>프리미엄 무선 이어폰</h1>
<p>₩199,000</p>
<p>노이즈 캔슬링 기능</p>
`;
</script>
</body>
크롤러의 인덱싱 처리는 다음과 같아집니다.
- 초기 HTML(<div id="product"></div>) ➡️ 콘텐츠 없음
- 며칠 후 JS 실행 후 콘텐츠 발견 ➡️ 인덱싱
이 문제는 SSR에서 받은 데이터를 template을 사용하여 서버에서 렌더링된 데이터를 HTML에 포함시켜 다음 예시와 같이 Javascript 실행 없이 콘텐츠를 전달할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
<body>
<div id="blog-post">
<!-- 선언적 Shadow DOM -->
<template shadowrootmode="open">
<article>
<h1>Web Components 가이드</h1>
<p>Web Components는 재사용 가능한 컴포넌트를 만드는 표준 기술입니다.</p>
<p>Shadow DOM을 활용하면 스타일 캡슐화가 가능합니다.</p>
</article>
</template>
</div>
</body>
4. 마치며
Shadow DOM은 다양한 외부 리소스가 섞이는 환경에서 스타일/DOM 충돌을 근본적으로 줄여주는 실용적인 해법입니다. 이번 개편에서도 Shadow DOM 덕분에 여러 플랫폼의 HTML 이력서를 안정적으로 통합해 보여줄 수 있었습니다.
Shadow DOM을 딥하게 사용한 예시가 많이 없어 FOUCC, document 치환, 외부 스크립트 의존성 등 크고 작은 고민과 어려움들이 많았습니다. 그럼에도 서비스 품질과 개발 경험 모두 크게 향상된 프로젝트였습니다.
이 글이 Shadow DOM을 실전 환경에 적용해야 하는 분들에게는 작은 도움이라도 되었으면 합니다. 긴 글 읽어주셔서 감사합니다 🙏


