Webpack Module Federation 도입 전에 알아야 할 것들
아이언맨은 그 하나로 완전한 빌드이며, 각각의 파츠를 미리 빌드해 두고 세계각지와 지구 바깥에 배포해 둔다. 그리고 헐크와 같이 난공불락의 강한 적을 만났을 때 더 강한 아이언맨, 헐크버스터로 런타임에 합체하여 멋지고 강력한 작품으로 거듭난다. Webpack5가 나오면서 Module Federation 기능을 선보인지도 거의 2년이 다 되어간다. 여러 개의 애플리케이션을 따로 빌드한 다음 런타임에 통합하여 하나의 애플리케이션으로 동작하게 한다는 멋진 기능을 Webpack 설정만으로 가능하게 되었다. 마이크로 프런트엔드의 빌드타임 통합방식은 모노레포를 사용함으로써 가능했지만 결국 여러 패키지를 한 번에 같이 빌드해야 했던 한계가 있었다. 이제는 Module Federation이 등장함으로써 보다 자연스럽게 런타임에 통합할 수 있다. 마치 독립적으로 만들어 두었다가 멋진 기능으로 합체하는 헐크버스터와 같다고나 할까. Module Federation은 무엇인가? 아직 잘 모르는 분이더라도 헐크버스터의 예를 보면 감은 올 것이다. Webpack은 자바스크립트 모듈의 의존 관계를 파악하고 실행할 때 필요한 모듈을 로딩해주는 역할을 한다. 그러나 단일 Webpack 빌드에 포함되었던 모듈만 처리되므로 다른 Webpack 빌드의 결과물로 여러 서버에 배포되어 있는 모듈을 로딩할 수는 없었다. Module Federation은 단일 Webpack 빌드에 포함된 모듈뿐만 아니라 여러 서버에 배포되어 있는 원격 모듈을 하나의 애플리케이션에서 로딩할 수 있는 기능이다. Module Federation의 사용방법이나 자세한 개념 설명은 이미 잘 설명한 문서와 맛보기를 위한 글이 많으므로 약간의 검색만으로 충분한 자료를 찾을 수 있을 것이다. 이 글은 Module Federation을 도입하기 전에 용어에 대한 이해를 높이고 동작 원리를 파악하며 아직은 불편한 점을 설명한다. 이 글의 코드 예제는 module-federation-examples의 basic-host-remote를 응용하여 설명에 맞게 각색하였다. 용어 정리 먼저 Module Federation을 잘 이해하기 위해서 정확한 용어와 개념을 살펴보자. 동료와 분명히 같은 이야기를 하고 있었는데 나중에 보면 서로 다르게 이해하는 경우를 겪어 보았을 것이다. 같은 용어로 대화하는 것이 그만큼 중요하다. Webpack에서 모듈이라고 하는 것은 Webpack으로 빌드할 때 사용하는 코드를 포함한 모든 리소스를 말한다. 자바스크립트, CSS, HTML, JSON, 각종 이미지 파일 등이며 Module Federation을 이야기할 때 모듈은 특히 자바스크립트 모듈을 의미한다. 그러나 CSS, JSON 등의 다른 타입의 리소스들도 자바스크립트 모듈로 번들링할 수 있기 때문에 다른 타입의 리소스도 모듈이라고 말할 수 있다. 로컬 모듈은 단일 Webpack 빌드에 포함되는 모듈이다. 서로 다른 Webpack 빌드의 결과물은 서로 다른 로컬 모듈이다. 로컬 모듈은 단일 빌드 안에서만 로딩할 수 있다. 원격 모듈은 다른 Webpack 빌드에서 만든 모듈을 대상으로 런타임에 로딩할 수 있는 모듈을 말한다. 즉 A 빌드와 B 빌드의 결과물은 서로 원격 모듈이 될 수 있다. 각 빌드는 개별 서버에 배포될 수 있으며 런타임에 Dynamic Imports하듯이 원격 모듈을 로딩할 수 있다. 컨테이너(Container) 는 각각의 빌드를 말하며 하나의 빌드가 하나의 웹 애플리케이션을 나타낸다. A 컨테이너는 B 컨테이너의 원격 모듈을 로딩할 수 있으며 B에서 A 방향으로도 로딩할 수 있다. Expose는 컨테이너가 외부에 노출한 원격 모듈의 목록을 나타내는 설정이다. 간단하게는 내보낼 모듈이름: 로컬 모듈 경로로 표현할 수 있으며 Webpack 설정의 일부를 보면 이해가 빠를 것이다. new ModuleFederationPlugin((
name: "app2",
exposes: (
"./Button": "./src/Button", ),
)) app2라는 컨테이너는 로컬 모듈 "./src/Button" 을 "./Button"이라는 이름의 원격 모듈로 Expose(노출)한다는 의미다. 원격 모듈을 사용하는 컨테이너(app1)에서는 다음과 같이 설정한다. new ModuleFederationPlugin((
name: "app1",
remotes: (
app2: app2@http://localhost:3002/remoteEntry.js, ),
)),
// import하여 Button 컴포넌트를 사용할 수 있다.
const RemoteButton = React.lazy(() => import("app2/Button")) 공유 모듈은 여러 컨테이너에서 같이 사용하는 모듈을 말하며 런타임에 한 번만 로딩된다. 예를 들어 여러 컨테이너에서 react를 사용한다면, react 모듈을 여러 번 로딩할 필요는 없다. new ModuleFederationPlugin((
name: "app2",
shared: ( react: ( singleton: true ), "react-dom": ( singleton: true ) ),)) 리모트 앱은 모듈을 Expose하는 컨테이너이고, 호스트 앱은 원격 모듈을 사용하는 컨테이너이다. 기술적인 정의 앞서 정리한 용어를 사용하여 Module Federation을 정의하면, 리모트 앱이 노출(Expose)한 원격 모듈을 호스트 앱에서 비동기 로딩하여 사용할 수 있는 도구라고 할 수 있다. Webpack에 의하면 상호 순환 참조도 가능하다. 정확한 용어를 알아보았으니 Module Federation이 왜 필요한지 알아보자. 왜 필요한가? 서비스를 개발하고 성공해 나가다보면 규모가 점점 커질 수 있다. 서비스 규모가 커질수록 작은 변경에도 영향 범위가 커질 수 있으며 영향 범위 예측도 점점 복잡하고 어려워진다.Module Federation은 컨테이너 빌드를 따로 하므로 빌드 시간, 영향 범위, 로딩 시간 측면에서 유리하다. 구분 기존 방식 Module Federation 적용 시 기대효과 빌드범위와 배포시간 작은 변경에도 전체 빌드를 하고 배포한다. 변경된 컨테이너만 빌드하고 배포해서 시간이 줄어든다. 영향도 전체 서비스를 대상으로 영향도를 검증한다. 변경 영향이 해당 컨테이너에 국한되므로 검증 범위도 줄어든다. (단, 원격 모듈의 인터페이스를 변경했다면 호스트 앱도 검증이 필요하다.) 로딩시간 전체 빌드가 변경되었으므로 배포 직후 로딩 시간도 오래 걸린다. (브라우저 캐시 적용 안됨) 배포한 컨테이너의 원격 모듈만 새로 로딩하므로 배포 직후 로딩 시간도 상대적으로 짧다. Module Federation의 동작 원리 리모트 앱을 빌드하면 remoteEntry.js라는 파일이 생성되며 Expose한 원격 모듈을 호스트 앱에서 로딩할 수 있도록 인터페이스를 정의한다. 먼저 이해를 돕기 위해 앞선 예제의 Webpack 설정과 모듈 사용 코드를 확인하자. 호스트 앱(app1)의 Webpack 설정 new ModuleFederationPlugin((
name: "app1",
remotes: (
app2: app2@http://localhost:3002/remoteEntry.js,
),
)), 호스트 앱에서 원격 모듈 사용 // "app2"라는 이름으로 원격 모듈임을 나타낸다.
const RemoteButton = React.lazy(() => import("app2/Button")) remoteEntry.js 파일을 기반으로 호스트 앱이 어떻게 리모트 앱의 원격 모듈을 로딩하는지 순서대로 알아보자. 호스트 앱은 리모트 앱의 remoteEntry.js 파일을 로딩한다. 호스트 앱의 번들 파일(main.js)를 확인해 보면 리모트 앱의 모듈을 어떻게 로딩하는지 확인할 수 있다. app1(호스트 앱)은 app2(리모트 앱)인 컨테이너를 찾기 위해 전역변수 app2를 찾는다. 전역변수 app2가 없다면 리모트 앱의 remoteEntry.js를 로딩한다. remoteEntry.js가 로딩되면 전역변수 app2가 설정된다. resolve 되면 전역변수 app2를 컨테이너로 최종 전달한다. module.exports = new Promise((resolve, reject) => (
if(typeof app2 !== "undefined") return resolve() webpack_require.l("//localhost:3002/remoteEntry.js", (event) => ( if(typeof app2 !== "undefined") return resolve()
... // 생략
), "app2")
)).then(() => (app2)) 리모트 앱의 원격 모듈 맵 설정 리모트 앱의 remoteEntry.js 파일은 호스트 앱에 로딩되면서 app2라는 전역 변수를 설정한다. 호스트 앱은 app2라는 전역 변수를 확인함으로써 app2/Button과 같은 모듈을 로딩할 수 있게 된다. 또한 moduleMap을 보면 원격 모듈 Button이 매핑되어 있음을 알 수 있다. var app2// (() => ( // webpackBootstrap
// "use strict"
// var webpack_modules = ((
// 677:
// ((__unused_webpack_module, exports, webpack_require) => (
var moduleMap = ( "./Button": () => ( return Promise.all([webpack_require.e(369), webpack_require.e(940)]).then(() => (() => ((webpack_require(940))))) ) )...// var webpack_exports = webpack_require(677)
// app2 = webpack_exports
)() 원격 모듈 로딩 이제 호스트 앱은 리모트 앱이 제공한 모듈이 무엇이 있는지 파악할 수 있으며 import('app2/Button')과 같이 원격 모듈의 로딩을 요청한다. Webpack은 remoteEntry.js 파일을 통해 리모트 앱의 url과 Module ID를 사용하여 청크 파일을 로딩한다. 그리고 청크 파일을 파싱하고 모듈을 제공한다. Webpack은 Module ID를 사용하여 모듈을 구분하며 로컬 모듈 로딩 시 사용한다. 마찬가지로 원격 모듈도 Module ID를 기반으로 로딩할 수 있다. 아! 궁금한게 풀렸다! 정리하면 호스트 앱이 리모트 앱의 remoteEntry.js를 로딩하고 app2와 같이 리모트 앱의 컨테이너 이름을 파악한다. 그러면 비동기 로컬 모듈처럼 원격 모듈을 로딩할 수 있다. // "app2"라는 이름으로 원격 모듈임을 나타낸다.
const RemoteButton = React.lazy(() => import("app2/Button")) 그리고 컨테이너 이름을 만들 때 주의할 것이 있다. Module Federation은 컨테이너의 이름을 나타낼 때 app2라는 전역 변수를 사용하기 때문에 컨테이너의 이름은 전역에서 유니크하도록 작명해야 한다! // webpack.config.js
new ModuleFederationPlugin((
name: "전역_스코프에서_유니크한_이름을_사용한다", exposes: (
"./Button": "./src/Button",
),
)) 로컬 모듈의 비동기 로딩과의 차이점 로컬 모듈도 비동기 로딩(Dynamic Imports)이 가능하고 원격 모듈도 비동기 로딩이 가능한데 무엇이 다를까? 공통점 사용하는 방식은 동일하다. 예> 로컬 모듈의 비동기 로딩 const OtherComponent = React.lazy(() => import("./OtherComponent")) 예> 원격 모듈의 비동기 로딩 // "app2"라는 이름으로 원격 모듈임을 나타낸다.
const RemoteButton = React.lazy(() => import("app2/Button")) 또한 코드 분할(Code Splitting) 효과도 동일하다. 코드 분할은 웹 페이지의 초기 로딩 시에 필요한 리소스 용량을 줄여서 로딩 속도를 향상한다. 다른 점 다른 점은 “왜 필요한가” 챕터에서 다루었던 것과 유사하지만 반복학습의 의미에서 다음과 같이 정리할 수 있다. 빌드 로컬 모듈 - 단일 빌드해야 한다. 수정 사항 반영 시 전체를 빌드하고 배포해야 한다. 원격 모듈 - 따로 빌드할 수 있다. 수정 사항 반영 시 해당 컨테이너만 따로 빌드하고 배포하면 된다. 로딩 로컬 모듈 - 모듈이 같은 도메인에서 제공되어야 한다. 원격 모듈 - 다른 도메인의 모듈도 로딩할 수 있다. 아직은 불편한 점 Module Federation 필요성과 원격 모듈 로딩 원리에 대해서 파악해 보았으나 아직은 사용하기에 불편한 점이 몇 가지 있다. 원격 모듈의 타입을 알기 어렵다. 개발 시점에서 호스트 앱이 리모트 앱의 원격 모듈이 무엇이 있는지 파악하려면 webpack.config.js 파일을 확인해야 한다. // webpack.config.js
new ModuleFederationPlugin((
name: "전역_스코프에서_유니크한_이름을_사용한다",
exposes: (
"./Button": "./src/Button",
),
)) 그래서 필자는 Monorepo가 원격 모듈 파악에 좀 더 유리하다고 생각한다. 그림 - IDE에서 호스트 앱과 리모트 앱의 원격 모듈을 다 찾아볼 수 있다. 또한 원격 모듈의 타입을 알기 어려워서 타입 정의 파일(*.d.ts)을 따로 정의해야 하는 불편함이 있다. 배포 시 remoteEntry.js 경로 처리 빌드 및 배포할 때 리모트 앱의 remoteEntry.js 파일 경로를 목적별 서버(예> local, dev, alpha, stage, real 등)에 맞게 작성해야 한다. 다음 예제에서 호스트 앱의 webpack.config.js를 보면 localhost:3002와 같은 url을 확인할 수 있다. new ModuleFederationPlugin((
remotes: (
app2: app2@http://localhost:3002/remoteEntry.js, ),
)), 불편한 점은 목적별 서버가 다르기 때문에 remoteEntry.js 파일의 URL이 달라진다는 점이다. 그래서 webpack 설정 파일을 따로 두거나 환경 변수로 받아서 환경별로 빌드해야 한다. Module Federation의 활성화를 주도하고 있는 Zack Jackson의 책 Practical Module Federation에서 제시하는 방식을 응용하면 다음과 같이 목적별 서버를 지정할 수 있다. // webpack.config.js
new ModuleFederationPlugin((
remotes: (
app2: app2@$(getRemoteEntryUrl(3002)),
),
)),
...
function getRemoteEntryUrl(port) ( const ( isLocal, hostName ) = process.env
if (!isLocal) (
return http://localhost:$(port)/remoteEntry.js
)
return https://$(hostName)/remoteEntry.js
) 정리 웹 애플리케이션의 규모가 점점 커짐에 따라 빌드 속도와 배포 속도, 변경에 대한 영향 범위를 제한하고 검증하는 것이 중요해지고 있다. Module Federation은 (다른 도구를 사용하지 않고)Webpack만으로 마이크로 프런트엔드를 구현할 수 있게 해주었고, 여러 컨테이너를 독립적으로 빌드, 검증, 배포할 수 있으므로 속도와 영향 범위 측면에서 큰 장점을 제공한다. 만일 HTTP/2를 사용할 수 없는 서버라면 리모트 앱들을 별도의 서버에 배포하여 도메인 샤딩을 활용한 약간의 로딩 속도 개선에도 활용해볼 수도 있을 것이다. 이 글을 쓰는 기간 동안 잠에서 깨어났을 때 왜 헐크버스터가 생각났는지는 아직도 의문이다. 아마 베놈처럼 이상하게(?) 결합하는 것보다는 아무래도 공돌이 필자의 로망인 아이언맨의 Module Federation을 더 이상 볼 수 없음이 아쉬웠기 때문일 것이다.
