Skip to content

크로스 프로젝트 통합 - 레거시 MFA 소비와 CSS 격리

webpack 설정 없이 asset-manifest.json과 Shadow DOM을 활용하여 레거시 프로젝트의 마이크로 컴포넌트를 소비하고 CSS를 격리한다

학습 목표

  1. asset-manifest.json의 구조와 활용 방법을 이해한다
  2. 레거시 스크립트를 동적으로 로드하는 loadLegacyScript를 구현할 수 있다
  3. Shadow DOM + link 태그를 사용한 레거시 CSS 격리를 구현할 수 있다
  4. 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-manifestCSS 항목 없음CSS 경로 포함
Legacy CSS 격리개발 시에는 작동하지 않을 수 있음<link> 태그로 Shadow DOM 주입

주의: Legacy 프로젝트의 CSS 격리는 빌드된 프로덕션 환경(Docker)에서 완전히 동작한다. 개발 모드에서는 webpack dev server가 CSS를 <style> 태그로 <head>에 직접 삽입하므로 asset-manifest.json에 CSS 항목이 포함되지 않는다.

핵심 정리

  1. asset-manifest.json 활용: webpack 설정 없는 레거시 프로젝트에서 빌드 결과물의 실제 파일 경로를 동적으로 파악할 수 있다
  2. loadLegacyScript: asset-manifest.json을 파싱하여 JS는 <script>로, CSS 경로는 별도 반환하여 Shadow DOM에 주입할 수 있게 한다
  3. link 태그 CSS 주입: toString-loader를 사용할 수 없는 레거시 환경에서는 <link rel="stylesheet">를 Shadow Root에 삽입하여 격리한다
  4. 프로덕션 환경 필수: 레거시 CSS 격리는 빌드된 환경에서만 완전히 동작하므로, 개발 시에는 Docker 빌드를 활용해야 한다

다음 단계

커스텀 엘리먼트 인터페이스 ->