
최근 Webpack Module Federation을 적용하여 레거시 프로젝트를 점진적으로 리뉴얼 중에 있다. 처음 접해보는 개념이라 삽질도 많았지만 의미있는 경험이라 생각하여 이에 대해 공유하고자 한다.
올초부터 vue.js 기반의 레거시 프로젝트를 react로 migration하는 작업을 진행하게 되었는데 개발 일정의 현실이 그렇듯이 일정이 넉넉하지 않고 프로젝트 내 레거시의 양이 꽤 있었기 때문에 단번에 교체하기 힘들다고 판단하였고 팀원들과의 수차례 의논 끝에 프로젝트를 부분적/점진적으로 교체하는 것이 최선이라는 결론을 내리게 되었다.
Module Federation의 react-in-vue 샘플
여러 방법을 검토해본 끝에 webpack의 Module Federation을 활용하여 vue.js프로젝트에 react 모듈을 띄워 앱을 구동한 예제를 발견하였고, 추가 검토 끝에 해당 예제를 통해 구현하기로 최종 결정하였다.

(위 이미지는 Module Federation 기반 점진적 리팩토링을 간단히 나타낸 것)
Webpack Module Federation 공식 문서
그럼 Module Federation이란 무엇인가에 대해 간단히 알아보자. 공식 문서에서는 다음과 같이 설명한다.
Module Federation은 자바스크립트 애플리케이션의 분산화를 위한 아키텍처 패턴입니다. 이것은 여러 자바스크립트 애플리케이션 내에서의 코드, 리소스의 공유를 가능하게 합니다.
이것은 당신에게 다음과 같은 도움을 줄 수 있습니다.
1. 코드 중복 감소
2. 유지보수성 증가
3. 애플리케이션 사이즈 감소
4. 애플리케이션 성능 향상
Module Federation은 Webpack 5에서 제공되는 모듈 통합 기법으로 최근 아키텍처의 대세가 된 Micro Frontend 아키텍처를 구현할 수 있는 방법 중 하나로 알려져 있다. 애플리케이션의 각 기능을 모듈로 만든 뒤, 메인 모듈에서 로드하여 사용하는 방식이라 할 수 있다. 이렇게 전체적으로 분산된 형태를 가지게 되어 애플리케이션의 성능 향상, 배포시간 단축에도 도움이 될 수 있다.
이러한 분산 구조는 Monorepo의 특징점과도 같은데, 가장 큰 차이점이라면 packages 내에 모듈이 포함되어야 하는 Monorepo와는 달리 Module Federation의 경우 모듈의 위치, 실행환경에 구애받지 않는다는 점이다.
의존성, 빌드 환경 이슈에서 자유로움
아까 언급한 것처럼 Module Federation은 앱 로드 시점에서 통합을 하기 때문에 빌드 환경에 구애받지 않는다. Webpack5에서 공식적으로 지원하는 기능이고, 개발자는 각 모듈을 독립적으로 빌드한 뒤 제대로 로드하는 것만 신경쓰면 되기 때문에, 애플리케이션 별로 불필요한 추가 의존성이나 설정이 전혀 필요 없다.
모듈들이 동일한 브라우저 환경에 통합되기 때문에 모든 모듈이 storage, cookie 등에 동등하게 접근 가능하다. 이로 인해 보안, 유저 인증 등의 처리가 용이하다.
프레임워크 독립성
1번과 어느정도 공통되는 장점으로, 자바스크립트 번들을 로드하여 사용하기만 하면 되기 때문에 이론적으로 프레임워크나 라이브러리에 구애받지 않고 사용할 수 있다. 필자의 경우 vue.js기반 애플리케이션에서 react 모듈을 사용한 경우이며 다른 라이브러리를 사용한 예제도 많이 찾아볼 수  있다.
다음은 필자가 프로젝트에 적용한 내용을 기반으로 작성한 Module Federation의 전반적인 과정이다.
용어 정리
Host: 모듈을 import하여 사용하는 애플리케이션, 로컬 모듈이라고도 함
Remote: Host 기준, 사용될 모듈을 export하는 애플리케이션을 의미
모듈: 독립적으로 빌드된 뒤 ModuleFederationPlugin에서 export된 프로그램, 컴포넌트, 객체, 함수 등 어떤 것이든 가능하다.
const { ModuleFederationPlugin } = require('webpack').container;
new ModuleFederationPlugin({
  name: 'remote',
  filename: 'remoteEntry.js',
  exposes: {
    './RemotePage': './src/RemotePage',
  },
}),
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        remote: `remote@${getUrlByEnv()}/remoteEntry.js`,
      },
    }),
// name: remote의 ModuleFederationPlugin에서 설정한 name값
// remoteEntry.js url: remoteEntry.js 파일이 배포된 full 경로 입력
// @: 네이밍 규칙이므로 반드시 입력
[name]: [name]@[remoteEntry.js url]
이렇게 하면 Module을 사용할 준비는 전부 끝났다.
import React from 'react';
// RemotePage 모듈을 lazy load
const RemotePage = React.lazy(() => import('remote/RemotePage'));
const App = () => (
  <div>
    <h1>MFed react-react</h1>
    <RemotePage />
  </div>
);
export default App;
하지만 Vue 환경에서는 react 컴포넌트를 인식할 수 없기 때문에 처리 과정이 하나 더 들어가는데, SPA React에서 루트 컴포넌트를 렌더링할때 사용하는 createRoot, 컴포넌트를 렌더링하는 함수인 React.createElement를 사용하여 렌더링하였다.
다음 RemoteComponent 컴포넌트는 필자가 실제 적용한 컴포넌트를 단순화한 것이다. RemoteComponent에는 모듈 렌더함수, 모듈로 전달할 props를 인자로 전달하고 있다.
<template>
  // react module 렌더 컴포넌트 컴포넌트
  <RemoteComponent
    :loadRemoteModule="loadRemoteModule"
    :props="{}"
  />
</template>
<script setup lang="ts">
  // 모듈 로드 함수, 모듈을 lazy loading 하고 있다.
  const loadRemoteModule = async () => {
    const res = (await import('User/branch/charge/list')).default;
    return res;
  };
</script>
다음은 RemoteComponent의 구조이다. Vue의 lifecycle에 맞춰 렌더링을 수행하고 있다.
<template>
  <div ref="root" class="mfed-container"></div>
  <div :isLoading="isLoading">Loading....</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, onUpdated, watch, toRaw } from 'vue';
import { createRoot } from 'react-dom/client';
import ReactDOM from 'react-dom';
import React from 'react';
const props = defineProps({
  props: {
    type: Object,
    required: true,
  },
  loadRemoteModule: {
    type: Function,
    required: true,
  },
});
const reactRoot = ref(null); // react root
const root = ref(null);
const error = ref(); // error 저장
const remoteModule = ref(null); // entry의 react module str
const isLoading = ref(true);
const updateReactComponent = () => {
  if (!!error.value || !remoteModule.value || !root.value) return;
  
  // prod 환경에서 실행시 에러발생하여 ReactDOM.render로 사용
  if (!reactRoot.value) {
    reactRoot.value = createRoot(root.value); // bundle의 element를 넣음
  }
  // root에서 컴포넌트 초기화 진행
  reactRoot.value.render(
    React.createElement(remoteModule.value, {
      ...props.props,
    }),
  );
};
// 컴포넌트 마운트 시
onMounted(() => {
  if (remoteModule.value) return;
  props
    .loadRemoteModule()
    .then((b) => {
      remoteModule.value = b;
      updateReactComponent();
    })
    .catch((e) => {
      console.error('Remote Module Load Error ', e);
      error.value = e;
    })
    .finally(() => {
      isLoading.value = false;
    });
});
// props 변경시
watch(() => props.props, updateReactComponent, { deep: true, immediate: true });
// Unmount 시
onBeforeUnmount(() => {
  reactRoot.value && reactRoot.value.unmount();
  reactRoot.value = null;
});
</script>
// 공유 의존성 관련 설정
const moduleShared = {
  react: {
    singleton: true, // 전역에서 단일 의존성만 사용
    eager: true, // 즉시 로드 사용
    requiredVersion: '18.2.0', // 18.2.0 버전 사용으로 통일
    strictVersion: true, // 버전 통일 강제
  },
  'react-dom': {
    singleton: true,
    eager: true,
    requiredVersion: '18.2.0',
    strictVersion: true,
  },
};
다음 과정을 거친 예제 repo는 다음에 repository에 정리되어있다.
https://github.com/imnotpizza/module-federation-example
Module Federation을 적용하는 데 있어 중요한 점 중 하나는 remoteEntry.js 파일은 항상 최신상태를 유지해야 한다는 점이다.
Module Federation이 적용된 프로젝트를 배포 후 prod환경에 배포시 간헐적으로 번들파일을 찾을 수 없다고 뜨는 문제가 발생하여 곤혹을 치른 적이 있다.
원인을 분석해본 결과 remoteEntry.js의 캐시가 문제였다. remoteEntry에 대해 간단히 설명하자면 모듈에서 사용할 애셋, 의존성 등에 대한 내용을 담고 있는 파일로 webpack 빌드시 자동으로 생성된다. 번들 내용을 살펴보면 remote의 bundle filename을 생성해 참조하는 부분이 있는데
// 필요한 번들파일의 filename을 생성하는 부분, 이 filename들을 가지고 있다 필요한 경우 호출
/******/ 	/* webpack/runtime/get javascript chunk filename */
/******/ 	(() => {
/******/ 		// This function allow to reference async chunks
/******/ 		__webpack_require__.u = (chunkId) => {
/******/ 			// return url for filenames based on template
/******/ 			return "" + chunkId + "." + {"vendors-node_modules_tanstack_react-query_build_modern_index_js":"6e7cae81854257a73e51","webpack_sharing_consume_default_react_react":"a7ac326eb37d3eb07461","vendors-node_modules_jotai_esm_index_mjs":"5b86602f2403096680c3","vendors-node_modules_react_index_js":"d0360329c860b3178b13","webpack_sharing_consume_default_tanstack_react-query_tanstack_react-query-webpack_sharing_con-552bf6":"56b40cd4210e50af9050","src_Button_js":"a7a6275a272b1b9e06fa"}[chunkId] + ".js";
/******/ 		};
/******/ 	})();
/******/ 	
이 부분에서 remoteEntry 파일이 최신화가 되지 않을경우 contenthash가 바뀌어 이미 사라진 번들파일을 참조해 404에러가 발생하는 것. 따라서 이 부분을 cache 옵션을 설정하여 항상 최신임을 검증한뒤 사용하여야 한다.
이렇게 하여 Module Federation이란 무엇인지, 연동은 어떻게 하는지에 대하여 정리해 보았다. 보통 프론트엔드에서 Micro Frontend Architecture 라고 하면 Monorepo 구조가 대표적이고 가장 많이 쓰여서 좋은 점만 있는 줄 알았는데 Module Federation에 대해 스터디 하면서 Monorepo의 복잡한 파일구조와 같은 문제점이 없으면서도 깔끔한 모듈 시스템을 구현할 수 있다는 점이 인상적이었다.
다음 포스트에서는 여기서는 얘기하지 못한 운영, 유지보수, 배포 등의 전략에서의 고민과 해결방식에 대해 정리해보겠다.
참고로 2024년 4월 26일 Webpack Module Federation2가 소개되었다고 하니 관심 있으신 분들은 한번 읽어보기 바란다.
https://module-federation.io/blog/announcement.html
What is Module Federation?-Module Federation 공식 문서
Understanding Module Federation: A Deep Dive-미디움 블로그
Webpack Module Federation 도입 전에 알아야 할 것들-카카오 기술블로그
[SaaS] Micro Frontends를 위해 Module Federation 적용하기-강남언니 기술블로그