고객 사이트에서 동작하는 React + Tailwind 앱 효율적으로 개발하기 (Production-Ready)
배너 삽입 시스템을 만들고 동작시키고 개발하면서 이렇게 특수한 환경에서의 프론트엔드 개발에 대해 알아봅니다.

목차
- 요구사항
- 요구사항을 보고 이 글의 제목을 해석해보자
- 요구사항 분석
- 비슷한 사례
- 요구사항 정리
- 소스코드 및 데모
- 프로젝트 세팅
main스크립트live-locator스크립트 로드하기- Custom Element 및 Shadow DOM 구현하기
- HMR 동작하게 하기
- Tailwind
- Trouble Shooting
- 마치며
- 레퍼런스
요구사항
우리가 판매하는 제품은 웹사이트 광고 배너 렌더링 시스템이며 고객은 사이트 운영자입니다. 우리가 고객의 인프라나 자산에 직접 접근하여 개발해주는 것이 아니라, 스크립트 하나를 동작시켜서 광고 배너를 노출할 것입니다. 고객이 딱 하나의 작업만 해줘야 하는데요, 아래와 같이 main 스크립트를 자신의 사이트에 삽입해놓는 것입니다.
<scriptsrc="https://cdn.banner.com/main.js?site_id=mysite"type="module"></script>
또한, 광고 배너를 어느 위치에 삽입할 건지를 실시간으로 지정할 수 있는 도구인 live-locator 라는 기능도 제공합니다. 고객 사이트와 상호작용하면서도 복잡한 UI가 고객 사이트에 Floating 되어 동작합니다. main 스크립트와 live-locator 스크립트는 분리되어 있고, 상황에 따라 동적으로 로딩되어 사이트 성능을 해치지 않으면서도 운영자만 에디터를 사용할 수 있도록 설계합니다.
모든 빌드는 Vite 기반으로 합니다. 결과물은 S3(+CloudFront) 에 업로드하는 것을 전제로 합니다.
이 글은 비즈니스 로직을 구현하고자 하는 목적이 없습니다. 따라서 배너니 실시간 위치 설정이니 하는 건 단순한 예시일 뿐이라는 점을... 미리 말씀드립니다.
요구사항을 보고 이 글의 제목을 해석해보자
제목이 상당히 짬뽕이지요? 이 글은 내용이 길 것 같습니다. 모든 토끼를 잡아야 하거든요.
- 고객의 사이트에서: 우리가 소유한 인프라나 환경에서 동작하는 앱을 개발하는 아니라는 뜻입니다. 제약사항이 이것저것 많아집니다.
- React + Tailwind 앱: 프론트 앱 개발에 있어서 생산성을 극적으로 올려주는 도구입니다. 쓸 수 있다면 쓰면 좋겠죠. 익숙하기도 하구요.
- 효율적으로: DX가 좋아야 한다는 말입니다. 개발 환경과 프로덕션 환경 사이에 큰 차이가 없도록 환경을 만들고 HMR도 잘 동작하도록 조치해야 합니다.
- Production Ready: DX뿐만 아니라 실제 고객 입장에서 사이트가 문제없이 잘 동작해야 합니다.
요구사항 분석
스크립트란 무엇일까
스크립트란 무엇이고, 어떤 스크립트가 필요한지 봅시다.
우리는 우리가 소유한 서비스에서 개발하는 게 아닙니다. 다른 이의 사이트에서 잘 동작하는 기능을 만들어야 합니다. 그렇다면 고객 회사로 파견을 나가서 그 쪽의 코드를 직접 수정해줘야 할까요? 그런 수고로움을 최소화할 수 있는 방법이 있는데요, 바로 스크립트를 삽입하는 겁니다. 스크립트가 삽입되어 있으면 우리가 작성한 코드를 그 쪽의 사이트에서 동작시키게 할 수 있습니다. 스크립트는 말 그대로 <script src="...">를 뜻합니다.
간단하게 예시를 한번 살펴봅시다. 전지현이 광고하는 Andar라는 브랜드의 쇼핑몰 사이트를 까봤는데요, 온갖 써드파티 스크립트가 동작하는 걸 확인할 수 있었습니다.

위 이미지만 봤을 때 눈에 띄는 서비스들이 있네요.
- Beusable: 사용자 행동 분석 도구
- 크리마(CREMA): 리뷰 데이터 기반 마케팅 플랫폼
- Google Tag Manager: 웹/앱 코드 수정 없이 다양한 마케팅·측정 태그를 한 대시보드에서 배포·관리하는 태그 관리 시스템.
스크립트 설치(삽입)는 쇼핑몰 운영자가 해주기는 해야 합니다. 그 이후는 스크립트의 차례입니다.
React란 무엇일까
React는 웹앱을 만들기 위한 프레임워크입니다. 전세계 프론트엔드 개발자들에게는 아주 익숙합니다. 이런 프레임워크의 장점은 다양한 프론트엔드 개발자가 같은 개념, 같은 이야기를 할 수 있도록 하여 생산성을 올려준다는 겁니다. 그래서 웹앱을 계속해서 잘 개발해야 한다면 이런 프레임워크를 쓰는 게 생산성 측면에서 좋을 때가 많습니다. 우리가 만들고자 하는 live-locator도 예외는 아닙니다.
단, main 스크립트에서는 React를 사용하지 않습니다. 생산성 향상의 장점보다 로딩 속도가 느려진다는 단점이 훨씬 크기 때문입니다. 이번에 빌드해봤을 때 React의 용량은 대략 200 KB였습니다. 5 Mbps의 속도라면 대충 0.3초의 지연시간이 추가로 발생해버립니다.
위에서 언급한 써드파티 스크립트 또한 UI가 필요한 게 아니라 사용자 정보를 수집하는 기능으로 충분하기 때문에 React같은 무거운 프레임워크가 필요 없습니다.
React는 편리한 개발 환경을 제공합니다. 특히 Hot Module Replacing(이하 HMR)은 정말 좋습니다. 만약 없으면 매번 새로고침해야 할 겁니다. 우리의 live-locator 또한 이런 편리한 개발 환경을 누리게 해줘야겠습니다.
동적 로딩
main 스크립트는 조건에 맞을 때에만 (관리자가 명시적으로 live-locator 쓰겠다 할 때에만) 그 스크립트를 로딩해야 합니다. 무엇을 기준으로 삼을까요? 일단은 ?bannerLocator=1 라는 searchParams가 있으면 로드되도록 간단히 합시다. 로드는 <script> Element를 만들어 넣어줍시다.
격리를 어떻게 시킬까
React와 같은 프레임워크는 내가 내 서비스 만들 때 쓰라고 만든 거지 남의 서비스 위에 내 서비스를 얹기 위해 나온 게 아닙니다. 그래서 당연히 남의 서비스에 영향을 주지 않는 기능 같은 건 고려된 게 없을 겁니다. live-locator를 React로 개발한다고 하면 다음과 같은 상황이 발생할 수 있습니다.
- live-locator 안에 있는
div.container에만 스타일을 지정하려고 했는데, 고객 사이트에 있던div.container가 영향을 받는다! - live-locator는 React를 사용하는데, 고객 사이트도 React를 써서 어떤 글로벌 변수가 서로 충돌한다! (재차 말하지만, React는 본래 하나의 완전한 웹앱으로 설계되었습니다.)
때문에 스타일을 격리시키기 쉬운 Shadow DOM + Custom Element 방법을 사용하겠습니다. 이 방법이 만능은 아니겠지만, 쉽고 빠르게 적용 가능한 방법이라고 생각하고 선택했습니다.
비슷한 사례
Vercel Toolbar 기능을 아시나요? Vercel은 Next.js 등의 웹앱을 편하게 배포해주는 서비스인데요, main 브랜치가 아니라 작업 중인 브랜치를 따고 PR을 만들고 하면은 해당 브랜치의 최신 버전으로 테스트(Preview)할 수 있는 빌드가 생깁니다.
아래는 test 브랜치에서 작업한 내용이 zip-up-git-…vercel.app으로 배포되는 모습입니다.

저 주소로 들어가면 우측에 조그마한 버튼이 떠있는 걸 볼 수 있고, 그걸 누르면 아주 복잡한 기능이 내 사이트에서 뜨는 걸 확인할 수 있습니다. 요지는 나의 사이트에 내가 만들지 않은 기능이 생겼다는 것입니다! 우리가 live-locator를 잘 만들어야 하는 상황과 아주 유사합니다.

이게 어디서 어떻게 동작하는 걸까요?
Next.js 앱이 Vercel에서 빌드될 때 Vercel Toolbar를 로드하는 코드 스니펫이 자동으로 삽입됩니다. 가장 먼저 로드되는 자바스크립트인 webpack-xxx.js 의 최하단에서 아래와 같은 코드가 있다는 걸 확인해볼 수 있습니다.
(function () {if (typeof document === "undefined") return;var s = document.createElement("script");s.src = "https://vercel.live/_next-live/feedback/feedback.js";s.setAttribute("data-deployment-id", "dpl_xxxxx");(document.head || document.documentElement).appendChild(s);})();
이 코드는 Vercel에서 만든 feedback.js 자바스크립트 파일을 로드하게 되고, 이 스크립트가 연쇄적으로 온갖 리소스를 로드하고 동작시킵니다. 결과적으로 아래와 같은 Element가 body 하단에 삽입됩니다.
<vercel-live-feedbackstyle="position: absolute; top: 0px; left: 0px; z-index: 2147483647;"></vercel-live-feedback>
이 희한하게 생긴 요소는 vercel-live-feedback라는 이름을 가진 Custom Element 입니다. 이 요소의 shadowRoot를 까보면 엄청나게 많은 style 태그가 있고, 각종 Element도 많고, React를 포함한 커다란 스크립트가 로드되는 것도 확인할 수 있습니다.

요구사항 정리
만들어야 할 건 아래와 같습니다.
main스크립트: 배너 렌더링 시스템. 용량은 적어야 함. 구조는 심플하게.live-locator스크립트: 사이트 운영자 전용 추가 도구. 조건에 따라main이 추가 로드합니다. React + Tailwind 기반.
소스코드 및 데모

프로젝트 세팅
간단한 모노레포로 구성했습니다. 전체 파일은 자잘한 파일들 제외하고 아래 정도가 되겠습니다.
package.jsonpackages/main/src/main.tsscripts/build.mjspackages/main/package.jsonpackages/main/index.htmlpackages/main/vite.config.mtspackages/main/public/banner-locations.jsonpackages/live-locator/package.jsonpackages/live-locator/vite.config.mtspackages/live-locator/index.htmlpackages/live-locator/src/live-locator.tsxpackages/live-locator/src/LiveLocatorApp.tsxpackages/live-locator/src/live-locator.csspackages/live-locator/src/utils/selector.ts
특이할만한 점은 아래와 같습니다.
- packages/main/ 폴더:
main스크립트 프로젝트 (단순 Vite) - packages/live-locator/ 폴더:
live-locator스크립트 프로젝트 (Vite + React + Tailwind) - scripts/build.mjs: 빌드 스크립트 (
npm run build하게 되면 실행됨)
빌드 및 배포 스크립트는 Jenkins로 하든, GitHub Actions로 하든, 스크립트로 만들어 정의하든 필요하긴 합니다. 최종 산출물은 자바스크립트 파일이며 이건 어딘가에 잘 업로드가 되어야 하기 때문입니다. 실제로는 S3같은 곳에 올려서 CDN을 태우기만 하면 충분합니다.
이 데모는 Vercel에 간단히 업로드합니다. Vercel 배포 설정에서 output 폴더를 명시하고 그 곳에 빌드 결과물을 모아두기만 하면 Vercel이 알아서 업로드를 해줍니다. scripts/build.mjs 스크립트는 각 모노레포를 돌면서 빌드하고 그 쪽으로 복사해주는 역할을 합니다.
pacakge.json에서 프로젝트 타입은 type: "module"로 갑니다. 실제 런타임에도 Node.js와 큰 연관이 있는 서버와 달리 프론트는 type: "module" 을 적용해도 이제는 큰 무리가 없어 보입니다. Vite로 새로운 프로젝트를 만들 때 이미 그게 기본값이기도 하구요. 그래서 전부 type: "module" 입니다.
Vite 빌드 설정 (library mode + IIFE)
Vite 기본 빌드는 index.html 중심이지만, 우리는 외부 사이트에 삽입될 단일 스크립트 파일이 필요합니다. 그래서 각 패키지의 vite.config에서 library mode를 켜고 출력 포맷을 iife로 고정했습니다. 이렇게 하면 스크립트가 로드되는 즉시 실행되는 형태로 떨어집니다.
import { defineConfig } from "vite";export default defineConfig({build: {lib: {entry: "src/main.ts",formats: ["iife"],fileName: () => "main.js",name: "BannerMain",},},});
live-locator도 같은 패턴으로 entry, name, fileName만 바꿔주면 됩니다.
main 스크립트
main 스크립트는 심플해요. banner-locations.json 파일을 갖고 와서 거기의 정보에 맞게 배너를 insert 해주는 게 전부입니다.
async function init(): Promise<void> {window.__BANNER_TOOL__ = {version,};const configUrl = new URL("banner-locations.json", window.location.href).href;const response = await fetch(configUrl, { cache: "no-store" });const config = (await response.json()) as BannerConfig;for (const location of config.locations) {insertBannerAt(location);}await loadLiveLocator(); // 조금 있다가 알아봅시다}if (document.readyState === "loading") {document.addEventListener("DOMContentLoaded",() => {void init();},{ once: true },);} else {void init();}
main.js는 고객이 삽입하는 스크립트인 점을 항상 유의해야 합니다. 그래서 본 스크립트가 head에 삽입되어 있다면 스크립트가 실행되는 시점에 body가 없을 수도 있습니다. 그 때 init 함수를 호출한다면 아무런 동작을 하지 않는 문제가 있습니다! 그래서 document.readyState를 체크하고 DOMContentLoaded 이벤트를 활용하여 init 함수를 호출합니다.
json 파일은 아래와 같이 단순하게 되어있습니다. 해당 selector를 찾아서 그 다음 위치에 넣는 행동을 합니다.
{"locations": [{ "selector": "#banner-slot" },{ "selector": "#section2 .banner-placeholder" }]}
insertBannerAt은 selector 다음에 배너를 만들어 넣는 일만 합니다.
function insertBannerAt(location: BannerLocation): void {const banner = document.createElement("div");banner.className = "banner-tool-banner";banner.style.padding = "12px";banner.style.margin = "8px 0";banner.style.backgroundColor = "#1f2937";banner.style.color = "#f9fafb";banner.style.borderRadius = "8px";banner.style.fontFamily ="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";banner.style.boxShadow = "0 4px 10px rgba(0,0,0,0.15)";banner.dataset.bannerTool = "banner";banner.textContent = "This is a banner injected by Banner Tool";const target = document.querySelector(location.selector);if (!target || !target.parentElement) {console.warn(`[banner-tool] Selector not found: ${location.selector}`);return;}target.insertAdjacentElement("afterend", banner);}
insertAdjacentElement 메소드를 활용하면 element를 원하는 위치에 넣기 좋습니다. 아래는 InsertPosition에 따라 삽입되는 위치입니다.
<!-- beforebegin --><p><!-- afterbegin -->foo<!-- beforeend --></p><!-- afterend -->
live-locator 스크립트 로드하기
틀은 아래와 같습니다.
liveLocatorsearchParams가 있는지 체크하고,"1"이면live-locator를 로드한다.banner-live-locatorCustom Element가 등록되었는지 기다린다 (whenDefined)banner-live-locator태그를 만들어body의 가장 마지막에 삽입한다.
const LIVE_LOCATOR_TAG = "banner-live-locator";let liveLocatorScriptPromise: Promise<void> | null = null;async function loadLiveLocator(): Promise<void> {const param = new URL(window.location.href).searchParams.get("liveLocator");if (param !== "1") {return;}try {if (!liveLocatorScriptPromise) {// live-locator를 불러와야 하는 부분}await liveLocatorScriptPromise;await customElements.whenDefined(LIVE_LOCATOR_TAG);let element = document.querySelector(LIVE_LOCATOR_TAG,) as HTMLElement | null;if (!element) {element = document.createElement(LIVE_LOCATOR_TAG);element.setAttribute("version", version);document.body.appendChild(element);} else {element.setAttribute("version", version);}} catch (error) {liveLocatorScriptPromise = null;console.warn("[banner-tool] Failed to bootstrap live locator", error);}}
저기에 어떤 로직이 들어가야 할까요?
일단 운영 환경부터 먼저 생각해봅시다. 우리는 다음과 같이 main.js와 live-locator.js를 같은 도메인으로 접근할 수 있도록 만들어둘 것입니다.
- https://mini-multi-scripts-live-locator.vercel.app/main.js
- https://mini-multi-scripts-live-locator.vercel.app/live-locator/live-locator.js
https://mini-multi-scripts-live-locator.vercel.app 라는 도메인을 그대로 이용해도 되겠지만 조금이나마 유연성을 올리기 위해서 우리는 import.meta.url를 사용할 수 있습니다. import.meta.url은 모듈로 삽입된 스크립트에서 자기 자신이 import된 url을 알 수 있는 기능입니다. 아래와 같이 사용해볼 수 있습니다.
function loadScript(url: string, id: string): Promise<void> {// 내용 중략. createElement("script")를 하고 head에 삽입.// Promise는 load 되면 resolve 하도록 설정.}const url = new URL(/* @vite-ignore */ `live-locator/live-locator.js?v=${version}`,import.meta.url,).href;liveLocatorScriptPromise = loadScript(url, "banner-live-locator-prod");
그런데 /* @vite-ignore */ 주석은 왜 붙어있나요? 이걸 붙이지 않으면 아래 경고가 뜨는데요,
new URL(`live-locator/live-locator.js?v=${version}`, import.meta.url) doesn't exist at build time, it will remain unchanged to be resolved at runtime. If this is intended, you can use the /* @vite-ignore */ comment to suppress this warning.
기본적으로 Vite는 빌드할 때 URL 관련된 건 어떻게든 해석하려고 합니다. 하지만 가변 경로는 빌드 타임 때 변환할 수 없는 문제가 있습니다. Vite의 결과물에 맞춰서 경로가 만들어지지 않기 때문에 이는 일반적인 상황에서는 에러가 뜰 확률이 굉장히 높습니다. 그러나 우리는 이 행동을 의도했습니다(live-locator는 main과는 연관이 없는 별도의 프로젝트입니다). 그러므로 /* @vite-ignore */ 주석을 붙여줍니다.
이제 dev 환경에서 스크립트를 가져와봅시다.
if (import.meta.env.DEV) {liveLocatorScriptPromise = loadScript("http://localhost:5174/src/live-locator.tsx","banner-live-locator-dev",);} else {const url = new URL(/* @vite-ignore */ `live-locator/live-locator.js?v=${version}`,import.meta.url,).href;liveLocatorScriptPromise = loadScript(url, "banner-live-locator-prod");}
참고로 import.meta.env.DEV 는 Vite에서만 특별하게 취급하는 값입니다. 그래서 만약 빌드하게 되면 import.meta.env.DEV는 false로 간주되어 위에서 색칠한 코드는 모두 삭제됩니다.
Custom Element 및 Shadow DOM 구현하기
live-locator는 아래와 같이 생겼습니다.
import { createRoot, Root } from "react-dom/client";import { LiveLocatorApp } from "./LiveLocatorApp";import { StrictMode } from "react";const ELEMENT_TAG = "banner-live-locator";declare global {interface HTMLElementTagNameMap {"banner-live-locator": BannerLiveLocatorElement;}}type ElementState = {root: Root;version: string;};class BannerLiveLocatorElement extends HTMLElement {private state: ElementState | null = null;static get observedAttributes(): string[] {return ["version"];}connectedCallback(): void {if (this.state) {return;}const shadowRoot = this.shadowRoot ?? this.attachShadow({ mode: "open" });// LiveLocatorApp를 shaodwRoot에 렌더링한다!!this.state = { root, version };console.log("[banner-tool] Live Locator mounted", { version });}disconnectedCallback(): void {this.state?.root.unmount();this.state = null;console.log("[banner-tool] Live Locator unmounted");}attributeChangedCallback(name: string,oldValue: string | null,newValue: string | null,): void {// 중략}}export function defineLiveLocatorElement(): void {if (!customElements.get(ELEMENT_TAG)) {customElements.define(ELEMENT_TAG, BannerLiveLocatorElement);console.log("[banner-tool] Live Locator element defined");}}defineLiveLocatorElement();
중요한 건 색깔을 칠했습니다.
- Custom Element는 HTMLElement를 상속 받는 클래스로 정의하고 추후 customElements.define로 등록합니다. (customElements는 전역 객체입니다)
- 특별한 역할을 하는 여러 메소드가 있습니다.
- connectedCallback: document에 추가될 때마다 호출됩니다.
- disconnectedCallback: document로부터 삭제될 때마다 호출됩니다.
- attributeChangedCallback: 속성이 추가/삭제/수정될 때마다 호출됩니다.
this.attachShadow({ mode: "open" });으로 Shadow DOM을 이 요소에 붙이고 거기에다가 React 앱을 그립니다.
- Custom Element에 대한 자세한 내용은 MDN 문서 Using custom elements를 참조하세요.
- Shadow DOM에 대한 자세한 내용은 MDN 문서 Using shadow DOM을 참조하세요.
자 일단 여기까지만 해도 [banner-tool] Live Locator mounted 로그는 콘솔에 잘 뜰 겁니다.
일단 LiveLocatorApp는 아래와 같이 대충 만듭시다.
type LiveLocatorAppProps = {version: string;onClose: () => void;};export function LiveLocatorApp({ version, onClose }: LiveLocatorAppProps) {return <div>hello world!</div>;}
이제 다시 돌아와서 렌더링을 해볼까요?
// 생략connectedCallback(): void {if (this.state) {return;}const shadowRoot = this.shadowRoot ?? this.attachShadow({ mode: "open" });const mountPoint = document.createElement("div");mountPoint.id = "banner-live-locator-root";shadowRoot.append(mountPoint);const version = this.getAttribute("version") ?? "dev";const root = createRoot(mountPoint);const handleClose = () => { /* 중략 */ };root.render(<StrictMode><LiveLocatorApp version={version} onClose={handleClose} /></StrictMode>);this.state = { root, version };console.log("[banner-tool] Live Locator mounted", { version });}// 후략
this.attachShadow 호출로 받은 Shadow Root에 div를 하나 넣고, 그 div로 createRoot 해버리고 React 앱을 render 합니다! 앗? 그런데 말입니다... 혹시 다음과 같은 에러가 뜨나요? 🤯🤯🤯
Uncaught Error: @vitejs/plugin-react can't detect preamble. Something is wrong.
계속해서 해 봅시다요. 이 에러는 무엇이고, 문제를 어떻게 접근할지 좀 상세히 설명할 예정입니다.

참고: 소소하게 타입 지원 받기
아래와 같이 타입을 지정한다면 document.createElement 할 때 타입 지원을 받을 수 있습니다.
declare global {interface HTMLElementTagNameMap {"banner-live-locator": BannerLiveLocatorElement;}}/** BannerLiveLocatorElement */const element = document.createElement("banner-live-locator");
HMR 동작하게 하기
preamble이라는 낯선 영단어가 등장했네요. 검색해보니 다음과 같은 뜻이 있다고 합니다.
프리앰블(Preamble)은 통신에서 데이터 전송 시작 전에 송신자와 수신자 간의 타이밍과 주파수 동기를 맞추기 위해 사용되는 서두 신호
그나저나 Something is wrong이라니요... 이런 불친절한 에러 메시지...!! 개발 서버에서 초기화되어 있어야 할 정보들이 없나봅니다. 그래서 HMR은 커녕 아예 로딩조차 되지 않습니다.
React 등의 개발 환경에서는 생산성도 중요하기 때문에 프레임워크 차원에서 편리한 기능을 제공해줍니다. HMR 혹은 Fast Refresh로 불리우는, 파일을 저장하면 자동으로 변경사항이 적용되는 기능 또한 그 일환입니다. HMR이 어떻게 동작하는지는 좀 더 자세히 봅시다. 아래는 npm run dev 으로 개발 서버를 띄웠을 때 모습입니다.
VITE v7.3.0 ready in 261 ms➜ Local: http://localhost:5173/➜ Network: use --host to expose➜ press h + enter to show helpVITE v7.3.0 ready in 457 ms➜ Local: http://localhost:5174/➜ Network: use --host to expose➜ press h + enter to show help
개발 서버가 두 개 떠있는 모습을 확인할 수 있습니다. 5173 포트는 main 쪽 프로젝트이고 5174는 live-locator 쪽입니다. 우리가 http://localhost:5173/ 라는 경로로 접근하게 되면 로컬에 떠있는 서버는 그 경로를 분석해 대략 다음과 같은 절차로 요청을 처리합니다.
- 브라우저가
http://localhost:5173또는 하위 파일 (http://localhost:5173/src/main.ts?t=123)에 접근합니다. - 개발 서버는 해당 경로에 있는 실제 로컬 파일을 읽습니다.
- 개발 서버는 파일을 적당히 변환하고 HMR 기능을 포함한 개발 편의성 코드 스니펫을 추가로 끼워 넣고 브라우저에게 전달합니다.
- 브라우저는 스니펫에 포함되어 있는 개발 도구를 로드합니다. (예:
http://localhost:5173/@vite/client) - 해당 스크립트는 서버와 WebSocket을 연결합니다.
- 또한 브라우저는 추후 서버로부터 신호가 왔을 때의 동작을 파일별로 미리 정의해 놓습니다. 이것도 스니펫에 포함되어 있는 내용입니다. 보통
accept라는 함수를 미리 정의하는데요, 새롭게 바뀐 요소를 적용한다는 뜻입니다.
자, 그럼 위 @vitejs/plugin-react can't detect preamble. 에러는 어디서 발생하는 걸까요?
import { createHotContext as __vite__createHotContext } from "/@vite/client";import.meta.hot = __vite__createHotContext("/src/LiveLocatorApp.tsx");// ******************************// 중략. 실제 LiveLocatorApp 내용// ******************************import * as RefreshRuntime from "/@react-refresh";if (import.meta.hot) {if (!window.$RefreshReg$) {throw new Error("@vitejs/plugin-react can't detect preamble. Something is wrong.",);}RefreshRuntime.__hmr_import(import.meta.url).then((currentExports) => {RefreshRuntime.registerExportsForReactRefresh("/Users/th.kim/Desktop/mini-multi-scripts/packages/live-locator/src/LiveLocatorApp.tsx",currentExports,);import.meta.hot.accept((nextExports) => {if (!nextExports) return;const invalidateMessage =RefreshRuntime.validateRefreshBoundaryAndEnqueueUpdate("/Users/th.kim/Desktop/mini-multi-scripts/packages/live-locator/src/LiveLocatorApp.tsx",currentExports,nextExports,);if (invalidateMessage) import.meta.hot.invalidate(invalidateMessage);});});}
앞서 이야기한 준비 단계 중 6단계, 추후 서버로부터 신호가 왔을 때의 동작을 파일별로 미리 정의해 놓는 단계에서 에러가 발생한 겁니다. 그렇다면 이 문제를 해결하려면 어떻게 해야 할까요?
다시 상황을 정리해봅시다.
- 개발용 서버에 진입한다 (
http://localhost:5173) →index.html파일이 로드됨 - 브라우저는
main.ts파일도 요청한다. (http://localhost:5173/src/main.ts) - 브라우저는 live-locator가 있는
http://localhost:5174/src/live-locator.tsx파일을 요청한다. - 줄줄이
http://localhost:5174/src/LiveLocatorApp.tsx도 요청한다. - 준비하는 과정에서 에러!
보통 우리는 React를 Vite 환경에서 개발할 때 index.html 파일이 있다는 걸 알 수 있습니다. 이 파일에 비밀이 숨겨져 있었습니다.

Vite 입장에서는, live-locator를 가져오는 index.html 파일에 미리 스니펫을 잘 끼워 넣어서 각 파일에서도 준비 과정에 에러가 없어야 하는 게 당연한데, 우리는 index.html 파일은 건너뛰고 직접 live-locator를 로드했으니 Vite는 이상함을 감지하고 그냥 종료시켜버린 것입니다.
해결법은 간단합니다. main.ts에서 저 preamble 코드를 직접 삽입하면 됩니다.
if (import.meta.env.DEV) {+const script = document.createElement("script");+script.type = "module";+script.textContent = `+import { injectIntoGlobalHook } from "http://localhost:5174/@react-refresh";+injectIntoGlobalHook(window);+window.$RefreshReg$ = () => {};+window.$RefreshSig$ = () => (type) => type;+`;+document.head.appendChild(script);liveLocatorScriptPromise = loadScript("http://localhost:5174/src/live-locator.tsx","banner-live-locator-dev",);} else {const url = new URL(/* @vite-ignore */ `live-locator/live-locator.js?v=${version}`,import.meta.url,).href;liveLocatorScriptPromise = loadScript(url, "banner-live-locator-prod");}
"/@react-refresh" 가 아니라 "http://localhost:5174/@react-refresh"로부터 모듈을 가져온다는 점에 유의하세요. 이제 더이상 @vitejs/plugin-react can't detect preamble 에러가 발생하지 않고 우리의 LiveLocatorApp 이 잘 로드된다는 걸 확인할 수 있습니다.
저 코드가 어디서 어떻게 만들어지는 지는 아래와 같이 Vite 소스코드에서도 확인해볼 수 있습니다.

Tailwind
Tailwind CSS 를 적용하려면 공식 문서를 일단 따라하면 됩니다.
- Tailwind 설치
npm install tailwindcss @tailwindcss/vite vite.config.ts에 tailwindcss 플러그인 추가- CSS 파일에
@import "tailwindcss";추가 (CSS 파일은 어디선가 import 되어야 함)
본래 React에서 하던 것처럼 LiveLocatorApp 에서 그 css 파일을 import 해봅시다.
import "./live-locator.css";
그런데 문제가 생깁니다.

우리의 스타일은 head에 생기면 안 됩니다. 고객 사이트에 영향을 주면 안되기 때문입니다(심지어 프로덕션 빌드는 그냥 이상하게 동작해서, CSS 파일 진입점 자체가 보이지 않습니다) 고객 사이트와 격리하기 위해 Shadow DOM을을 채택한 만큼, Tailwind도 Shaodw DOM의 바운더리 내에서 동작하게끔 해야 합니다. 문제는, Shadow DOM에서도 잘 동작하도록 Tailwind가 설계된 건 아니란 것입니다. 이를 문제 없이 잘 동작하도록 하는 건 우리의 미션이라는 겁니다.
다행히도 쉽게 진행할 수 있습니다.
import styles from "./live-locator.css?inline";type LiveLocatorAppProps = {version: string;onClose: () => void;};export function LiveLocatorApp({ version, onClose }: LiveLocatorAppProps) {return (<><style>{styles}</style><div>hello world!</div></>);}
Vite 문서에 따르면 ?inline을 붙이면 단순 string 값으로 스타일의 내용을 가져올 수 있다고 합니다.
Disabling CSS injection into the page
The automatic injection of CSS contents can be turned off via the
?inlinequery parameter. In this case, the processed CSS string is returned as the module's default export as usual, but the styles aren't injected to the page.
그래서 그걸 스타일에 그대로 넣으면 됩니다. 그럼 아래 이미지와 같이 Shadow DOM 내에 스타일이 삽입되었음을 확인할 수 있습니다. 우리의 style 태그는 고객의 사이트에 영향을 줄 수 없습니다.

다른 문제도 있습니다. Shadow DOM 내부에서는 CSS @property 기능이 동작하지 않습니다. (tailwindcss#15005). Tailwind 문제는 아니고 표준이 정해지지 않은 문제라 브라우저에서 구현도 되지 않은 상태입니다. 저는 border 스타일만 정해지지 않아서 문제를 살펴봤었는데요, Tailwind v4는 border-style이 @property로서 기본값이 정해지는데, 이 기본값 조차 Shadow DOM 내부에서 유효하지 않았기 때문에 아예 안보였던 것입니다!
그래서 아래 내용도 CSS 파일에 추가해줬습니다.
:host {--tw-divide-y-reverse: 0;--tw-border-style: solid;--tw-font-weight: initial;--tw-tracking: initial;--tw-translate-x: 0;--tw-translate-y: 0;--tw-translate-z: 0;--tw-rotate-x: rotateX(0);--tw-rotate-y: rotateY(0);--tw-rotate-z: rotateZ(0);--tw-skew-x: skewX(0);--tw-skew-y: skewY(0);--tw-space-x-reverse: 0;--tw-gradient-position: initial;--tw-gradient-from: #0000;--tw-gradient-via: #0000;--tw-gradient-to: #0000;--tw-gradient-stops: initial;--tw-gradient-via-stops: initial;--tw-gradient-from-position: 0%;--tw-gradient-via-position: 50%;--tw-gradient-to-position: 100%;--tw-shadow: 0 0 #0000;--tw-shadow-color: initial;--tw-inset-shadow: 0 0 #0000;--tw-inset-shadow-color: initial;--tw-ring-color: initial;--tw-ring-shadow: 0 0 #0000;--tw-inset-ring-color: initial;--tw-inset-ring-shadow: 0 0 #0000;--tw-ring-inset: initial;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-offset-shadow: 0 0 #0000;--tw-blur: initial;--tw-brightness: initial;--tw-contrast: initial;--tw-grayscale: initial;--tw-hue-rotate: initial;--tw-invert: initial;--tw-opacity: initial;--tw-saturate: initial;--tw-sepia: initial;--tw-drop-shadow: initial;--tw-duration: initial;--tw-ease: initial;}
Trouble Shooting
빌드 결과물에 process.env.NODE_ENV 가 있어요
이 문제는 Vite 에서 library mode 를 사용하기 때문에 나오는 문제입니다.
library mode는 말 그대로 결과물이 라이브러리로 사용되도록 빌드합니다. 라이브러리는 개발자들이 사용하는 겁니다. 최종 고객에게 전달되는 게 아니기 때문에 개발과 관련된 정보도 남아있어야 합니다. 예를 들어 빌드 포맷을 es로 한다면 트리셰이킹과 관련된 정보(/*@__PURE__*/ 주석 등)를 보존하기 위해 공백을 그대로 남겨놓습니다.
live-locator에는 React가 포함되어 있습니다. 그래서 React 개발에 필요한 정보도 함께 담겨져 있고, Node.js에서 live-locator가 또다시 빌드될 것을 전제로 하여 process.env.NODE_ENV 에 따른 분기 처리도 보존되었습니다. 그런데 live-locator는 브라우저에서 구동되어야 하고 엄밀히 라이브러리가 아니라 최종 결과물입니다. 그러므로 정보를 보존할 필요가 없습니다.
아래와 같이 build 명령어라면 process.env.NODE_ENV를 "production"으로 고정함으로써 빌드 결과물에 process.env.NODE_ENV를 전부 날려버릴 수 있습니다.
import tailwindcss from "@tailwindcss/vite";import react from "@vitejs/plugin-react";import { defineConfig } from "vite";export default defineConfig(({ command }) => ({// 중략define:command === "build"? { "process.env.NODE_ENV": '"production"' }: undefined,// 중략}));
마치며
Production-Ready 라고 제목에 써넣어두긴 했지만 실제 서비스에 적용할 땐 해야 할 게 더 많긴 합니다. 특히 live-locator와 같은 기능이 로드되는 건 권한 체크(관리자 인증)가 필요하죠. 그외 에러/로그 수집과 같은 운영에 필요한 부분도 신경써야 할 것입니다.
다른 사이트에서 동작하는 앱을 만든다는 건, 정말 어렵습니다. 기존 도구들은 그런 상황을 전제해놓지 않았죠, 사이트는 건드리면 안되죠, 또한 사이트로부터 영향을 받아서도 안됩니다. 각종 제약은 많으면서도 개발 생산성도 좋아야 하죠. 이 글에서는 main과 live-locator를 분리하고, 조건부 로딩, Shadow DOM 격리, Vite HMR 보정 같은 현실적인 문제를 하나씩 해결해봤습니다.
실제로 이런 앱을 만든다면 이 글에 쓰여진 것 말고도 기상천외한 문제들을 많이 겪게 되실 겁니다. 브라우저가 어떤 방식으로 동작하고, Vite와 같은 번들러가 코드를 어떻게 만들고, 프론트 앱의 개발 및 빌드 환경이 어떻게 구성되어 있는지에 대해서 이해를 하고 있다면! 난관을 잘 해쳐나가실 수 있을 겁니다.
레퍼런스
- Andar 공식 사이트
- Beusable 공식 사이트
- CREMA 공식 사이트
- Google Tag Manager
- Vercel Live Feedback 스크립트 (난독화)
- mini-multi-scripts GitHub 저장소
- mini-multi-scripts 라이브 데모
- Amazon S3
- MDN: Element.insertAdjacentElement
- MDN: CustomElementRegistry.whenDefined
- 데모 main.js (난독화)
- 데모 live-locator.js (난독화)
- MDN: import.meta
- MDN: Using custom elements
- MDN: Using shadow DOM
- Vite Plugin React refresh utils
- Tailwind CSS Installation Guide (using Vite)
- Vite: Disabling CSS injection into the page
- Tailwind CSS Issue "@property isn't supported in shadow roots" #15005