By kimcoder
2022.07.01

Micro Frontend, 그리고 Webpack Module Federation

이 글에서는 마이크로 프론트엔드에 관한 고찰과 웹팩 5의 Module Federation 일부 기능에 대해 알아보고, 적당한 예시 코드 작성을 통해 얻은 생각을 나눠 볼 생각이다.

내가 마이크로 프론트엔드 아키텍처에 관심을 가지고 고찰을 하게 된 계기는 현재 개발/운영 중인 배민상회라는 서비스의 환경과 관련이 있다. ( 배민상회는 요식업 사장님들을 대상으로 필요한 상품들을 판매하는 이커머스 서비스 )

여느 쇼핑몰들과 마찬가지로 많은 페이지가 존재하고, 지난 수년 간 많은 개발자들의 열정과 노력으로 생산된 코드의 양도 풍족한 상황이다. Webpack Bundle Analyzer 를 통해 분석한 결과를 잠깐 확인을 해보면,

https://www.kimcoder.io/assets/images/baeminmart-158.png

쇼핑몰에서 제공하는 약 120개의 페이지는 14M에 육박하는 번들 코드가 158개의 청크로 분리되어 있다.

( 서버사이드 코드와 프로젝트 외부에 있는 디자인 시스템과 같은 코드들까지 고려하면 조금 더 많을 것 같다. ) 과거 개발 초기 단계에서 선택된 모노리스한 Single-Page-Application 방식의 개발은 현재까지 고수되어 왔고,

비대해진 애플리케이션의 빌드 퍼포먼스, 코드 탐사, 의존 관리 등의 비용은 점차 증가하고 있다.

개발 편의성과 생산성을 위해 아키텍처 개선이 필요한 시간이 된 것이라고 생각이 들었다.

여러가지 방식을 통해 개선이 가능하겠지만,

나는 마이크로 프론트엔드에 대한 고찰게임 체인저라고 불리우는 웹팩 5의 Module Federation의 일부 기능에 대해 알아 볼 예정이고, 예시 코드 작성 경험을 통해 리팩터링의 밑거름을 만들 생각이다.

Micro Frontend?

마이크로 프론트엔드의 개념은 수많은 개발자, 그리고 조직들이 모노리스 아키텍처에서 마이크로 서비스 아키텍처로 전환하면서 얻은 이점들을 웹 애플리케이션 개발 환경과 생태계에도 적용하고자 했던 접근으로부터 나온 것이다.

마이크로 프론트엔드에 대한 설명과 세부적인 구현 예는 마틴파울러 글에도 소개된 Cam Jackson의 글에 잘 설명이 되어있다. 이 글에서는 마이크로 프론트엔드의 구현으로 여러 가지 방식의 예를 들었지만 결과적으로 채택한 방식은 컨테이너 애플리케이션와 마이크로 애플리케이션을 분리하고 런타임에서 컨테이너 애플리케이션이 마이크로 애플리케이션들을 통합하는 방식이었다. 애플리케이션들은 모두 독립된 환경에서 개발할 수 있으며, 필요에 의해 공통 UI 구성 요소들을 사용할 수도 있다. 컨테이너 애플리케이션 통합하는 역할을 맡아 라우팅 경로에 따라 각 마이크로 애플리케이션을 제공할 수 있도록 처리된다.

https://www.kimcoder.io/assets/images/micro-frontend-1.png

마이크로 프론트엔드의 주요한 장단점은 아래와 같이 요약할 수 있다.

장점

  • 마이크로 애플리케이션들의 기술 스택, 코드 베이스, 외부 패키지 버전 관리는 개별적으로 처리할 수 있다.
  • 마이크로 애플리케이션들의 개발, 테스트, 배포는 독립적이다.
    • 코드베이스는 모두 각 애플리케이션의 명세에 맞게 단순하게 설계 가능.
  • 사용자에게는 단일 애플리케이션처럼 동작한다.
    • 각 기능에서 개별로 구현된 UI/UX는 동일한 룩앤필과 경험을 제공.

단점

  • 사용자로부터의 외부 패키지 번들 중복 요청.
    • ex) react와 같은 패키지가 애플리케이션 A와 B의 번들링 코드에 중복으로 들어갈 수 있음.
  • 개발 환경.
    • 독립적인 개발 환경을 보장받을 수 있지만, 통합된 애플리케이션의 환경과 차이가 있으므로 발생할 수 있는 문제점.
  • 운영 복잡도
    • 기술 스택, 개발 관습의 파편화 가능성.
    • 코드 저장소, 배포 파이프라인, 테스트 및 릴리즈 프로세스 증가 가능성.

아키텍처에는 항상 다양한 관점과 상황에서의 해법이 여럿 있고 마이크로 프론트엔드에 대해 생각하는 것도 마찬가지일 것이다. 나는 Cam Jackson 글에 소개된 마이크로 프론트엔드에 대한 해법에 전적으로 동의한다.

애플리케이션들은 런타임 환경에서 통합이 되어야 하고, 각자의 개발 생명 주기를 가질 수 있어야 한다. 이는 CI/CD도 모두 독립적으로 구성이 될 수 있다는 뜻이며, 일부 변경된 기능을 위해 애플리케이션 전체가 다시 빌드, 배포가 되지 않아도 된다는 것이다.

이제, 마이크로 프론트엔드 구성을 손쉽게 해줄 웹팩 5의 Module Federation에 대해서 알아보도록 하자.

Module Federation?

웹팩 5 버전의 출시와 함께 나온 기능 중 하나인 모듈 페더레이션은 특정 애플리케이션(모듈)에서 동적으로 다른 빌드의 모듈 코드를 불러와 실행을 할 수 있게 해준다.

즉, 여러 개의 개별 빌드 코드가 하나의 어플리케이션 내에서 구동되는 것이다. 또한, 개별 빌드는 서로 의존성이 없어 개별적으로 빌드, 배포가 가능하다.

모듈 페더레이션의 몇 가지 용어와 특징을 ( 고수준 관점으로 )요약해 보겠다. ( 상세한 내용은 공식 문서에서 확인할 수 있다. )

  • 각각의 빌드는 컨테이너 역할을 한다.
  • 각각의 빌드는 호스트와 리모트 모두 될 수 있다.
    • host : 호스트는 페이지 로드 시 가장 먼저 초기화되는 빌드.
    • remote : 리모트는 호스트에 의해 소비되는 빌드.
    • 빌드 된 모듈은 런타임에서 모두 양방향(Bidirectional)으로 소비될 수 있다.
  • 각 컨테이너들에서 공통적인 외부 패키지의 디펜던시는 설정으로 청크의 중복을 막을 수 있다.
    • 특정 버전을 명시한다면, 해당 버전으로 사용할 수 있다.
    • ex) react, react-dom, etc..
  • 위의 설정들은 웹팩의 ModuleFederationPlugin을 통해 할 수 있다.
  • 환경에 의존하지 않는다.
    • ex) browser, node.js 등에서 사용 가능.

유즈케이스

  • 공용 컴포넌트 라이브러리의 컨테이너로 사용 여러 애플리케이션에서 사용하는 디자인 시스템과 같은 공용 컴포넌트 혹은 추상화된 코드들을 라이브러리 컨테이너로 만들어 공유하는 방법이다. 이는 애플리케이션과 공용 코드들의 독립적인 개발, 배포가 가능하다. 공용 컴포넌트의 업데이트가 일어날 경우, 애플리케이션들은 배포 없이 런타임에서 최신 버전의 컴포넌트 라이브러리를 자동으로 사용할 수 있다.

https://www.kimcoder.io/assets/images/federation-component-libarary-container.png


  • 페이지별 독립적 빌드 시 사용 단일 페이지 애플리케이션에서 각 페이지를 분리하여 독립적인 개발과 배포 프로세스를 가질 수 있다. 애플리케이션 쉘을 상위 레벨에 두고, 페이지 라우팅에 맞는 각 애플리케이션을 원격 모듈로 참조하는 방식이다. 애플리케이션 쉘은 일반적으로 사용되는 라이브러리를 공유 모듈로 정의할 수 있고, 라우팅 경로의 변경이 있어날 경우에 배포를 하게 된다. 각 페이지 애플리케이션은 다른 페이지와 상관없이 독립적인 배포가 가능하다.

https://www.kimcoder.io/assets/images/federation-page-seperation.png


리모트와 호스트 사이의 화살표는 모듈들이 런타임에서 모두 양방향(Bidirectional)으로 소비가 가능한 것을 보여준다. 이는 예제에서 조금 더 자세히 다루겠다.

위의 두 가지의 사례를 적절하게 조합하여 다음 예제를 구성해 보자.

예제

https://www.kimcoder.io/assets/images/federation-example.png

위와 같은 구조로 설계를 한다고 가정하여, 몇 가지 설정에 대해 구체적인 코드를 작성해 보자

디렉토리 구조

- packages
    - core
        - ...
        - webpack.config.js
        - package.json
    - order
        - core와 유사
    - shell
        - core와 유사
    - products
        - core와 유사
    - ...
- ...
- package.json // 패키지 스크립트 병렬 실행을 위한 스크립트
  • 각 모듈은 packages 디렉토리 하위에 위치한다.
  • 각 모듈은 webpack을 사용하며, 독립적으로 외부 패키지 의존성을 가진다.
  • 루트 디렉토리의 package.json은 각 모듈의 스크립트를 병렬로 실행할 수 있게 설정한다.

Shell Module

webpack.config.js

  • shell의 webpack 설정은 아래와 같은 내용을 따르도록 한다.
    • 호스트 애플리케이션으로써, 모든 리모트 애플리케이션 모듈 소비.
    • order와 같은 다른 애플리케이션들에서 페더레이트가 가능하도록 Shell App 을 리모트로 노출.
      • 만약, 페이지 전역에서 사용될 Context 등과 같은 코드가 있다면 노출.
    • 코어 라이브러리 컨테이너 모듈 소비.
    • 로컬에서 3000번 포트로 동작.
    • 공용 외부 패키지는 중복된 청크가 발생되지 않도록 선언.
plugins: [
    new ModuleFederationPlugin({
      name: 'shell',
      filename: 'shell.remoteEntry.js',   // shell의 리모트 엔트리
      remotes: {
                core: 'core@http://localhost:2000/core.remoteEntry.js',
                main: "main@http://localhost:3001/main.remoteEntry.js",
        products: "products@http://localhost:3002/products.remoteEntry.js",
        order: "order@http://localhost:3003/order.remoteEntry.js",
      },
      exposes: {
        './Shell': './src/Shell'
      },
      shared: [
        {
          react: {
            singleton: true,
            requiredVersion: deps.react,
          },
          'react-dom': {
            singleton: true,
            requiredVersion: deps['react-dom'],
          },
        },
      ],
    }),
]

Entry of webpack

  • 모듈 페더레이션 시, 일반적으로 비동기 경계를 사용하는 것이 추천되기 때문에 이 내용을 따른다.
    • 비동기 경계가 추천되는 이유는 성능 향상을 위해 청크의 초기화 코드를 분할하기 위함.
// index.js
import('bootstrap');

// bootstrap.js
import ShellApp from "./ShellApp";
import React from "react";
import { createRoot } from "react-dom/client";

const root = createRoot(document.getElementById("root"));

root.render(<ShellApp />);

React App

  • 코어 컴포넌트, 혹은 디자인 시스템 컴포넌트는 코어 컴포넌트 라이브러리 컨테이너 모듈에서 가져온다.
  • 페이지들은 개별 페이지 애플리케이션 모듈에서 가져온다.
const Tabs = React.lazy(() => import('core/Tabs'));

const MainPage = React.lazy(() => import('main/MainPage'));
const ProductsPage = React.lazy(() => import('products/ProductsPage'));
....

const ShellApp = () => {
    return (
      <Provider>
            <React.Suspense fallback={'Loading'}>
                <GNB>
                    <Tabs>
              <Link to="/">...</Link>
              <Link to="/products">Products</Link>
                            ....
            </Tabs>
                </GNB>
            </React.Suspense>
            <React.Suspense fallback={'Loading'}>
                <Routes>
          <Route path="/" element={<MainPage />} />
          <Route path="products/*" element={<ProductsPage />} />
                        ....
                </Routes>
            </React.Suspense>
        </Provider>
    )
};

export default ShellApp;

Core Component Library Module

webpack.config.js

  • Core Component Library의 webpack 설정은 아래와 같은 내용을 따르도록 한다.
    • 리모트 모듈로만 사용.
    • 로컬에서 2000번 포트로 동작.
    • 공용 외부 패키지는 중복된 청크가 발생되지 않도록 선언.
plugins: [
    new ModuleFederationPlugin({
      name: 'core',
      filename: 'core.remoteEntry.js',
      exposes: {
        './Button': './src/Button'
        './Tabs': './src/Tabs'
      },
      shared: {
        react: {
          singleton: true,
          requiredVersion: deps.react,
        },
        'react-dom': {
          singleton: true,
          requiredVersion: deps['react-dom'],
        },
      },
    }),
  ],

Components

  • 컴포넌트들은 일반적인 코어 컴포넌트 혹은 디자인 시스템의 설계 패턴으로 구현한다.

Page Application Module

webpack.config.js

  • page application webpack 설정은 아래와 같은 내용을 따르도록 한다.
    • 리모트 애플리케이션이지만, 로컬에서는 호스트가 될 수 있다.
    • 페이지를 리모트로 노출.
    • 코어 라이브러리 컨테이너 모듈 소비.
    • 로컬에서 3000번 이상의 포트에서 호스트로 동작.
    • 공용 외부 패키지는 중복된 청크가 발생되지 않도록 선언.
plugins: [
    new ModuleFederationPlugin({
      name: 'main',
      filename: 'main.remoteEntry.js',
      remotes: {
        shell: 'shell@http://localhost:3000/shell.remoteEntry.js',
        core: 'core@http://localhost:2000/core.remoteEntry.js',
      },
      exposes: {
        "./MainApplication": "./src/MainApplication",
      },
      shared: {
        react: {
          singleton: true,
          requiredVersion: deps.react,
        },
        'react-dom': {
          singleton: true,
          requiredVersion: deps['react-dom'],
        }
      },
    }),
    new HtmlWebpackPlugin({
      template: './public/index.html',
    }),
  ],

Entry of webpack

  • Shell 과 마찬가지로 엔트리는 비동기로 구현되도록 처리한다.
  • 로컬 개발 시, Shell을 소비하여 단일 애플리케이션처럼 동작할 수 있도록 한다.
// index.js
import('bootstrap');

// bootstrap.js
import ShellApp from "./ShellApp";
import React from "react";
import { createRoot } from "react-dom/client";

const root = createRoot(document.getElementById("root"));

root.render(<ShellApp />);

// app.js
import React from "react";

const Shell = React.lazy(() => import("shell/Shell"));

function App() {
  return (
    <React.Suspense fallback={"Loading Shell"}>
      <Shell />
    </React.Suspense>
  );
}

export default App;

Consumed React App

  • 코어 컴포넌트, 혹은 디자인 시스템 컴포넌트는 코어 컴포넌트 라이브러리 컨테이너 모듈에서 가져온다.
import React from "react";

const Button = React.lazy(() => import("core/Button"));

const MainApplication = () => {
  return (
    <div>
      <section>
        <h1>MAIN PAGE</h1>
      </section>
      <section>
        <React.Suspense fallback="fallback">
          <Button>Button of MAIN</Button>
        </React.Suspense>
      </section>
    </div>
  );
};

export default MainApplication;

위와 같이 구현 후 웹 페이지의 네트워크 로그는 아래와 같은 모습으로 나타난다.

https://www.kimcoder.io/assets/images/federation-example-network.png

공용 외부 패키지로 선언한 리액트는 하나의 청크로 구성되어 로드되고, 각 모듈들은 원격 엔트리를 통해 로드된다. 네트워크 로그만 보았을 때는, 단순히 코드 스플리팅이 된 것처럼 보이지만 가장 중요한 것은 각 모듈들이 독립적으로 개발, 운영, 배포가 되었다는 것이다.

단일 애플리케이션으로써의 통합은 런타임에서 일어나게 된다.

이 예제의 코드들은 모두 아래에서 확인이 가능하다.

마치며

이 장문의 글에서 소개한 Micro Frontend Architecture의 지향점과 Webpack 5의 Module Federation의 주요한 포인트는 아래 2가지라고 생각된다.

  • 각 모듈은 독립적으로 개발, 운영, 배포를 할 수 있다.
  • 런타임에서 여러 모듈들이 통합되고, 사용자에게는 모듈들이 단일 애플리케이션처럼 동작한다.

단일 모듈의 독립적이고 유연한 개발 프로세스를 가질 수 있고, 배포와 롤백도 보다 더 민첩하게 할 수 있다는 추가적인 장점이 있을 수 있다. 하지만 분명한 것은 좋은 방식의 설계에 대한 그만한 비용이 발생할 것이며 거버넌스 측면에서도 많은 고민과 협의, 그리고 합리적인 정책들이 필요하다는 것이다.

추가적으로 Angular, Vue, Server Side Rendering, React & Vue 등 이 링크에서 다양한 Module Federation의 예제들도 있으니 확인해 보면 좋을 것 같다.

참고