최근에 우연찮게 Micro Frontends에 대해 알아볼 기회가 생겼습니다다. 사실 그 전까지는 알아볼 필요성을 못느꼈다고 봐야 될 거 같습니다. 이전 회사에서 제가 맡은 서비스가 대규모인것도 아니었고, 개인적인 공부나 실습에서도 사용할 필요가 없었기 때문입니다.
Webpack 5 Module Federation 에 대해서도 얼핏 듣긴 했었지만 제대로 알아보게 된 것도 이번이 처음입니다. 그래서 이번 글은 틀리거나 주관적인 내용이 있을 수 있으니 참고해서 봐주셨으면 좋겠습니다.
대규모 웹 애플리케이션이란 어떤 것을 말하는 걸까요? Micro Frontends에 대해 공부하다보니 여러 회사의 사례를 찾을 수 있었습니다.
저는 여기서 한가지 공통점을 찾을 수 있었습니다. 서비스가 정말 클 수 있다는 것입니다. 특히, 독립적이지 않은 연관된 서비스들이 하나의 웹 애플리케이션이 되어야 할 때 이를 대규모 웹 애플리케이션이라고 볼 수 있을 거 같습니다.
가령, NHN Dooray 서비스를 참고하면 애플리케이션은 하나인데, 그 안에 홈, 업무, 메일, 캘린더, 드라이브, 위키, 주소록 같은 큼지막한 서비스가 있습니다. 이들 각각은 독립적이지 않습니다. 메일을 사용한다고 했을 때 드라이브를 불러와야 하는 경우도 있을 것입니다.
이렇게 프로젝트 규모가 커지면 여러가지 문제가 발생한다고 합니다. (물론 저는 경험해보지 못했습니다...😂)
그 중 공통적으로 발생하는 문제는 빌드 타임입니다. 조그만 수정사항에도 전체를 빌드해야하므로 비효율적이면서 시간도 오래걸린다고 합니다. 듣기로는 30~40분도 걸린다고 합니다. 빌드 타임 뿐만 아니라 소스코드 관리도 힘들어집니다. 각 서비스마다 개발하고 통합하고 QA 하는 과정도 문제가 될 수 있습니다.
그래서 이상적으로 생각하기로는 서비스를 분리해서 개발한 다음에, 운영에서 보여줄 때는 하나로 합쳐서 보여주면 되지 않을까요? 이것이 바로 Micro-Frontends 입니다.
Micro Frontends에서 서비스를 어떻게 통합할 것인가에 대해서는 크게 빌드 타임 통합과 런타임 통합이 있습니다.
빌드 타입 통합이냐 런타임 통합이냐는 특성이 다른거 같고, Micro Frontends에 대해서 각 서비스별로 구현해서 통합하기 위해서는 런타임 통합이 여러모로 맞는거 같습니다. 그리고 webpack 5 module federation 을 사용하면 webpack 설정으로 자바스크립트 런타임 통합을 구현해볼 수 있습니다.
상위에 vite-mod-fed 이라는 폴더를 생성합니다. 그리고 나서 remote를 vite로 생성하겠습니다.
❯ pnpm create vite remote --template react
여기서 remote란 개념이 익숙하지 않을거 같은데, 쉽게 설명해서 remote에서 버튼을 만든다고 했을 때 이를 expose로 내보내기 설정을 하면 host는 곳에서 이를 받아서 사용할 수 있습니다.
그리고 @originjs/vite-plugin-federation 를 사용해서 vite에서 federation을 사용해보겠습니다.
❯ pnpm install -D @originjs/vite-plugin-federation
A Vite/Rollup plugin which support Module Federation. Inspired by Webpack and compatible with Webpack Module Federation.
remote 앱의 포트는 5001번으로 설정해주었습니다.
"scripts": {
"dev": "vite --port 5001",
"build": "vite build",
"preview": "vite preview --port 5001"
},
그리고 간단하게 Remote에서 버튼 컴포넌트를 하나 만들어봤습니다.
import { useState } from "react";
function Button() {
const [state, setState] = useState(0);
return (
<div>
<button className="shared-btn" onClick={() => setState((s) => s + 1)}>
Click me: {state}
</button>
</div>
);
}
export default Button;
마찬가지로 host 프로젝트를 만들어보겠습니다. 그리고 federation 플러그인도 설치해줍니다.
❯ pnpm create vite host --template react
❯ pnpm install -D @originjs/vite-plugin-federation
이제 remote에 설정을 해보도록 하겠습니다. 설정방법에 대해서는 깃허브에 잘 나와있습니다.
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import federation from "@originjs/vite-plugin-federation";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
federation({
name: "remote-app",
filename: "remoteEntry.js",
// Modules to expose
exposes: {
"./Button": "./src/components/Button",
},
shared: ["react", "react-dom"],
}),
],
build: {
modulePreload: false,
target: "esnext",
minify: false,
cssCodeSplit: false,
},
});
실제 빌드를 해보면 remoteEntry.js 파일을 볼 수 있습니다. 그 외 Button에 대해서도 볼 수 있습니다.
remoteEntry.js 를 살펴보면 해당 파일은 실제 데이터 파일이 아니라는 것입니다. 경로만 가리키고 있는 것을 알 수 있습니다.
let moduleMap = {
"./Button":()=>{
dynamicLoadingCss(["style-e8125edc.css"]);
return __federation_import('./__federation_expose_Button-fa1bfa2d.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},};
...
const get =(module) => {
return moduleMap[module]();
};
const init =(shareScope) => {
globalThis.__federation_shared__= globalThis.__federation_shared__|| {};
...
}
export { dynamicLoadingCss, get, init };
다음은 host에 대해서 remote를 명시해주는 단계입니다.
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import federation from "@originjs/vite-plugin-federation";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
federation({
name: "host-app",
remotes: {
remoteApp: "http://localhost:5001/assets/remoteEntry.js",
},
shared: ["react", "react-dom"],
}),
],
build: {
modulePreload: false,
target: "esnext",
minify: false,
cssCodeSplit: false,
},
});
그리고 Host 앱에서 Remote App에 있는 Button을 가져다가 사용해보겠습니다.
import { useState } from "react";
import "./App.css";
import Button from "remoteApp/Button";
function App() {
const [count, setCount] = useState(0);
return (
<>
<h1>Host Application</h1>
<Button />
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.jsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
);
}
export default App;
와 정말 됩니다. Remote에서 만든 버튼을 Host에서 가져와서 사용할 수 있다니... 뭔가 색다른 경험인거 같습니다. 이런식으로 서비스도 분리해서 개발할 수 있겠군요.
그리고 사실상 host는 redeploy 없이 runtime에 바뀔 수 있다는 점이 가장 큰 장점입니다. 한번 remote에 button style을 바꿔보겠습니다.
.shared-btn {
background-color: skyblue;
border: 1px solid white;
color: white;
padding: 15px 30px;
text-align: center;
text-decoration: none;
font-size: 18px;
}
그리고 remote를 빌드하고 실행해보겠습니다. 짜잔. Host에는 아무것도 하지 않았습니다. 근데 잘 바뀐것을 알 수 있습니다.
❯ yarn run build && yarn run preview
상태 처리에 대한 공유도 가능합니다. atomic state manager, Recoil, Jatai를 사용을 추천합니다. 여기서는 Jotai를 사용해보도록 하겠습니다. 이제 해볼건 Click me를 클릭하면 count is 에 대한 카운트도 올라가도록 해볼 것입니다. 그 상태 처리를 Remote에서 해볼 것이고, 이를 Host에서도 동일하게 넘겨줘서 잘 되는지 실험해볼 것입니다.
Jotai은 처음인데 사용법은 간단한거 같습니다. recoil이랑도 비슷한거 같기도 합니다. 그냥 useState랑도 흡사..
// src/store/countStore.js
import { atom, useAtom } from 'jotai'
const countAtom = atom(0) // atom은 state 조각을 표현
const useCount = () => useAtom(countAtom) // useState 사용과 비슷하게 useAtom을 사용
export default useCount;
그리고 useState로 된 부분을 useCount로 교체해주면 됩니다. App.jsx도 마찬가지입니다. 그래서 버튼 두개가 하나의 상태를 공유하도록 하는 것입니다.
import useCount from "../store/countStore";
function Button() {
const [state, setState] = useCount(0);
...
}
상태를 공유하도록 하려면 어떻게 해야 할까요? 먼저 Remote 설정에서 store를 추가해줍니다. shared도 jotai를 추가해줍니다.
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import federation from "@originjs/vite-plugin-federation";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
federation({
name: "remote-app",
filename: "remoteEntry.js",
// Modules to expose
exposes: {
"./Button": "./src/components/Button",
"./store/countStore": "./src/store/countStore",
},
shared: ["react", "react-dom", "jotai"],
}),
],
build: {
modulePreload: false,
target: "esnext",
minify: false,
cssCodeSplit: false,
},
});
그리고 나서 Host에서 사용해보면 역시 잘 됩니다. 이처럼 단순 UI 컴포넌트 뿐만 아니라 상태랑 다른 것들도 공유가 가능하다는 것을 보여줍니다.
import "./App.css";
import Button from "remoteApp/Button";
import useCount from "remoteApp/store/countStore";
function App() {
const [count, setCount] = useCount(0);
return (
<>
<h1>Host Application</h1>
<Button />
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.jsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
);
}
export default App;
전체 코드 참고 : https://github.com/ckstn0777/vite-mod-fed
이번 시간에는 미약하게나마 대규모 웹 애플리케이션에서 발생할 수 있는 문제점과 이를 해결하기 위한 Micro Frontends 아키텍처에 대해 알아봤고, 여러가지 통합 방법이 있는 것을 알 수 있었습니다. 그리고 최근에는 런타임 자바스크립트 통합에 대한 기술과 Module Federation 에 대한 Webpack, Vite, Rollup 지원이 이뤄지고 있는거 같습니다.
대규모 아키텍처 설계와 방법론을 공부하고 실습해본건 사실상 처음인데 아직 공부할 게 많구나... 뒤돌아보게 되네요.
단순히 어떤 UI, 어떤 서비스를 만들까를 벗어나서 어떻게 하면 대규모 애플리케이션에 대해 아키텍처와 구조를 잘 설계할 수 있을까에 대한 고민도 해볼 수 있어서 좋았습니다.