(번역) 모듈 페더레이션으로 마이크로프론트 아키텍처 문제 해결하기

dosilv·2025년 1월 26일
0

모듈 페더레이션이 뭐고, 어떻게 쓰면 될까? 🤔

원문: https://blog.logrocket.com/solving-micro-frontend-challenges-module-federation/

마이크로 프론트엔드는 모놀리식* 프론트엔드 애플리케이션을 작고 관리하기 쉬운 부분으로 분리하는 효과적인 방법이 되었습니다. 이 방식은 애플리케이션의 확장성을 높이고, 팀이 복잡한 문제를 해결해 더 일관성 있는 양질의 솔루션을 제공할 수 있게 합니다.

webpack Module Federation은 독립적인 애플리케이션들이 코드와 의존성을 공유할 수 있게 해주는 도구입니다. 이 글에서는 모듈 페더레이션이 어떻게 작동하는지와 마이크로 프론트엔드에서의 중요성, 그리고 통합 시에 발생하는 흔한 문제들을 효과적으로 해결하기 위한 전략에 대해 자세히 알아보겠습니다.

*모놀리식: 하나의 통합된 코드 베이스로 여러 비즈니스 기능을 수행하는 전통적인 아키텍처를 의미

모듈 페더레이션이 무엇인가요?

webpack 5에서 도입된 webpack 모듈 페더레이션은 JavaScript 애플리케이션이 코드를 공유하고 런타임에 동적으로 모듈을 가져올 수 있게 해주는 기능입니다.
이 현대적인 의존성 공유 방식은 중복되는 것들을 제거하고, 불필요한 코드 없이 다른 애플리케이션 간에 라이브러리와 의존성을 유연하게 공유할 수 있도록 합니다. 이를 통해 애플리케이션은 런타임에 필요한 코드만 로드합니다.

모듈 페더레이션은 어떻게 작동하나요?

Module Federation은 호스트 애플리케이션과 원격 애플리케이션의 개념을 도입합니다.
호스트(host): 다른 애플리케이션의 모듈을 사용하는 애플리케이션
원격(remote): 호스트가 사용할 수 있도록 모듈을 제공하는 애플리케이션
아래는 webpack의 ModuleFederationPlugin을 사용해 호스트와 원격을 설정하는 configuration 예시입니다.

plugins: [
  new ModuleFederationPlugin({
    name: 'host',
    remotes: {
      app1: 'app1@http://localhost:3001/remoteEntry.js',
    },
    shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
  }),
];
  • name: 호스트 이름 정의
  • remote: 원격 애플리케이션(예: app1)과 경로(예: remoteEntry.js) 명시
  • shared: React, React Dom처럼 두 앱에서 공유하는 의존성이 동일한 버전을 사용하도록 보장

다음은 원격 configuration 예시입니다.

plugins: [
  new ModuleFederationPlugin({
    name: 'app1',
    filename: 'remoteEntry.js',
    exposes: {
      './Button': './src/Button',
    },
    shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
  }),
],
  • name: 원격 애플리케이션 이름 지정 (예: home).
  • filename: 원격에서 사용 가능한 파일 이름 지정
  • exposes: 호스트가 접근할 수 있는 모듈 명시 (예: ./Button).
  • shared: 호스트 설정과 동일하게, 의존성 버전이 일관되도록 보장

더 잘 이해하기 위해 리액트 애플리케이션을 설정하는 상황을 고려해 봅시다. 개별적인 리액트 프로젝트라고 상상해 보세요.

  1. 제품 캐러셀 컴포넌트가 있는 Home App
  2. 같은 제품 캐러셀 컴포넌트를 재사용해야 하는 Search App

전통적인 접근 방식에서는 캐러셀을 npm 패키지로 분리한 후, 코드를 리팩토링하고 private 혹은 public npm에 게시할 것입니다. 그렇게 되면 변경사항이 있을 때마다 두 애플리케이션에서 이 패키지를 설치하고 업데이트해야 할 겁니다. 이 과정은 지루하고 오래 걸리며, 종종 버전 관리 문제로 이어진다는 것을 알게 될 거예요.

모듈 페더레이션은 이러한 수고를 덜어줍니다. 모듈 페더레이션을 이용하면 Home App이 계속 캐러셀 컴포넌트를 가지고 있습니다. 그럼 Search App은 런타임에 동적으로 캐러셀을 가져옵니다.

다음은 그 작동 방식입니다:

// Home App의 webpack.config.js
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'home',
      filename: 'remoteEntry.js',
      exposes: {
        './Carousel': './src/components/Carousel',
      },
      shared: ['react', 'react-dom'], // Share dependencies
    }),
  ],
};
// Search App의 webpack.config.js
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'search',
      remotes: {
        home: 'home@http://localhost:3000/remoteEntry.js',
      },
    }),
  ],
};
// Search App은 동적으로 Carousel component를 받아옴
import React from 'react';
const Carousel = React.lazy(() => import('home/Carousel'));
export const App = () => (
  <React.Suspense fallback={<div>Loading...</div>}>
    <Carousel />
  </React.Suspense>
);

모듈 페더레이션과 마이크로 프론트엔드가 본질적으로 연관이 있나요?

모듈 페더레이션과 마이크로 프론트엔드는 종종 함께 사용되지만, 본질적으로 의존관계에 있는 것은 아닙니다.

마이크로 프론트엔드 아키텍처는 모놀리식 프론트엔드를 더 작고 독립적인 애플리케이션으로 분리해 개발을 더 모듈화하고 확장 가능하게 합니다. 개발자들은 iframe, 서버 사이드 렌더링, 또는 모듈 페더레이션과 같은 도구를 사용해 마이크로 프론트엔드를 구현할 수 있습니다.

반면, 모듈 페더레이션은 애플리케이션 간에 코드와 의존성을 공유하기 위한 강력한 도구입니다. 마이크로 프론트엔드에 유용하지만, 모놀리식 애플리케이션에서도 독립적으로 사용할 수 있습니다.

마이크로 프론트엔드 없이 모듈 페더레이션을 사용할 수 있나요?

물론이죠. 모듈 페덜데이션은 마이크로 프론트엔드에만 국한되지 않습니다. 예를 들어, 여러 모놀리식 애플리케이션끼리 디자인 시스템을 공유할 수 있게 해줍니다. 또한 단일 페이지 애플리케이션(SPA)에서 플러그인이나 기능을 동적으로 불러올 수 있어, 전체 앱을 다시 빌드하지 않고 업데이트할 수 있습니다.

마이크로 프론트엔드가 모듈 페더레이션에 의존적인가요?

아니요, 마이크로 프론트엔드에 모듈 페더레이션이 꼭 필요한 것은 아닙니다. 서버 사이드 인클루드(SSI), 커스텀 JavaScript 프레임워크, 또는 정적 번들링과 같은 다른 방법을 사용해 구축할 수도 있습니다.

하지만 모듈 페더레이션은 코드 공유와 의존성 관리를 쉽게 해주기 때문에 많은 개발자들이 선호하는 도구입니다.

모듈 페더레이션이 왜 중요한가요?

모듈 페더레이션은 코드 중복을 줄이고 애플리케이션들이 공유하는 모듈을 쉽게 업데이트할 수 있게 해준다는 점에서 중요한 역할을 합니다. 이러한 효율성 덕분에 애플리케이션은 가볍고, 유지보수가 쉬우며, 최신 상태를 유지할 수 있습니다.

모듈 페더레이션의 통합 과제

확장성이 뛰어난 애플리케이션을 구축하고자 할 때 Module Federation은 많은 이점이 있습니다. 하지만 모든 기술이 그러하듯 적용 과정에서 직면할 수 있는 고유한 문제들이 있습니다. 주요 문제점과 이를 효과적으로 해결하는 방법을 살펴보겠습니다.

모듈 페더레이션의 스타일 충돌

문제점

마이크로 프론트엔드 아키텍처에서 종종 여러 팀이 같은 CSS 프레임워크(예: Tailwind CSS)를 사용합니다. 두 개의 마이크로 프론트엔드가 button이나 primary-btn과 같은 전역 클래스명을 사용하면, 스타일이 덮어써지거나 예상치 못한 결과가 발생할 수 있습니다.

예를 들어, 호스트 애플리케이션이 파란색 배경으로 button 클래스를 적용하고 원격 애플리케이션이 빨간색 배경으로 button 클래스를 적용하는 경우, 두 애플리케이션을 통합하면 서로 스타일을 덮어쓰게 됩니다. 이는 일관성 없는 디자인으로 이어져 사용자 경험에 영향을 미칩니다.

해결방안

스타일 충돌을 피하기 위해 Tailwind CSS의 prefix 옵션을 사용하세요. 이는 원격 애플리케이션의 모든 클래스 이름이 고유하도록 보장합니다. 이렇게 하면 스타일을 분리하고 호스트 애플리케이션과의 충돌을 방지할 수 있습니다.

이를 구현하려면 먼저 tailwind.config.js 파일에 고유한 접두사를 추가하세요:

module.exports = {
  prefix: 'remote-', // 원격 앱의 모든 클래스에 'remote-'를 붙임
};

이러한 설정을 통해 Tailwind CSS는 모든 클래스명에 자동으로 접두사를 붙입니다. 예를 들어,

  • btn-primaryapp1-btn-primary로,
  • text-lgapp1-text-lg로 변환됩니다.
    그리고 컴포넌트에 이러한 접두사가 붙은 클래스명을 사용하세요.
const MyButton = () => (
  <button className="remote-btn-primary remote-text-lg">
    Click Me
  </button>
);

이렇게 하면 최종적으로 빌드된 애플리케이션에서 원격 앱의 remote-btn-primary가 호스트 애플리케이션의 유사한 클래스명 host-btn-primary와 충돌하지 않습니다.

의존성 버전 불일치 해결하기

문제점

호스트 애플리케이션은 React 18.2.0을, 원격 애플리케이션이 React 17.0.2를 사용한다고 가정해봅시다. 이러한 불일치로 인해 React 인스턴스가 중복되어 useState, useEffect나 공유되는 컨텍스트 등이 오작동할 수 있습니다.

해결방안

이를 해결하기 위해 webpack의 모듈 페더레이션으로 공유하는 의존성을 하나의 버전으로 강제할 수 있습니다.

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'remoteApp',
      filename: 'remoteEntry.js',
      remotes: {},
      exposes: {},
      shared: {
        react: {
          singleton: true,
          requiredVersion: '^18.2.0',
        },
        'react-dom': {
          singleton: true,
          requiredVersion: '^18.2.0',
        },
      },
    }),
  ],
};

이 설정은 모든 마이크로 프론트엔드가 하나의 React 버전을 사용하도록 보장합니다.

Nx나 Turborepo와 같은 모노레포를 사용한다면, package.json에서 버전을 강제할 수 있습니다.

{
  "resolutions": {
    "react": "18.2.0",
    "react-dom": "18.2.0"
  }
}

마이크로 프론트엔드에서의 전역 상태 관리

문제점

마이크로 프론트엔드 간에 상태를 공유할 때, 종종 문제가 발생할 수 있습니다. 예를 들어, 사용자 인증을 관리하는 호스트 애플리케이션과 장바구니를 관리하는 원격 애플리케이션이 있다고 가정해봅시다. 두 애플리케이션 간 사용자 데이터 동기화나 인증 토큰 전달은 빠르게 복잡해질 수 있습니다.

해결

이 문제를 해결하기 위해 Redux, RxJS 또는 Custom Event API 같은 중앙 집중식 상태 관리 도구를 사용할 수 있습니다.

먼저, 마이크로 프론트엔드 간 통신을 위해 공유할 Redux 스토어를 생성합니다.

import { configureStore } from '@reduxjs/toolkit';
const store = configureStore({
  reducer: {
    user: userReducer,
    cart: cartReducer,
  },
});
export default store;

그리고 window.dispatchEventwindow.addEventListener를 이용해 이벤트를 전송하세요.

// Host App: Emit login event
window.dispatchEvent(new CustomEvent('user-login', { detail: { userId: '12345' } }));
// Remote App: Listen for login event
window.addEventListener('user-login', (event) => {
  console.log('User logged in:', event.detail.userId);
});

마이크로 프론트엔드간 라우팅 충돌

문제점

라우팅 충돌은 각각의 마이크로 프론트엔드가 일치하거나 겹치는 경로를 정의할 때 발생합니다. 예를 들어, 호스트와 원격 애플리케이션이 각각 /settings 경로를 생성하면 예측할 수 없는 문제가 발생할 수 있습니다. 한 경로가 다른 경로를 덮어쓰거나, 유저가 잘못된 페이지로 이동할 수 있습니다.

해결방안

라우팅 충돌을 해결하려면 지연 로딩과 고유한 라우트 네임스페이스를 사용하세요. 서로 간섭하지 않도록 각 마이크로 프론트엔드가 자체 경로를 독립적으로 관리하도록 합니다.

지연 로딩은 필요할 때만 경로를 가져오기 때문에 라우팅을 깔끔하고 충돌 없이 유지할 수 있습니다. 아래처럼 구현할 수 있습니다.

const routes = [
  { path: '/host', loadChildren: () => import('host/Routes') },
  { path: '/remote', loadChildren: () => import('remote/Routes') },
];

이 설정에서 /host로 이동하면 host/Routes에 정의된 경로만 가져오고, /remote로 이동하면 remote/Routes의 경로를 가져옵니다. 이를 통해 각 애플리케이션이 격리된 상태를 유지하고 충돌을 피할 수 있습니다.

또한 /settings와 같이 유사한 이름을 가진 페이지에 대해서도, 네임스페이스를 사용해 각 마이크로 프론트엔드가 고유한 경로를 갖도록 할 수 있습니다.

네임스페이스 사용의 예시입니다.

  • 호스트 앱: /app1/settings
  • 원격 앱: /app2/settings
const app1Routes = [
  { path: '/app1/settings', component: SettingsComponent },
  { path: '/app1/profile', component: ProfileComponent },
];
const app2Routes = [
  { path: '/app2/settings', component: SettingsComponent },
  { path: '/app2/notifications', component: NotificationsComponent },
];

접두사가 붙은 네임스페이스(/app1/, /app2/)를 사용하면 경로가 중복되는 것을 피할 수 있습니다.

동적 모듈 로딩 오류

문제점

마이크로 프론트엔드의 동적 임포트는 모듈 로딩에 실패할 경우 오류를 발생시킬 수 있습니다. 예를 들어, 호스트 애플리케이션이 페더레이션된 모듈의 경로를 잘못 설정하면 404 오류가 발생할 수 있습니다. 호스트가 원격 애플리케이션에서 공유 컴포넌트를 가져오려고 하는데, 모듈의 URL이 잘못되었거나 사용 불가능한 상황을 상상해보세요.

해결방안

webpack의 publicPath를 올바르게 설정해서 동적 임포트가 항상 올바른 경로를 가져올 수 있도록 하세요.

먼저, webpack의 output.publicPathauto로 설정해 모듈에 대한 올바른 경로를 동적으로 알아내도록 합니다.

module.exports = {
  output: {
    publicPath: 'auto', // 동적 임포트를 위해 자동으로 경로를 해석
  },
};

publicPath가 설정되면 React 애플리케이션에서 연관된 모듈을 동적으로 가져올 수 있습니다.

import React from 'react';
// 원격 컴포넌트 지연 로드
const MyRemoteComponent = React.lazy(() => import('app2/MyComponent'));
const App = () => (
  <React.Suspense fallback={<div>Loading...</div>}>
    <MyRemoteComponent />
  </React.Suspense>
);
export default App;

이렇게 하면 React.lazy가 원격 모듈(app2)에서 MyComponent를 로드합니다. 모듈 로딩에 시간이 걸리면 fallback UI(예시의 <div>Loading...</div>)가 유지됩니다.

마이크로 프론트엔드 간 리소스 공유

문제점

마이크로 프론트엔드는 일반적으로 이미지, 폰트, 스타일 또는 유틸리티 함수와 같은 공통 asset에 접근해야 합니다. 중앙화된 처리 방법 없이 각 마이크로 프론트엔드에 이러한 리소스를 이중으로 복제하게 되면 번들 크기가 커지고, 시각적 일관성이 유지되지 않으며, 페이지 로드 속도가 느려질 수 있습니다.

예를 들어, 날짜를 포맷팅하는 유틸리티 함수와 UI용 사용자 정의 폰트를 가진 호스트 애플리케이션이 있고, 동일한 유틸리티와 폰트 파일을 복제한 원격 애플리케이션이 있다고 가정해 보겠습니다. 이들을 함께 로드하면 불필요한 중복으로 시간을 낭비하고 성능이 악화될 수 있습니다.

해결방안

애플리케이션 전반에서 공유되는 리소스를 효율적으로 처리하려면 이를 한 곳에 생성하고, 모든 마이크로 프론트엔드가 일관되게 접근해 사용할 수 있도록 해야 합니다.

이를 위해 먼저 폰트, 스타일시트 또는 스크립트와 같은 공용 asset을 CDN이나 공용 서버에 둡니다. 이렇게 하면 모든 마이크로 프론트엔드가 동일한 소스를 공유하기 때문에 중복이 줄어들고 로드 성능이 향상됩니다.

예를 들어 전역 스타일시트와 유틸리티를 호스팅하려는 경우, 아래처럼 공용 리소스에 추가할 수 있습니다.

<link rel="stylesheet" href="https://cdn.example.com/styles/global.css" />  
<script src="https://cdn.example.com/utils.js"></script>  

브라우저 캐싱을 활용하면 이러한 공용 리소스가 업데이트될 때 모든 마이크로 프론트엔드에 자동으로 반영되어 앱의 성능이 향상됩니다.

그런 다음, 중복 코드를 피하기 위해 재사용 가능한 함수나 유틸리티를 공유 라이브러리로 추출하여 모든 마이크로 프론트엔드에서 사용할 수 있도록 게시합니다.

예를 들어, 공용 utils/formatDate.js 라이브러리에 날짜 유틸리티 함수를 만든다고 가정해 보겠습니다.

export const formatDate = (date) => new Intl.DateTimeFormat('en-US').format(date);

라이브러리를 Verdaccio 같은 프라이빗 npm 레지스트리에 게시합니다.

npm publish --registry https://registry.example.com/ 

그리고 이를 마이크로 프론트엔드에 설치하고 사용합니다.

npm install @myorg/utils  

이제 코드에서 사용할 수 있습니다.

import { formatDate } from '@myorg/utils';  
console.log(formatDate(new Date())); // Output: 12/28/2024 

공유 리소스를 CDN에 호스팅하고 공유 라이브러리를 사용하면, 불필요한 중복을 줄이고 모든 마이크로 프론트엔드에서 일관된 동작을 보장할 수 있습니다.

항상 필요하지 않은 리소스의 경우, 성능 최적화를 위해 동적으로 로드할 수 있습니다. 아래는 유틸리티를 동적으로 가져오는 예시입니다.

import('https://cdn.example.com/utils.js').then((utils) => {
  const formattedDate = utils.formatDate(new Date());
  console.log(formattedDate);
});

이러한 방법으로 필요한 것만 로드하면 애플리케이션의 초기 로딩 시간을 효과적으로 줄일 수 있습니다. 또한 필요할 때 최신 리소스를 가져오도록 보장합니다.

결론

모듈 페더레이션은 마이크로 프론트엔드 프로젝트에서 의존성을 관리하고 코드를 공유할 수 있는 획기적인 도구입니다. 통합하는 과정이 어려울 수 있지만, 이 가이드에서 설명한 좋은 사례들이 스타일 충돌, 버전 불일치, 라우팅 오류 등의 일반적인 문제들을 해결하는 데 도움이 될 것입니다.

즐거운 코딩 되세요!


내맘대로 요약 정리

모듈 페더레이션(Module Federation)이란?

  • webpack 5에서 도입된 기술로, 마이크로 프론트엔드 프로젝트의 코드와 의존성들을 효율적으로 공유할 수 있는 방법
  • 원격(romte) 애플리케이션이 제공하는 모듈을 호스트(host) 애플리케이션이 사용

🔍 사용 시 발생 가능한 문제점들과 해결방안

  • 스타일 충돌 ➡️ Tailwind CSS의 prefix 옵션으로 고유한 접두사 추가
  • 의존성 버전 충돌 ➡️ config에서 단일 버전 강제
  • 전역 상태 관리 ➡️ Redux, RxJS, Custom Event API등을 이용한 이벤트 브로드캐스팅
  • 라우팅 충돌 ➡️ Lazy loading, 접두사를 사용한 고유한 경로 정의
  • dynamic import 오류 ➡️ config에서 publicPath를 auto로 설정
  • 중복 리소스 문제 ➡️ 공유 라이브러리 개발
profile
DevelOpErUN 성장일기🌈

0개의 댓글