테마
CSS 격리 기법 - toString Loader를 활용한 스타일 주입
toString Loader로 CSS를 문자열로 변환하여 Shadow DOM 내부에 주입하는 기법을 학습한다
학습 목표
- webpack의 CSS 처리 흐름(style-loader vs mini-css-extract-plugin)을 이해한다
- toString Loader의 동작 원리와 설정 방법을 익힌다
- 개발/프로덕션 환경에서 일관된 CSS 처리 전략을 구성할 수 있다
- Shadow DOM 내부에 CSS를 안전하게 주입하는 방법을 구현할 수 있다
본문
1. 마이크로 프론트엔드에서 CSS 격리가 필요한 이유
마이크로 프론트엔드 아키텍처에서는 여러 프로젝트의 컴포넌트가 하나의 페이지에 렌더링된다. 이때 각 프로젝트의 CSS가 전역 스코프를 공유하면 스타일 충돌이 발생한다. Shadow DOM은 이 문제를 해결하는 핵심 기술이지만, CSS를 Shadow DOM 내부로 주입하려면 특별한 처리가 필요하다.
2. webpack의 CSS 처리 방식: Dev vs Prod
webpack은 개발 모드와 프로덕션 모드에서 CSS를 다르게 처리한다. 이 차이가 마이크로 프론트엔드에서 문제를 일으킨다.
| 모드 | 로더 | 결과물 | Shadow DOM 호환 |
|---|---|---|---|
| 개발 | style-loader | <style> 태그 → <head> | 불가 (전역에 삽입) |
| 프로덕션 | MiniCssExtractPlugin | .css 파일 → <link> | 불가 (전역에 삽입) |
| 통합 | toString-loader | CSS 문자열 | 가능 (수동 주입) |
3. toString Loader 설치 및 webpack 설정
설치
bash
npm install --save-dev to-string-loaderconfig-overrides.js 설정 (react-app-rewired 기준)
javascript
const { override, addWebpackPlugin } = require("customize-cra");
module.exports = override((config) => {
// 1) MiniCssExtractPlugin 제거
config.plugins = config.plugins.filter(
(plugin) => plugin.constructor.name !== "MiniCssExtractPlugin"
);
// 2) CSS 로더 규칙 변경
const cssRule = config.module.rules.find((rule) =>
rule.oneOf?.some((r) => r.test?.toString().includes("css"))
);
if (cssRule) {
cssRule.oneOf.forEach((rule) => {
if (rule.test?.toString().includes("css")) {
// style-loader 또는 MiniCssExtractPlugin.loader를
// toString-loader로 교체
rule.use = rule.use?.map((loader) => {
const loaderName =
typeof loader === "string" ? loader : loader.loader;
if (
loaderName?.includes("style-loader") ||
loaderName?.includes("mini-css-extract-plugin")
) {
return "to-string-loader";
}
return loader;
});
}
});
}
return config;
});4. CSS 타입 선언과 모듈 임포트
toString Loader를 사용하면 CSS 파일을 import했을 때 문자열이 반환된다. TypeScript 환경에서는 타입 선언이 필요하다.
typescript
// src/types.d.ts
declare module "*.css" {
const content: string;
export default content;
}typescript
// CSS를 문자열로 임포트
import styles from "./index.css";
console.log(typeof styles); // "string"
console.log(styles);
// 출력: ".container { display: flex; } .title { font-size: 24px; }"5. Shadow DOM 내부에 CSS 주입하기
구현 코드
typescript
import styles from "./index.css";
// Shadow DOM 컨테이너 생성
const host = document.createElement("div");
const shadowRoot = host.attachShadow({ mode: "open" });
// CSS를 <style> 태그로 Shadow Root 내부에 주입
const styleEl = document.createElement("style");
styleEl.textContent = styles;
shadowRoot.appendChild(styleEl);
// 컴포넌트 렌더링 컨테이너
const container = document.createElement("div");
shadowRoot.appendChild(container);
// React 컴포넌트 렌더링
ReactDOM.render(<App />, container);6. ANTD (Ant Design)와 함께 사용하기
ANTD 같은 UI 라이브러리를 Shadow DOM 내부에서 사용할 때는 추가 설정이 필요하다. ANTD는 CSS-in-JS를 사용하여 런타임에 스타일을 생성하는데, 기본적으로 <head>에 삽입한다.
typescript
import { StyleProvider } from "@ant-design/cssinjs";
function GlobalProvider({ children, shadowRoot }) {
return (
<StyleProvider container={shadowRoot}>
<Provider store={store}>{children}</Provider>
</StyleProvider>
);
}ANTD의 StyleProvider에 container로 Shadow Root를 전달하면, CSS-in-JS로 생성되는 스타일이 Shadow DOM 내부에 삽입된다. Modal처럼 포탈을 사용하는 컴포넌트도 getContainer로 Shadow Root를 지정해야 한다.
typescript
<Modal
open={!!selectedEmail}
title="메일 상세"
onCancel={() => setSelectedEmail(undefined)}
getContainer={shadowRoot} // 모달도 Shadow DOM 내부에 렌더링
>
{/* ... */}
</Modal>7. 개발/프로덕션 환경 일관성 검증
| 검증 항목 | Dev 서버 | Docker(Prod) | 기대 결과 |
|---|---|---|---|
| CSS 문자열 변환 | toString-loader | toString-loader | 동일한 문자열 |
| Shadow DOM 주입 | <style> 태그 | <style> 태그 | 동일한 방식 |
| 스타일 격리 | Shadow Root 내부 | Shadow Root 내부 | 전역 오염 없음 |
| ANTD 스타일 | StyleProvider | StyleProvider | 정상 렌더링 |
핵심 정리
- CSS 처리의 불일치 문제: webpack은 개발 모드(style-loader)와 프로덕션 모드(MiniCssExtractPlugin)에서 CSS를 다르게 처리하므로 마이크로 프론트엔드에서 일관성 문제가 발생한다
- toString Loader의 역할: CSS 파일을 JavaScript 문자열로 변환하여 환경에 관계없이 동일하게 처리할 수 있게 한다
- Shadow DOM 주입 패턴: CSS 문자열을
<style>엘리먼트의textContent로 설정한 후 Shadow Root에 삽입하면 완벽한 스타일 격리가 가능하다 - CSS-in-JS 라이브러리 대응: ANTD의 StyleProvider처럼 스타일 주입 대상을 Shadow Root로 변경하는 설정이 필요하다