Skip to main content

Chapter 4. 개발 및 구현 방법

4.1 기본 설정 구조

마이크로 프론트엔드 환경에서는 각 애플리케이션이 독립적으로 빌드되고 배포되지만, 실행 시점에는 하나의 사용자 경험으로 연결되어야 한다. 이를 위해 Webpack의 ModuleFederationPlugin을 사용하여 애플리케이션 간 모듈을 동적으로 주고받는다.

webpack ModuleFederationPlugin

ModuleFederationPlugin은 애플리케이션을 크게 두 역할로 나눈다.

  • Host: 다른 애플리케이션의 모듈을 소비하는 쪽
  • Remote: 자신이 가진 모듈을 외부에 노출하는 쪽

이 구조를 통해 각 앱은 독립적으로 개발·배포되면서도, 런타임에 필요한 화면이나 기능을 조합할 수 있다.


host 설정

Host는 Remote 애플리케이션의 진입점(remoteEntry.js)을 등록하고, 필요한 모듈을 가져와 사용한다.

const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");

module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "host",
remotes: {
account: "account@http://localhost:3001/remoteEntry.js",
product: "product@http://localhost:3002/remoteEntry.js",
},
shared: {
react: { singleton: true, requiredVersion: "^18.0.0" },
"react-dom": { singleton: true, requiredVersion: "^18.0.0" },
},
}),
],
};

Host 설정의 핵심은 다음과 같다.

  • remotes: 가져올 Remote 앱의 이름과 URL을 정의한다.
  • shared: 여러 앱에서 공통으로 사용하는 라이브러리를 중복 로딩하지 않도록 설정한다.
  • 공통 라이브러리는 가능한 한 singleton으로 관리하여 런타임 충돌을 줄인다.

remote 설정

Remote는 외부에 공개할 모듈을 exposes로 등록한다.

const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");

module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "account",
filename: "remoteEntry.js",
exposes: {
"./AccountPage": "./src/pages/AccountPage",
"./UserMenu": "./src/components/UserMenu",
},
shared: {
react: { singleton: true, requiredVersion: "^18.0.0" },
"react-dom": { singleton: true, requiredVersion: "^18.0.0" },
},
}),
],
};

Remote 설정 시 유의할 점은 다음과 같다.

  • 외부에 공개할 대상은 페이지 단위 또는 기능 단위로 제한하는 것이 좋다.
  • 내부 구현 상세까지 과도하게 노출하면 의존성이 커지고 변경 비용이 높아진다.
  • filename은 Host가 접근할 수 있는 진입 파일 이름이다. 일반적으로 remoteEntry.js를 사용한다.

shared 설정

shared는 여러 앱에서 함께 사용하는 라이브러리의 중복 번들링과 버전 충돌을 줄이기 위한 설정이다.

shared: {
react: { singleton: true, eager: false, requiredVersion: "^18.0.0" },
"react-dom": { singleton: true, eager: false, requiredVersion: "^18.0.0" },
"react-router-dom": { singleton: true, requiredVersion: "^6.0.0" },
}

shared 설정 원칙은 다음과 같다.

  • React, React DOM, Router 같은 런타임 핵심 라이브러리는 singleton을 권장한다.
  • UI 라이브러리나 유틸 라이브러리는 공유 여부를 신중히 판단한다.
  • 공통 의존성을 무조건 공유하기보다, 실제 충돌 위험이 크거나 번들 크기에 큰 영향을 주는 패키지 위주로 공유한다.
  • 버전 불일치가 발생하면 런타임 에러나 예측 불가능한 동작이 생길 수 있으므로 버전 정책을 명확히 관리해야 한다.

4.2 실제 개발 방식

실제 구현 단계에서는 Remote 모듈을 어떤 단위로 가져오고, 어떤 시점에 로딩할지 결정하는 것이 중요하다.

remote component import

가장 기본적인 방식은 Host에서 Remote 컴포넌트를 직접 import하여 사용하는 것이다.

import React from "react";

const RemoteUserMenu = React.lazy(() => import("account/UserMenu"));

export default function Header() {
return (
<React.Suspense fallback={<div>Loading...</div>}>
<RemoteUserMenu />
</React.Suspense>
);
}

이 방식은 다음 상황에 적합하다.

  • 공통 헤더, 사용자 메뉴, 위젯 등 재사용 가능한 UI 조각
  • Host 안의 특정 위치에 삽입되는 독립 기능
  • 페이지 전체가 아니라 일부 컴포넌트만 분리하고 싶은 경우

lazy loading

Remote 모듈은 네트워크를 통해 로드되므로, 초기 진입 성능을 위해 지연 로딩이 중요하다.

  • 초기 화면에 반드시 필요하지 않은 Remote는 lazy loading을 적용한다.
  • Suspense와 fallback UI를 함께 구성해 로딩 중 사용자 경험을 보완한다.
  • 에러 발생 시를 대비해 ErrorBoundary를 함께 두는 것이 좋다.
const RemoteProductPage = React.lazy(() => import("product/ProductPage"));

권장 사항은 다음과 같다.

  • 페이지 단위 Remote는 기본적으로 지연 로딩한다.
  • 사용자 인터랙션 이후 필요한 기능도 가능한 한 동적 로딩한다.
  • 로딩 실패 시 재시도 UI 또는 대체 화면을 제공한다.

dynamic import

Remote URL이나 모듈 구성이 환경별로 달라질 수 있는 경우 동적 import 전략이 필요하다.

예를 들어:

  • 개발/스테이징/운영 환경에 따라 Remote 주소가 다름
  • 특정 테넌트만 사용하는 기능을 조건부로 로드
  • A/B 테스트 또는 플러그인 구조 지원

이 경우 단순 정적 import보다, 런타임 설정을 읽어 Remote를 연결하는 구조를 고려할 수 있다.

const loadRemote = async () => {
const module = await import("account/AccountPage");
return module;
};

동적 로딩을 사용할 때는 다음을 함께 고려해야 한다.

  • 로딩 실패 처리
  • 타임아웃 및 fallback 정책
  • 캐시 무효화 방식
  • 환경별 Remote 주소 관리 방식

route 기반 연동

Remote를 컴포넌트 단위가 아니라 라우트 단위로 연결하는 방식도 널리 사용된다.

import { Routes, Route } from "react-router-dom";

const AccountPage = React.lazy(() => import("account/AccountPage"));

export default function AppRouter() {
return (
<Routes>
<Route path="/" element={<HomePage />} />
<Route
path="/account/*"
element={
<React.Suspense fallback={<div>Loading...</div>}>
<AccountPage />
</React.Suspense>
}
/>
</Routes>
);
}

라우트 기반 연동의 장점은 다음과 같다.

  • 기능 경계가 명확하다.
  • 팀 간 소유권 분리가 쉽다.
  • 페이지 단위 배포와 책임 범위가 선명해진다.

반면 단점도 있다.

  • 라우팅 규칙이 복잡해질 수 있다.
  • Host와 Remote 간 URL 설계 충돌이 발생할 수 있다.
  • SEO, 인증, 에러 처리 기준을 통일해야 한다.

4.3 상태/라우팅 처리

마이크로 프론트엔드에서는 상태와 라우팅을 어디서 관리할지에 따라 전체 구조의 복잡도가 크게 달라진다.

host 중심 라우팅

대부분의 경우 라우팅의 최상위 제어권은 Host가 갖는 것이 바람직하다.

이유는 다음과 같다.

  • 앱 전체 URL 체계를 일관되게 유지할 수 있다.
  • 인증, 권한, 공통 레이아웃, 에러 페이지를 중앙에서 관리할 수 있다.
  • Remote 교체나 추가 시 전체 구조를 통제하기 쉽다.

권장 방식:

  • 최상위 경로는 Host가 관리한다.
  • Remote는 자신에게 할당된 하위 경로 영역만 담당한다.
  • 브라우저 히스토리와 네비게이션 규칙은 Host 기준으로 정의한다.

remote 내부 라우팅

Remote 내부에서도 자체 라우팅이 필요할 수 있다. 예를 들어 /account/profile, /account/orders처럼 하나의 도메인 안에 여러 하위 화면이 있는 경우다.

이 경우 다음 원칙을 권장한다.

  • Host는 /account/*까지만 책임지고,
  • 그 이후의 세부 라우팅은 Remote 내부에서 처리한다.

예시:

  • Host: /account/*
  • Remote(account): /profile, /orders, /settings

이 구조는 Remote의 독립성을 높이지만, 다음 문제를 주의해야 한다.

  • 브레드크럼, 메뉴 활성화 상태, 페이지 타이틀을 Host와 어떻게 맞출 것인지
  • 뒤로가기/새로고침 시 라우팅이 정확히 복원되는지
  • 인증 만료, 404, 권한 부족 등의 예외를 누가 처리할지

global state 공유 여부 판단

Global state는 공유할수록 편해 보이지만, 실제로는 앱 간 결합도를 크게 높인다. 따라서 정말 필요한 상태만 최소한으로 공유하는 것이 원칙이다.

공유를 고려할 수 있는 상태:

  • 로그인 사용자 정보
  • 인증 토큰 또는 세션 상태
  • 전역 테마
  • 다국어 설정
  • 전사 공통 권한 정보

공유를 지양해야 하는 상태:

  • 특정 페이지 내부 UI 상태
  • Remote 전용 폼 입력값
  • 한 기능에만 종속된 비즈니스 상태

판단 기준은 다음과 같다.

  • 여러 앱이 동시에 반드시 알아야 하는가?
  • 실시간 동기화가 필요한가?
  • Host가 중앙 관리하지 않으면 사용자 경험이 깨지는가?

답이 명확하지 않다면 공유하지 않는 편이 낫다.


이벤트/props 기반 통신

앱 간 통신은 가능한 한 단순하게 유지해야 한다. 가장 기본적인 방식은 다음 두 가지다.

1) props 전달

Host가 Remote에 필요한 값과 콜백을 전달하는 방식이다.

<RemoteUserMenu
user={user}
onLogout={handleLogout}
/>

장점:

  • 구조가 단순하다.
  • 의존 관계가 명확하다.
  • 테스트가 쉽다.

2) 커스텀 이벤트

서로 직접 props 관계가 아니거나, 느슨한 결합이 필요한 경우 브라우저 이벤트를 사용할 수 있다.

window.dispatchEvent(
new CustomEvent("cart:updated", {
detail: { itemCount: 3 },
})
);
window.addEventListener("cart:updated", (event) => {
console.log(event);
});

이벤트 방식은 유연하지만 다음을 관리해야 한다.

  • 이벤트 이름 충돌 방지
  • payload 구조 명세
  • 발행/구독 해제 누락 방지
  • 디버깅 난이도 증가

따라서 이벤트 통신은 반드시 명확한 계약 문서와 함께 사용해야 한다.


4.4 타입과 계약 관리

Remote 간 연결이 늘어날수록 가장 중요한 것은 구현 자체보다 계약(contract) 관리다. 타입이 맞지 않거나 응답 형식이 달라지면 런타임에서 문제가 발생한다.

TypeScript 타입 공유 전략

TypeScript를 사용하면 Host와 Remote 간 전달하는 props, 이벤트 payload, API 응답 타입을 명시적으로 관리할 수 있다.

대표 전략은 다음과 같다.

  • 공통 타입을 별도 패키지로 분리
  • 각 앱이 해당 패키지를 의존성으로 참조
  • 배포 전 타입 호환성을 CI에서 검사

예시:

export interface UserSummary {
id: string;
name: string;
role: "admin" | "user";
}

export interface UserMenuProps {
user: UserSummary;
onLogout: () => void;
}

contract package 운영

공통 계약은 contract package로 운영하는 것이 좋다.

예:

  • @company/contracts
  • @company/types
  • @company/mfe-contracts

이 패키지에는 다음이 포함될 수 있다.

  • 컴포넌트 props 타입
  • Custom Event payload 타입
  • API 요청/응답 타입
  • 공통 enum
  • 스키마 정의
  • 버전 정보

운영 원칙은 다음과 같다.

  • 구현 코드와 계약 코드를 분리한다.
  • 계약 변경은 일반 기능 개발보다 더 엄격하게 리뷰한다.
  • 각 앱은 contract package의 버전을 명시적으로 관리한다.

schema 기반 검증

TypeScript 타입은 컴파일 시점 검증에는 유리하지만, 런타임 데이터의 안전성을 보장하지는 않는다. 따라서 외부에서 들어오는 값이나 앱 간 교환 데이터는 schema 기반 검증을 함께 사용하는 것이 좋다.

예를 들어 zod와 같은 라이브러리를 사용할 수 있다.

import { z } from "zod";

export const UserSchema = z.object({
id: z.string(),
name: z.string(),
role: z.enum(["admin", "user"]),
});

export type UserSummary = z.infer<typeof UserSchema>;

이 방식의 장점은 다음과 같다.

  • 런타임 데이터 검증 가능
  • 타입과 검증 규칙을 함께 관리 가능
  • 예기치 않은 breaking change를 조기에 발견 가능

특히 다음 상황에서 유용하다.

  • Remote가 전달하는 props
  • 이벤트 payload
  • Backend API 응답
  • localStorage/sessionStorage 복원 데이터

breaking change 관리

마이크로 프론트엔드에서는 한 앱의 변경이 다른 앱을 깨뜨릴 수 있으므로 breaking change 관리가 매우 중요하다.

권장 원칙:

  • 계약 변경 시 semantic versioning을 적용한다.
  • 하위 호환이 깨지는 경우 major 버전을 올린다.
  • 필드 제거보다 추가 중심의 확장 방식을 우선한다.
  • 배포 전에 Host-Remote 조합 테스트를 자동화한다.

예:

  • 1.2.0 → 필드 추가
  • 2.0.0 → 필드 제거, 필수값 변경, 이벤트 이름 변경

추가로 권장되는 운영 방식:

  • deprecated 필드를 일정 기간 유지
  • migration guide 제공
  • CI에서 계약 호환성 체크 수행
  • 운영 배포 전 통합 Smoke Test 수행

4.5 로컬 개발 환경

로컬 환경에서는 여러 앱을 동시에 실행해야 하므로 실행 편의성과 장애 대응 전략이 중요하다.

여러 앱 동시 실행

Host와 여러 Remote를 함께 개발하려면 동시에 실행할 수 있어야 한다.

일반적인 방법:

  • 루트 워크스페이스에서 병렬 실행 스크립트 제공
  • pnpm, turbo, nx, yarn workspaces 등 모노레포 도구 활용
  • concurrently 같은 도구로 다중 dev server 실행

예시:

{
"scripts": {
"dev": "concurrently \"pnpm --filter host dev\" \"pnpm --filter account dev\" \"pnpm --filter product dev\""
}
}

dev server 포트 관리

각 앱은 충돌 없는 고정 포트를 사용하는 것이 좋다.

예시 규칙:

  • Host: 3000
  • Account Remote: 3001
  • Product Remote: 3002

운영 원칙:

  • 포트 규칙을 팀 내 문서로 고정한다.
  • 환경 변수 또는 공통 설정 파일로 관리한다.
  • 포트 충돌 시 자동 대체보다 명시적 실패가 디버깅에 유리하다.

예:

HOST_PORT=3000
ACCOUNT_PORT=3001
PRODUCT_PORT=3002

mock/fallback remote

로컬 개발 시 모든 Remote가 항상 실행되고 있지는 않다. 이 경우 mock 또는 fallback 전략이 필요하다.

대표 방식:

  • Remote 미실행 시 로컬 mock 컴포넌트 사용
  • 연결 실패 시 placeholder 화면 노출
  • 개발 환경에서는 특정 Remote를 비활성화 가능하도록 설정

예시 개념:

const RemoteComponent = isRemoteAvailable
? React.lazy(() => import("account/UserMenu"))
: LocalMockUserMenu;

이 전략의 장점:

  • 특정 팀이 다른 팀 개발 상태에 덜 의존한다.
  • 기능 단위 독립 개발이 가능하다.
  • 로컬 개발 생산성이 높아진다.

독립 개발과 통합 테스트 방식

마이크로 프론트엔드에서는 독립 개발통합 검증을 모두 지원해야 한다.

독립 개발

각 Remote는 단독 실행 가능한 상태를 유지해야 한다.

  • 자체 라우팅으로 단독 구동 가능
  • API mock 또는 fixture 제공
  • 공통 의존성 없이도 핵심 화면 검증 가능
  • Storybook 또는 단독 샌드박스 활용 가능

통합 테스트

최종적으로는 Host와 연결한 상태에서 반드시 통합 테스트가 필요하다.

권장 테스트 레벨:

  • 단위 테스트: 개별 컴포넌트/유틸 검증
  • 계약 테스트: Host-Remote 간 props, 이벤트, 스키마 검증
  • 통합 테스트: 실제 Host에 Remote 연결 후 주요 사용자 흐름 검증
  • E2E 테스트: 로그인, 이동, 데이터 조회 등 핵심 시나리오 검증

특히 다음 항목은 통합 테스트에서 반드시 확인해야 한다.

  • Remote 로딩 실패 시 fallback 동작
  • 라우팅 이동 및 뒤로가기 동작
  • 인증/권한 처리 일관성
  • 공통 상태 반영 여부
  • 버전 불일치 시 에러 발생 여부

정리

이 장에서 중요한 핵심 원칙은 다음과 같다.

  1. 라우팅과 공통 정책은 Host 중심으로 관리한다.
  2. Remote는 페이지 또는 기능 단위로 명확한 경계를 가진다.
  3. 상태 공유는 최소화하고, props 또는 이벤트 기반 통신을 우선한다.
  4. 타입과 계약은 별도 패키지와 스키마 검증으로 관리한다.
  5. 각 앱은 독립 실행 가능해야 하며, 최종적으로는 통합 테스트로 검증한다.

원하면 다음 단계로 이어서 “문서체로 더 딱딱하게 다듬은 버전” 또는 “실제 사내 가이드 형식(원칙/예시/주의사항)”으로 재작성해주겠다.