우리도 해보자 : MFA(Micro Frontend Architecture) 도입기

KJH·2023년 7월 13일
4
post-thumbnail

MFA 도입 시도하기로 했다... 그 과정을 풀어보려한다, 가자 ~~~ 🏃

아 MFA에 대한 기초적인 개념이 없다면 아래 링크를 눌러 보는것을 추천한다.

https://maxkim-j.github.io/posts/runtime-integration-micro-frontends/
위 링크의 방법 중
Run-time Integration via javascript를 사용해 구현하였다.

1. Vite기반 프로젝트에 도입하기

도입 과정 중 문제점 발견해서 다른 방향 찾기로 함...😥

🤯 문제점

위 과정까지는 좋았다....

전역스토어로 zustand를 사용하고 있었는데
아래 이미지와 같은 오류 발생함


좌측이 host측 화면이고 우측이 remote측 화면이다.
useRef관련 오류가 보이는가...

관련 이슈를 vite module federation에서 찾아본결과 그나마 원인에 대한 작은 단서를 발견하게 되었다

바로

적용해본 federation 플러그인이 현재 React 18의 useSyncExternalStore 을 지원하지 않는 것으로 보임

위와 같은 결론이 나왔다...

이는 리엑트 쿼리를 사용할때도 같은 오류가 발생한다.

그러면 jotai는 어떨까?

웬걸 잘된다... jotai는 그럼 리액트 18의 useSyncExternalStore를 사용하지 않는다는 것인가...
암튼 꺼림찍해서 좀더 지원한지 오래된 webpack module federation을 적용하기로 했다

webpack으로 바꾸는게 귀찮지만 괜찮아..... 😈

2.Webpack으로 전환 후 Module Federation 적용하기

역시 근본은 webpack이다 드디어 MFA를 이걸로 구현했다 🙌

⭐️ 구현과정

  1. yarn berry => pnpm으로 변경
    • yarn berry pnp의 zip 기반 module 관리가 기존 node module 기반으로 작동되던 ide 등등의 호환이슈가 있을것을 대비해 pnpm으로 마이그레이션 진행
  2. webpack 적용
  3. module federation 적용
  4. zustand, react router dom v6 적용
  5. 배포 환경에서 테스트
  6. 에러 바운더리 적용

🔬도출내용

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 코드

1. 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;

2. remote

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 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

/* eslint-disable @typescript-eslint/no-var-requires */
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 같은 편법을 사용해 풀수도 있지만 근본적으로는 주소창은 브라우저에 유일하므로 이에 맞춰 라우터도 단일로 가야한다고 보여진다.

    위 의견에 맞춰 리모트 개발시 편의를 위해 라우터가 필요할텐데 이는 아래 코드를 사용해 개발에 사용해 해소할 수 있다.

기존 삽질 코드 첨부... 넘겨도 됨 최종은 저렇게 사용하지 않음
remote 내 routes.tsx

import QueryProvider from 'shared/provider/QueryProvider';
import { CounterPage, HomePage, PostPage } from './pages';

export const routes = [
  {
    path: '/',
    element: <HomePage />,
  },
  {
    path: '/counter',
    element: <CounterPage />,
  },
  {
    path: '/posts',
    element: (
    // PostPage에는 리액트 쿼리 사용해 페이지 구성됨
    // QueryProvider 제공하지 않으면 host에 있는 provider를 상속받는게 아니라 없는 취급하여 직접 부여해야함
      <QueryProvider>
        <PostPage />
      </QueryProvider>
    ),
  },
];

remote 내 ferderation.cjs

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'],
    },
  },
};

host 측 router 내 index.tsx

// 리모트 모듈을 가져와 비동기적으로 라우터에 입혀준다
import { Outlet, createBrowserRouter } from 'react-router-dom';
import { HomePage, TestPage, RemoteApp } from './pages';

type RoutesType = Array<{
  path: string;
  element: JSX.Element;
}>;

type RouteModule = {
  routes: RoutesType;
};

const RemoteRoutes = async (): Promise<RouteModule> => await import('Remote/RemoteRoutes');

const { routes = [] } = await RemoteRoutes();

const router = createBrowserRouter([
  {
    path: '/',
    element: <HomePage />,
  },
  {
    path: '/test',
    element: <TestPage />,
  },
  {
    path: '/remote',
    element: <Outlet />,
    children: [
      {
        index: true,
        element: <RemoteApp />,
      },
      //children에서 / path를 가지면 오류가 나기에 제거 및 /pathname으로 되어 있는 부분 pathname만 적용되도록 수정 / 존재시 에러 발생
      ...routes.filter((route) => route.path !== '/').map((route) => ({ ...route, path: route.path.slice(1) })),
    ],
  },
]);

export default router;

🔮 최종적으로 React error boudary 적용 후 라우터를 host로 옮긴 후 로직

에러 바운더리 적용

ErrorBoundary.tsx 코드

/* eslint-disable @typescript-eslint/no-explicit-any */
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 코드 스프릿팅이 지원되지 않는다.

동적 라우팅이 궁금하다면 👉 동적라우팅포스팅

👀 마지막으로 작업하는데 많은 도움을 준 참조 링크 공유

https://github.com/module-federation/module-federation-examples
여기 다양한 예제가 다 있으니까 참고하길 바란다

✊ 위에 언급한 문제 해결방법

사용자가 이용하는 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"
  },
profile
Front-End Developer

3개의 댓글

comment-user-thumbnail
2023년 9월 18일

넘나뤼 감사해영 ~

1개의 답글
comment-user-thumbnail
2024년 9월 26일

엄청 쉽게 잘 설명해주셨네요 감사합니다.

답글 달기