크롬익스텐션 만들어보기(1) 챕터를 시작하기전에 프로젝트를 이미 어느정도 구성해둔 상태였다. 1~2일 마다 진행과정과 어려움을 쓰려고했는데 현생에 치이고 너무 게으른 관계로 게속 미루다 미루다.. 이제서야 내용을 정리하려한다.
챕터1에서 작성한 내용처럼 엘리먼트의 css를 완벽하진 않지만 TailwindCss로 변환하는것을 목표로 시작했다. 엄청 간단할거라고 생각하고 무작정 진행했는데 생각보다 쉽지많은 않았고 vite 설정에서 많이 꼬이기도 했다.
생각보다 단순한 아이디어에서 시작됐다.
개발자라면 익숙한 크롬 개발자도구(F12) 의 커서 모양 아이콘(검사할 페이지 요소 선택) 기능 — 마우스로 페이지의 요소를 직접 클릭해 구조를 확인할 수 있는 그 기능에서 착안했다.
이 원리를 응용해, 익스텐션 내부에서도 비슷하게 요소를 클릭해 확인할 수 있는 기능을 구현했다.
//content/inspector.ts
import { logger } from "../hooks/useUtils";
let overlay: HTMLDivElement | null = null;
let label: HTMLDivElement | null = null;
let blocker: HTMLDivElement | null = null;
export function removeInspectorInfo() {
overlay?.remove();
overlay = null;
label?.remove();
label = null;
}
export function initInspector(setTarget: (el: HTMLElement) => void) {
logger("initInspector (main doc)");
if (blocker) return;
function stopInspector() {
blocker?.remove();
blocker = null;
document.removeEventListener("mousemove", handleMove, true);
document.removeEventListener("click", handleClick, true);
document.removeEventListener("keydown", handleKeyDown, true);
logger("[inspector] stopped");
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") stopInspector();
}
function handleClick(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (overlay) overlay.style.backgroundColor = "transparent";
const el = document
.elementsFromPoint(e.clientX, e.clientY)
.find((n) => n !== blocker && n !== overlay && n !== label) as HTMLElement | undefined;
if (el) {
setTarget(el);
}
stopInspector();
}
function handleMove(e: MouseEvent) {
const el = document
.elementsFromPoint(e.clientX, e.clientY)
.find((n) => n !== blocker && n !== overlay && n !== label) as HTMLElement | undefined;
if (!el) return;
const rect = el.getBoundingClientRect();
showOverlay(rect, el);
}
function showOverlay(rect: DOMRect, el: HTMLElement) {
if (!overlay) {
overlay = document.createElement("div");
Object.assign(overlay.style, {
position: "absolute",
zIndex: "999999",
pointerEvents: "none",
border: "2px solid magenta",
background: "rgba(255,0,255,0.1)",
});
document.body.appendChild(overlay);
}
Object.assign(overlay.style, {
top: `${rect.top + window.scrollY}px`,
left: `${rect.left + window.scrollX}px`,
width: `${rect.width}px`,
height: `${rect.height}px`,
});
if (!label) {
label = document.createElement("div");
Object.assign(label.style, {
position: "absolute",
zIndex: "1000000",
fontFamily: "monospace",
fontSize: "14px",
color: "#fff",
background: "rgba(0,0,0,0.8)",
padding: "2px 5px",
borderRadius: "3px",
whiteSpace: "nowrap", // 긴 텍스트 한 줄로
});
document.body.appendChild(label);
}
const tag = el.tagName.toLowerCase();
const siblings = el.parentElement
? Array.from(el.parentElement.children).filter((child) => child.tagName === el.tagName)
: [];
const index = siblings.indexOf(el);
const id = el.id ? `#${el.id}` : `#${index}`;
const classes = el.classList.length ? "." + Array.from(el.classList).join(".") : "";
label.textContent = `${tag}${id}${classes}`;
// ✅ label 높이 계산해서 그만큼 위로 올림
const labelHeight = label.offsetHeight || 20;
const top = rect.top + window.scrollY - labelHeight - 8; // 8px 여백
Object.assign(label.style, {
top: `${Math.max(top, 0)}px`, // 화면 위로 벗어나지 않게 보정
left: `${rect.left + window.scrollX}px`,
});
}
blocker = document.createElement("div");
Object.assign(blocker.style, {
position: "fixed",
top: "0",
left: "0",
width: "100vw",
height: "100vh",
background: "transparent",
cursor: "crosshair",
zIndex: "999998",
});
document.body.appendChild(blocker);
document.addEventListener("mousemove", handleMove, true);
document.addEventListener("click", handleClick, true);
document.addEventListener("keydown", handleKeyDown, true);
logger("[inspector] started");
}
위 코드처럼 단순히 인스펙터가 시작됐을때 오버레이,라벨 등 엘리먼트를 만들어 사용하고 있는 웹페이지에 append하면 끝이였다.

보라색 영역은 선택한 엘리먼트의 영역이고 그 위의 라벨은 태그이름을 나열하였다.(사실 개발자도구의 인스펙터와 거의 똑같다)
생각보다 빨리 여기서 난처한 상황이 나왔는데 어느때와 같이 UI를 테일윈드로 진행하려했으나 적용이 되질 않았다. 왜지? 분명히 크롬익스텐션에 등록해서 사용했던 팝업은 적용이 됐었는데..?
처음에는 Tailwind를 이용해 content script 쪽 UI를 만들고 싶었다.
하지만 빌드 후 실제 확장 프로그램을 실행해보니,
외부 웹페이지에 삽입되는 content script에서는 Tailwind가 전혀 적용되지 않았다.
처음엔 단순히 CSS 경로 문제라고 생각했지만,
실제 원인은 content script의 실행 환경 때문이었다.
Chrome 확장 프로그램의 popup이나 options 페이지는
chrome-extension:// 내부에서 실행되기 때문에
Tailwind CSS가 일반적인 방식(import "./global.css")으로 잘 작동한다.
하지만 content script는 외부 웹페이지의 DOM에 삽입되는 코드라서
확장 내부의 CSS 리소스를 직접 불러올 수 없다.
결과적으로 Tailwind 스타일이 적용되지 않았다.
이 문제를 해결하기 위해 Vite의 ?inline 옵션을 사용했다.
Tailwind로 빌드된 CSS를 별도의 파일로 링크하는 대신,
빌드 시점에 CSS 내용을 문자열로 변환하여 JS 번들에 포함시키는 방식이다.
먼저, vite.config.ts에서 다음과 같이 빌드 구조를 설정했다.
//vite.config.js
root: "src",
publicDir: "../public",
build: {
outDir: "../dist",
emptyOutDir: true,
rollupOptions: {
input: {
popup: resolve(__dirname, "src/popup/index.html"),
options: resolve(__dirname, "src/options/index.html"),
background: resolve(__dirname, "src/background.ts"),
},
output: {
entryFileNames: (chunkInfo) => {
if (chunkInfo.name === "background") return "background.js";
if (chunkInfo.name === "popup") return "popup/popup.js";
if (chunkInfo.name === "options") return "options/options.js";
return "assets/[name]-[hash].js";
},
chunkFileNames: "assets/[name]-[hash].js",
assetFileNames: "assets/[name]-[hash][extname]",
},
},
}
이 설정을 통해 Tailwind가 처리된 CSS(global.css)가
빌드 시 자동으로 dist/assets/ 폴더에 생성된다.
그리고 content script에서 다음처럼 import 했다:
//content/index.js
import tailwindCss from "../global.css?inline";
const style = document.createElement("style");
style.textContent = tailwindCss;
document.head.appendChild(style);
그리고 확인해보면?

이렇게 깔끔하게 적용이 된걸 확인할수 있다.
“한 문제를 해결했다고 안심할 때쯤, 또 다른 문제가 그 틈을 비집고 들어온다.”
그렇다. 나름 잘되어가고있다고 자신만만할 즈음 또 다른 문제가 나왔다..
어느때처럼 쇼핑을 하던와중 웹사이트가 이상하다는걸 느꼈다.

응? 아이콘 위치가 이상한데? 익스텐션을 삭제해볼까? (제발..)


왜 또다른 문제가 나오는것이냐.. 나 너무 힘들어..
//tailwind.config.js
module.exports = {
prefix: "ex-tw-",
corePlugins: { preflight: false },
};
GPT를 통해 해결책을 들었으나, 이렇게 설정한다고 결과는 달라지지 않았다.
그럼 어떻게 해야할까?
방법은 생각보다 간단했다.
웹페이지에 직접 Tailwind를 삽입할 필요가 없었다.
대신 iframe 을 하나 만들어 완전히 격리된 환경에서 사용하는 방식으로 해결할 수 있었다.
//content/index.js
function createInspectorIframe() {
const iframe = document.createElement("iframe");
iframe.id = "tw-inspector-iframe";
Object.assign(iframe.style, {
position: "fixed",
inset: "0",
zIndex: "2147483647",
border: "none",
width: "100vw",
height: "100vh",
display: "none",
pointerEvents: "none",
});
document.body.appendChild(iframe);
const iframeDoc = iframe.contentDocument!;
iframeDoc.open();
iframeDoc.write("<!DOCTYPE html><html><head></head><body></body></html>");
iframeDoc.close();
// Tailwind 주입
const styleEl = iframeDoc.createElement("style");
styleEl.textContent = tailwindCss;
iframeDoc.head.appendChild(styleEl);
injectFonts(iframeDoc);
const resetEl = iframeDoc.createElement("style");
resetEl.textContent = `
html, body {
margin: 0; padding: 0;
background: transparent;
overflow: hidden;
width: 100%; height: 100%;
}
* { box-sizing: border-box; }
`;
iframeDoc.head.appendChild(resetEl);
const mountEl = iframeDoc.createElement("div");
iframeDoc.body.appendChild(mountEl);
return { iframe, iframeDoc, mountEl };
}
쉐도우 DOM이니 뭐니 여러 가지 방법을 시도해봤지만, 결국 이 방식이 가장 깔끔했다.
빠르게 후딱 끝내려던 토이 프로젝트였는데, 생각보다 진전이 더뎠다.
(찍먹하려다 실패)
예기치 못한 문제들이 계속 발생했고,
이렇게 글로 정리해보니 짧아 보이지만 실제로는 꽤 많은 시간이 걸렸다.
그래도 결국 해결됐으니 해피엔딩!
이제 다음 챕터에서는 Converter 완성 과정을 정리해보려 한다. ㄱㄱ