Module Federation으로 Micro Frontend 구현하기

정다빈·2023년 12월 28일
3
post-thumbnail

키즈노트는 원장님, 선생님, 학부모님의 소통을 돕기 위해 키즈노트 서비스를 제공하고 있어요. 그런데 원장님, 선생님의 행정 업무를 돕는 전자문서+ 서비스도 제공하고 있다는 것, 알고 계셨나요?

대부분의 서비스가 그렇듯이, 키즈노트는 작은 서비스에서 시작되었어요. 사용자들이 늘어가면서 키즈노트도 함께 커졌고, 곧이어 전자문서+도 탄생하게 되었습니다.

키즈노트와 전자문서+, 그리고 이 서비스들이 공통으로 사용하는 공통 레이아웃 컴포넌트는 모두 모놀리스 아키텍처를 따르고 있어요.

모놀리스 아키텍처는 초기 구현이 간단하기 때문에 개발을 빠르게 진행할 수 있다는 장점이 있지만, 키즈노트와 전자문서+의 규모가 커지면서 모놀리스 아키텍처의 단점이 드러나기 시작했어요.

  • 생산성 저하 : 코드 베이스 규모가 크기 때문에 신규 개발자가 전체 구조를 파악하는데 많은 시간이 필요해요.
  • 복잡성 증가 : 코드의 의존성이 높기 때문에 한 부분을 수정하면 다른 부분에서 예상하지 못한 버그가 발생할 수 있어요.
  • 배포 시간 증가 : 모든 어플리케이션을 하나로 관리하기 때문에 하나의 어플리케이션을 수정하더라도 모든 어플리케이션을 함께 빌드하고 배포해야 해요.

키즈노트 / 전자문서+ / 공통 레이아웃을 분리해서 각각 개발하고 관리하면 위 단점들을 해결할 수 있을 것 같은데요, 어떻게 구현할 수 있을까요?

이 문제에 대해 고민하던 중, FEConf 2023에서 대형 웹 애플리케이션 Micro Frontends 전환기 세션을 듣게 되었어요. 플렉스 FE Labs 팀은 Module Federation을 이용해서 마이크로 프론트엔드를 구현할 수 있었는데요, 이를 키즈노트에도 적용해 볼 수 있을 것 같습니다.

🧩 Micro Frontend와 Module Federation은 무엇인가요?

Micro Frontend

마이크로 프론트엔드는 프론트엔드 어플리케이션을 작은 독립적인 모듈로 분리하여 개발하고 관리하는 아키텍처를 의미해요. 모놀리식 아키텍처와 다르게, 여러 개의 독립적인 어플리케이션을 조합하여 전체 어플리케이션을 구성할 수 있어요.

각각의 모듈은 독립적으로 기술 스택을 선택하고 업그레이드할 수 있습니다. 또한 독립적으로 개발 및 배포가 가능하다는 특징이 있어요.

키즈노트와 전자문서+처럼 큰 규모의 어플리케이션에 마이크로 프론트엔드 아키텍처를 사용하면 좀 더 유연하고 확장성 좋은 어플리케이션을 만들 수 있을 것 같습니다.

Module Federation

Module Federation은 웹팩에서 제공하는 기능 중 하나로, 마이크로 프론트엔드 아키텍처를 구현하는 데 사용해요.

Module Federation은 아래와 같은 핵심 개념을 가지고 있어요.

  • Local Module : 현재 빌드 내부에 존재하는 일반적인 모듈
  • Remote Module : 현재 빌드의 일부가 아닌, 원격 컨테이너로부터 런타임에 로드되는 모듈
  • Expose : 호스트에게 공개할 원격 모듈 목록을 나타내는 설정
  • Host : 원격 모듈을 불러오는 어플리케이션
  • Shared Module : 여러 컨테이너 간에 공유되는 모듈 (여러 어플리케이션에서 공통으로 사용되는 함수나 라이브러리가 포함될 수 있어요.)

Module Federation을 사용하면 독립적으로 개발된 어플리케이션들을 통합하여 하나의 어플리케이션으로 구성할 수 있어요. 이는 코드의 재사용성과 유지보수성을 증가시켜 줍니다.

➗ 이렇게 분리해서 개발하고 싶어요

먼저 어플리케이션들을 어떤 구조로 분리하고 싶은지 간단하게 정리해 보았어요.

  1. 키즈노트 / 전자문서+ / 공통 레이아웃을 분리하여 모노레포 아키텍처를 따라야 합니다.
  2. 키즈노트 / 전자문서+ / 공통 레이아웃을 각각 별도로 개발하고 배포할 수 있어야 합니다.
  3. 키즈노트 / 전자문서+ / 공통 레이아웃은 원격 모듈로 개발하고, 호스트는 해당 모듈이 필요한 시점에서 import 할 수 있어야 합니다.
  4. 키즈노트 / 전자문서+는 각각 원격 모듈 내부에서 라우팅 처리를 해야 합니다.

요구사항 4번의 라우팅 처리에 대해 조금 더 설명드리면, 키즈노트와 전자문서+는 모놀리스 아키텍처를 따르고 있기 때문에 하나의 파일 안에서 두 서비스의 모든 라우팅 처리가 이루어지고 있어요.
성격이 조금 다른 서비스임에도 모든 path(140여 개...)를 하나의 파일에서 관리한다는 건 꽤 복잡한 일인데요, 이를 명확하게 분리할 필요가 있다고 생각했어요.

키즈노트는 URL path에 /service라는 prefix를 사용하고, 전자문서+는 /e-docs라는 prefix를 사용해서 라우팅 구조를 분리해 줄 거예요.

요구사항 4번은 다른 것들에 비해 레퍼런스가 거의 없었기 때문에 구현이 가능할지 고민이 많았어요.(Dynamic Import된 라우터를 react-router-dom에서 처리하지 못하거나 등)
하지만 반드시 개선해야 하는 부분이기 때문에 일단 시도해 보았어요.

🛠️ 뚝딱뚝딱 만들어봅시다!

🚨 여기부터는 마이크로 프론트엔드 구현의 성공과 실패가 모두 담겨있습니다. 성공만 빠르게 확인하고 싶은 분은 여기에서 확인해 주세요.

1. 프로젝트 생성하기

CRA를 이용해서 4개의 어플리케이션을 생성해 줄게요.

  • e-docs : 전자문서+ 어플리케이션 디렉토리
  • kidsnote : 키즈노트 어플리케이션 디렉토리
  • layout : 공통 레이아웃 어플리케이션 디렉토리
  • main : 원격 모듈을 사용할 호스트 어플리케이션 디렉토리

2. 공통 레이아웃 생성하기

layout 어플리케이션의 ./src/components 디렉토리에서 HeaderFooter 컴포넌트를 생성해 줄게요. 저는 요즘 잘 사용하고 있는 Chakra UI를 사용했어요.

layout 어플리케이션은 3000번 포트에서 실행해 줍니다.

이제 HeaderFooter를 호스트 어플리케이션에서 사용할 수 있도록 expose 해주어야 해요. webpack.config.js의 초기 설정을 위해 터미널에서 아래 명령어를 실행해 줍니다.

webpack init

명령어를 실행하면 아래와 같은 질문들이 나오게 되는데요, 프로젝트 설정에 따라 Y/N를 선택하면 됩니다.

모든 대답을 완료한 후 webpack.config.js 파일이 생성된 것을 확인할 수 있습니다. 여기서 불필요한 코드는 제거하고, 프로젝트에 맞춰서 수정해 줄게요.

// layout > webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  entry: './src/index',
  mode: 'development',
  output: {
    path: path.resolve(__dirname, 'build'),
  },
  devServer: {
    open: true,
    host: 'localhost',
    port: 3000,
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './public/index.html',
    }),
  ],
  module: {
    rules: [
      {
        test: /\.(ts|tsx)$/i,
        loader: 'ts-loader',
        exclude: ['/node_modules/'],
      },
    ],
  },
  resolve: {
    extensions: ['.tsx', '.ts', '.jsx', '.js', '...'],
  },
};

(이 포스트에서는 웹팩 설정을 하나하나 설명하지 못하지만, 공식 문서에서 내용을 자세히 확인할 수 있어요.)

Module Federation 플러그인을 plugins 배열에 추가해야 해당 기능을 사용할 수 있어요. 아래와 같이 플러그인을 추가해 줄게요.

// layout > webpack.config.js
plugins: [
  ...
  new ModuleFederationPlugin({
    name: 'layout',
    filename: 'remoteEntry.js',
    exposes: {
      './Header': './src/components/Header',
      './Footer': './src/components/Footer',
    },
  }),
],
  • name : 원격 어플리케이션의 이름
  • filename : 원격 어플리케이션이 호스트에게 제공할 모듈의 파일명 (통상적으로 remoteEntry.js를 사용하며, 번들링 후 파일이 생성돼요.)
  • exposes : 원격 어플리케이션이 호스트에게 제공할 모듈 목록

아래와 같이 exposes를 설정하면 ./src/components 경로를 모두 붙이지 않고 짧은 경로를 사용해서 모듈을 불러올 수 있어요.

// layout > webpack.config.js
new ModuleFederationPlugin({
  name: 'layout',
  filename: 'remoteEntry.js',
  exposes: {
    './Header': './src/components/Header',
  },
})

// main > GlobalLayout.tsx
const Header = React.lazy(() => import('layout/Header'));

3. 호스트 어플리케이션 생성하기

원격 모듈을 만들었으니 3001번 포트에서 실행할 호스트 어플리케이션도 만들어줄게요. main 어플리케이션에서 GlobalLayout 컴포넌트를 만들고, GlobalLayout 컴포넌트 안에서 HeaderFooter 원격 모듈을 사용해 줄 거예요.

위 스텝과 동일한 방법으로 webpack.config.js 파일을 추가하고, plugins 배열에 Module Federation 플러그인을 추가해 줍니다.

// main > webpack.config.js
plugins: [
  ...
  new ModuleFederationPlugin({
    name: 'main',
    remotes: {
      layout: 'layout@http://localhost:3000/remoteEntry.js',
    },
  }),
],

remotes는 사용할 원격 모듈 목록을 의미해요. 사용할 원격 모듈의 이름과 remoteEntry.js 파일 경로를 작성해 줍니다.

이제 아래와 같이 원격 모듈을 동적으로 불러옵니다.

// main > GlobalLayout.tsx
import React from 'react';
import { Outlet } from 'react-router-dom';
import { Box } from '@chakra-ui/react';

const Header = React.lazy(() => import('layout/Header'));
const Footer = React.lazy(() => import('layout/Footer'));

export default function GlobalLayout() {
  return (
    <>
      <Header />
      <Box>
        <Outlet />
      </Box>
      <Footer />
    </>
  );
}

그런데, HeaderFooter 컴포넌트에서 타입 에러가 발생하네요!

HeaderFooter 컴포넌트는 런타임에서 통합되는 원격 모듈이기 때문에, 호스트는 해당 모듈이 어떤 타입을 갖는지 알 수 없는 상태입니다.

./src 디렉토리 하위에 declarations.d.ts 파일을 추가하고 HeaderFooter 컴포넌트의 타입을 정의해 줄게요.

// main > declarations.d.ts
declare module 'layout/Header';
declare module 'layout/Footer';

더 이상 타입 에러가 발생하지 않는 것을 확인할 수 있어요.

이게 왜 안되지? (1차 - webpack config override)

호스트에서 원격 모듈을 정상적으로 가져오는지 테스트하기 위해 layoutmain을 빌드 / 실행해 줄게요.

그런데, main에서 빌드가 되지 않습니다.😨

layoutmain에 Module Federation을 잘 적용한 것 같은데, 왜 빌드에 실패하는 걸까요?

아무래도 원격 어플리케이션이 제대로 빌드가 되었는지, 그래서 HeaderFooter 컴포넌트를 정상적으로 제공하고 있는지 확인해 볼 필요가 있을 것 같습니다.

layout./build 디렉토리에서 파일 목록을 확인해 볼게요.

빌드 파일 목록을 살펴보니 호스트에서 접근할 remoteEntry.js 파일이 존재하지 않는군요!

웹팩에 대해 검색해 보니, CRA로 만들어진 어플리케이션에서 웹팩 설정을 변경하려면 별도의 라이브러리를 사용해야 한다고 해요.(너무 당연하게 오버라이드 될 줄 알았었던...)

react-app-rewired 라이브러리를 이용해서 웹팩 설정을 오버라이드 해줄게요. layoutmain의 루트에 config-overrides.js 파일을 추가하고, 문서에 따라 아래와 같이 작성합니다. 기존에 작성했던 설정을 거의 그대로 가져왔어요.

// layout > config-overrides.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  webpack: (config) => {
    return {
      ...config,
      entry: './src/index',
      mode: 'development',
      output: {
        path: path.resolve(__dirname, 'build'),
      },
      plugins: [
        new HtmlWebpackPlugin({
          template: './public/index.html',
        }),
        new ModuleFederationPlugin({
          name: 'layout',
          filename: 'remoteEntry.js',
          exposes: {
            './Header': './src/components/Header',
            './Footer': './src/components/Footer',
          },
        }),
      ],
      module: {
        rules: [
          {
            test: /\.(ts|tsx)$/i,
            loader: 'ts-loader',
            exclude: ['/node_modules/'],
          },
        ],
      },
      resolve: {
        extensions: ['.tsx', '.ts', '.jsx', '.js', '...'],
      },
    };
  },
  devServer: (configFunction) => {
    return (proxy, allowedHost) => ({
      ...configFunction(proxy, allowedHost),
      port: 3000,
      host: 'localhost',
      static: {
        directory: path.resolve(__dirname, 'build'),
      },
    });
  },
};
// main > config-overrides.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  webpack: (config) => {
    return {
      ...config,
      entry: './src/index',
      mode: 'development',
      output: {
        path: path.resolve(__dirname, 'build'),
      },
      plugins: [
        new HtmlWebpackPlugin({
          template: './public/index.html',
        }),
        new ModuleFederationPlugin({
          name: 'main',
          remotes: {
            layout: 'layout@http://localhost:3000/remoteEntry.js',
          },
        }),
      ],
      module: {
        rules: [
          {
            test: /\.(ts|tsx)$/i,
            loader: 'ts-loader',
            exclude: ['/node_modules/'],
          },
        ],
      },
      resolve: {
        extensions: ['.tsx', '.ts', '.jsx', '.js', '...'],
      },
    };
  },
  devServer: (configFunction) => {
    return (proxy, allowedHost) => ({
      ...configFunction(proxy, allowedHost),
      port: 3001,
      host: 'localhost',
      static: {
        directory: path.resolve(__dirname, 'build'),
      },
    });
  },
};

package.json에서 명령어도 아래와 같이 바꿔줄게요.

// layout, main > package.json
"scripts": {
  "start": "react-app-rewired start",
  "build": "react-app-rewired build",
},

layout을 빌드 한 후 ./build 디렉토리에서 remoteEntry.js 파일을 확인할 수 있어요.

이게 왜 안되지? (2차 - eager)

layoutmain을 다시 빌드 / 실행해볼게요.

터미널에서는 에러 메세지 없이 잘 실행되는듯싶었지만, localhost:3001로 접속해 보면 새로운 에러 메세지를 확인할 수 있습니다.

"네...?"

이 에러는 웹팩 공식 문서의 트러블 슈팅에 나와있어요.

모듈을 비동기 청크에 넣지 않고 동기식으로 제공하는 Module Federation 고급 API 내에서 의존성을 eager로 설정할 수 있습니다. 이를 통해 초기 청크에서 이러한 공유 모듈을 사용할 수 있습니다. 그러나 제공된 모든 모듈과 예비 모듈은 항상 다운로드 되므로 주의하세요. 쉘 같이 애플리케이션의 한 지점에서만 제공하는 것이 좋습니다.
비동기 경계를 사용하는 것을 추천합니다. 추가 왕복을 방지하고 일반적인 성능 향상을 위해 더 큰 청크의 초기화 코드를 분할합니다.

무슨 소리인지 하나도 모르겠는 번역이지만, 읽어보니 비동기 청크를 만들기 위해 bootstrap.js를 추가해 주어야 하는 것 같아요.

사실 bootstrap.js의 역할을 제대로 이해하기 힘들었는데, 다행히도 우아콘 2023 프론트엔드 개발의 미래, Module Federation의 적용 세션에서 명확하게 설명해 주셔서 이해할 수 있었어요.

bootstrap.js를 추가한 상태에서 main 어플리케이션을 빌드 / 실행해 볼까요?

넵... 여전히 실행되지 않습니다. 콘솔 창에서 리액트 에러를 확인할 수 있어요.

위 에러는 리액트를 정상적으로 실행할 수 없기 때문에 발생한 에러에요. 호스트 어플리케이션이 초기에 실행될 때, 원격 어플리케이션으로부터 remoteEntry.js 파일을 동기적으로 가져오게 됩니다. 이때 리액트도 초기에 함께 가져와야 추후에 불러오는 원격 모듈이 정상적으로 동작할 수 있어요.
리액트는 초기에 동기적으로 가져오고 원격 모듈은 비동기적으로 가져오기 위해 원격 모듈을 비동기 청크인 bootstrap.js로 분리해 주어야 합니다.

그리고 Module Federation의 shared 옵션에서 리액트에 eager: true를 설정해 줄게요.

// layout, main > config-overrides.js
new ModuleFederationPlugin({
  ...
  shared: {
    react: {
      eager: true,
      singleton: true,
      requiredVersion: '^18.2.0',
    },
  },
}),
  • eager : true로 설정할 경우, 초기 실행 시 해당 모듈을 동적으로 가져올 수 있어요.
  • singleton : true로 설정할 경우, 여러 모듈에서 해당 모듈을 사용하더라도 하나의 인스턴스만 생성해요.
  • requiredVersion : 패키지의 필수 버전을 의미해요.

이 외에 다양한 설정들은 여기서 확인할 수 있습니다.

이제 다시 main을 빌드 / 실행하고 localhost:3001로 진입하면...

layoutHeaderFooter 컴포넌트를 정상적으로 가져오고 있습니다! 🥳 🎊 🎉

네트워크 탭에서도 여러 개의 청크를 불러오는 것을 확인할 수 있어요.

4. 키즈노트 어플리케이션 생성하기

이제 kidsnote 디렉토리에서 키즈노트를 만들어볼게요. 키즈노트는 3002번 포트에서 실행해 줄 예정입니다.

./src/components/common 디렉토리에는 키즈노트에서 사용하는 LNB와 레이아웃 컴포넌트를 만들고, ./src/components 디렉토리에는 페이지 컴포넌트를 만들었어요.
실제 키즈노트는 수많은 페이지를 가지고 있지만, 이번에는 알림장 / 공지사항 / 앨범 페이지만 만들어 줄게요.

라우팅 처리를 위해 react-router-dom 라이브러리를 추가하고, 라우팅 구조를 아래와 같이 만들어볼 거예요.

./src 디렉토리에 AppRoutes.tsx 파일을 추가하고, 라우팅 처리를 해줍니다. 나중에 AppRoutes.tsx 파일을 expose 해줄 예정이에요.

// kidsnote > AppRoutes.tsx
import React from 'react';
import { Routes, Route } from 'react-router-dom';

import Layout from './components/common/Layout';

const ReportPage = React.lazy(() => import('./components/ReportPage'));
const NoticePage = React.lazy(() => import('./components/NoticePage'));
const AlbumPage = React.lazy(() => import('./components/AlbumPage'));

export default function AppRoutes() {
  return (
    <Routes>
      <Route
        path="/service"
        element={<Layout />}
      >
        <Route
          path="report"
          element={<ReportPage />}
        />
        <Route
          path="notice"
          element={<NoticePage />}
        />
        <Route
          path="album"
          element={<AlbumPage />}
        />
      </Route>
    </Routes>
  );
}

App.tsx 파일에서 BrowserRouterAppRoutes를 넣어줄게요.

// kidsnote > App.tsx
import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import AppRoutes from './AppRoutes';

export default function App() {
  return (
    <Router>
      <AppRoutes />
    </Router>
  );
}

localhost:3002로 접속하면 kidsnote 어플리케이션이 정상적으로 동작하는 것을 확인할 수 있어요.

layout과 동일하게 config-overrides.js를 생성하고 아래와 같이 AppRoutes를 expose 해줍니다. (package.json 수정도 잊지 마세요!)

// kidsnote > config-overrides.js
plugins: [
  ...
  new ModuleFederationPlugin({
    name: 'kidsnote',
    filename: 'remoteEntry.js',
    exposes: {
      './AppRoutes': './src/AppRoutes',
    },
    shared: {
      react: {
        eager: true,
        singleton: true,
        requiredVersion: '^18.2.0',
      },
    },
  }),
],

키즈노트 원격 모듈을 만들었으니 main에서 Module Federation 설정을 수정해야 해요. 아래와 같이 remoteskidsnote를 추가해 줄게요.

// main > config-overrides.js
plugins: [
  ...
  new ModuleFederationPlugin({
    name: 'main',
    remotes: {
      layout: 'layout@http://localhost:3000/remoteEntry.js',
      kidsnote: 'kidsnote@http://localhost:3002/remoteEntry.js',
    },
    shared: {
      react: {
        eager: true,
        singleton: true,
        requiredVersion: '^18.2.0',
      },
    },
  }),
],

그리고 mainApp.tsx에서 /service 라우팅 처리를 해줍니다.

// main > App.tsx
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';

import GlobalLayout from './GlobalLayout';

const KidsnoteAppRoutes = React.lazy(() => import('kidsnote/AppRoutes'));

export default function App() {
  return (
    <Router>
      <Routes>
        <Route
          path="/"
          element={<GlobalLayout />}
        >
          // KidsnoteAppRoutes 내부에서 /service path를 가지고 있으므로 path 생략
          <Route element={<KidsnoteAppRoutes />}/>
        </Route>
      </Routes>
    </Router>
  );
}

이게 왜 안되지? (3차 - router)

main을 빌드 / 실행한 후 localhost:3001으로 진입하면 react-router-dom에서 에러가 발생하는 것을 확인할 수 있습니다.

에러 메세지를 보니 <Routes><BrowserRouter>로 감싸지 않았기 때문에 에러가 발생한 것 같아요.
그런데... mainApp.tsx에서 이미 <BrowserRouter>로 감싸주었는데, 왜 이런 에러가 발생하는 걸까요?

이 부분에서 생각보다 고민이 많았는데요, 의외로 답은 간단했습니다. 바로 아래와 같이 라우터 구조를 변경하는 것이었어요.

kidsnoteAppRoutes.tsx를 제거하고, App.tsx 안에 <BrowserRouter>를 포함시켜줄게요.

// kidsnote > App.tsx
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';

import Layout from './components/common/Layout';

const ReportPage = React.lazy(() => import('./components/ReportPage'));
const NoticePage = React.lazy(() => import('./components/NoticePage'));
const AlbumPage = React.lazy(() => import('./components/AlbumPage'));

export default function App() {
  return (
    <Router basename="/service">
      <Routes>
        <Route
          path="/"
          element={<Layout />}
        >
          <Route
            path="report"
            element={<ReportPage />}
          />
          <Route
            path="notice"
            element={<NoticePage />}
          />
          <Route
            path="album"
            element={<AlbumPage />}
          />
        </Route>
      </Routes>
    </Router>
  );
}

그리고 App.tsx를 expose 해줍니다.

// kidsnote > config-overrides.js
plugins: [
  ...
  new ModuleFederationPlugin({
    name: 'kidsnote',
    filename: 'remoteEntry.js',
    exposes: {
      './App': './src/App',
    },
    shared: {
      react: {
        eager: true,
        singleton: true,
        requiredVersion: '^18.2.0',
      },
    },
  }),
],

mainApp.tsx도 함께 수정해 줄게요.

// main > App.tsx
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';

import GlobalLayout from './GlobalLayout';

const KidsnoteApp = React.lazy(() => import('kidsnote/App'));

export default function App() {
  return (
    <Router>
      <Routes>
        <Route
          path="/"
          element={<GlobalLayout />}
        >
          <Route
            path="/service/*"
            element={<KidsnoteApp />}
          />
        </Route>
      </Routes>
    </Router>
  );
}

5. 전자문서+ 어플리케이션 생성하기

마지막으로 e-docs 디렉토리에서 전자문서+를 만들어야 합니다. 전자문서+는 3003번 포트에서 실행해 줄 예정이에요.

사실, 전자문서+는 바로 전 단계에서 만든 키즈노트와 완전히 동일한 구조를 사용했어요. 다른 점이라면 폴더명, 파일명 정도가 되겠네요!

키즈노트와 동일하게 ./src/components/common 디렉토리에는 전자문서+에서 사용하는 LNB와 레이아웃 컴포넌트를 만들고, ./src/components 디렉토리에는 페이지 컴포넌트를 만들었어요.
실제 전자문서+ 또한 수많은 페이지를 가지고 있지만, 이번에는 운영 일지 / 교직원 관리 / 원아 관리 페이지만 만들어 줄게요.

키즈노트와 동일한 구조로 라우터를 설정하고 localhost:3003으로 접속하면 e-docs 어플리케이션이 정상적으로 동작하는 것을 확인할 수 있어요.

config-overrides.jspackage.json 설정도 잊지 말고 해주어야 합니다.

// e-docs > config-overrides.js
plugins: [
  ...
  new ModuleFederationPlugin({
    name: 'e_docs',
    filename: 'remoteEntry.js',
    exposes: {
      './App': './src/App',
    },
    shared: {
      react: {
        eager: true,
        singleton: true,
        requiredVersion: '^18.2.0',
      },
    },
}),

mainconfig-overrides.jsApp.tsx도 함께 수정해 줄게요.

// main > config-overrides.js
plugins: [
  ...
  new ModuleFederationPlugin({
    name: 'main',
    remotes: {
      layout: 'layout@http://localhost:3000/remoteEntry.js',
      kidsnote: 'kidsnote@http://localhost:3002/remoteEntry.js',
      e_docs: 'e_docs@http://localhost:3003/remoteEntry.js',
    },
    shared: {
      react: {
        eager: true,
        singleton: true,
        requiredVersion: '^18.2.0',
      },
    },
  }),
],
// main > App.tsx
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';

import GlobalLayout from './GlobalLayout';

const KidsnoteApp = React.lazy(() => import('kidsnote/App'));
const EDocsApp = React.lazy(() => import('e_docs/App'));

export default function App() {
  return (
    <Router>
      <Routes>
        <Route
          path="/"
          element={<GlobalLayout />}
        >
          <Route
            path="/service/*"
            element={<KidsnoteApp />}
          />
          <Route
            path="/e-docs/*"
            element={<EDocsApp />}
          />
        </Route>
      </Routes>
    </Router>
  );
}

6. 어플리케이션 통합

이제 layout / kidsnote / e-docs / main 어플리케이션을 빌드 / 실행하고, 호스트 어플리케이션이 실행되고 있는 localhost:3001로 진입해 보겠습니다.

모든 컴포넌트를 제대로 불러오고 있고, 라우팅도 정상적으로 동작하고 있습니다! 여기까지 오는 데 정말 오래 걸렸네요. 🥹

🫤 아쉬운 점

이로써 Module Federation으로 마이크로 프론트엔드를 구현해 보았습니다.
비록 작은 어플리케이션들로 실습을 진행했지만, 작업을 하면서 아래와 같은 단점들을 느끼게 되었어요.

원격 모듈을 추가할 때마다 웹팩 설정을 함께 수정해야 합니다.

실습에서는 적은 수의 원격 모듈을 사용했기 때문에 이 부분이 크게 문제 되지 않았는데요, 하지만 마이크로 프론트엔드 아키텍처는 큰 규모의 어플리케이션에 적합한 아키텍처인 만큼 실무에서는 훨씬 더 많은 원격 모듈을 사용하게 될 것입니다.

저는 원격 모듈을 추가할 때마다 Module Federation의 exposes를 수정해 주었는데요, 강남언니 프론트엔드 개발팀은 내부 패키지를 이용해서 특정 디렉토리 내 컴포넌트들을 자동으로 expose 하도록 개발했다고 해요.

expose를 자동화 처리한다면 파일명 오타, exposes 목록 내 누락 등 휴먼 에러를 방지할 수 있을 것 같네요!

원격 모듈을 추가할 때마다 호스트 어플리케이션에서 타입을 정의해 주어야 합니다.

실습에서는 호스트 어플리케이션이 원격 모듈을 사용할 때마다 declarations.d.ts 파일에 모듈 타입을 정의해 주었어요. 이는 원격 모듈을 추가할 때마다 호스트 어플리케이션을 수정해야 한다는 의미이고, 마이크로 프론트엔드 아키텍처의 "독립적 개발 및 배포"라는 장점을 상쇄시키게 됩니다.

강남언니 프론트엔드 개발팀은 expose-typed라는 CLI를 이용해서 모듈의 타입 선언 패키지를 만들고 배포하는 방법을 사용했다고 해요. 호스트 어플리케이션은 타입 선언 패키지를 설치하면 해당 모듈의 타입을 알 수 있게 되는 것이죠.

우아한형제들 배민커머스웹프론트개발팀은 module-federation/typescript 패키지를 이용하여 모듈의 타입 선언 파일을 만들고, 모듈과 함께 expose 하는 방법을 사용했다고 해요. 그러나 호스트 어플리케이션과 원격 어플리케이션의 양방향 타입 지원에 대한 한계를 느꼈고, native-federation-typescript라는 패키지를 사용하여 해결했다고 합니다.

원격 모듈의 타입을 호스트 어플리케이션에서 관리하지 않고, 강남언니와 우아한형제들처럼 원격 어플리케이션에서 관리하면 어플리케이션 간의 커플링 이슈를 해결할 수 있을 것 같아요.

공유 모듈을 잘 관리하기 위한 고민이 필요합니다.

실습에서는 리액트를 초기 청크에 불러오기 위해 shared 설정에 추가해 주었습니다. 그런데 라이브러리에 따라 초기 청크에 불러올지, 인스턴스를 몇 개 생성할지, 어떤 버전을 사용할지 등 꼼꼼한 관리가 필요해 보였어요. 모듈과 라이브러리가 늘어날수록 공유 모듈의 관리 난이도도 함께 올라갈 것 같은데요, 팀 내에서 충분한 논의 후 공유 모듈을 관리해야 할 것 같습니다.

🤓 마무리

실습을 통해 Module Federation은 마이크로 프론트엔드를 구현하는 데 (완벽한 도구는 아니지만) 꽤 좋은 도구인 것을 알게 되었어요. 강력한 장점을 가지고 있는 만큼 빨리 프로덕션에 적용해 보고 싶은데요, 하지만 위 아쉬운 점들 외에도 상태 관리 등 추가로 고려해야 할 부분들이 있고 웹팩 설정에 대한 러닝 커브 등 당장 도입은 쉽지 않은 것 같아요.

하지만 파트 내부에서 Module Federation 도입을 긍정적으로 검토하고 있기 때문에 프로덕션에 적용할 날이 그리 멀지 않은 것 같기도 합니다. 😊

📚 Reference

profile
Frontend Developer

0개의 댓글