Module Federation 으로 MFE(Micro Frontend) 찍먹하기 (feat. Nx)
✏️

Module Federation 으로 MFE(Micro Frontend) 찍먹하기 (feat. Nx)

Description
Published
Published June 18, 2022

MFE(Micro Frontend)?

4~5년 전쯤부터였던 것 같은데 유행처럼 모두가 “MSA! MSA!”를 외쳤던 시기가 있었던 것 같습니다. 모놀리식 아키텍쳐에서 마이크로 서비스 아키텍쳐는 잘 모르는 제가 보더라도 “그래 이게 옳은 아키텍쳐지"라는 생각이 들 정도로 엔지니어의 가려운 감성을 긁어주는 느낌이었습니다. 지금에서는 실패사례도 심심치 않게 볼 수 있고 굉장히 당연한 소리지만 무조건 정답인 아키텍쳐 보다는 우리에게 더 적절한 아키텍쳐를 찾고 적용하는 게 정답이라는 분위기 같습니다.
재밌는 건 프론트엔드 쪽도 비슷한 바람이 서서히 불고 있는 것 같습니다. 웹앱이 점점 복잡해지고 서비스의 덩어리가 커지면서 기존의 모놀리식 아키텍처의 높은 결합도, 쉽지 않은 확장, 혼란스러운 디팬던시 관리와 충돌 등을 경험하면서 “이거... 맞아...?”라는 생각이 종종 듭니다. 그리곤 해결하기 위해 자연스럽게 마이크로 프론트엔드 아키텍쳐를 접하게 됩니다.
마이크로 프론트엔드를 제 마음대로 정리하면 독립적으로 배포 가능한 여러 개의 프론트엔드 앱의 조합으로 서비스를 구성할 수 있는 아키엑쳐 라고 할 수 있을 것 같습니다.마이크로 프론트엔드라는 용어가 처음 언급 된 것은 2016으로 알고 있습니다. 그렇다면 왜 인제야 마이크로 프론트엔드가 조금씩 눈에 보이는 걸까요? 개인적인 생각으로는 뚜렷한 솔루션이나 Best Practice가 없었고(지금도..) 웹앱이나 서비스가 복잡하고 거대 해진지 그리 오랜 시간이 지나지 않아서 그런 게 아닐까 생각합니다. 사실 규모가 큰 회사들은 이미 이러한 아키텍처를 연구하고 적용하였고 감사하게도 그 사례를 공유해 주신 멋진 분들이 있어 조금은 덜 헤매게 됩니다.
 
 
짧게 정리를 하면 마이크로 프론트엔드 아키텍처를 가져간다는 것은 결국 독립적으로 관리되는 코드 혹은 앱을 어느 시점에 통합해야 한다는 겁니다. 즉, 가장 단순하게는 빌드 타임에 통합하거나 iframe 안에 별도의 문서를 올리고 서로 통신할 수 있는 인터페이스를 만드는 방법이 있을 겁니다. 하지만 빌드 타임에 통합하는 것은 독립적으로 관리되는 코드일 뿐 독립적으로 빌드 되고 배포될 수는 없으므로 근본적인 해결책이 될 수 없습니다 (따라서 런타임 통합만 마이크로프론트엔드로 보는 시각도 있습니다). iframe은 보안과 성능 이슈가 있고 별도의 통신 인터페이스(postMessage)를 구현해야 합니다.
최근에는 게임 체인저로 불리는 Webpack5의 Module Federation을 활용한 마이크로 프론트엔드 아키텍처가 많은 관심을 받고 있습니다. MFE의 여러 방법 혹은 Module Federation의 자세한 내용들은 다른 좋은 레퍼런스들을 참고하시면 좋을 것 같습니다.

NX 로 찍먹해보기

Module Federation을 이용하여 마이크로 프런트엔드를 구성할 때 제일 힘든 포인트 중 하나는 개발과 빌드 환경입니다. 간단히 생각해도 리모트 앱이 3개가 있으면 호스트 앱을 포함해 4개의 앱이 동시에 적절히 빌드 되고 서빙되어야 할 텐데 이를 관리하는 것은 쉽지 않습니다. 또한, 공용으로 사용하는 라이브러리(shared)로 인한 커플링도 문제가 됩니다. 공용 코드에 업데이트가 발생하면 영향을 받는 앱들만 다시 빌드가 되어야 빌드 시간도 줄이고 버전 미스매치의 문제도 해결하여 독립적이라는 의미가 생길 건데 이것 역시 쉽지 않습니다. Monorepo 빌드 시스템인 Nx 와 Nx에서 제공하는 Module federation 관련 기능들을 이용하면 간단한 커맨드로 대부분의 복잡한 설정들을 수행할 수 있습니다.
 

Nx Workspace 생성

npx커맨드를 이용해 빈 nx workspace를 생성합니다.
npx create-nx-workspace@latest mfe-module-federation --preset=empty ❯ npx create-nx-workspace@latest mfe-module-federation --preset=empty ✔ Use Nx Cloud? (It's free and doesn't require registration.) · Yes > NX Nx is creating your v14.3.3 workspace. To make sure the command works reliably in all environments, and that the preset is applied correctly, Nx will run "npm install" several times. Please wait. ✔ Installing dependencies with npm ✔ Nx has successfully created the workspace. ✔ NxCloud has been set up successfully
 

Nx CLI 설치

좋은 개발자 환경을 위해 Nx CLI를 글로벌로 설치하는것이 좋습니다.
npm install -g nx
 

react 플러그인 설치

npm install -D @nrwl/react
 

Module Federation host, remotes 앱 생성

제네레이션 커맨드를 이용하여 host 라는 이름의 host 앱과 cat dog 이라는 이름의 remotes 앱을 생성합니다.
nx g @nrwl/react:host host --remotes=cat --- ❯ nx g @nrwl/react:host host --remotes=cat > NX Generating @nrwl/react:host ? Which stylesheet format would you like to use? … CSS SASS(.scss) [ http://sass-lang.com ] Stylus(.styl) [ http://stylus-lang.com ] LESS [ http://lesscss.org ] styled-components [ https://styled-components.com ] ❯ emotion [ https://emotion.sh ] styled-jsx [ https://www.npmjs.com/package/styled-jsx ] None
 

폴더구조

생성이 성공적으로 마쳤다면 아래와 같은 폴더 구조를 갖게 될것입니다.
apps 는 독립적으로 빌드되고 실행 될 수 있는 app이 자리하게 됩니다.
mfe-module-federation ├──  apps │ ├──  cat │ ├──  cat-e2e │ ├──  host │ └──  host-e2e ├──  babel.config.json ├──  jest.config.ts ├──  jest.preset.js ├──  libs ├──  nx.json ├──  package-lock.json ├──  package.json ├──  README.md ├──  tools │ ├──  generators │ └──  tsconfig.tools.json ├──  tsconfig.base.json └──  workspace.json
 

remote 추가하기

nx g @nrwl/react:remote 커맨드를 통해 remote 앱을 추가할 수 있습니다.
nx g @nrwl/react:remote dog --host=host
 
host앱의 module federation 설정, module에 대한 declare도 자동으로 업데이트 됩니다.
Nx를 사용하면 아주 쉽게 host, remote 설정이 가능합니다.
 

host app 실행하기

serve 명령어로 host 앱을 실행합니다.
nx serve host
 
host 앱을 실행하면 apps/host/module-federation.config.js 에 정의되어있는 remotes 인 catdog 앱도 함께 실행됩니다.
 
devRemotes 옵션을 이용하면 remotes의 빌드된 스태틱만 서빙하는게 아니라 개발서버로 실행하여 cat 이나 dog에 코드 수정이 있을 때 빠른 피드백이 가능합니다.
nx serve host --devRemotes=cat,dog
 
notion image
 

remotes 수정하기

좀 더 이해하기 쉽게 remote 앱들을 심플하게 수정해보겠습니다.
host 앱과의 연동을 보기 위해 prop을 하나 받아 출력해 봅니다.
 
apps/cat/src/app/app.tsx
import styled from '@emotion/styled'; const StyledApp = styled.div` border: 1px solid #c9c9c9; background-color: #C5D6D2; `; interface AppProps { query?: string } export function App({query = 'query'}: AppProps) { return ( <StyledApp> <h1>cat</h1> <h2>{query}</h2> </StyledApp> ); } export default App;
 
apps/dog/src/app/app.tsx
import styled from '@emotion/styled'; const StyledApp = styled.div` border: 1px solid #c9c9c9; background-color: #D0BDAF; `; interface AppProps { query?: string } export function App({query = 'query'}: AppProps) { return ( <StyledApp> <h1>dog</h1> <h2>{query}</h2> </StyledApp> ); } export default App;
 
4201 포트와 4202 포트에 cat 과 dog 앱은 독립적으로 서빙되고 있습니다.
 
notion image
 

host app 수정하기

그다음은 호스트에서 독립적인 cat 과 dog를 런타임에 동적으로 로드해 오겠습니다.
(webpack config에서 실제 Module federation을 직접 설정하는 개념은 다루지 않습니다. Nx가 자동으로 제공해주는 셋업을 기반으로 실전을 위한 기본 사용 방법만 다룹니다.)
 
apps/host/src/app/app.tsx
import { useState } from 'react'; import * as React from 'react'; import { Link, Route, Routes } from 'react-router-dom'; const Cat = React.lazy(() => import('cat/Module')); const Dog = React.lazy(() => import('dog/Module')); export function App() { const [query, setQuery] = useState('Hello World!'); return ( <React.Suspense fallback={null}> <ul> <li> <Link to="/">Home</Link> </li> <li> <Link to="/cat">Cat</Link> </li> <li> <Link to="/dog">Dog</Link> </li> </ul> <h1>query: {query}</h1> <Routes> <Route path="/" element={<div />} /> <Route path="/cat" element={<Cat query={query} />} /> <Route path="/dog" element={<Dog query={query} />} /> </Routes> </React.Suspense> ); } export default App;
 
props로 넘겨준 query도 정상적으로 렌더링 합니다. (부모와 자식간의 통신)
 
 
notion image
 
Dynamic import와 크게 다를 게 없어 보이지만 중요한 것은 CatDog는 외부에 독립적으로 관리되고 서빙되는 리소스라는 겁니다.
즉, 예를 들어 Cat 앱 코드에 수정이 발생한다면 Host 앱을 다시 빌드하고 배포할 필요 없이 Cat 앱만 배포하면 동적으로 Cat 앱을 로드 하게 됩니다. 또한 여러 Host 역할을 하는 여러 앱에서 사용할 수 있겠죠?
 
 

shared

모노리포의 장점을 살려 공통 모듈도 추가해 보겠습니다.
라이브러리 제네레이터 커맨드를 사용하여 shared/ui 라는 라이브러리를 생성 합니다.
nx g @nrwl/react:library shared/ui ❯ nx g @nrwl/react:library shared/ui > NX Generating @nrwl/react:library CREATE libs/shared/ui/project.json UPDATE workspace.json CREATE libs/shared/ui/.eslintrc.json CREATE libs/shared/ui/.babelrc CREATE libs/shared/ui/README.md CREATE libs/shared/ui/src/index.ts CREATE libs/shared/ui/tsconfig.json CREATE libs/shared/ui/tsconfig.lib.json UPDATE tsconfig.base.json CREATE libs/shared/ui/jest.config.ts CREATE libs/shared/ui/tsconfig.spec.json CREATE libs/shared/ui/src/lib/shared-ui.spec.tsx CREATE libs/shared/ui/src/lib/shared-ui.tsx
 
공통으로 사용할 JButton 이라는 컴포넌트를 생성합니다.
컴포넌트 생성 커맨드를 사용하면 편리하게 컴포넌트를 추가할 수 있습니다.
nx g @nrwl/react:component -p shared-ui --name JButton --fileName JButton
 
libs/shared/ui/src/lib/jbutton/JButton.tsx
import styled from '@emotion/styled'; import { ComponentPropsWithoutRef } from 'react'; export interface JButtonProps extends ComponentPropsWithoutRef<'button'> { label: string; } const StyledJButton = styled.button` color: pink; `; export function JButton({ label, ...rest }: JButtonProps) { return <StyledJButton {...rest}>{label}</StyledJButton>; } export default JButton;
 
그리고 catdog 에 버튼을 만들어봅니다.
 
apps/cat/src/app/app.tsx
import styled from '@emotion/styled'; import { JButton } from '@mfe-module-federation/shared/ui'; const StyledApp = styled.div` border: 1px solid #c9c9c9; background-color: #c5d6d2; `; interface AppProps { query?: string; } export function App({ query = 'query' }: AppProps) { return ( <StyledApp> <h1>cat</h1> <h2>{query}</h2> <JButton label="cat button" /> </StyledApp> ); } export default App;
 
apps/dog/src/app/app.tsx
import styled from '@emotion/styled'; import { JButton } from '@mfe-module-federation/shared/ui'; const StyledApp = styled.div` border: 1px solid #c9c9c9; background-color: #d0bdaf; `; interface AppProps { query?: string; } export function App({ query = 'query' }: AppProps) { return ( <StyledApp> <h1>dog</h1> <h2>{query}</h2> <JButton label="dog button" /> </StyledApp> ); } export default App;
 
버튼이 정상적으로 랜더링 됩니다.
 
notion image
 
catdog 은 독립적인 앱이라고 하였는데 네트워크 탭을 보면 재밌는 점이 있습니다.
두 개의 스크린숏이 있는데요. 우리가 만든 @mfe-module-federation/shared/ui 공용 라이브러리에 대해 위는 shared 옵션을 주었고 아래는 주지 않았을 때의 차이입니다. 번들의 내용을 보지 않고 얘기하는것은 무의미 하나 직관적으로 보더라도 위의 스크린샷의 경우 cat 에 클라이언트 사이드 라우팅시 한번만 shared ui 에 대해 로드를 하고, dog로 라우팅시 다시 로드하지 않는것 처럼 보입니다. 아래의 스크린샷은 공통으로 사용하는 shared ui의 소스가 각각의 src_app_app_tsx.js 에 똑같이 중복되어 들어가 있을것 같은 합리적인 의심이 듭니다. (궁금하신 분들은 직접 확인해보시길 바랍니다.)
 
notion image
 
notion image
 
단순한 컴포넌트 코드 베이스 모듈이야 똑같은 코드를 중복해서 받아오는 정도의 문제만 있겠지만 컨텍스트를 갖는 모듈의 경우 문제가 됩니다.
예를들어 리액트를 공용 모듈로 설정하지 않는다면 아래와 같이 한 앱에 여러개의 리액트 카피가 생겨 터질것입니다. 따라서 적절한 공유 모듈 설정이 필요합니다.
notion image
 
하지만 Nx는 이또한 크게 설정할것이 없습니다. 각앱의 webpack.config.js 에서 사용되는 Nx 플러그인(@nrwl/react/module-federation)의withModuleFederation 메소드는 npm 패키지들과 nx workspace의 모듈들을 자동으로 싱글톤 옵션의 공용 모듈로 설정해 줍니다.
 

Main

catdog 앱이 마치 별도의 페이지로 나뉘어야 되는것처럼 구성되었지만 영역과 범위는 정하기 나름입니다. 예제 코드를 조금 더 추가해보겠습니다.
 
apps/dog/src/app/app.tsx
import styled from '@emotion/styled'; import { JButton } from '@mfe-module-federation/shared/ui'; const StyledApp = styled.div` border: 1px solid #c9c9c9; background-color: #d0bdaf; `; interface AppProps { query?: string; handleButtonClick?: (foo: string) => void; } export function App({ query = 'query', handleButtonClick }: AppProps) { return ( <StyledApp> <h3>by localhost:4202</h3> <h1>dog</h1> <h2>{query}</h2> <JButton label="dog button" onClick={() => handleButtonClick && handleButtonClick('dog foo')} /> </StyledApp> ); } export default App;
 
 
apps/cat/src/app/app.tsx
import styled from '@emotion/styled'; import { JButton } from '@mfe-module-federation/shared/ui'; const StyledApp = styled.div` border: 1px solid #c9c9c9; background-color: #c5d6d2; `; interface AppProps { query?: string; handleButtonClick?: (foo: string) => void; } export function App({ query = 'query', handleButtonClick }: AppProps) { return ( <StyledApp> <h3>by localhost:4201</h3> <h1>cat</h1> <h2>{query}</h2> <JButton label="cat button" onClick={() => handleButtonClick && handleButtonClick('cat foo')} /> </StyledApp> ); } export default App;
 
apps/host/src/app/Main.tsx
import styled from '@emotion/styled'; import * as React from 'react'; const Cat = React.lazy(() => import('cat/Module')); const Dog = React.lazy(() => import('dog/Module')); const StyledBox = styled.div` display: flex; `; const Wrapper = styled.div` flex: 1; `; interface MainProps { query: string; handleButtonClick?: (foo: string) => void; } export function Main({ query, handleButtonClick }: MainProps) { return ( <div> <StyledBox> <Wrapper> <Cat query={query} handleButtonClick={handleButtonClick} /> </Wrapper> <Wrapper> <Dog query={query} handleButtonClick={handleButtonClick} /> </Wrapper> </StyledBox> </div> ); }
 
 
apps/host/src/app/app.tsx
import { useState } from 'react'; import * as React from 'react'; import { Link, Route, Routes } from 'react-router-dom'; import { Main } from './Main'; const Cat = React.lazy(() => import('cat/Module')); const Dog = React.lazy(() => import('dog/Module')); export function App() { const [query, setQuery] = useState('Hello World!'); const handleButtonClick = (query: string) => { setQuery(query); }; return ( <React.Suspense fallback={null}> <ul> <li> <Link to="/">Home</Link> </li> <li> <Link to="/cat">Cat</Link> </li> <li> <Link to="/dog">Dog</Link> </li> </ul> <h1>query: {query}</h1> <Routes> <Route path="/" element={<Main query={query} handleButtonClick={handleButtonClick} />} /> <Route path="/cat" element={<Cat query={query} handleButtonClick={handleButtonClick} />} /> <Route path="/dog" element={<Dog query={query} handleButtonClick={handleButtonClick} />} /> </Routes> </React.Suspense> ); } export default App;
 
notion image
 
 

마무리

다양한 환경에서의 패턴들이 정립되고 좀 더 성숙해진다면 규모가 어느 정도 있는 프로젝트에서 모노리포와 Module Federation은 좋은 선택지 이상의 게임 체인저가 될 것으로 생각합니다. 여기를 참고하시면 많은 예제를 확인할 수 있습니다. (확실히 게임 체인저가 될 거로 생각해서 인지 이미 module federation 관련해서 여러 유로 설루션들이 있다는 점도 재밌네요.)
저도 여러 환경에서 어떻게 구성하면 효율적이고 효과적일지 조금 더 연구해 보아야겠습니다.
 
 

References

module-federation-examples
module-federationUpdated Aug 30, 2023