이 글에서는 마이크로 프론트엔드에 관한 고찰과 웹팩 5의 Module Federation 일부 기능에 대해 알아보고, 적당한 예시 코드 작성을 통해 얻은 생각을 나눠 볼 생각이다.
내가 마이크로 프론트엔드 아키텍처에 관심을 가지고 고찰을 하게 된 계기는 현재 개발/운영 중인 배민상회라는 서비스의 환경과 관련이 있다. ( 배민상회는 요식업 사장님들을 대상으로 필요한 상품들을 판매하는 이커머스 서비스 )
여느 쇼핑몰들과 마찬가지로 많은 페이지가 존재하고, 지난 수년 간 많은 개발자들의 열정과 노력으로 생산된 코드의 양도 풍족한 상황이다. Webpack Bundle Analyzer
를 통해 분석한 결과를 잠깐 확인을 해보면,
쇼핑몰에서 제공하는 약 120개의 페이지는 14M에 육박하는 번들 코드가 158개의 청크로 분리되어 있다.
( 서버사이드 코드와 프로젝트 외부에 있는 디자인 시스템과 같은 코드들까지 고려하면 조금 더 많을 것 같다. ) 과거 개발 초기 단계에서 선택된 모노리스한 Single-Page-Application 방식의 개발은 현재까지 고수되어 왔고,
비대해진 애플리케이션의 빌드 퍼포먼스, 코드 탐사, 의존 관리 등의 비용은 점차 증가하고 있다.
개발 편의성과 생산성을 위해 아키텍처 개선이 필요한 시간이 된 것이라고 생각이 들었다.
여러가지 방식을 통해 개선이 가능하겠지만,
나는 마이크로 프론트엔드에 대한 고찰과 게임 체인저라고 불리우는 웹팩 5의 Module Federation의 일부 기능에 대해 알아 볼 예정이고, 예시 코드 작성 경험을 통해 리팩터링의 밑거름을 만들 생각이다.
마이크로 프론트엔드의 개념은 수많은 개발자, 그리고 조직들이 모노리스 아키텍처에서 마이크로 서비스 아키텍처로 전환하면서 얻은 이점들을 웹 애플리케이션 개발 환경과 생태계에도 적용하고자 했던 접근으로부터 나온 것이다.
마이크로 프론트엔드에 대한 설명과 세부적인 구현 예는 마틴파울러 글에도 소개된 Cam Jackson의 글에 잘 설명이 되어있다. 이 글에서는 마이크로 프론트엔드의 구현으로 여러 가지 방식의 예를 들었지만 결과적으로 채택한 방식은 컨테이너 애플리케이션와 마이크로 애플리케이션을 분리하고 런타임에서 컨테이너 애플리케이션이 마이크로 애플리케이션들을 통합하는 방식이었다. 애플리케이션들은 모두 독립된 환경에서 개발할 수 있으며, 필요에 의해 공통 UI 구성 요소들을 사용할 수도 있다. 컨테이너 애플리케이션 통합하는 역할을 맡아 라우팅 경로에 따라 각 마이크로 애플리케이션을 제공할 수 있도록 처리된다.
마이크로 프론트엔드의 주요한 장단점은 아래와 같이 요약할 수 있다.
장점
단점
아키텍처에는 항상 다양한 관점과 상황에서의 해법이 여럿 있고 마이크로 프론트엔드에 대해 생각하는 것도 마찬가지일 것이다. 나는 Cam Jackson 글에 소개된 마이크로 프론트엔드에 대한 해법에 전적으로 동의한다.
애플리케이션들은 런타임 환경에서 통합이 되어야 하고, 각자의 개발 생명 주기를 가질 수 있어야 한다. 이는 CI/CD도 모두 독립적으로 구성이 될 수 있다는 뜻이며, 일부 변경된 기능을 위해 애플리케이션 전체가 다시 빌드, 배포가 되지 않아도 된다는 것이다.
이제, 마이크로 프론트엔드 구성을 손쉽게 해줄 웹팩 5의 Module Federation에 대해서 알아보도록 하자.
웹팩 5 버전의 출시와 함께 나온 기능 중 하나인 모듈 페더레이션은 특정 애플리케이션(모듈)에서 동적으로 다른 빌드의 모듈 코드를 불러와 실행을 할 수 있게 해준다.
즉, 여러 개의 개별 빌드 코드가 하나의 어플리케이션 내에서 구동되는 것이다. 또한, 개별 빌드는 서로 의존성이 없어 개별적으로 빌드, 배포가 가능하다.
모듈 페더레이션의 몇 가지 용어와 특징을 ( 고수준 관점으로 )요약해 보겠다. ( 상세한 내용은 공식 문서에서 확인할 수 있다. )
리모트와 호스트 사이의 화살표는 모듈들이 런타임에서 모두 양방향(Bidirectional)으로 소비가 가능한 것을 보여준다. 이는 예제에서 조금 더 자세히 다루겠다.
위의 두 가지의 사례를 적절하게 조합하여 다음 예제를 구성해 보자.
위와 같은 구조로 설계를 한다고 가정하여, 몇 가지 설정에 대해 구체적인 코드를 작성해 보자
- packages
- core
- ...
- webpack.config.js
- package.json
- order
- core와 유사
- shell
- core와 유사
- products
- core와 유사
- ...
- ...
- package.json // 패키지 스크립트 병렬 실행을 위한 스크립트
Shell App
을 리모트로 노출.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'],
},
},
],
}),
]
// 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 />);
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;
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
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',
}),
],
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;
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;
위와 같이 구현 후 웹 페이지의 네트워크 로그는 아래와 같은 모습으로 나타난다.
공용 외부 패키지로 선언한 리액트는 하나의 청크로 구성되어 로드되고, 각 모듈들은 원격 엔트리를 통해 로드된다. 네트워크 로그만 보았을 때는, 단순히 코드 스플리팅이 된 것처럼 보이지만 가장 중요한 것은 각 모듈들이 독립적으로 개발, 운영, 배포가 되었다는 것이다.
단일 애플리케이션으로써의 통합은 런타임에서 일어나게 된다.
이 예제의 코드들은 모두 아래에서 확인이 가능하다.
이 장문의 글에서 소개한 Micro Frontend Architecture의 지향점과 Webpack 5의 Module Federation의 주요한 포인트는 아래 2가지라고 생각된다.
단일 모듈의 독립적이고 유연한 개발 프로세스를 가질 수 있고, 배포와 롤백도 보다 더 민첩하게 할 수 있다는 추가적인 장점이 있을 수 있다. 하지만 분명한 것은 좋은 방식의 설계에 대한 그만한 비용이 발생할 것이며 거버넌스 측면에서도 많은 고민과 협의, 그리고 합리적인 정책들이 필요하다는 것이다.
추가적으로 Angular, Vue, Server Side Rendering, React & Vue 등 이 링크에서 다양한 Module Federation의 예제들도 있으니 확인해 보면 좋을 것 같다.