Skip to content

공통 모듈 설계와 앱쉘(App Shell)

여러 마이크로앱이 일관된 UX를 제공하려면 공통 모듈과 이를 조합하는 앱쉘이 필요하다. 빌드타임 공통 모듈의 종류와 관리 원칙, 앱쉘의 구조와 마운트 방식을 학습한다.

학습 목표

  • 빌드타임 공통 모듈의 세 가지 종류(UI 라이브러리, 공통 설정/유틸리티, 인프라 코드)를 구분하고 각각의 역할을 설명할 수 있다
  • 공통 모듈 관리 시 지켜야 할 원칙과 주의사항을 이해할 수 있다
  • 앱쉘의 핵심 역할과 URL 라우팅 구조를 설명할 수 있다
  • 마이크로앱의 mount/unmount 라이프사이클이 앱쉘 안에서 어떻게 동작하는지 이해할 수 있다

Part A: 빌드타임 공통 모듈

1. 왜 공통 모듈이 필요한가

마이크로프론트엔드는 각 팀이 독립적으로 개발하는 것이 원칙이다. 그러나 사용자 입장에서는 하나의 서비스로 느껴져야 한다. 버튼 스타일이 팀마다 다르고, API 호출 방식이 제각각이면 사용자 경험이 파편화된다.

이 문제를 해결하기 위해 공통으로 사용할 코드를 별도 패키지로 분리하여 각 마이크로앱에 제공한다. 이것이 빌드타임 공통 모듈이다.

2. 공통 모듈의 세 가지 종류

종류 1: UI 라이브러리

재사용 가능한 UI 컴포넌트의 모음이다. 디자인 시스템의 구현체로, 모든 마이크로앱에서 동일한 룩앤필을 보장한다.

typescript
// @shared/ui 패키지
export { Button } from './components/Button';
export { Input } from './components/Input';
export { Modal } from './components/Modal';
export { DataTable } from './components/DataTable';
export { Dropdown } from './components/Dropdown';
typescript
// 마이크로앱에서 사용
import { Button, DataTable } from '@shared/ui';

function ProductList() {
  return (
    <div>
      <DataTable data={products} columns={columns} />
      <Button variant="primary">상품 추가</Button>
    </div>
  );
}

종류 2: 공통 설정 / 유틸리티

상수, 서비스 로직, 유틸리티 함수처럼 비즈니스 로직과 무관하지만 여러 앱에서 동일하게 필요한 코드이다.

typescript
// @shared/config 패키지
export const API_BASE_URL = process.env.API_URL;
export const AUTH_TOKEN_KEY = 'auth_token';

// @shared/utils 패키지
export function formatDate(date: Date, pattern: string): string { /* ... */ }
export function debounce<T extends Function>(fn: T, ms: number): T { /* ... */ }
export function validateEmail(email: string): boolean { /* ... */ }

종류 3: 인프라 레벨 코드

HTTP 클라이언트 추상화, 에러 핸들링, 로깅 등 기반 인프라 코드이다. 각 마이크로앱이 직접 구현하면 동작이 제각각이 되므로, 하나로 통일하는 것이 중요하다.

typescript
// @shared/http 패키지
import axios from 'axios';

const httpClient = axios.create({
  baseURL: API_BASE_URL,
  timeout: 10000,
});

// 인터셉터로 공통 처리
httpClient.interceptors.request.use((config) => {
  const token = localStorage.getItem(AUTH_TOKEN_KEY);
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

httpClient.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      // 공통 인증 만료 처리
      window.dispatchEvent(new CustomEvent('auth:expired'));
    }
    return Promise.reject(error);
  }
);

export { httpClient };

3. 공통 모듈 관리 원칙

공통 모듈은 편리하지만 잘못 관리하면 마이크로프론트엔드의 독립성을 해치는 병목이 될 수 있다. 다음 원칙을 지켜야 한다.

원칙 1: 변경 빈도를 최소화하라

빌드타임 공통 모듈이 변경되면 이를 의존하는 모든 마이크로앱을 재빌드하고 재배포해야 한다. 따라서 공통 모듈은 안정적이어야 하고, 변경이 잦은 코드는 공통 모듈에 넣지 않는다.

공통 모듈 변경 → 마이크로앱 A 재빌드 + 재배포
                  마이크로앱 B 재빌드 + 재배포
                  마이크로앱 C 재빌드 + 재배포
                  ... (의존하는 모든 앱)

원칙 2: 어느 정도의 중복은 허용하라

"모든 중복을 제거해야 한다"는 강박은 오히려 위험하다. 사소한 유틸 함수 하나를 공유하기 위해 공통 모듈에 넣으면, 그 함수를 수정할 때 전체 앱에 영향을 준다. 중복의 비용 < 결합의 비용 이라면 중복을 허용한다.

원칙 3: 순환 참조를 방지하라

공통 모듈 간 의존 관계가 단방향이어야 한다. 순환 참조가 생기면 빌드 실패, 무한 루프, 예측 불가능한 동작이 발생한다.

[올바른 의존 방향]
마이크로앱 → @shared/ui → @shared/config
마이크로앱 → @shared/http → @shared/config

[잘못된 순환 참조]
@shared/ui → @shared/http → @shared/ui   (순환!)

원칙 4: 시맨틱 버저닝을 엄격하게 지켜라

공통 모듈은 반드시 시맨틱 버저닝(major.minor.patch)을 따르고, breaking change는 major 버전을 올린다. 마이크로앱 팀이 업그레이드 시점을 스스로 결정할 수 있어야 한다.


Part B: 앱쉘(App Shell) 설계

1. 앱쉘이란

앱쉘은 마이크로프론트엔드 전체 애플리케이션의 껍데기(container) 이다. 사용자가 보는 화면의 가장 바깥쪽 프레임을 담당하며, URL에 따라 어떤 마이크로앱을 표시할지 결정한다.

앱쉘 자체는 비즈니스 로직이 거의 없다. 주요 역할은 다음과 같다.

  • URL 라우팅: 현재 경로에 따라 어떤 마이크로앱을 로드할지 결정
  • 공통 레이아웃: 헤더, 사이드바, 푸터 등 모든 페이지에 공통으로 나타나는 영역
  • 인증 관리: 로그인/로그아웃 상태 관리, 토큰 갱신
  • 마이크로앱 오케스트레이션: 마이크로앱의 로딩, 마운트, 언마운트 관리

2. 앱쉘 라우팅 구조

3. 앱쉘 코드 구조

앱쉘은 일반적인 React 앱이지만, 콘텐츠 영역에 다른 React 앱(마이크로앱)을 동적으로 로드한다.

typescript
// App Shell - App.tsx
import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { Header } from './layout/Header';
import { Sidebar } from './layout/Sidebar';
import { LoadingSpinner } from '@shared/ui';

// 마이크로앱을 코드 스플리팅으로 지연 로딩
const ProductApp = lazy(() => import('productApp/App'));
const OrderApp = lazy(() => import('orderApp/App'));
const MyPageApp = lazy(() => import('mypageApp/App'));

function App() {
  return (
    <BrowserRouter>
      <Header />
      <div className="layout">
        <Sidebar />
        <main className="content">
          <Suspense fallback={<LoadingSpinner />}>
            <Routes>
              <Route path="/products/*" element={<ProductApp />} />
              <Route path="/orders/*" element={<OrderApp />} />
              <Route path="/mypage/*" element={<MyPageApp />} />
            </Routes>
          </Suspense>
        </main>
      </div>
    </BrowserRouter>
  );
}

4. 마이크로앱의 mount/unmount 라이프사이클

앱쉘과 마이크로앱의 관계는 "React 앱 안에 또 다른 React 앱" 이 실행되는 구조이다. 각 마이크로앱은 독립된 React 앱이지만, 앱쉘이 제공하는 DOM 엘리먼트 안에서 동작한다.

5. mount/unmount 구현 예시

각 마이크로앱은 mount()unmount() 함수를 외부에 노출한다. 이것이 앱쉘과 마이크로앱 사이의 계약(contract) 이다.

typescript
// 마이크로앱 (예: 상품 서비스) - bootstrap.tsx
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { ProductApp } from './ProductApp';

let root: Root | null = null;

export function mount(container: HTMLElement) {
  root = createRoot(container);
  root.render(<ProductApp />);
}

export function unmount() {
  if (root) {
    root.unmount();
    root = null;
  }
}
typescript
// 앱쉘에서 마이크로앱을 감싸는 래퍼 컴포넌트
import React, { useEffect, useRef } from 'react';

interface MicroAppWrapperProps {
  mount: (container: HTMLElement) => void;
  unmount: () => void;
}

function MicroAppWrapper({ mount, unmount }: MicroAppWrapperProps) {
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (containerRef.current) {
      mount(containerRef.current);
    }
    return () => {
      unmount();
    };
  }, [mount, unmount]);

  return <div ref={containerRef} />;
}

6. 앱쉘 설계 시 고려사항

항목설명
경량화앱쉘은 가능한 한 가볍게 유지. 비즈니스 로직은 마이크로앱에 위임
에러 격리마이크로앱 로드 실패 시 앱쉘 전체가 죽지 않도록 Error Boundary 적용
로딩 상태마이크로앱 로드 중 사용자에게 적절한 로딩 UI 표시 (Skeleton, Spinner)
인증 연동앱쉘에서 인증을 관리하고, 마이크로앱에 인증 정보를 전달하는 방식 결정
라우팅 충돌 방지앱쉘의 라우팅과 마이크로앱 내부 라우팅이 충돌하지 않도록 basePath 설정
공통 레이아웃 변경헤더/사이드바 변경이 마이크로앱에 영향을 주지 않도록 API 설계

7. 결과: React 앱 안에 React 앱

앱쉘 방식의 마이크로프론트엔드를 완성하면 다음과 같은 구조가 된다.

[브라우저]
  └── 앱쉘 (React SPA)
        ├── 헤더 컴포넌트
        ├── 사이드바 컴포넌트
        └── 콘텐츠 영역
              └── 마이크로앱 A (독립 React SPA)
                    ├── 자체 라우터
                    ├── 자체 상태 관리
                    └── 자체 컴포넌트 트리

앱쉘의 React 트리와 마이크로앱의 React 트리는 별개이다. 마이크로앱은 앱쉘이 제공한 DOM 노드를 루트로 삼아 자신만의 React 트리를 독립적으로 관리한다. 이 구조 덕분에 마이크로앱은 자체 라우터, 상태 관리, 스타일을 가질 수 있으며, 앱쉘과 무관하게 독립적으로 개발하고 배포할 수 있다.


핵심 정리

  1. 빌드타임 공통 모듈은 세 가지로 분류된다. UI 라이브러리(시각적 일관성), 공통 설정/유틸리티(코드 중복 방지), 인프라 레벨 코드(HTTP, 에러, 로깅 통일)이다.

  2. 공통 모듈의 최대 리스크는 변경 시 전체 재배포이다. 따라서 변경 빈도를 최소화하고, 사소한 중복은 허용하며, 순환 참조를 방지하고, 시맨틱 버저닝을 엄격하게 지켜야 한다.

  3. 앱쉘은 마이크로앱을 조합하는 컨테이너로, 핵심 역할은 URL 라우팅이다. URL에 따라 어떤 마이크로앱을 로드하고 어디에 마운트할지 결정한다.

  4. 마이크로앱은 mount/unmount 함수를 노출하여 앱쉘과 계약을 맺는다. 앱쉘이 DOM 엘리먼트를 제공하면, 마이크로앱이 해당 엘리먼트에 자신의 React 트리를 렌더링한다.

  5. 최종 구조는 "React 앱 안에 React 앱"이다. 앱쉘 SPA 내부에서 각 마이크로앱이 독립된 SPA로 동작하며, 사용자에게는 하나의 매끄러운 애플리케이션처럼 보인다.


다음 단계

공통 모듈과 앱쉘로 기술적 통합 구조를 갖추었다면, 여러 팀이 만드는 마이크로앱들이 시각적으로 일관된 경험을 제공하도록 보장해야 한다. 다음 문서에서는 디자인 시스템의 정의, 구성 요소, 그리고 마이크로프론트엔드 환경에서 디자인 시스템이 특히 중요한 이유를 다룬다.

다음: 디자인 시스템 →