테마
크로스 프로젝트 통합 - 레거시 MFA 소비와 CSS 격리
webpack 설정 없이 asset-manifest.json과 Shadow DOM을 활용하여 레거시 프로젝트의 마이크로 컴포넌트를 소비하고 CSS를 격리한다
학습 목표
- asset-manifest.json의 구조와 활용 방법을 이해한다
- 레거시 스크립트를 동적으로 로드하는 loadLegacyScript를 구현할 수 있다
- Shadow DOM + link 태그를 사용한 레거시 CSS 격리를 구현할 수 있다
- docs, web, legacy 세 프로젝트의 크로스 통합을 완성할 수 있다
본문
1. 레거시 프로젝트 통합의 핵심 문제
docs/web 프로젝트는 webpack 설정으로 output.filename = "main.js"를 고정했지만, legacy 프로젝트는 CRA 기본 설정을 사용하므로 빌드 시 파일명에 해시값이 붙는다. 또한 CSS 파일을 문자열로 변환할 수 없다.
2. asset-manifest.json 구조
CRA로 빌드한 프로젝트는 build/asset-manifest.json을 생성한다. 이 파일에 모든 번들 파일의 경로가 매핑되어 있다.
json
{
"files": {
"main.css": "/static/css/main.abc123.css",
"main.js": "/static/js/main.def456.js",
"main.css.map": "/static/css/main.abc123.css.map",
"main.js.map": "/static/js/main.def456.js.map"
},
"entrypoints": [
"static/css/main.abc123.css",
"static/js/main.def456.js"
]
}| 필드 | 용도 |
|---|---|
files | 논리적 이름 -> 실제 경로 매핑 |
entrypoints | 앱 실행에 필요한 파일 순서 목록 |
3. loadLegacyScript 구현
typescript
interface AssetManifest {
files: Record<string, string>;
entrypoints: string[];
}
export async function loadLegacyScript({
url,
appName,
}: {
url: string;
appName: string;
}): Promise<{ microApp: MicroApp; cssPaths: string[] }> {
// 이미 로드된 경우
const existing = (window as any)[appName];
if (existing) {
return { microApp: existing, cssPaths: [] };
}
// 1) asset-manifest.json 파싱
const response = await fetch(`${url}/asset-manifest.json`);
const manifest: AssetManifest = await response.json();
// 2) entrypoints에서 JS와 CSS 분리
const jsPaths = manifest.entrypoints
.filter((entry) => entry.endsWith(".js"))
.map((entry) => `${url}/${entry}`);
const cssPaths = manifest.entrypoints
.filter((entry) => entry.endsWith(".css"))
.map((entry) => `${url}/${entry}`);
// 3) JS 스크립트 로드
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = jsPaths[0]; // 메인 JS
script.onload = () => {
const microApp = (window as any)[appName];
if (microApp) {
resolve({ microApp, cssPaths });
} else {
reject(new Error(`${appName} 로드 실패`));
}
};
script.onerror = reject;
document.head.appendChild(script);
});
}4. Shadow DOM을 통한 CSS 격리
레거시 프로젝트의 CSS는 문자열로 변환할 수 없으므로, <link> 태그로 Shadow DOM 내부에 주입한다.
5. Legacy ShadowDOM 래퍼 구현
jsx
// legacy/ShadowDom.js (레거시 프로젝트 내부)
import React from "react";
import ReactDOM from "react-dom";
export default class ShadowDom extends React.Component {
constructor(props) {
super(props);
this.divRef = React.createRef();
this.state = { shadowRoot: null };
}
componentDidMount() {
const div = this.divRef.current;
if (div && !div.shadowRoot) {
const shadowRoot = div.attachShadow({ mode: "open" });
this.setState({ shadowRoot });
}
}
render() {
const { children, cssPaths = [] } = this.props;
const { shadowRoot } = this.state;
return (
<div ref={this.divRef}>
{shadowRoot &&
ReactDOM.createPortal(
<>
{/* CSS를 link 태그로 주입 */}
{cssPaths.map((path, i) => (
<link key={i} rel="stylesheet" href={path} />
))}
{children}
</>,
shadowRoot
)}
</div>
);
}
}6. MicroComponent에 Legacy 지원 추가
typescript
// MicroComponent 확장
export default class MicroComponent extends React.Component<MicroComponentProps> {
private async renderMicroApp() {
const {
url, appName, componentName = "default",
isLegacy = false, props
} = this.props;
let microApp: MicroApp;
let cssPaths: string[] = [];
if (isLegacy) {
// Legacy: asset-manifest.json 기반 로딩
const result = await loadLegacyScript({ url, appName });
microApp = result.microApp;
cssPaths = result.cssPaths;
} else {
// Modern: main.js 직접 로딩
microApp = await loadScript({ url, appName });
}
const component = microApp[componentName] || microApp.default;
if (!component?.render) return;
// CSS 경로를 props로 전달
this.unmountFn = component.render(container, {
...props,
cssPaths,
});
}
}7. 크로스 프로젝트 통합 전체 구성
8. 개발 환경 vs 프로덕션 환경
| 항목 | 개발 (dev server) | 프로덕션 (Docker/Nginx) |
|---|---|---|
| JS 파일명 | main.js (docs/web), bundle.js (legacy) | 해시값 포함 |
| CSS 파일 | <style> 태그로 주입 (legacy) | 별도 .css 파일 생성 |
| asset-manifest | CSS 항목 없음 | CSS 경로 포함 |
| Legacy CSS 격리 | 개발 시에는 작동하지 않을 수 있음 | <link> 태그로 Shadow DOM 주입 |
주의: Legacy 프로젝트의 CSS 격리는 빌드된 프로덕션 환경(Docker)에서 완전히 동작한다. 개발 모드에서는 webpack dev server가 CSS를 <style> 태그로 <head>에 직접 삽입하므로 asset-manifest.json에 CSS 항목이 포함되지 않는다.
핵심 정리
- asset-manifest.json 활용: webpack 설정 없는 레거시 프로젝트에서 빌드 결과물의 실제 파일 경로를 동적으로 파악할 수 있다
- loadLegacyScript: asset-manifest.json을 파싱하여 JS는
<script>로, CSS 경로는 별도 반환하여 Shadow DOM에 주입할 수 있게 한다 - link 태그 CSS 주입: toString-loader를 사용할 수 없는 레거시 환경에서는
<link rel="stylesheet">를 Shadow Root에 삽입하여 격리한다 - 프로덕션 환경 필수: 레거시 CSS 격리는 빌드된 환경에서만 완전히 동작하므로, 개발 시에는 Docker 빌드를 활용해야 한다