티스토리 뷰
MFA 도입 시도하기로 했다... 그 과정을 풀어보려한다, 가자 ~~~ 🏃
아 MFA에 대한 기초적인 개념이 없다면 아래 링크를 눌러 보는것을 추천한다.
https://maxkim-j.github.io/posts/runtime-integration-micro-frontends/
위 링크의 방법 중 Run-time Integration via javascript를 사용해 구현하였다.
Vite기반 프로젝트에 도입하기
도입 과정 중 문제점 발견해서 다른 방향 찾기로 함...😥
🤯 문제점
- monorepo 기반 vite module federation 적용
- https://github.com/originjs/vite-plugin-federation
- host, remote 폴더를 기반으로 테스트 완료
- 별도의 빌드결과물이 host에서 import되어 실행됨 확인
위 과정까지는 좋았다....
전역스토어로 zustand를 사용하고 있었는데
아래 이미지와 같은 오류 발생함
좌측이 host측 화면이고 우측이 remote측 화면이다.
useRef관련 오류가 보이는가...
관련 이슈를 vite module federation에서 찾아본결과 그나마 원인에 대한 작은 단서를 발견하게 되었다
바로
적용해본 federation 플러그인이 현재 React 18의 useSyncExternalStore 을 지원하지 않는 것으로 보임
위와 같은 결론이 나왔다...
이는 리액트 쿼리를 사용할때도 같은 오류가 발생한다.
그러면 jotai는 어떨까?
웬걸 잘된다... jotai는 그럼 리액트 18의 useSyncExternalStore를 사용하지 않는다는 것인가...
암튼 꺼림찍해서 좀더 지원한지 오래된 webpack module federation을 적용하기로 했다
Webpack으로 전환 후 Module Federation 적용하기
역시 근본은 webpack이다 드디어 MFA를 이걸로 구현했다 🙌
⭐️ 구현과정
- yarn berry => pnpm으로 변경
- yarn berry pnp의 zip 기반 module 관리가 기존 node module 기반으로 작동되던 ide 등등의 호환이슈가 있을것을 대비해 pnpm으로 마이그레이션 진행
- webpack 적용
- module federation 적용
- zustand, react router dom v6 적용
- 배포 환경에서 테스트
- 에러 바운더리 적용
🔬도출내용
webpack federation 사용시 zustand 사용 가능
remote App자체를 호스트에서 적용할때는 아래와 같은 provider 이슈가 생김
(router, react-query 등 동일)
monorepo에 shared라는 공통 모듈을 만들고 해당 모듈에서 query provider를 제공하도록 변경
monorepo에서 공통모듈 만들기
monorepo에서 공통모듈 만드는 과정은
pnpm 기준
shaerd package.json
{
...
"name": "shared",
"version": "1.0.0",
"description": "common",
"main": "index.ts",
...
}
위와 같이 작성해서 패키지 진입점은 main에 표시하고
shared index.ts
export * from './provider';
export * from './constants';
shared provider 폴더내 index.ts
export * from './QueryProvider';
export * from './checkQueryProvider';
위처럼 작성한뒤
host package.json
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.11.1",
"@tanstack/react-query": "^4.29.19",
"@tanstack/react-query-devtools": "^4.29.19",
"shared": "*"
},
remote package.json
"dependencies": {
"@tanstack/react-query": "^4.29.19",
"@tanstack/react-query-devtools": "^4.29.19",
"axios": "^1.4.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.11.1",
"shared": "*",
"zustand": "^4.3.8"
},
위와 같이 dependencies에 넣어준 뒤
pnpm i라고 터미널에서 실행하여 linking 작업을 거치면 사용이 가능하다.
shared provider 코드
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
onError(err) {
console.log({ err });
},
},
mutations: {
onError(err) {
console.log({ err }, '전체 포괄 오류');
},
},
},
});
function QueryProvider({ children }: { children: JSX.Element }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
export default QueryProvider;
host app, remote app 둘다 적용
(remote에 적용한 이유는 remote만 켜서 개발할때 환경을 제공해주기 위함
host만 생각하면 필요없음 👉 이미 host에서 provider 제공하기 때문)
host, remote App.tsx
import './App.css';
import QueryProvider from 'shared/provider/QueryProvider';
import { HOST, ROLE } from 'shared/constants';
import { RouterProvider } from 'react-router-dom';
import { Suspense } from 'react';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import router from './router';
function App() {
return (
<>
<QueryProvider>
<>
<Suspense fallback={<div>loading...</div>}>
<RouterProvider router={router} />
</Suspense>
<ReactQueryDevtools initialIsOpen={false} position="bottom-right" />
</>
</QueryProvider>
</>
);
}
export default App;
📜 webpack config 및 federation config 코드
host
webpack config
/* eslint-disable @typescript-eslint/no-var-requires */
const path = require('path');
const { ModuleFederationPlugin } = require('webpack').container;
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const dotenv = require('dotenv').config({
path: path.join(__dirname, './.env'),
});
const { isDev } = require('./src/constant/mode.cjs');
const federationConfig = require('./federationConfig.cjs');
const REMOTE_URL = isDev ? process.env.DEV_REMOTE_URL : process.env.PROD_REMOTE_URL;
module.exports = {
entry: {
main: path.join(__dirname, './src/index.js'), // 번들링 시작 위치
},
output: {
publicPath: '/',
filename: '[name].[contenthash].bundle.js',
},
module: {
rules: [
{
test: /\.(js|ts)x?$/, // add |ts
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
'@babel/preset-typescript',
[
'@babel/preset-env',
{
useBuiltIns: 'usage',
corejs: 3,
},
],
['@babel/preset-react', { runtime: 'automatic' }],
],
},
},
},
{
test: /\.s?css$/,
use: ['style-loader', 'css-loader', 'sass-loader'],
},
{
test: /\.(png|jpg|gif|svg)$/i,
use: [
{
loader: 'url-loader',
options: {
limit: 8192,
},
},
],
type: 'javascript/auto',
},
],
},
resolve: {
modules: [path.join(__dirname, 'src'), 'node_modules'], // 모듈 위치
extensions: ['.tsx', '.ts', '.jsx', '.js'],
},
plugins: [
new webpack.DefinePlugin({
'process.env': dotenv.parsed,
}),
new ModuleFederationPlugin(federationConfig(REMOTE_URL)),
new HtmlWebpackPlugin({
template: './index.html', // 템플릿 위치
favicon: './src/assets/webpack.png',
hash: true,
}),
// Typescript(타입스크립트)의 컴파일 속도 향상을 위한 플러그인을 설정
new ForkTsCheckerWebpackPlugin(),
],
};
federation config
/* eslint-disable @typescript-eslint/no-var-requires */
const { dependencies } = require('./package.json');
const federationConfig = (REMOTE_URL) => ({
name: 'Host',
filename: 'remoteEntry.js',
remotes: {
Remote: `Remote@${REMOTE_URL}/remoteEntry.js`,
},
shared: {
...dependencies,
react: {
singleton: true,
requiredVersion: dependencies.react,
},
'react-dom': {
singleton: true,
requiredVersion: dependencies['react-dom'],
},
},
});
module.exports = federationConfig;
remote
webpack config
const path = require('path');
const { ModuleFederationPlugin } = require('webpack').container;
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const dotenv = require('dotenv').config({
path: path.join(__dirname, './.env'),
});
const federationConfig = require('./federationConfig.cjs');
module.exports = {
entry: {
main: path.join(__dirname, './src/index.js'), // 번들링 시작 위치
},
output: {
publicPath: 'auto',
filename: '[name].[contenthash].bundle.js',
},
module: {
rules: [
{
test: /\.(js|ts)x?$/, // add |ts
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
'@babel/preset-typescript',
[
'@babel/preset-env',
{
useBuiltIns: 'usage',
corejs: 3,
},
],
['@babel/preset-react', { runtime: 'automatic' }],
],
},
},
},
{
test: /\.s?css$/,
use: ['style-loader', 'css-loader', 'sass-loader'],
},
{
test: /\.(png|jpg|gif|svg)$/i,
use: [
{
loader: 'url-loader',
options: {
limit: 8192,
},
},
],
type: 'javascript/auto',
},
],
},
resolve: {
modules: [path.join(__dirname, 'src'), 'node_modules'], // 모듈 위치
extensions: ['.tsx', '.ts', '.jsx', '.js'],
},
target: 'web',
plugins: [
new webpack.DefinePlugin({
'process.env': dotenv.parsed,
}),
new ModuleFederationPlugin(federationConfig),
new HtmlWebpackPlugin({
template: './index.html', // 템플릿 위치
favicon: './src/assets/webpack.png',
hash: true,
}),
// Typescript(타입스크립트)의 컴파일 속도 향상을 위한 플러그인을 설정
new ForkTsCheckerWebpackPlugin(),
],
};
federation config
const { dependencies } = require('./package.json');
const federationConfig = {
name: 'Remote',
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/components/Button',
'./RemoteHomePage': './src/pages/HomePage.tsx',
'./RemoteCounterPage': './src/pages/CounterPage.tsx',
'./RemotePostPage': './src/pages/PostPage.tsx',
'./Cat': './src/components/Cat.tsx',
},
shared: {
...dependencies,
react: {
singleton: true,
requiredVersion: dependencies.react,
},
'react-dom': {
singleton: true,
requiredVersion: dependencies['react-dom'],
},
},
};
module.exports = federationConfig;
번외 ) ⛏ 삽질
라우터에서 고생을 했는데
결론부터 말하면
라우팅은 리모트에서 가져가면 안되고 무조건 호스트에서 작동해야한다고 생각된다.
이유는 아래 이미지와 같은 영역이 리모트라고 했을때 주소창에 url은 하나인데 표시된 영역 제외하고 움직이는 url과 리모트 url이 하나의 주소창을 두면서 병렬관리되기에 라우팅이 꼬이거나 동작하지 않을 수 있다.
최악은 어떤 주소로 갔을때 표시영역은 page2로 나머지 영역은 page5로 가야한다고 치면 머리가 벌써 복잡해진다. 이를 param 같은 편법을 사용해 풀수도 있지만 근본적으로는 주소창은 브라우저에 유일하므로 이에 맞춰 라우터도 단일로 가야한다고 보여진다.
위 의견에 맞춰 리모트 개발시 편의를 위해 라우터가 필요할텐데 이는 아래 코드를 사용해 개발에 사용해 해소할 수 있다.
🔮 최종적으로 React error boudary 적용 후 라우터를 host로 옮긴 후 로직
에러 바운더리 적용
ErrorBoundary.tsx 코드
import React from 'react';
class ErrorBoundary extends React.Component<any, { hasError: boolean }> {
constructor(props: any) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(_error: any) {
// Update state so the next render will show the fallback UI.
return { hasError: true };
}
componentDidCatch(error: any, errorInfo: any) {
// You can also log the error to an error reporting service
console.log(error, errorInfo);
}
render() {
if (this.state.hasError) {
// You can render any custom fallback UI
return (
<>
<h1>일시적으로 오류가 발생했습니다</h1>
<h2>새로고침을 하시거나 지속적 문제 발생시 개발자에게 문의바랍니다.</h2>
</>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
라우터 관련 로직 호스트로 이관 및 일부 스타일 및 홈페이지 돌아가기 로직 수정
remote ferderation config
const { dependencies } = require('./package.json');
const federationConfig = {
name: 'Remote',
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/components/Button',
'./RemoteHomePage': './src/pages/HomePage.tsx',
'./RemoteCounterPage': './src/pages/CounterPage.tsx',
'./RemotePostPage': './src/pages/PostPage.tsx',
'./Cat': './src/components/Cat.tsx',
},
shared: {
...dependencies,
react: {
singleton: true,
requiredVersion: dependencies.react,
},
'react-dom': {
singleton: true,
requiredVersion: dependencies['react-dom'],
},
},
};
module.exports = federationConfig;
host router 내 pages
import { lazy } from 'react';
const retryLazy = (componentImport: any) =>
lazy(async () => {
const pageAlreadyRefreshed = JSON.parse(window.localStorage.getItem('pageRefreshed') ?? 'false');
try {
const component = await componentImport();
window.localStorage.setItem('pageRefreshed', 'false');
return component;
} catch (error) {
if (!pageAlreadyRefreshed) {
window.localStorage.setItem('pageRefreshed', 'true');
return window.location.reload();
}
throw error;
}
});
export const HomePage = retryLazy(async () => await import('../pages/HomePage'));
export const TestPage = retryLazy(async () => await import('../pages/Test'));
export const RemoteHomePage = retryLazy(async () => await import('Remote/RemoteHomePage'));
export const RemoteCounterPage = retryLazy(async () => await import('Remote/RemoteCounterPage'));
export const RemotePostPage = retryLazy(async () => await import('Remote/RemotePostPage'));
host router
import { Outlet, createBrowserRouter } from 'react-router-dom';
import ErrorBoundary from '../ErrorBoundary';
import { HomePage, TestPage, RemoteHomePage, RemoteCounterPage, RemotePostPage } from './pages';
const router = createBrowserRouter([
{
path: '/',
element: <HomePage />,
},
{
path: '/test',
element: <TestPage />,
},
{
path: '/remote',
element: <Outlet />,
children: [
{
index: true,
element: (
<ErrorBoundary>
<RemoteHomePage />
</ErrorBoundary>
),
},
{
path: 'counter',
element: (
<ErrorBoundary>
<RemoteCounterPage />
</ErrorBoundary>
),
},
{
path: 'posts',
element: (
<ErrorBoundary>
<RemotePostPage />
</ErrorBoundary>
),
},
],
},
]);
export default router;
아직 테스트해볼거리는 많겠지만 이정도면 쓸만하다는 생각이 든다 😎
서비스를 쪼개서 기능별로 별도 배포만 해도 된다니 얼마나 좋고 안정성이 있겠는가 🤟
👺 추가 배포 관련 번외 작업!
리모트 배포에 따라 호스트에 적절하게 변경서빙되기는 하나 클라우트 프론트 무효화 이후 브라우저 자체에서 새로고침으로 자원을 다시 받아오지 않으면 이전에 캐싱된 내용이 서빙되어 변경이전 내역이 나오게 됨
위에 대한 원인으로
remoteEntry라는 js파일에 의존해 현재 리모트 파일을 받아오고 있는데 해당 자원 이름이 같기에 새로고침으로 자원을 새로받지 않는이상 기존에 브라우저가 받은 내용으로 움직임
원인으로 추정되는 코드
host 내 federation 설정 코드
const { dependencies } = require('./package.json');
const federationConfig = (REMOTE_URL) => ({
name: 'Host',
filename: 'remoteEntry.js',
remotes: {
Remote: `Remote@${REMOTE_URL}/remoteEntry.js`,
},
shared: {
...dependencies,
react: {
singleton: true,
requiredVersion: dependencies.react,
},
'react-dom': {
singleton: true,
requiredVersion: dependencies['react-dom'],
},
},
});
module.exports = federationConfig;
remote 내 federation 설정 코드
const { dependencies } = require('./package.json');
const federationConfig = {
name: 'Remote',
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/components/Button',
'./RemoteApp': './src/App.tsx',
'./RemoteRoutes': './src/router/routes.tsx',
},
shared: {
...dependencies,
react: {
singleton: true,
requiredVersion: dependencies.react,
},
'react-dom': {
singleton: true,
requiredVersion: dependencies['react-dom'],
},
},
};
module.exports = federationConfig;
위 코드에서 각 config에 filename이 remoteEntry.js로 같은 부분을 해쉬화 한 js 이름으로 동시에 변경하여 배포 반영해야 새로고침 없이 브라우저 반영이 될것이다.
하지만 이럴 경우 리모트 배포시 호스트도 같이 배포되야 한다는 모순점이 생겨 MFA의 이점을 챙기지 못하므로
리모트가 배포될때 전체 웹앱서비스인 호스트에서 새로고침은 필연적으로 이뤄져야한다.
✚ ps ) 해당 부분이 페이지 라우팅 개념이라면 동적라우팅을 통해 페이지 로드시 js 스플릿해 다운하는 형태라면 해쉬화된 js로 자동 새로고침 로직을 넣어 개선할 수 있겠지만 remote 자체는 라우팅 상관없이 해당 js 파일 하나에 연결되어 서빙되므로 js 코드 스프릿팅이 지원되지 않는다.
✊ 위에 언급한 문제 해결방법
사용자가 이용하는 host 서비스를 remote에서 빌드시 자동 새로고침 되도록 할 수 있게 해결해보았다...!
먼저 생각해본 방법 리스트
프론트 호스팅을 s3와 cloud front를 사용하기에 호스팅서버가 없으므로 remote의 빌드 버전이 달라졌다는 것을 host 쪽에서 전달받을 마땅한 방법이 없음, 따라서 변경사항을 전달해줄 주체를 하나 생성해야겠다고 생각했다.
1. 서버리스 함수로 버전 변경내역 전달해주어 새로고침 시기 알려주기 (AWS @lambda)
2. 실제 서버를 두어 api 통신을 통해 버전 체크 후 새로고침 하기 (벡엔드 서버에 녹여서 해결하기)
위 두가지로 압축되어 생각했는데 어차피 실 프로젝트 운용시 백엔드측에서 서버를 운용할테고 버전 체크 api 추가하는데에 공수가 많이 들지 않을것이라 판단해 2번으로 채택해 테스트 해보기로 했다.
테스트는 로컬에서 진행할예정이라 간단히 json mockserver로 api 구축해 테스트를 진행했다.
📜 구현 상세 내역
json mock server 코드
// data.json
{
"appInfo": {
"version": "0.0.1"
}
}
위 내용 입력 후 json-server --watch data.json --port 8888 터미널 실행
host 측 페이지 이동시마다 버전 체크해 새로고침 되도록 라우터 가드 작성
VersionRoute.tsx
import axios from 'axios';
interface PropsType {
children: JSX.Element;
}
// 라우터 가드
const VersionRoute = ({ children }: PropsType) => {
const checkVersion = async () => {
const { data } = await axios.get('http://localhost:8888/appInfo');
const { version } = data;
const keepVersion = localStorage.getItem('version');
if (keepVersion !== version) {
window.location.reload();
}
localStorage.setItem('version', version);
};
checkVersion();
return children;
};
export default VersionRoute;
라우터 각 페이지마다 가드 적용
import { Outlet, createBrowserRouter } from 'react-router-dom';
import ErrorBoundary from '../ErrorBoundary';
import { HomePage, TestPage, RemoteHomePage, RemoteCounterPage, RemotePostPage } from './pages';
import VersionRoute from './VersionRoute';
const router = createBrowserRouter([
{
path: '/',
element: (
<VersionRoute>
<HomePage />
</VersionRoute>
),
},
{
path: '/test',
element: (
<VersionRoute>
<TestPage />
</VersionRoute>
),
},
{
path: '/remote',
element: (
<VersionRoute>
<Outlet />
</VersionRoute>
),
children: [
{
index: true,
element: (
<ErrorBoundary>
<RemoteHomePage />
</ErrorBoundary>
),
},
{
path: 'counter',
element: (
<ErrorBoundary>
<RemoteCounterPage />
</ErrorBoundary>
),
},
{
path: 'posts',
element: (
<ErrorBoundary>
<RemotePostPage />
</ErrorBoundary>
),
},
],
},
]);
export default router;
remote에서 빌드시 json mock server에 버전을 올려주도록 스크립트 작성
changeVersion.cjs
const axios = require('axios');
const { version } = require('../package.json');
console.log(version);
const requestVersion = async () => {
const res = await axios.put('http://localhost:8888/appInfo', {
version,
});
console.log(res.data);
};
requestVersion();
remote 변경사항 적용 후 패키지 버전 올려주고 빌드시 위 스크립트 실행시켜 버전 관리해주도록 명령 수정
(아래 예제는 테스트용도로 개발 빌드에 넣어서 테스트함)
remote package.json
"name": "remote",
"private": true,
"version": "0.0.1",
"main": "index.ts",
"scripts": {
"dev": "node ./scripts/changeVersion.cjs && NODE_ENV=development webpack serve --config webpack.dev.cjs",
"build": "NODE_ENV=production webpack --config webpack.prod.cjs",
"preview": "NODE_ENV=production webpack serve --config webpack.prod.cjs",
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives"
},
👀 마지막으로 작업하는데 많은 도움을 준 참조 링크 공유
https://github.com/module-federation/module-federation-examples
여기 다양한 예제가 다 있으니까 참고하길 바란다
'개발' 카테고리의 다른 글
선언적 에러처리 : Vue ErrorBoundary 구현 (feat : Sentry를 곁들인...) (1) | 2023.10.24 |
---|---|
웹 성능 최적화 : Dynamic Import로 js 코드 스플릿하기 (1) | 2023.10.24 |
PnP 그리고 Yarn berry가 뭐길래 쓰는지 이제 알아보자 (0) | 2023.10.24 |
Vue3 setup에서 defineProps 사용하기 With Typescript (0) | 2023.10.23 |
Vue에서 scoped된 컴포넌트의 하위로 scss 스타일을 강제로 줘보자!(::v-deep, :deep) (0) | 2023.10.23 |
- Total
- Today
- Yesterday
- test
- deploy
- aws
- Infra
- pnpm
- defineProps
- Module Federation
- 서버상태관리
- MFA
- Style
- 독후감
- yarn-berry
- 프론트엔드아키텍처
- Micro Frontend Architecture
- TanStackQuery
- subrouting
- 상태관리전략
- vue3
- Flutter
- 독서
- node module
- error handle
- 프론트엔드최적화
- DevOps
- 당신은 결국 무엇이든 해내는 사람
- vue
- zero install
- CI
- design system
- frontend
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |