Micro Frontend를 위한 Module Federation
이번 포스팅에서는 마이크로 프론트엔드의 개념과 도입방식, Webpack의 Module Federation에 대해 알아보고 예시 코드를 통해 간단한 마이크로 프론트엔드를 구현해보려고 한다.
최근에 여러 기업들의 Micro Frontend 도입기와 기술블로그를 읽으며 해당 개념에 대해 관심을 가지게 되었다. Webpack5에서 Module Federation이 등장한지도 벌써 4년이 넘었는데, 이제는 많은 테크 기업들이 안정적으로 도입한 같다. 당장 실무에서 도입할 스펙은 아니더라도 서비스의 확장과 분리에 대해 항상 염두해둬야한다 생각하기에 알아보았고, 감사하게도 많은 분들이 도입 사례를 공유해줘서 조금은 덜 헤맬 수 있었다.
마이크로 프론트엔드
글에 들어가기 앞서, 마이크로 프론트엔드란 무엇인지 정의부터 짚고 넘어가보자. 여러 레퍼런스를 참고해 한 문장으로 정의를 내려본다면 서로 독립된 형태의 배포 가능한 어플리케이션(모듈)의 조합으로 하나의 큰 어플리케이션을 구성하는것 이라고 할 수 있겠다. 여기서 중요한 키워드는 '독립'과 '배포 가능한'으로 각각의 어플리케이션이 독립적으로 개발, 빌드, 배포를 통해 하나의 큰 서비스를 제공하는 것을 목표로 한다.
예를 들어 다음과 같이 거대한 엔터프라이즈급 서비스를 살펴보자. 금융계의 슈퍼앱인 '토스'의 경우 모든 서비스만 해도 30개가 넘고, 마찬가지로 라이프스타일 슈퍼앱인 '오늘의집'도 한 사이트에서 여러개의 거대한 서비스를 운영중이다.
이러한 서비스를 분리하지 않고 하나의 큰 모놀리식에서 운영한다면 많은 리스크가 생겨날 수 있다. 높아지는 코드의 복잡도와 길어지는 빌드 속도, 서비스 간 에러 전파 등등. 유지보수 뿐만 아니라 빠른 속도가 필요한 신규피쳐 작업에도 신경써야할 점이 늘어나게 된다.
우리는 높은 확률로 코드 베이스의 분리 및 이사를 계획하고 있는 개발팀을 목격하게 될 것이다.
그렇다면 마이크로 프론트엔드를 어떠한 방식으로 구현할 수 있을까 ? 운영하고 있는 서비스의 환경과 상황에 따라 모노레포, 디자인 시스템 등 여러 선택지가 있겠지만 이번 포스팅에서는 Webpack 5에 등장한 Module Federation에 대해 다뤄 보려고 한다.
Module Federation
필자는 어떠한 개념에 대해 익히기 전에 단어의 사전적 개념에 대해 먼저 알아보는편이다. 단어의 뜻만 파악하면 반은 먹고 들어간다고 생각하기 때문이다. Module Federation ? Module을 Federation하겠다는 의미인데 Federation의 뜻을 찾아보면 '연합'이라고 설명해준다. 복수의 Module들을 연합, 한마디로 런타임에 통합시켜준다는 의미이다.
감사하게도 Next.js에서 편리하게 Module Federation을 사용할 수 있는 플러그인을 제공하고 있다. Module Federation은 하나의 구현체 혹은 인터페이스에 가깝기 때문에 Next.js, Vite등 다양한 개발환경에 적용가능한 플러그인들이 존재한다. Webpack을 사용하던 플러그인을 사용하던, 각자의 상황에 맞는 도구를 선택하면 된다.
다음으로 Module Federtaion의 핵심 컨셉에 대해 간략하게 이해해보고 코드를 통해 구현해보도록 하자.
Container
각각의 빌드된 앱은 Container 역할을 한다. 이러한 Container는 여러 모듈을 불러오는 호스트가 될 수도 있고, 원격으로 로딩되는 모듈이 될 수도 있다. 설정에 따라 A 앱에서 B를 호출할 수도 있고 반대로 B앱에서 A를 요청 할 수도 있다.
아래 moduleAppA의 예시를 통해 확인해보자. 해당 앱은 exposes 선언을 통해 Next.js 번들을 외부로 내보내는 동시에 remotes를 통해 moduleAppB또한 호출하고 있다. 양방향 호출이 가능하다고 보면 된다.
const mfConfig = {
name: "moduleAppA",
filename: "static/chunks/remoteEntry.js",
exposes: {
"./App": "./src/pages/_app.tsx",
},
remotes: {
moduleAppB: `moduleAppB@http://localhost:3001/_next/static/chunks/remoteEntry.js`,
},
};
Expose
빌드된 앱을 원격으로 내보내는 역할을 한다. 아래 코드의 경우 Next.js 번들을 moduleAppB라는 이름을 통해 외부로 내보낸다. exposes라는 복수형 단어에서 확인할 수 있듯이 단일 파일뿐만 아니라 여러 파일을 외부에 반출할 수 있다.
const mfConfig = {
name: "moduleAppB",
filename: "static/chunks/remoteEntry.js",
exposes: {
"./App": "./src/pages/_app.tsx",
},
};
Remote
Expose와 반대로 원격으로 Container의 앱들을 호출한다. 예를들어 하나의 부모앱 혹은 호스트 앱에서 여러개의 마이크로 서비스를 요청할 경우, 다음과 같이 작성할 수 있다.
이때 주의해야할 점은 하나의 오타 없이 정확한 Container 이름과 배포 위치를 입력해줘야한다는 것이다. 빌드타임이 아닌 런타임에 불러오기 때문에 사전에 정의한 셋팅을 정확히 입력해주기를 바란다.
const mfConfig = {
name: "host",
filename: "static/chunks/remoteEntry.js",
remotes: {
moduleAppA: `moduleAppA@http://localhost:4000/_next/static/chunks/remoteEntry.js`,
moduleAppB: `moduleAppB@http://localhost:4001/_next/static/chunks/remoteEntry.js`,
moduleAppC: `moduleAppC@http://localhost:4002/_next/static/chunks/remoteEntry.js`,
},
};
Host App에서 복수의 앱을 요청
Host 앱의 remotes 설정을 위의 코드와 같이 하면 3개의 모듈을 원격으로 요청할 수 있다. 간단한 모듈 앱 3개를 만들고 Host 앱에서 원격으로 요청해보자.
// Host App에서 3개의 모듈 앱을 요청
import { Suspense, lazy } from "react";
const ModuleAppA = lazy(() => import("moduleAppA/App"));
const ModuleAppB = lazy(() => import("moduleAppB/App"));
const ModuleAppC = lazy(() => import("moduleAppC/App"));
function App() {
return (
<div>
<h1>Host App</h1>
<h2>Host App Loaded</h2>
<Suspense fallback={<div>loading</div>}>
<ModuleAppA />
<ModuleAppB />
<ModuleAppC/>
</Suspense>
</div>
);
}
export default App;
코드를 실행하면 Host App에서 다음과 같이 작동한다.
localhost 3000에서 4000, 4001, 4002의 A,B,C의 앱을 불러오는것을 확인할 수 있다. 예시 코드는 아래 저장소에 있으니 설치 후 확인해볼 수 있다. 설정이 복잡할뿐이지 코드 자체가 어렵진 않아서 금방 개념을 이해할 수 있을것이다.
개발하면서 느낀점과 이슈
Module Federation을 테스트 해보며 첫번째로 느낀점은 "아, 설정이 빡세다" 였다. Webpack의 환경설정만으로 Micro Frontend를 손쉽게 구현할 수 있어 좋았지만, 이는 곧 설정을 제대로 해야만 정상적으로 구현이 가능하다는 의미이기도 하다.
expose와 remote를 통해 모듈을 내보내거나 불러올 때 매번 경로를 입력 & 수정해줘야 했고, path에 오타라도 하나 존재하면 정상적으로 작동하지 않았다.
만약 개발 환경이 local, stage, prod 등 여러개가 존재한다면 ? 테스트 해볼때는 로컬에서 작동하니 포트번호만 잘 입력하면 되지만 프로덕션 레벨에서 개발할때는 좀 더 공수가 많이들겠구나 싶었다.
// Host App
const NextFederationPlugin = require("@module-federation/nextjs-mf");
const mfConfig = {
name: "host",
filename: "static/chunks/remoteEntry.js",
exposes: {
"./App": "./src/pages/_app.tsx",
"./Button": "./src/Button",
"./Tabs": "./src/Tabs",
},
remotes: {
moduleAppA: isProd
? `moduleAppA@http://localhost:4000/_next/static/chunks/remoteEntry.js`
: isStage
? `moduleAppA@http://localhost:4000/_next/static/chunks/remoteEntry.js`
: `moduleAppA@http://localhost:4000/_next/static/chunks/remoteEntry.js`,
moduleAppB: isProd
? `moduleAppB@http://localhost:4000/_next/static/chunks/remoteEntry.js`
: isStage
? `moduleAppB@http://localhost:4000/_next/static/chunks/remoteEntry.js`
: `moduleAppB@http://localhost:4000/_next/static/chunks/remoteEntry.js`,
moduleAppC: isProd
? `moduleAppC@http://localhost:4000/_next/static/chunks/remoteEntry.js`
: isStage
? `moduleAppC@http://localhost:4000/_next/static/chunks/remoteEntry.js`
: `moduleAppC@http://localhost:4000/_next/static/chunks/remoteEntry.js`,
},
};
물론 이정도까지는 아니겠지만 .. 유틸 함수 혹은 패키지를 통해 좀 더 편리하게 환경설정을 구성하겠지만 꼼꼼히 신경은 써야겠다고 느꼈다.
두번째 이슈는 Host에서 원격으로 모듈을 불러올 때 타입추론을 하지못해 발생하는 타입 에러이다. Module Federation은 빌드 시점이 아닌 런타임에 모듈들을 통합하기 때문에, 개발환경에서는 모듈 컴포넌트에 대해 어떤 타입인지에 대해 추론을 할 수 없다. 호스트 앱이 리모트 앱의 원격 모듈이 어떤 타입인지 알 수 있게끔 설정을 해줘야한다.
실습에서는 Host App이 원격 모듈을 불러올 때 마다 types.d.ts파일에 모듈 타입을 정의해줬다. 하지만 이는 원격 모듈을 추가 할 때마다 설정을 수정해줘야한다는 의미이고 마이크로 프론트엔드의 취지와 맞지 않는다.
강남언니팀의 경우 exposed-typed라는 명령형 CLI를 통해 원격 모듈의 타입 선언 패키지를 생성해주는 방법을 고안했고, 배민커머스웹프론트엔드개발팀은 native-federation-typescript 라는 패키지를 사용해 해결했다고 한다.
아직 typescript에 대한 완벽한 지원이 이뤄지지 않아 각 팀의 방식대로 해결하고 레퍼런스를 공유해주고 있다.
// types.d.ts
declare module "moduleAppA/App";
declare module "moduleAppB/App";
declare module "moduleAppC/App";
마치며
마이크로 프론트엔드의 개념과 컨셉에 대해 어렴풋이 알고 있었는데 이번 기회를 통해 조금이나마 파악하게 되어서 좋았다. Module Federation이란 개념도 글이나 영상으로 접할때는 막연하고 난해한 개념이었는데, 확실히 코드를 통해 구현해보니 쉽게 이해가 갔다.
모든 서비스가 마이크로 프론트엔드를 도입할 필요는 없다고 생각한다. 규모가 작은 서비스의 경우 모놀리식 아키텍쳐의 단순성, 간편한 배포 등 여러 장점을 활용할 수 있다. 마이크로 프론트엔드 도입시에 러닝 커브 또한 존재하고 여러 개발자와 협의해야하는 커뮤니케이션 비용도 존재한다.
하지만 이러한 확장가능한 아키텍쳐를 미리 알아두는것도 나쁘지 않다고 생각한다. 좋은 서비스는 필연적으로 코트 베이스가 커지고 언젠가는 리팩토링 및 코드 분리가 필요하기 때문이다. 나 또한 더이상 주니어는 아니기에 이러한 개념을 알아가는게 중요하다 생각한다.
https://github.com/hurdle92/module-federation-example