React와 마이크로프런트엔드: 기본부터 고급까지 완전 가이드

발행: (2025년 12월 16일 오후 02:55 GMT+9)
12 min read
원문: Dev.to

Source: Dev.to

마이크로프런트엔드란 무엇일까?

이렇게 생각해 보세요 – 모든 사람이 커밋하는 거대한 React 앱 대신, 별도로 존재하고 서로 통신하는 작은 React 앱들로 나누는 것입니다.

클래식 예시:

  • 플랫폼 팀이 만든 헤더 컴포넌트
  • 커머스 팀이 만든 상품 목록
  • 체크아웃 팀이 만든 카트

모두 독립적으로 배포되고, 같은 페이지에서 동시에 실행됩니다. 이것이 마이크로프런트엔드 설정입니다.

Why I Started Using Them (The Real Reasons)

I was working on an e‑commerce platform with about five different teams, and our main app became a nightmare:

  • 200 KB bundle just for vendor‑dashboard stuff that 90 % of users never saw.
  • A change to the header meant rebuilding everything.
  • Deploy conflicts happened every day.

Micro‑frontends seemed like “okay, each team owns its domain, they ship code independently, no more conflicts.”
That part actually works—when you set it up right.

Source:

모듈 연합 실제 작동 방식

Webpack 5는 Module Federation을 도입했습니다. 이는 모든 코드를 미리 번들링하는 대신 런타임에 서로 다른 도메인/포트에서 코드를 로드할 수 있게 해줍니다.

Header 앱 ( localhost:3001 에서 실행)

// webpack.config.js
const { ModuleFederationPlugin } = require("webpack").container;

module.exports = {
  mode: "production",
  entry: "./src/index",
  output: {
    path: __dirname + "/dist",
    filename: "[name].[contenthash].js",
  },
  devServer: {
    port: 3001,
    headers: {
      "Access-Control-Allow-Origin": "*",
    },
  },
  plugins: [
    new ModuleFederationPlugin({
      name: "header",
      filename: "remoteEntry.js",
      exposes: {
        "./Header": "./src/Header",
      },
      shared: {
        react: { singleton: true, requiredVersion: "^18.0.0" },
        "react-dom": { singleton: true, requiredVersion: "^18.0.0" },
      },
    }),
  ],
};

핵심 포인트

속성의미
name다른 앱에 “나는 헤더 앱이다” 라고 알려줍니다.
exposes이 앱이 공유하는 내용(헤더 컴포넌트)을 선언합니다.
shared ( singleton 사용)“React를 아직 로드하지 않았다면 버전을 사용하고, React를 두 번 로드하지 마세요.”

메인 쉘 앱 ( localhost:3000 에서 실행)

new ModuleFederationPlugin({
  name: "mainApp",
  remotes: {
    header: "header@http://localhost:3001/remoteEntry.js",
  },
  shared: {
    react: { singleton: true, requiredVersion: "^18.0.0" },
    "react-dom": { singleton: true, requiredVersion: "^18.0.0" },
  },
});

원격 컴포넌트 사용

import React, { lazy, Suspense } from "react";

const Header = lazy(() => import("header/Header"));

export default function App() {
  return (
    <Suspense fallback={<div>loading header…</div>}>
      <Header />
      {/* rest of app */}
    </Suspense>
  );
}

예상보다 훨씬 잘 동작합니다. 헤더 앱은 독립적으로 로드되고, React는 한 번만 로드되며, 모든 것이 원활하게 작동합니다.

상태 관리: 나를 물어뜯은 부분

Here’s where I messed up initially. I thought, “I can just use the Context API across modules!”

Nope. Context only works within a single React tree. When you lazy‑load a component from a different webpack bundle, it’s technically a different React root. Your context provider lives in the shell, but the remote module has its own React instance. I burned about three days figuring that out.

실제로 작동하는 방법

옵션 1 – URL + localStorage (Simple approach)

// When user logs in in the auth module
const user = { id: 123, name: "Alice" };
localStorage.setItem("user", JSON.stringify(user));
window.location.hash = "#user-logged-in";

// In other modules, listen to storage events
useEffect(() => {
  const handleStorageChange = (e) => {
    if (e.key === "user") {
      const newUser = JSON.parse(e.newValue);
      setUser(newUser);
    }
  };
  window.addEventListener("storage", handleStorageChange);
  return () => window.removeEventListener("storage", handleStorageChange);
}, []);

Honestly? This works for simple stuff (user login, preferences, basic state). Not great for complex state.

옵션 2 – Window Events (Better)

// Auth module emits an event
function handleLogin(user) {
  const event = new CustomEvent("app:user-login", { detail: user });
  window.dispatchEvent(event);
}

// Header module listens
useEffect(() => {
  function handleUserLogin(e) {
    setUser(e.detail);
  }
  window.addEventListener("app:user-login", handleUserLogin);
  return () => window.removeEventListener("app:user-login", handleUserLogin);
}, []);

Each module stays independent and communicates via events. If one crashes, the others keep running.

옵션 3 – Shared State Service (What We Do Now)

// shared-state-service.js – tiny module both import
class StateService {
  constructor() {
    this.listeners = new Map();
    this.state = {
      user: null,
      theme: "light",
      cart: [],
    };
  }

  subscribe(key, callback) {
    if (!this.listeners.has(key)) {
      this.listeners.set(key, []);
    }
    this.listeners.get(key).push(callback);

    // Return an unsubscribe function
    return () => {
      const cbs = this.listeners.get(key);
      const idx = cbs.indexOf(callback);
      if (idx > -1) cbs.splice(idx, 1);
    };
  }

  setState(key, value) {
    this.state[key] = value;
    const cbs = this.listeners.get(key) || [];
    cbs.forEach((cb) => cb(value));
  }

  getState(key) {
    return this.state[key];
  }
}

// Export a singleton
export const stateService = new StateService();

모든 마이크로 프론트엔드에서 사용법

import { stateService } from "./shared-state-service";

// Update
stateService.setState("user", { id: 1, name: "Bob" });

// Subscribe
useEffect(() => {
  const unsubscribe = stateService.subscribe("user", setUser);
  return unsubscribe;
}, []);

This gives us a lightweight, framework‑agnostic way to share state without pulling in a full‑blown store.

TL;DR

  • 마이크로‑프론트엔드는 독립적인 팀이 서로 충돌하지 않으면서 코드를 배포할 수 있게 해 주지만, 런타임 복잡성을 증가시킵니다.
  • Webpack 5의 Module Federation은 단일 React 인스턴스를 공유하면서 별도의 번들을 실시간으로 로드할 수 있게 해 줍니다.
  • 상태 공유는 React Context만으로는 충분하지 않으므로, 애플리케이션 복잡도에 맞는 전략을 선택하세요 (localStorage + 이벤트, 커스텀 window 이벤트, 혹은 작은 공유‑state 서비스 등).

올바르게 설정하면 얻을 수 있는 이점은 큽니다: 빌드 속도 향상, 독립적인 배포, 그리고 더 건강한 코드베이스. 다만 추가적인 인프라 작업을 대비하세요. 즐거운 Federation 생활 되세요!

// shared/state-service.js
class StateService {
  constructor() {
    this.state = {};
    this.listeners = new Map();
  }

  setState(key, value) {
    this.state[key] = value;
    if (this.listeners.has(key)) {
      this.listeners.get(key).forEach((cb) => cb(value));
    }
  }

  getState(key) {
    return this.state[key];
  }

  subscribe(key, cb) {
    if (!this.listeners.has(key)) {
      this.listeners.set(key, new Set());
    }
    this.listeners.get(key).add(cb);
    // Return an unsubscribe function
    return () => this.listeners.get(key).delete(cb);
  }
}

export default new StateService();

모듈에서 서비스 사용하기

인증 모듈

// Auth module
import stateService from "shared/state-service";

function handleLogin(user) {
  stateService.setState("user", user);
}

헤더 모듈

// Header module
import stateService from "shared/state-service";
import { useEffect, useState } from "react";

export default function Header() {
  const [user, setUser] = useState(stateService.getState("user"));

  useEffect(() => {
    // Subscribe to changes and clean up on unmount
    return stateService.subscribe("user", setUser);
  }, []);

  return <>Hello {user?.name}</>;
}

이것이 현재 우리가 사용하는 방식입니다. 작고 신뢰성이 높으며, 각 모듈이 독립성을 유지합니다.

아무도 이야기하지 않는 내용

1. 버전 충돌은 실제 문제다

모듈 A는 lodash@4가 필요하고 모듈 B는 lodash@3가 필요할 상황이 발생합니다. 두 모듈이 모두 shared에 있기 때문에 프로덕션에 두 버전이 모두 포함되고 번들 크기가 갑자기 약 200 KB 정도 증가합니다.

우리가 하는 일: 셸 수준에서 버전을 고정합니다. 모든 원격 저장소는 반드시 우리가 지정한 버전을 사용해야 합니다.

2. 오류 처리는 고통스럽다

const Header = lazy(() =>
  import("header/Header").catch((err) => {
    console.error("header load failed", err);
    // Return fallback component
    return { default: () => <>Header unavailable</> };
  })
);

프로덕션에서 헤더 앱이 다운되면 전체 페이지가 깨지지는 않지만, 이를 명시적으로 처리해 주어야 합니다.

3. 로컬 개발이 엉망이 된다

헤더를 3001, 사이드바를 3002, 메인 앱을 3000에서 실행하고 있나요? 세 개를 한 번에 시작하는 스크립트가 필요합니다. DevTools가 혼란스러워지고, 코드가 여러 탭에 흩어져 있어 디버깅이 번거롭습니다.

우리는 이제 로컬 개발에 docker‑compose를 사용합니다.

마이크로‑프론트엔드가 실제로 의미가 있을 때

  • 여러 독립적인 팀이 서로 다른 일정에 배포
  • 대규모 앱으로, 서로 다른 도메인이 실제로 거의 상호작용하지 않을 때
  • 다양한 성능 요구사항 (예: 카트는 성능이 중요하고, 관리자 대시보드는 그렇지 않음)

그들이 과도할 때

  • 작은 팀, 단일 앱
  • 무거운 도메인 간 상태 공유
  • 실제로 모놀리식좋아할

솔직히? 우리는 지금 필요해서 사용하고 있고, 잘 작동합니다. 만약 다시 돌아갈 수 있다면, 메인 앱은 모놀리식으로 유지하고 정말 독립적인 기능에만 마이크로‑프론트엔드를 사용할 것 같습니다(예: 별도의 앱으로 된 관리자 대시보드).

내가 겪은 실제 함정

  • CSS 충돌 – 모듈 A는 body { font-size: 16px }를 설정하고, 모듈 B는 18px를 기대합니다. CSS 모듈이나 Shadow DOM을 사용하세요.
  • 번들 중복 – React를 공유로 표시하는 것을 잊어버려 세 번 로드되었습니다. 페이지 용량이 200 KB에서 500 KB로 급증했습니다.
  • CORS 문제remoteEntry.js가 올바른 헤더와 함께 제공되지 않아 모든 것이 조용히 실패했습니다.
  • 상태 불일치 – 한 모듈은 사용자 데이터를 캐시하고, 다른 모듈은 새로운 데이터를 받아 혼란이 발생했습니다.

내가 다르게 할 점

  • 마이크로‑프론트엔드가 진정으로 필요할 때까지는 모놀리식 구조를 유지하세요.
  • 10개가 아니라 2~3개의 원격만 시작하세요.
  • 첫날부터 상태 서비스를 위한 공유 라이브러리를 마련하세요.
  • 기능 플래그를 사용해 각 모듈을 점진적으로 롤아웃하세요.
  • 쉘/오케스트레이션 코드를 담당할 사람을 한 명 지정하세요.

실제로 도움이 된 자료

  • Module Federation 문서는 괜찮습니다.
  • Zack Jackson의 강연은 금과 같습니다.
  • 솔직히 말해서, 자신의 코드가 최고의 스승입니다.

마이크로 프론트엔드는 모든 사람에게 미래가 되는 것은 아닙니다. 특정 문제를 아주 잘 해결해 주는 도구입니다. 멋져 보여서 사용하지 마세요. 힘들게 배웠습니다.

Back to Blog

관련 글

더 보기 »