Module

Federation

Quick Start

12/13/2024

 Timothy @ Daangn Frontend Core

Software Engineer, Frontend @Daangn Frontend Core Team

ex) Senior Lead Software Engineer @NHN Dooray (2021 ~ 2023)

ex) Lead Software Engineer @ProtoPie (2016 ~ 2021)

ex) Microsoft MVP

Timothy Lee

이 웅재

Intro

  • Module Federation 약 팔려는 것이 아닙니다.

  • Module Federation 은 계속해서 진화 중입니다.

    • ​어제까지 문제가 있었지만, 오늘은 괜찮을 수 있습니다.

    • 하지만 생각보다 문제가 있을 수 있습니다.

  • ​적절한 경우에 사용하지 않으면, 힘든 여정이 될 수 있습니다.

Module Federation?

  • Module Federation 은 (서버 측의 Microservices 와 유사한)
    JavaScript 애플리케이션의 탈중앙화를 위한 아키텍처 패턴입니다.

Module Federation?

  • Module Federation 은 (서버 측의 Microservices 와 유사한)
    JavaScript 애플리케이션의 탈중앙화를 위한 아키텍처 패턴입니다.

  • 이를 통해 여러 JavaScript 애플리케이션 (또는 마이크로 프론트엔드) 간에
    코드와 리소스를 공유할 수 있습니다.

Module Federation?

  • Module Federation 은 (서버 측의 Microservices 와 유사한)
    JavaScript 애플리케이션의 탈중앙화를 위한 아키텍처 패턴입니다.

  • 이를 통해 여러 JavaScript 애플리케이션 (또는 마이크로 프론트엔드) 간에
    코드와 리소스를 공유할 수 있습니다.

  • 여러 작은 JavaScript 애플리케이션이 모여 하나의 커다란 애플리케이션을 이루는
    Micro Frontends 를 달성할 수 있는 구체적인 실행 기술 중 하나입니다.

"코드와 리소스를 공유"

function add(a: number, b: number): number {
  return a + b;
}

const PI: number = 3.14;

console.log(add(2, 3)); // 5
console.log(PI); // 3.14
// main.ts

import('./math').then(({
  add,
  PI,
}) => {
  console.log(add(2, 3)); // 5
  console.log(PI); // 3.14
});
// math.ts

export function add(
  a: number,
  b: number,
): number {
  return a + b;
}

export const PI: number = 3.14;

Module 을 같은 서버에 둡니다.

"코드와 리소스를 공유"

"코드와 리소스를 공유"

Module?

  • Module 은 코드의 재사용성과 유지보수를 향상시키기 위해 코드를 독립된 단위로 분리하고 관리할 수 있도록 하는 구조입니다.

  • 모듈은 변수, 함수, 클래스 등을 외부로 내보내거나(export) 다른 파일에서 가져와(import) 사용할 수 있습니다.

  • 실제로 자바스크립트 런타임에서 모듈 시스템을 활용해서 코드를 실행하는 방식은 여러가지가 있습니다.

  • 대부분 번들러들이 각자의 방식으로 import/export 를 처리합니다.

  • 최신 번들러들은 리소스도 하나의 모듈로 취급합니다.

Module Federation?

 

// main.ts

import { add, PI } from "./math";

console.log(add(2, 3)); // 출력: 5
console.log(PI); // 출력: 3.14
// math.ts

export function add(
  a: number,
  b: number,
): number {
  return a + b;
}

export const PI: number = 3.14;

Module 을 다른 서버에 둡니다.

Module Federation?

 

Module 을 다른 서버에 둡니다.

Module Federation ?

  • 모듈을 다른 서버에 두려면,
  • 어떤 서버에서 가져올지 알아야 하고
  • 어떤 모듈을 가져올지 알아야 하고,
  • 가져올때 비동기적인 처리가 되어야 하고,
  • 가져올 모듈이 사용하는 디펜던시 중에 나도 가지고 있으면 중복으로 쓰지 않게 해야하고
  • 등등이 필요해요... (더 많이)

Module Federation ?

  • 모듈을 다른 서버에 두려면,
  • 어떤 서버에서 가져올지 알아야 하고
  • 어떤 모듈을 가져올지 알아야 하고,
  • 가져올때 비동기적인 처리가 되어야 하고,
  • 가져올 모듈이 사용하는 디펜던시 중에 나도 가지고 있으면 중복으로 쓰지 않게 해야하고
  • 등등이 필요해요... (더 많이)
  • 실제로 모듈을 가져와서 사용하는 과정은 런타임에 처리됩니다.
  • 번들러가 빌드 타임에 이런 처리를 쉽게 해줍니다.
  • 번들러 플러그인들은 이러한 모듈 페더레이션 런타임을 사용하게 도와주는 역할입니다.
  • 우린 이렇게 추상화된 인터페이스를 가진 번들러 플러그인을 통해 모듈 페더레이션 기술을 쉽게 활용 가능합니다.

Terms

Provider / Producer / Remote

"exposes 설정을 가진 모듈 페더레이션 빌드 플러그인을 통해 다른 JavaScript 애플리케이션이 사용할 수 있도록 다른 모듈을 노출하는 애플리케이션 ” 을 모듈 페더레이션에서 Provider (Producer) 라고 합니다. Producer 는 Consumer 역할을 할 수도 있습니다.

 

Consumer / Host

"remotes 설정이 있는 모듈 페더레이션 빌드 플러그인을 통해 다른 Producers 들의 모듈을 사용하는 애플리케이션"을 Consumer 라고 합니다. Consumer 는 Producer 역할을 할 수도 있습니다.

 

Module Federation can help you

  • 코드 중복 감소

  • 코드 유지보수성 향상

  • 애플리케이션의 전체 크기 감소

  • 애플리케이션의 성능 향상

Module Federation can help you

  • 코드 중복 감소
    • 함께 생각해보아요
    • 보통 여기저기서 사용되는 코드는 패키지로 만들어서 사용해요.
    • 온전히 하나의 애플리케이션에서 SSOT를 잘 지켜서 만든 코드와 비교
    • 여러 애플리케이션이 패키지로 코드 중복을 감소 시키는 것과 비교
    • 모듈 페더레이션을 사용하고 공유 모듈을 적절하게 정의했을 때와 비교
  • 코드 유지보수성 향상

  • 애플리케이션의 전체 크기 감소

  • 애플리케이션의 성능 향상

Module Federation can help you

  • 코드 중복 감소

  • 코드 유지보수성 향상

    • 프로바이더와 컨슈머가 연결되는 Edge에서만 주의하면, 관심사를 분리해낼 수 있어요.

    • 마이크로서비스 A의 배포가 마이크로서비스 B의 동작에 영향이 가지 않아야 해요.

    • 휴먼이 유지보수 가능한 범위를 줄여요.

  • 애플리케이션의 전체 크기 감소

  • 애플리케이션의 성능 향상

Module Federation can help you

  • 코드 중복 감소

  • 코드 유지보수성 향상

  • 애플리케이션의 전체 크기 감소

    • 모놀리식 < 여러 마이크로 애플리케이션의 합

    • 단일 애플리케이션의 크기 감소

  • 애플리케이션의 성능 향상

Module Federation can help you

  • 코드 중복 감소

  • 코드 유지보수성 향상

  • 애플리케이션의 전체 크기 감소

  • 애플리케이션의 성능 향상

    • ​모듈을 적절히 나누고, dynamic import 를 하고, 적절히 Suspense 를 처리하고...

    • 원래 해야할 일을 하게 해주는 역할

Module Federation 2.0?

 모듈 페더레이션 2.0 은 모듈 내보내기, 로드, 종속성 공유의 핵심 기능뿐만 아니라 동적 유형 힌트, 매니페스트, 페더레이션 런타임 및 런타임 플러그인 시스템추가로 제공한다는 점에서 Webpack5 에 내장된 모듈 페더레이션과 다릅니다.

 이러한 기능 덕분에 모듈 페더레이션은 대규모 웹 애플리케이션에서 마이크로 프론트엔드 아키텍처로 사용하기에 더욱 적합합니다.

Module Federation?

  • 핵심 기능
    • 모듈 내보내기
    • 모듈 로드
    • 종속성 공유 (Dependency reuse)

Module Federation 2.0?

  • 핵심 기능
    • 모듈 내보내기
    • 모듈 로드
    • 종속성 공유 (Dependency reuse)
  • 추가 기능
    • 동적 유형 힌트
    • 매니페스트
    • 페더레이션 런타임
    • 런타임 플러그인 시스템

Features

  • ⚡ Code sharing、Dependency reuse

  • 📝 Manifest

  • 🎨 Module Federation Runtime

  • 🧩 Runtime Plugins System

  • 🚀 Dynamic type prompt

  • 🛠️ Chrome Devtool (manifest 만)

  • 🦀 Rspack and Webpack Support

Module Federation Runtime

런타임에 공유 종속성을 등록하고 원격 모듈을 동적으로 등록 및 로드하는 기능을 지원합니다.

Module Federation Runtime

빌드 동작에서 사용할 때 이점

이전의 모듈 페더레이션에서는 모듈 내보내기와 소비 모두 순전히 빌드 동작이었으며 모든 모듈 로딩 프로세스가 빌드 도구에 의해 캡슐화되었습니다. Systemjs나 esmodule과 같은 모듈 로더와 비교하면 다음과 같은 두 가지 이점이 있습니다.

  • 기존 프로젝트에서 모듈을 내보내는 비용이 매우 낮습니다. 과도한 추가 종속 요소와 빌드 구성을 설치할 필요 없이 모듈 이름과 내보낸 모듈의 경로만 선언하면 모듈 내보내기가 완료됩니다.
  • 원격 모듈을 소비하려면 원격 모듈의 이름과 주소만 선언하면 되며, 가져오기를 통해 NPM 종속 요소처럼 사용할 수 있습니다.

Module Federation Runtime

런타임이 필요한 이유

그러나 이 모델은 프로젝트의 유연성과 빌드 플러그인의 유지 관리 비용에 다음과 같은 영향을 줍니다:

  • Webpack, Rspack, Vite 등 다양한 구축 도구는 모두 Module Federation의 구축 도구와 런타임을 별도로 구현해야 하며 이는 유지 비용과 기능 일관성에 영향을 미칩니다.
  • Webpack 4와 같이 모듈 페더레이션을 지원하지 않는 빌드 플러그인에서는 원격 모듈을 사용할 수 없습니다.
  • 유연성이 부족하여 모듈을 동적으로 추가하고 모듈 동작을 변경하며 프레임워크 기능을 더 추가할 수 없습니다.

따라서 새 버전의 모듈 페더레이션 설계에서는 런타임이 분리되어 여러 빌드 도구가 런타임을 기반으로 모듈 내보내기 빌드, 공유 모듈 정보 수집, 원격 모듈 참조 처리 등을 구현합니다. 공유 종속성 재사용 및 원격 모듈 로딩과 같은 기타 특정 동작은 모두 Runtime에 내장되어 있습니다.

Differences Between Build Plugins and Runtime

 페더레이션 런타임은 새 버전의 모듈 페더레이션의 주요 기능 중 하나입니다. 런타임에 공유 종속성 등록, 원격 모듈의 동적 등록 및 로드를 지원하며 플러그인을 통해 런타임에 모듈 페더레이션의 기능을 확장할 수 있습니다. 빌드 플러그인은 런타임의 기본 구현을 기반으로 합니다.

Federation Runtime 방식 Build Plugin 방식
빌드 플러그인과 독립적으로 사용할 수 있으며, 웹팩4와 같은 프로젝트에서 모듈 로딩을 위해 순수 런타임을 직접 사용할 수 있습니다. 빌드 플러그인에는 Webpack5, Rspack, Vite 이상이 필요합니다.
모듈의 동적 등록 지원합니다. 모듈의 동적 등록을 지원하지 않습니다.
import 구문을 사용한 모듈 로딩을 지원하지 않습니다. import 구문과 동시에 모듈 로딩 지원
모듈 로딩을 위한 loadRemote 지원 모듈 로딩을 위한 loadRemote 지원
shared 는 특정 버전 및 인스턴스 정보를 제공해야 합니다. shared 에는 특정 버전이나 인스턴스 정보 없이 구성 규칙만 필요합니다.
shared 종속성은 외부에서만 사용할 수 있으며, 외부 shared 종속성을 사용할 수 없습니다. shared 종속성은 특정 규칙에 따라 양방향으로 공유할 수 있습니다.
런타임의 플러그인 메커니즘을 통해 로딩 프로세스에 영향을 줄 수 있습니다. 런타임 플러그인 설정을 통해 로딩 프로세스에 영향을 줄 수 있습니다.
퓨어 런타임은 원격 유형 힌트를 지원하지 않습니다. 원격 유형 힌트 지원
import { init } from "@module-federation/enhanced/runtime";
import React from "react";
import ReactDOM from "react-dom";

init({
  name: "host",
  remotes: [],
  shared: {
    react: {
      version: "18.3.1",
      lib: () => React,
      shareConfig: {
        singleton: true,
        requiredVersion: "^18.3.1",
      },
    },
    "react-dom": {
      version: "18.3.1",
      lib: () => ReactDOM,
      shareConfig: {
        singleton: true,
        requiredVersion: "^18.3.1",
      },
    },
  },
});

import("./bootstrap");
import { registerRemotes } from "@module-federation/enhanced/runtime";
import App from "./App";

/**
 * register remotes
 */
registerRemotes([
  {
    name: "hashtag_flea_market",
    entry: "https://hashtag-flea-market.alpha.karrotwebview.com/remoteEntry.js",
    // entry: "http://localhost:4001/remoteEntry.js",
    type: "module",
  },
  {
    name: "hashtag_community_agora",
    entry:
      "https://hashtag-community-agora.alpha.karrotwebview.com/remoteEntry.js",
    // entry: "http://localhost:4002/remoteEntry.js",
    type: "var",
  },
]);

...
import { loadRemote } from "@module-federation/enhanced/runtime";
import { type ElementType, useEffect, useState } from "react";

export function useDynamicImport({ module, scope }: { module: string; scope: string; }) {
  const [component, setComponent] = useState<ElementType | null>(null);

  useEffect(() => {
    if (!module || !scope) return;

    const loadComponent = async () => {
      try {
        const { default: Component } = (await loadRemote(
          `${scope}/${module}`,
        )) as {
          default: ElementType;
        };
        setComponent(() => Component);
      } catch (error) {
        console.error(`Error loading remote module ${scope}/${module}:`, error);
      }
    };

    loadComponent();
  }, [module, scope]);

  return component;
}

Runtime Plugins System

 모듈 페더레이션은 대부분의 기능을 구현하고 사용자가 기능을 확장할 수 있는 경량 런타임 플러그인 시스템을 제공합니다. 개발자가 개발한 플러그인은 모듈 페더레이션의 기본 동작을 수정하고 다음과 같은 다양한 추가 기능을 추가할 수 있습니다.

  • 컨텍스트 정보 가져오기

  • 수명 주기 후크 등록하기

  • 모듈 페더레이션 구성 수정하기

Dynamic type prompt

npm 패키지와 마찬가지로 모듈 페더레이션 제품도 타입을 생성하고 유형 핫 리로딩을 지원하지만, 제품이 원격 cdn에서 호스팅됩니다.

@module-federation/enhanced 는 기본적으로 타입 프롬프트 기능을 활성화합니다.

 

Generate type

@module-federation/enhanced 에서 제공하는 빌드 플러그인을 사용하여 빌드하면 자동으로 유형 파일이 생성됩니다.

Consume type

  • Build Plugin 방식

  • Federation Runtime 방식

Generate type

@module-federation/enhanced 에서 제공하는 빌드 플러그인을 사용하여 빌드하면 자동으로 유형 파일이 생성됩니다.

  • /@mf-types.d.ts

  • /@mf-types.zip

Consume type

  • Build Plugin 방식

  • Federation Runtime 방식

// tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "*": ["./@mf-types/*"]
    }
  },
}
// tsconfig.json
{
  "include": ["./@mf-types/*"]
}

"Component" Module Federation

in Hashtag Webview

Check Ponit

  • rsbuild 와 vite 로 만든 모듈을 혼용해서 consume 가능한지

  • 모듈의 타입은 어떻게 사용할 수 있는지

  • host 와 remote 가 같은 디펜던시를 사용할 때 함께 사용하려면

  • 모듈은 언제 가져오는가

  • 런타임에 remote 를 설정하고, 가져오는 방식과 번들러에 통합하는 방식을 동시에 사용할 수 있는가

  • 새로 배포한 모듈은 언제 업데이트 되는가

Scenario

  • 실제 서비스되는 해시태그 웹뷰를 만드는 T

  • 중고 거래팀에서 제공하는 해시태그 피드 아이템 컴포넌트를 만드는 F

    • vite 를 사용해서 제작

  • 커뮤니티실에서 제공하는 해시태그 피드 아이템 컴포넌트를 만드는 R

    • rsbuild 를 사용해서 제작

  • 기본적으로 provider 는 build plugin 방식을 사용한다.

    • official을 사용한다.

      • import { federation } from "@module-federation/vite";

      • import { ModuleFederationPlugin }
        from "@module-federation/enhanced/rspack";

  • consumer는 build plugin 방식을 사용하지만,
    @module-federation/enhanced/runtime 을 사용해서
    동적으로 consume 할 수 있는지 검토한다.

    • shared module 때문

  • ​consumer 는 provider 의 역할을 하지 않는다.

    • ​함께 생각해보아요

    • 제 생각은 ?

Provider 1

(hashtag-flea-market)

Provider 1
(hashtag-flea-market)

  • Provider 1 만 돌아가는 배포 서버가 있음
  • 컨슈머에게 코드를 전달하기 위해 어떤 코드를 전달할지가 설정되어 있음
  • 전달할 코드가 의존하는 디펜던시(npm)를 공유 모듈로 설정해서 미리 분리되어야 함
pnpm add @module-federation/vite -D
const mfConfig = {
  name: "hashtag_flea_market",
  filename: "remoteEntry.js",
  manifest: true,
  exposes: {
    "./FeedCardFlea": "./src/components/FeedCardFlea.tsx",
  },
  shared: {
    react: {
      singleton: true,
      requiredVersion: "18.3.1",
    },
    "react-dom": {
      singleton: true,
      requiredVersion: "18.3.1",
    },
    "comma-number": {
      version: "2.1.0",
    },
    "react-lazy-load-image-component": {
      version: "1.6.2",
    },
    "@seed-design/design-token": {
      version: "1.0.3",
    },
    // ERR_REQUIRE_ESM
    // "@daangn/react-monochrome-icon": {
    //   version: "0.0.11",
    // },
    // ERR_PACKAGE_PATH_NOT_EXPORTED (patch)
    "react-intersection-observer": {
      version: "9.13.1",
    },
    // ERR_PACKAGE_PATH_NOT_EXPORTED (patch)
    "@daangn/sprout-components-layout": {
      version: "0.0.0-main-20240404063637",
    },
    // ERR_PACKAGE_PATH_NOT_EXPORTED (patch)
    "@daangn/sprout-components-text": {
      version: "0.0.0-main-20240405062222",
    },
  },
};
import { federation } from "@module-federation/vite";
import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import checker from "vite-plugin-checker";
import topLevelAwait from "vite-plugin-top-level-await";

export default defineConfig({
  build: {
    modulePreload: false,
    target: "esnext",
    minify: false,
    cssCodeSplit: false,
  },
  base: process.env.BASE_URL ?? "http://localhost:4001",
  server: {
    port: 4001,
  },
  preview: {
    port: 4001,
  },
  plugins: [
    process.env.STORYBOOK !== "true" && federation({ ...mfConfig }),
    react(),
    vanillaExtractPlugin({
      unstable_mode: "transform",
    }),
    checker({
      typescript: true,
    }),
    topLevelAwait(),
  ],
});
  • normalize.css 은 모듈에서 제공하지 않는다

  • vite 플러그인은 아직 타입 생성 기능을 지원하지 않는다.

    • 풀어낼 방법이 필요함

      • type 만 제공하는 패키지를 만들거나

      • type 을 다른 플러그인 처럼 제공하는 방식

  • ERR_PACKAGE_PATH_NOT_EXPORTED 에 대한 대응이 필요하다.

    • 일단 patch

    • 내부 패키지는 지원하는 방향 검토

  • ERR_REQUIRE_ESM 에 대한 대응이 필요하다.

  • vanillaExtractPlugin 의 transform 모드를 설정한다.

  • base 설정이 필요하고, 빌드 타임에 넣어줘야 한다.

    • ​mf-manifest.json 안에 값을 들어있다.

  • singleton 을 기억하자.

Provider 2

(hashtag-community-agora)

Provider 2
(hashtag-community-agora)

  • Provider 2 만 돌아가는 배포 서버가 있음
  • 컨슈머에게 코드를 전달하기 위해 어떤 코드를 전달할지가 설정되어 있음
  • 전달할 코드가 의존하는 디펜던시(npm)를 공유 모듈로 설정해서 미리 분리되어야 함
pnpm add @module-federation/enhanced -D
const mfConfig = {
  name: "hashtag_community_agora",
  filename: "remoteEntry.js",
  exposes: {
    "./FeedCardAgora": "./src/components/FeedCardAgora.tsx",
  },
  shared: {
    react: {
      singleton: true,
      requiredVersion: "18.3.1",
    },
    "react-dom": {
      singleton: true,
      requiredVersion: "18.3.1",
    },
    "@daangn/react-monochrome-icon": {
      version: "0.0.11",
    },
    "@daangn/sprout-components-layout": {
      version: "0.0.0-main-20240404063637",
    },
    "@daangn/sprout-components-text": {
      version: "0.0.0-main-20240405062222",
    },
    "@seed-design/design-token": {
      version: "1.0.3",
    },
    "comma-number": {
      version: "2.1.0",
    },
    "react-intersection-observer": {
      version: "9.13.1",
    },
    "react-lazy-load-image-component": {
      version: "1.6.2",
    },
  },
};
import { ModuleFederationPlugin } from "@module-federation/enhanced/rspack";
import { defineConfig } from "@rsbuild/core";
import { pluginReact } from "@rsbuild/plugin-react";
import { VanillaExtractPlugin } from "@vanilla-extract/webpack-plugin";

export default defineConfig({
  html: {
    template: "./index.html",
  },
  source: {
    entry: {
      index: "./src/entry.tsx",
    },
  },
  dev: {
    assetPrefix: "http://localhost:4002",
  },
  server: {
    port: 4002,
  },
  plugins: [
    pluginReact({
      reactRefreshOptions: {
        exclude: [/node_modules/, /\.css\.ts$/],
      },
    }),
  ],
  tools: {
    rspack: {
      plugins: [
        new VanillaExtractPlugin({
          externals: ["@seed-design"],
        }),
        process.env.STORYBOOK !== "true" &&
          new ModuleFederationPlugin({ ...mfConfig }),
      ],
    },
  },
  output: {
    overrideBrowserslist: ["chrome >= 64", "ios_saf >= 14"],
    assetPrefix: process.env.BASE_URL ?? "http://localhost:4002",
    filenameHash: true,
  },
});
  • normalize.css 은 모듈에서 제공하지 않는다

  • rspack 플러그인은 타입 생성 기능을 지원한다.

    • https://hashtag-community-agora.alpha.karrotwebview.com/@mf-types.zip

    • consumer 가 rsbuild 이면 너무 편하다.

  • @vanilla-extract/webpack-plugin 을 사용한다.

  • assetPrefix 설정이 필요하고, 빌드 타임에 넣어줘야 한다.

    • ​mf-manifest.json 안에 값을 들어있다.

  • ​설정 자체가 크게 다르지 않다.

Consumer

hastag-webview

Consumer

  • Consumer 만 돌아가는 배포 서버가 있음
  • 다른 서버에서 리액트 컴포넌트 코드를 전달받음
  • 해당 리액트 컴포넌트 코드가 사용하는 디펜던시를 내가 가지고 있다면, 내 것을 사용
  • 내가 가지고 있지 않다면, 리액트 컴포넌트 쪽 서버에 있는 디펜던시를 사용

Provider 1

Provider 2

pnpm add @module-federation/enhanced -D
const mfConfig = {
  name: "hashtag_webview",
  remotes: {
    hashtag_community_agora: process.env.HASHTAG_COMMUNITY_AGORA_URL
      ? `hashtag_community_agora@${process.env.HASHTAG_COMMUNITY_AGORA_URL}/mf-manifest.json`
      : "hashtag_community_agora@https://hashtag-community-agora.alpha.karrotwebview.com/mf-manifest.json",
  },
  shared: {
    react: {
      singleton: true,
      requiredVersion: "18.3.1",
    },
    "react-dom": {
      singleton: true,
      requiredVersion: "18.3.1",
    },
    "@daangn/react-monochrome-icon": {
      version: "0.0.11",
    },
    "@daangn/sprout-components-layout": {
      version: "0.0.0-main-20240404063637",
    },
    "@daangn/sprout-components-text": {
      version: "0.0.0-main-20240405062222",
    },
    "@seed-design/design-token": {
      version: "1.0.3",
    },
    "comma-number": {
      version: "2.1.0",
    },
    "react-intersection-observer": {
      version: "9.13.1",
    },
    "react-lazy-load-image-component": {
      version: "1.6.2",
    },
  },
};
import { ModuleFederationPlugin } from "@module-federation/enhanced/rspack";
import { defineConfig } from "@rsbuild/core";
import { pluginReact } from "@rsbuild/plugin-react";
import { VanillaExtractPlugin } from "@vanilla-extract/webpack-plugin";

export default defineConfig({
  html: {
    template: "./index.html",
  },
  server: {
    port: 3000,
  },
  source: {
    entry: {
      index: "./src/entry.tsx",
    },
    define: {
      "import.meta.env.VITE_DEV_AUTH_TOKEN": JSON.stringify(
        import.meta.env.VITE_DEV_AUTH_TOKEN,
      ),
      "import.meta.env.VITE_SENTRY_DSN": JSON.stringify(
        import.meta.env.VITE_SENTRY_DSN,
      ),
    },
  },
  plugins: [
    pluginReact({
      reactRefreshOptions: {
        exclude: [/node_modules/, /\.css\.ts$/],
      },
    }),
  ],
  tools: {
    rspack: {
      plugins: [
        new VanillaExtractPlugin({
          externals: ["@seed-design"],
        }),
        process.env.STORYBOOK !== "true" &&
          new ModuleFederationPlugin({
            ...mfConfig,
            runtimePlugins: ["./src/mfPlugins"],
          }),
      ],
    },
  },
  output: {
    overrideBrowserslist: ["chrome >= 64", "ios_saf >= 14"],
  },
});
{
  "compilerOptions": {
    "target": "ES2018",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "lib": ["ESNext", "DOM"],
    "esModuleInterop": true,
    "jsx": "preserve",
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "paths": {
      "*": ["./@mf-types/*"]
    }
  }
}
import { registerRemotes } from "@module-federation/enhanced/runtime";

/**
 * register remotes
 */
registerRemotes([
  {
    name: "hashtag_flea_market",
    entry:
      stage === "production"
        ? "https://hashtag-flea-market.karrotwebview.com/mf-manifest.json"
        : "https://hashtag-flea-market.alpha.karrotwebview.com/mf-manifest.json",
    type: "module",
  },
]);
import { useDynamicImport } from "../hooks/useDynamicImport";

const CardList: React.FC<CardListProps> = ({
  ...
}) => {
  const FeedCardFlea = useDynamicImport({
    module: "FeedCardFlea",
    scope: "hashtag_flea_market",
  });

  ...

  return (
    <>...</>
  );
};

export default CardList;
import { loadRemote } from "@module-federation/enhanced/runtime";
import * as Sentry from "@sentry/react";
import { type ElementType, useEffect, useState } from "react";

interface DynamicImportProps {
  module: string;
  scope: string;
}

export function useDynamicImport({ module, scope }: DynamicImportProps) {
  const [component, setComponent] = useState<ElementType | null>(null);

  useEffect(() => {
    if (!module || !scope) return;

    const loadComponent = async () => {
      try {
        const { default: Component } = (await loadRemote(
          `${scope}/${module}`,
        )) as {
          default: ElementType;
        };
        setComponent(() => Component);
      } catch (error) {
        Sentry.captureException(error);
      }
    };

    loadComponent();
  }, [module, scope]);

  return component;
}
import { ErrorBoundary } from "@sentry/react";
import { Suspense, lazy } from "react";

const FeedCardAgora = lazy(
  () => import("hashtag_community_agora/FeedCardAgora"),
);

const FeedCardAgoraContainer: React.FC<FeedCardAgoraContainerProps> = ({
  ...
}) => {
  ...

  return (
    <ErrorBoundary>
      <Suspense fallback={null}>
        <FeedCardAgora
          ...
        />
      </Suspense>
    </ErrorBoundary>
  );
};

export default FeedCardAgoraContainer;
  • rspack 플러그인은 실행을 하면, 타입을 자동으로 가져온다.

    • @mf-types 폴더가 생성된다.

  • lazy import를 하지 않으면, 앱이 실행될 때 모듈을 가져오려고 한다.

  • Suspense 와 ErrorBoundary 를 이용해서 적절한 UI를 표현해야 한다.

  • useDynamicImport 는 null 일때 Suspense 같은 처리가 되어야 한다.

  • 모듈 페더레이션 런타임을 모듈 페더레이션 빌드 플러그인과 함께 사용 가능하다.

Live Deploy

"Activity" Module Federation

in Hashtag Webview

Provider 3
(hashtag-my-follow)

  • Provider 3 만 돌아가는 배포 서버가 있음
  • 컨슈머에게 코드를 전달하기 위해 어떤 코드를 전달할지가 설정되어 있음
  • 전달할 코드가 의존하는 디펜던시(npm)를 공유 모듈로 설정해서 미리 분리되어야 함
import { defineConfig } from "@stackflow/config";
import { feedActivityLoader } from "../activities/FeedActivity.loader";
import { myFollowActivityLoader } from "../activities/MyFollowActivity.loader";

export const stackflowTheme = /iphone|ipad|ipod/i.test(
  window.navigator.userAgent.toLowerCase(),
)
  ? "cupertino"
  : "android";

export const config = defineConfig({
  activities: [
    {
      name: "FeedActivity",
      path: "/v1/feed",
      loader: feedActivityLoader,
    },
    {
      name: "MyFollowActivity",
      path: "/v1/my-follow",
      loader: myFollowActivityLoader,
    },
  ],
  transitionDuration: stackflowTheme === "cupertino" ? 270 : 350,
});
import { IconChevronLeftLine } from "@daangn/react-monochrome-icon";
import { vars } from "@seed-design/design-token";
import { basicUIPlugin } from "@stackflow/plugin-basic-ui";
import { historySyncPlugin } from "@stackflow/plugin-history-sync";
import { basicRendererPlugin } from "@stackflow/plugin-renderer-basic";
import { stackDepthChangePlugin } from "@stackflow/plugin-stack-depth-change";
import { stackflow } from "@stackflow/react/future";
import React from "react";

import FeedActivity from "../activities/FeedActivity";
import MyFollowActivity from "../activities/MyFollowActivity";
import { bridge } from "../bridge";
import { config, stackflowTheme } from "./stackflow.config";

export const { Stack } = stackflow({
  config,
  components: {
    FeedActivity,
    MyFollowActivity,
  },
  plugins: [
    basicRendererPlugin(),
    basicUIPlugin({
      appBar: {
        borderColor:
          stackflowTheme === "cupertino"
            ? vars.$semantic.color.divider3
            : vars.$semantic.color.divider2,
        closeButton: {
          onClick() {
            bridge.closeRouter({});
          },
          renderIcon: () => (
            <IconChevronLeftLine height="1.5rem" width="1.5rem" />
          ),
        },
        iconColor: vars.$semantic.color.inkText,
        textColor: vars.$semantic.color.inkText,
      },
      backgroundColor: vars.$semantic.color.paperDefault,
      theme: stackflowTheme,
    }),
    historySyncPlugin({
      config,
      fallbackActivity: () => "FeedActivity",
    }),
    stackDepthChangePlugin({
      onDepthChanged({ depth }) {
        bridge.styleCurrentRouter({
          router: {
            backSwipable: depth === 1,
            enableSafeAreaInsets: false,
            navbar: false,
            scrollable: false,
          },
        });
      },
      onInit({ depth }) {
        bridge.styleCurrentRouter({
          router: {
            backSwipable: depth === 1,
            enableSafeAreaInsets: false,
            navbar: false,
            scrollable: false,
          },
        });
      },
    }),
  ],
});
pnpm add @module-federation/enhanced -D
const mfConfig = {
  name: "hashtag_my_follow",
  filename: "remoteEntry.js",
  exposes: {
    "./MyFollowActivity": "./src/activities/MyFollowActivity.tsx",
    "./MyFollowActivity.loader": "./src/activities/MyFollowActivity.loader.ts",
  },
  shared: {
    react: {
      singleton: true,
      requiredVersion: "18.3.1",
    },
    "react-dom": {
      singleton: true,
      requiredVersion: "18.3.1",
    },
    "@stackflow/config": {
      requiredVersion: "1.2.0",
    },
    "@stackflow/core": {
      requiredVersion: "1.1.0",
    },
    "@stackflow/plugin-basic-ui": {
      requiredVersion: "1.10.0",
    },
    "@stackflow/plugin-history-sync": {
      requiredVersion: "1.7.0",
    },
    "@stackflow/plugin-renderer-basic": {
      requiredVersion: "1.1.13",
    },
    "@stackflow/plugin-stack-depth-change": {
      requiredVersion: "1.1.5",
    },
    "@stackflow/react": {
      requiredVersion: "1.4.0",
    },
    "@stackflow/react/future": {
      requiredVersion: "1.4.0",
    },
    "@daangn/react-monochrome-icon": {
      version: "0.0.11",
    },
    "@daangn/sprout-components-layout": {
      version: "0.0.0-main-20240404063637",
    },
    "@daangn/sprout-components-text": {
      version: "0.0.0-main-20240405062222",
    },
    "@seed-design/design-token": {
      version: "1.0.3",
    },
  },
};
import { ModuleFederationPlugin } from "@module-federation/enhanced/rspack";
import { defineConfig } from "@rsbuild/core";
import { pluginReact } from "@rsbuild/plugin-react";
import { VanillaExtractPlugin } from "@vanilla-extract/webpack-plugin";

export default defineConfig({
  html: {
    template: "./index.html",
  },
  server: {
    port: 3000,
  },
  source: {
    entry: {
      index: "./src/entry.tsx",
    },
    define: {
      "import.meta.env.VITE_DEV_AUTH_TOKEN": JSON.stringify(
        import.meta.env.VITE_DEV_AUTH_TOKEN,
      ),
      "import.meta.env.VITE_SENTRY_DSN": JSON.stringify(
        import.meta.env.VITE_SENTRY_DSN,
      ),
    },
  },
  plugins: [
    pluginReact({
      reactRefreshOptions: {
        exclude: [/node_modules/, /\.css\.ts$/],
      },
    }),
  ],
  tools: {
    rspack: {
      plugins: [
        new VanillaExtractPlugin({
          externals: ["@seed-design"],
        }),
        process.env.STORYBOOK !== "true" &&
          new ModuleFederationPlugin({ ...mfConfig }),
      ],
    },
  },
  dev: {
    assetPrefix: "http://localhost:3000",
  },
  output: {
    overrideBrowserslist: ["chrome >= 64", "ios_saf >= 14"],
    assetPrefix: process.env.BASE_URL ?? "http://localhost:3000",
    filenameHash: true,
  },
});

Consumer

hastag-webview

const mfConfig = {
  name: "hashtag_webview",
  remotes: {
    hashtag_community_agora: process.env.HASHTAG_COMMUNITY_AGORA_URL
      ? `hashtag_community_agora@${process.env.HASHTAG_COMMUNITY_AGORA_URL}/mf-manifest.json`
      : "hashtag_community_agora@https://hashtag-community-agora.alpha.karrotwebview.com/mf-manifest.json",
    hashtag_my_follow: process.env.HASHTAG_MY_FOLLOW_URL
      ? `hashtag_my_follow@${process.env.HASHTAG_MY_FOLLOW_URL}/mf-manifest.json`
      : "hashtag_my_follow@https://hashtag-my-follow.alpha.karrotwebview.com/mf-manifest.json",
  },
  shared: {
    ...
    "@stackflow/config": {
      requiredVersion: "1.2.0",
    },
    "@stackflow/core": {
      requiredVersion: "1.1.0",
    },
    "@stackflow/plugin-basic-ui": {
      requiredVersion: "1.10.0",
    },
    "@stackflow/plugin-history-sync": {
      requiredVersion: "1.7.0",
    },
    "@stackflow/plugin-renderer-basic": {
      requiredVersion: "1.1.13",
    },
    "@stackflow/plugin-stack-depth-change": {
      requiredVersion: "1.1.5",
    },
    "@stackflow/react": {
      requiredVersion: "1.4.0",
    },
    "@stackflow/react/future": {
      requiredVersion: "1.4.0",
    },
  },
};
import { type ActivityLoaderArgs, defineConfig } from "@stackflow/config";
import { feedActivityLoader } from "../activities/FeedActivity.loader";

export const stackflowTheme = /iphone|ipad|ipod/i.test(
  window.navigator.userAgent.toLowerCase(),
)
  ? "cupertino"
  : "android";

export const config = defineConfig({
  activities: [
    {
      name: "FeedActivity",
      path: "/v1/feed",
      loader: feedActivityLoader,
    },
    {
      name: "MyFollowActivity",
      path: "/v1/my-follow",
      loader: ({ params }: ActivityLoaderArgs<"MyFollowActivity">) =>
        import("hashtag_my_follow/MyFollowActivity.loader").then((module) =>
          module.myFollowActivityLoader({ params, config }),
        ),
    },
  ],
  transitionDuration: stackflowTheme === "cupertino" ? 270 : 350,
});
import { IconChevronLeftLine } from "@daangn/react-monochrome-icon";
import { vars } from "@seed-design/design-token";
import { basicUIPlugin } from "@stackflow/plugin-basic-ui";
import { historySyncPlugin } from "@stackflow/plugin-history-sync";
import { basicRendererPlugin } from "@stackflow/plugin-renderer-basic";
import { stackDepthChangePlugin } from "@stackflow/plugin-stack-depth-change";
import { stackflow } from "@stackflow/react/future";
import React from "react";

import FeedActivity from "../activities/FeedActivity";
import { bridge } from "../bridge";
import { config, stackflowTheme } from "./stackflow.config";

export const { Stack } = stackflow({
  config,
  components: {
    FeedActivity,
    MyFollowActivity: React.lazy(
      () => import("hashtag_my_follow/MyFollowActivity"),
    ),
  },
  ...
});
  • Stackflow 만세

  • Activity Component 와 loader 함수를 동적으로 사용한다.

    • 다른 액티비티를 보는 동안 코드를 가져오지 않도록 한다.

    • 문제가 있을 경우, 에러 페이지가 보인다.

  • 공유 모듈의 singleton 처리가 적절하게 필요하다.

Limitations

  • 타입 전달 문제

    • ​vite 에서 진행할 가능성이 높아요.

  • ​vite 가 host 인 경우 top level await 문제

    • ​공유를 버리거나, vite 가 고치거나, rsbuild 를 쓰거나,

  • shared 모듈을 좀더 올바르게 쓰기

    •                                                                                    ​

  • 전역 공유 패키지의 규정

    • ​shell 이 중요

  • manifest 에러

    • ​일해라

  • ​인터페이스의 변경 배포

    • ​여러가지 전략이 가능

Plans | Ideas

  • 당근 모듈 페더레이션 허브 구축
  • 상상 말고, 실제적인 설계 제안 및 구축
    • 지역사업실과 구상 중
  • ​슬랙 채널