Microfrontends with Turborepo: Build to Runtime Sharing

Turborepo 사용자를 위한 마이크로 프론트엔드 완전 정복: 빌드타임 공유에서 런타임 공유로

Microfrontends with Turborepo: Build to Runtime Sharing

모노레포를 운영하다 보면, 어느 순간 조용히 벽에 부딪히는 경험을 하게 됩니다.

처음엔 모든 게 순조롭습니다. Turborepo는 빠르게 돌아가고, 공유 패키지는 깔끔하게 버전 관리되고, 빌드 캐시는 제대로 작동합니다. 그런데 제품이 커지고 팀이 늘어나면서, 그 아름다웠던 모노레포가 슬슬 발목을 잡기 시작하죠.

공유 컴포넌트 라이브러리 하나 고쳤을 뿐인데 배포가 줄줄이 엮이고, 누가 이 패키지 소유자냐는 논쟁이 시작되고, 한 팀의 변경이 다른 팀의 릴리즈를 막는 상황이 반복됩니다.

이 글은 바로 그 지점에서 시작합니다. Turborepo를 실제로 사용하는 개발자 입장에서, 마이크로 프론트엔드(MFE) 아키텍처가 왜 현실적인 선택지가 되는지, 그리고 어떻게 접근해야 하는지를 구체적으로 풀어봅니다. 이론이 아니라, 실제로 마주치는 문제들을 중심으로요.


Turborepo가 잘하는 것, 그리고 한계가 오는 순간

런타임 공유의 장점을 이해하기 전에, 먼저 빌드타임 패키지 공유가 왜 그토록 매력적인지 — 그리고 왜 한계가 오는지 — 를 짚고 넘어가야 합니다.

Turborepo의 핵심 강점

Turborepo는 JavaScript/TypeScript 모노레포를 위한 고성능 빌드 시스템입니다. 핵심 가치는 단순하지만 강력합니다. 여러 패키지를 하나의 저장소에서 관리하면서, 지능적인 캐싱과 병렬 태스크 실행, 명확한 의존성 그래프를 제공하는 것이죠.

실무적으로는 이런 게 가능해집니다:

  • ui-components 패키지를 다섯 개의 앱에서 코드 중복 없이 공유
  • turbo build 한 번으로 변경되지 않은 패키지는 자동으로 스킵
  • 디자인 시스템, 유틸 함수, API 클라이언트를 전체 제품 라인업에서 일관되게 유지

소규모에서 중규모 팀에게는 이 조합이 정말 강력합니다. 런타임 복잡성 없이 코드 재사용을 달성할 수 있고, 개발자 경험도 훌륭합니다.

균열이 시작되는 지점

문제는 빌드타임 공유의 본질적인 특성에 있습니다. 공유 의존성이 변경될 때마다 배포가 함께 엮입니다.

버튼 컴포넌트 하나를 수정했나요? 그 패키지를 사용하는 모든 앱이 다시 빌드되고 배포되어야 합니다. 조직이 커질수록 이 구조는 실질적인 병목이 됩니다:

  • 배포 결합(Deployment Coupling): 팀들이 독립적으로 배포할 수 없습니다. 공유 패키지 하나의 변경이 여러 제품 영역의 릴리즈를 막습니다.
  • 번들 중복: 각 앱이 공유 코드를 자체 번들에 포함시킵니다. 사용자 입장에서는 React 같은 라이브러리를 여러 번 내려받게 됩니다.
  • 버전 드리프트: 팀마다 내부 패키지의 서로 다른 버전을 고정(pin)하면서 "내 로컬에서는 됩니다" 문제가 반복됩니다.
  • 거버넌스 오버헤드: 도움이 되려던 공유 라이브러리가 서서히 소유권 논쟁의 대상이 됩니다. 누가 PR을 리뷰하고, 누가 API를 결정하는가?

이것들은 가상의 시나리오가 아닙니다. Turborepo를 실제로 운영하는 팀들이 일정 규모 이상이 되면 반드시 마주치는 마찰 지점들입니다.


패키지 공유 vs 런타임 공유: 핵심 차이 이해하기

이 개념적 전환이 모든 것을 바꿉니다. 실무에서 경계가 흐릿하게 느껴지는 경우가 많으니, 여기서 시간을 충분히 투자할 가치가 있습니다.

빌드타임(패키지) 공유의 작동 방식

Turborepo 환경에서 공유 패키지는 빌드 시점에 소비됩니다. App A가 @company/ui-button을 임포트하면, 그 컴포넌트의 코드는 App A의 최종 번들에 직접 포함됩니다. 코드가 브라우저에 도달하기 전에 공유가 완료되는 방식입니다.

레시피 카드를 인쇄하는 것과 비슷합니다. 모든 주방(앱)이 레시피의 물리적인 사본을 각자 갖습니다. 레시피가 바뀌면? 모든 카드를 다시 인쇄해서 배포해야 합니다.

빌드타임 공유:
@company/ui → App A 번들 (ui 코드 포함)
@company/ui → App B 번들 (ui 코드 포함)
@company/ui → App C 번들 (ui 코드 포함)

각 앱은 자기완결적입니다. 배포는 단순하지만, 업데이트마다 전체 재빌드가 필요합니다.

런타임 공유의 작동 방식

런타임 공유 — 현대 MFE의 핵심 메커니즘 — 는 이 모델을 뒤집습니다. 공유 코드가 필요한 시점에, 브라우저에서 동적으로 로드됩니다.

현재 가장 검증된 구현체는 Webpack Module Federation(Webpack 5에서 도입)이며, Vite Federation과 Native ESM도 주목받고 있습니다.

런타임 공유:
App A → 런타임에 CDN에서 ui@latest 가져옴
App B → 런타임에 CDN에서 ui@latest 가져옴
App C → 런타임에 CDN에서 ui@latest 가져옴

UI 라이브러리를 업데이트했나요? 한 번만 배포하면 됩니다. 소비하는 모든 앱이 다음 로드 시 자동으로 새 버전을 받습니다. 재빌드가 필요 없습니다.

트레이드오프 한눈에 보기

항목패키지 공유 (Turborepo)런타임 공유 (MFE)
배포 결합도높음낮음
번들 크기큼 (중복)작음 (런타임 공유)
타입 안전성우수추가 도구 필요
운영 복잡성낮음높음
팀 자율성중간높음
초기 설정 비용낮음높음

어느 쪽이 무조건 낫다는 건 없습니다. 팀 규모, 배포 빈도, 운영 복잡성에 대한 허용 범위에 따라 정답이 달라집니다.


Module Federation으로 런타임 공유 구현하기

런타임 공유가 목적지라면, Module Federation은 현재 가장 실전에서 검증된 이동 수단입니다. 실제로 어떻게 작동하는지, 그리고 왜 대부분의 입문 자료가 설명하는 것보다 훨씬 미묘한지를 살펴보겠습니다.

기본 멘탈 모델

Module Federation은 JavaScript 애플리케이션이 특정 모듈을 노출(expose)하고, 다른 애플리케이션의 모듈을 소비(consume)할 수 있게 해줍니다 — 모두 런타임에. Webpack이 로딩, 버전 협상, 폴백 동작을 처리합니다.

앱은 두 가지 역할로 나뉩니다:

  • Host(Shell): 다른 마이크로 프론트엔드를 조율하는 메인 애플리케이션
  • Remote: 특정 컴포넌트나 페이지를 노출하는, 독립적으로 배포되는 앱

Remote 앱의 최소 Webpack 설정은 다음과 같습니다:

// webpack.config.js (Remote App)
new ModuleFederationPlugin({
  name: 'productApp',
  filename: 'remoteEntry.js',
  exposes: {
    './ProductList': './src/components/ProductList',
    './ProductDetail': './src/components/ProductDetail',
  },
  shared: {
    react: { singleton: true, requiredVersion: '^18.0.0' },
    'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
  },
})

이를 소비하는 Host 앱:

// webpack.config.js (Host App)
new ModuleFederationPlugin({
  name: 'shell',
  remotes: {
    productApp: 'productApp@https://products.myapp.com/remoteEntry.js',
  },
  shared: {
    react: { singleton: true },
    'react-dom': { singleton: true },
  },
})

shared 설정이 핵심이자 함정인 이유

shared 키는 Module Federation에서 가장 중요하면서도 가장 오해를 많이 받는 부분입니다. 어떤 의존성을 런타임에 공유할지 선언하는 곳입니다.

singleton: true 플래그는 React처럼 두 인스턴스가 동시에 실행되면 절대 안 되는 라이브러리에 필수입니다. Module Federation이 이를 보장하지만, Host와 Remote 간의 꼼꼼한 버전 협상이 필요합니다.

이걸 잘못 설정하면 커리어에서 가장 난해한 디버깅 세션을 경험하게 됩니다.

Turborepo + Module Federation 함께 쓰기

여기서 Turborepo 사용자에게 중요한 포인트가 나옵니다. 두 접근법은 상호 배타적이지 않습니다. 성숙한 팀 중 다수가 Turborepo로 MFE 모노레포를 관리하면서, Module Federation으로 런타임 공유를 구현합니다.

아키텍처는 이런 형태가 됩니다:

turborepo/
├── apps/
│   ├── shell/          # Host 애플리케이션
│   ├── products/       # Remote MFE
│   ├── checkout/       # Remote MFE
│   └── account/        # Remote MFE
├── packages/
│   ├── ui/             # 빌드타임 공유 (디자인 토큰, 타입)
│   ├── utils/          # 빌드타임 공유 (순수 함수)
│   └── mfe-config/     # 공유 Module Federation 설정

Turborepo가 빌드 파이프라인, 캐싱, 툴링 설정을 담당합니다. Module Federation이 런타임 구성을 담당합니다. 모노레포의 개발 경험과 마이크로서비스의 배포 독립성, 둘 다 가져가는 구조입니다.


문서에 안 나오는 실전 함정들

이론은 이론입니다. 실제로 MFE 시스템을 프로덕션에 배포하는 건 전혀 다른 이야기입니다. 대부분의 팀이 이 전환 과정에서 예상치 못하게 마주치는 네 가지 문제를 정리했습니다.

1. 모듈 경계를 넘는 TypeScript 타입

가장 흔한 고통 포인트입니다. Module Federation Remote에서 컴포넌트를 임포트할 때, TypeScript는 런타임에 주입될 원격 모듈의 실체를 로컬 빌드 시점에 알 수 없습니다.

기본 상태에서는 import('productApp/ProductList') 코드가 모듈을 찾을 수 없다는 에러를 뱉거나, 어쩔 수 없이 declare module 'productApp/*'로 뭉뚱그려 any 타입을 감수하게 됩니다. 타입 안정성을 위해 TypeScript를 쓰는데, 핵심 아키텍처 경계에서 타입이 끊어지는 아이러니가 발생하는 것이죠.

이 문제를 해결하는 방법은 크게 두 가지입니다:

  1. @module-federation/typescript 플러그인 도입: 빌드 시점에 Remote 앱의 노출 파일에서 .d.ts 번들을 자동으로 생성해 배포하고, Host가 이를 내려받아 동기화하는 자동화 도구를 사용합니다.

  2. Turborepo 내부 타입 패키지(packages/contracts) 활용: 컴포넌트의 Props 인터페이스와 API 응답 스키마(Zod 등)만 모노레포 내 별도 패키지로 분리해 빌드타임에 공유합니다. 런타임 코드는 페더레이션으로 분리하되, 계약(Contract)은 모노레포의 빌드타임 장점을 그대로 누리는 하이브리드 방식입니다.

// packages/contracts/src/product.ts
export interface ProductListProps {
  category: string;
  onItemSelect: (id: string) => void;
}
// apps/shell/src/App.tsx
import React, { lazy, Suspense } from 'react';
import type { ProductListProps } from '@company/contracts';

const RemoteProductList = lazy(() => import('productApp/ProductList')) as React.ComponentType<ProductListProps>;

export function App() {
  return (
    <Suspense fallback={<div>Loading Products...</div>}>
      <RemoteProductList category="electronics" onItemSelect={(id) => console.log(id)} />
    </Suspense>
  );
}

2. 라우팅 충돌과 브라우저 히스토리 동기화

Host 앱과 각 Remote 앱이 각자 BrowserRouter 인스턴스를 들고 있으면 브라우저 히스토리 스택이 엉망이 됩니다. 사용자가 '뒤로 가기'를 눌렀을 때 엉뚱한 페이지로 가거나 URL은 바뀌는데 컴포넌트가 렌더링되지 않는 현상이 일어납니다.

이 문제의 정석 패턴은 책임 분리입니다:

  • Host 앱: 유일하게 브라우저 실제 주소창과 연동되는 최상위 브라우저 라우터(예: BrowserRouter)를 소유합니다.
  • Remote 앱: 자체적인 라우팅이 필요할 경우 내부적으로 MemoryRouter를 사용하고, 페이지 전환 이벤트를 Host에 알리거나 URL 변경 시 Host의 경로를 수신하는 방식으로 동작해야 합니다.

Host와 Remote 간에 라우팅 변경 통보를 위해 가벼운 네이티브 이벤트(CustomEvent)나 URL 동기화 콜백을 바인딩하는 것이 안정적입니다.

3. 스타일 충돌 및 CSS 누수(Style Leak)

빌드타임 모노레포에서는 하나의 번들러가 CSS를 취합해 중복을 제거하고 최적화합니다. 하지만 런타임에 여러 독립된 앱의 스타일이 한 DOM 트리에 주입되면 상황이 급변합니다.

  • 전역 CSS 충돌: Reset CSS(예: Tailwind의 Preflight)가 여러 번 로드되면서 미묘하게 폰트나 여백이 깨집니다.
  • 클래스명 충돌: CSS Modules나 해시 기반 빌더가 우연히 동일한 해시 문자열을 생성하여 Remote 앱의 스타일이 Host 앱의 내비게이션 바를 덮어씁니다.
  • CSS-in-JS 캐시 공유 실패: Emotion이나 Styled-components를 쓸 때 테마 프로바이더나 스타일 캐시 인스턴스가 쪼개져 런타임 성능 저하와 플리커링을 유발합니다.

이를 방지하기 위해 다음과 같은 엄격한 규칙이 요구됩니다:

  • Remote 앱은 자체 Reset CSS를 번들에 포함하지 않고 Host의 글로벌 스타일을 상속받습니다.
  • Tailwind CSS 사용 시 Remote 앱마다 고유한 접두사(prefix: 'remote-product-')를 부여하거나 CSS 네임스페이스 격리를 적용합니다.
  • 필요하다면 Shadow DOM 기술을 적용하거나, CSS Modules 빌드 시 앱 이름을 포함한 해시 네이밍 규칙을 강제합니다.

4. 부분 장애 격리(Fault Tolerance)와 회복 탄력성

Remote 앱 중 하나를 호스팅하는 CDN이나 서버에 장애가 나면 어떻게 될까요? 적절한 조치가 없다면 remoteEntry.js의 404/500 에러 하나 때문에 전체 Host 앱이 흰색 화면(White Screen of Death)으로 멈춰버립니다. 모놀리스보다 가용성이 떨어지는 최악의 결과가 발생하는 셈입니다.

런타임 공유를 도입했다면 Error Boundary(에러 바운더리)는 선택이 아닌 필수입니다.

// apps/shell/src/components/MfeErrorBoundary.tsx
import React, { Component, ErrorInfo, ReactNode } from 'react';

interface Props {
  fallback?: ReactNode;
  children: ReactNode;
}

interface State {
  hasError: boolean;
}

export class MfeErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false };

  static getDerivedStateFromError(): State {
    return { hasError: true };
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    console.error('MFE Load Error:', error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || (
        <div className="p-4 border border-red-300 rounded bg-red-50 text-red-700">
          현재 이 기능을 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.
        </div>
      );
    }
    return this.props.children;
  }
}

Host 앱에서 Remote 컴포넌트를 불러올 때는 항상 MfeErrorBoundary와 Suspense를 결합해 감싸주어야 합니다. 체크아웃 영역이 죽더라도 홈 화면과 검색 기능은 정상적으로 동작해야 MFE의 진정한 가치가 드러납니다.


하이브리드 아키텍처: 실무에서 추천하는 최선의 타협점

많은 팀들이 MFE를 도입할 때 저지르는 실수는 "모든 것을 런타임 공유로 바꾼다"는 극단주의에 빠지는 것입니다. 하지만 모든 컴포넌트를 Remote 모듈로 띄우면 네트워크 요청 폭증, 캐싱 비효율, 관리 포인트 폭발로 이어집니다.

가장 현실적이고 성공 확률이 높은 전략은 Turborepo의 빌드타임 공유와 Module Federation의 런타임 공유를 혼합한 하이브리드 아키텍처입니다.

┌─────────────────────────────────────────────────────────────┐
│                    Turborepo Workspace                      │
│                                                             │
│  [Build-time Shared Packages]                               │
│  - @company/ui-primitives (Button, Modal, Icons)            │
│  - @company/design-tokens (Colors, Spacing, Typography)     │
│  - @company/contracts     (TypeScript Types, Schemas)       │
│  - @company/utils         (Date, Formatters, Pure Functions)│
│                                                             │
│  ---------------------------------------------------------  │
│                                                             │
│  [Runtime Shared Applications] (Module Federation)          │
│  - Shell (Host)                                             │
│      ├── Header / Global Nav                                │
│      ├── [Remote] Products Micro-app                        │
│      ├── [Remote] Checkout Micro-app                        │
│      └── [Remote] User Profile Micro-app                    │
└─────────────────────────────────────────────────────────────┘

무엇을 어디에 둘지 나누는 기준은 명확합니다:

1. 빌드타임 패키지 공유로 남겨둘 것 (packages/*)

  • 디자인 토큰 및 기본 UI 프리미티브: 버튼, 인풋, 모달 등 상태가 없고 자주 바뀌지 않는 기초 UI 컴포넌트
  • 공통 유틸리티 및 헬퍼: 날짜 포맷팅, 수학 연산 등 순수 함수 모음
  • 타입 정의 및 스키마: Host-Remote 간의 통신 계약(Contract)
  • 빌드 및 린트 설정: ESLint, TSConfig, Tailwind 프리셋 등

2. 런타임 MFE로 분리할 것 (apps/*)

  • 비즈니스 도메인 단위 단위: 결제(Checkout), 검색(Search), 상품 카탈로그(Products), 마이페이지(Account)
  • 배포 주기가 다른 기능: 하루에도 수십 번 변경 배포가 일어나는 마케팅 캠페인 페이지나 이벤트 배너
  • 독립적인 소유권이 필요한 영역: 서로 다른 스쿼드/피처팀이 명확히 소유하고 독립적으로 릴리즈해야 하는 제품 영역

점진적 마이그레이션 가이드: 모노레포에서 MFE로 전환하는 4단계

기존 Turborepo 모노레포를 한 번에 런타임 MFE 구조로 뒤엎는 '빅뱅 리라이트'는 실패 확률이 매우 높습니다. 프로덕션 서비스를 중단 없이 유지하면서 점진적으로 마이그레이션하는 4단계를 권장합니다.

1단계: 도메인 경계 식별 및 패키지 리팩토링

먼저 현재 Turborepo 내의 애플리케이션 코드를 도메인별로 완벽히 모듈화해야 합니다. 한 컴포넌트가 다른 도메인의 비즈니스 로직에 깊게 얽혀 있다면 런타임 분리가 불가능합니다. 결제 도메인, 상품 도메인처럼 명확한 바운디드 컨텍스트(Bounded Context)를 정의하세요.

2단계: Shell(Host) 애플리케이션 뼈대 구축

전체 레이아웃(GNB, Footer, 인증 세션 관리)을 전담할 경량 Shell 앱을 apps/shell에 신설합니다. 이 시점에서는 아직 Remote를 연결하지 않고, 단순 껍데기 역할만 수행하도록 설정합니다.

3단계: 가장 영향도가 낮은 말단 도메인 1개를 Remote로 전환

위험도가 낮은 화면(예: '공지사항' 또는 '이벤트 페이지')을 첫 번째 Remote MFE 후보로 선정합니다. Webpack Module Federation 설정을 적용하고 Shell에서 동적으로 렌더링하는 PoC(Proof of Concept)를 거칩니다. 이 단계에서 번들 크기, 로딩 지연, CI/CD 배포 스크립트를 철저히 검증합니다.

4단계: CI/CD 파이프라인의 배포 독립화

Turborepo의 태스크 러너 설정을 조정하여, 특정 Remote 앱의 커밋이 발생했을 때 해당 앱만 빌드되어 독립된 S3/CDN 경로에 배포되도록 파이프라인을 분리합니다. Turborepo의 캐시 기능 덕분에 수정되지 않은 다른 Remote와 Host는 빌드 파이프라인에서 완전히 제외되어 빠른 배포 속도를 유지할 수 있습니다.


결론: 복잡성에 휘둘리지 않는 똑똑한 아키텍처 선택

Turborepo 모노레포를 운영하다가 배포 병목과 거버넌스 한계에 도달했다면, 마이크로 프론트엔드와 런타임 공유는 조직의 릴리즈 속도를 회복시켜 주는 훌륭한 해법이 됩니다.

하지만 MFE는 은총알(Silver Bullet)이 아닙니다. 런타임 공유는 배포 결합을 풀어주는 대신 네트워크 복잡성, 타입 관리의 어려움, 런타임 장애 위험이라는 새로운 비용 청구서를 내밉니다.

핵심 요약을 정리해 드립니다:

  1. 무조건적인 전환은 피하세요: 팀 규모가 작거나 배포 주기가 얽혀도 큰 문제가 없다면 Turborepo의 고속 빌드타임 패키지 공유만으로도 충분히 훌륭합니다.
  2. 하이브리드 전략을 취하세요: 기반이 되는 UI 컴포넌트와 인터페이스 계약은 Turborepo 빌드타임 패키지로 유지하고, 비즈니스 도메인 단위만 Module Federation을 통해 런타임으로 노출하세요.
  3. 방어적으로 설계하세요: 에러 바운더리, 엄격한 CSS 스코핑, 그리고 라우터 분리를 반드시 적용하여 한 Remote의 결함이 전체 서비스로 전이되지 않도록 격리하세요.

현재 운영 중인 Turborepo 모노레포에서 배포 대기열이 길어지고 팀 간 마찰이 잦아지고 있다면, 가장 배포가 잦은 도메인 하나를 골라 작은 단위의 Module Federation PoC부터 시작해 보세요. 빌드의 효율성과 배포의 자율성, 두 마리 토끼를 모두 잡는 단단한 프론트엔드 아키텍처를 완성할 수 있을 것입니다.

댓글 0개

댓글

댓글을 불러오는 중…

지원 문의