웹 컴포넌트 기반 마이크로 프론트엔드 구현

핵심 아키텍처 설계

마이크로 프론트엔드 구성 요소

interface MicroAppConfig {
  appId: string;
  entryPoint: string;
  mountSelector: string;
  activationPath: string;
  customProps?: Record<string, unknown>;
}

interface ComponentOptions {
  elementName: string;
  useShadow?: boolean;
  externalStyles?: string[];
}

class MicroFrontend {
  private config: MicroAppConfig;
  private componentOpts: ComponentOptions;
  private mountedState: boolean = false;
  
  constructor(appConfig: MicroAppConfig, compOptions: ComponentOptions) {
    this.config = appConfig;
    this.componentOpts = compOptions;
  }
  
  async loadResources(): Promise<void> {
    // 외부 자원 동적 로드
  }
  
  attachToDOM(container: HTMLElement): void {
    // DOM에 애플리케이션 마운트
  }
  
  detachFromDOM(): void {
    // DOM에서 애플리케이션 언마운트
  }
}

웹 컴포넌트 기본 구현

커스텀 요소 베이스 클래스

abstract class BaseMicroApp extends HTMLElement {
  protected shadowRootRef: ShadowRoot;
  protected appRoot: HTMLElement;
  protected componentCSS: string;
  
  constructor() {
    super();
    this.shadowRootRef = this.attachShadow({ mode: 'open' });
    this.componentCSS = this.generateStyles();
    this.appRoot = document.createElement('div');
    this.appRoot.className = 'app-root-element';
  }
  
  connectedCallback() {
    this.initializeComponent();
    this.mountApplication();
  }
  
  disconnectedCallback() {
    this.unmountApplication();
  }
  
  attributeChangedCallback(attrName: string, prevVal: string, newVal: string) {
    if (prevVal !== newVal) {
      this.handleAttrUpdate(attrName, newVal);
    }
  }
  
  protected abstract generateStyles(): string;
  protected abstract mountApplication(): Promise<void>;
  protected abstract unmountApplication(): void;
  protected abstract handleAttrUpdate(attrName: string, value: string): void;
  
  protected initializeComponent() {
    this.shadowRootRef.innerHTML = `
      <style>${this.componentCSS}</style>
      <div class="micro-app-wrapper">
        <div class="loader" id="loader">로드 중...</div>
        ${this.appRoot.outerHTML}
      </div>
    `;
    this.appRoot = this.shadowRootRef.querySelector('.app-root-element') as HTMLElement;
  }
  
  protected displayLoader() {
    const loader = this.shadowRootRef.getElementById('loader');
    if (loader) loader.style.display = 'flex';
  }
  
  protected hideLoader() {
    const loader = this.shadowRootRef.getElementById('loader');
    if (loader) loader.style.display = 'none';
  }
}

구현 예시: 사용자 관리 앱

class UserAdminComponent extends BaseMicroApp {
  private appScript?: HTMLScriptElement;
  private appStyles?: HTMLLinkElement;
  private application: any;
  
  static get observedAttributes() {
    return ['api-endpoint', 'color-scheme', 'language'];
  }
  
  protected generateStyles(): string {
    return `
      :host { display: block; width: 100%; height: 100%; }
      .micro-app-wrapper { width: 100%; height: 100%; }
      .loader { display: flex; justify-content: center; align-items: center; height: 200px; }
    `;
  }
  
  protected async mountApplication(): Promise<void> {
    try {
      this.displayLoader();
      await this.fetchDependencies();
      await this.initAppInstance();
      this.hideLoader();
    } catch (err) {
      console.error('사용자 관리 앱 마운트 실패:', err);
      this.showFailureMessage('앱 로드 실패');
    }
  }
  
  protected unmountApplication(): void {
    if (this.application?.destroy) this.application.destroy();
    if (this.appScript) document.head.removeChild(this.appScript);
    if (this.appStyles) document.head.removeChild(this.appStyles);
    this.appRoot.innerHTML = '';
  }
  
  private async fetchDependencies(): Promise<void> {
    const resourcePath = this.getAttribute('resource-path') || 'https://cdn.example.com/user-app';
    
    this.appStyles = document.createElement('link');
    this.appStyles.rel = 'stylesheet';
    this.appStyles.href = `${resourcePath}/styles.css`;
    document.head.appendChild(this.appStyles);
    
    this.appScript = document.createElement('script');
    this.appScript.src = `${resourcePath}/app.js`;
    this.appScript.type = 'module';
    
    await new Promise((resolve, reject) => {
      this.appScript!.onload = resolve;
      this.appScript!.onerror = reject;
      document.head.appendChild(this.appScript!);
    });
  }
  
  private async initAppInstance(): Promise<void> {
    const appFactory = (window as any).UserAdminFactory;
    if (!appFactory) throw new Error('앱 생성 함수 없음');
    
    const appConfig = {
      apiUrl: this.getAttribute('api-endpoint') || '/api',
      theme: this.getAttribute('color-scheme') || 'light',
      locale: this.getAttribute('language') || 'ko-KR',
      rootElement: this.appRoot,
      eventHandler: this.handleAppEvents.bind(this)
    };
    
    this.application = appFactory(appConfig);
    if (this.application?.init) await this.application.init();
  }
  
  private handleAppEvents(eventData: any) {
    this.dispatchEvent(new CustomEvent('app-event', {
      detail: eventData,
      bubbles: true,
      composed: true
    }));
  }
}

customElements.define('user-admin-app', UserAdminComponent);

라우팅 및 상태 관리

경로 기반 애플리케이션 로더

class AppRouter {
  private registeredApps: Map<string, MicroFrontend> = new Map();
  private activeApp: string | null = null;
  private routeMap: RouteDefinition[] = [];
  
  constructor(private hostContainer: HTMLElement) {
    this.setupRouting();
  }
  
  registerApplication(config: MicroAppConfig): void {
    const application = new MicroFrontend(config);
    this.registeredApps.set(config.appId, application);
    this.routeMap.push({
      appId: config.appId,
      pathRule: config.activationPath
    });
  }
  
  initialize(): void {
    this.routeTo(window.location.pathname);
    window.addEventListener('popstate', () => {
      this.routeTo(window.location.pathname);
    });
  }
  
  private routeTo(path: string): void {
    const targetApp = this.findTargetApplication(path);
    if (!targetApp) return this.showMissingPage();
    
    if (this.activeApp === targetApp.appId) return;
    
    if (this.activeApp) {
      const current = this.registeredApps.get(this.activeApp);
      current?.detachFromDOM();
    }
    
    const nextApp = this.registeredApps.get(targetApp.appId);
    if (nextApp) {
      this.activeApp = targetApp.appId;
      nextApp.attachToDOM(this.hostContainer);
      
      if (window.location.pathname !== path) {
        window.history.pushState(null, '', path);
      }
    }
  }
  
  private findTargetApplication(path: string): RouteDefinition | null {
    return this.routeMap.find(route => 
      typeof route.pathRule === 'function' 
        ? route.pathRule(path)
        : path.startsWith(route.pathRule as string)
    ) || null;
  }
}

interface RouteDefinition {
  appId: string;
  pathRule: string | ((path: string) => boolean);
}

애플리케이션 간 통신

class AppEventSystem {
  private eventHandlers: Map<string, Function[]> = new Map();
  
  publish(eventType: string, payload?: any): void {
    const handlers = this.eventHandlers.get(eventType) || [];
    handlers.forEach(handler => handler(payload));
    window.dispatchEvent(new CustomEvent(`app:${eventType}`, { detail: payload }));
  }
  
  subscribe(eventType: string, handler: Function): () => void {
    if (!this.eventHandlers.has(eventType)) {
      this.eventHandlers.set(eventType, []);
    }
    this.eventHandlers.get(eventType)!.push(handler);
    return () => this.unsubscribe(eventType, handler);
  }
  
  unsubscribe(eventType: string, handler: Function): void {
    const handlers = this.eventHandlers.get(eventType);
    if (handlers) {
      const index = handlers.indexOf(handler);
      if (index > -1) handlers.splice(index, 1);
    }
  }
}

class SharedStateManager {
  private appState: Record<string, any> = {};
  private stateListeners: Map<string, Function[]> = new Map();
  
  updateState(key: string, value: any): void {
    const previous = this.appState[key];
    this.appState[key] = value;
    const listeners = this.stateListeners.get(key) || [];
    listeners.forEach(listener => listener(value, previous));
  }
  
  getState<T = any>(key: string): T | undefined {
    return this.appState[key];
  }
  
  addStateListener(key: string, callback: Function): () => void {
    if (!this.stateListeners.has(key)) this.stateListeners.set(key, []);
    this.stateListeners.get(key)!.push(callback);
    return () => this.removeStateListener(key, callback);
  }
  
  removeStateListener(key: string, callback: Function): void {
    const listeners = this.stateListeners.get(key);
    if (listeners) {
      const index = listeners.indexOf(callback);
      if (index > -1) listeners.splice(index, 1);
    }
  }
}

export const eventSystem = new AppEventSystem();
export const sharedState = new SharedStateManager();

성능 최적화 기법

애플리케이션 사전 로드

class ResourcePreloader {
  private preloaded: Set<string> = new Set();
  
  async preloadApplication(appName: string, resourceUrl: string): Promise<void> {
    if (this.preloaded.has(appName)) return;
    
    try {
      const preloadScript = document.createElement('script');
      preloadScript.src = resourceUrl;
      preloadScript.async = true;
      
      await new Promise((resolve, reject) => {
        preloadScript.onload = resolve;
        preloadScript.onerror = reject;
        document.head.appendChild(preloadScript);
      });
      
      this.preloaded.add(appName);
    } catch (err) {
      console.error(`${appName} 사전 로드 실패:`, err);
    }
  }
  
  predictAndPreload(currentRoute: string): void {
    const predicted = this.predictNextApplications(currentRoute);
    predicted.forEach(app => this.preloadApplication(app.name, app.url));
  }
  
  private predictNextApplications(currentRoute: string): {name: string, url: string}[] {
    const predictions = [];
    if (currentRoute === '/') {
      predictions.push({ name: 'user-admin', url: '/apps/user-admin/app.js' });
    }
    return predictions;
  }
}

성능 모니터링

class AppPerformanceTracker {
  private metricTimers: Map<string, number> = new Map();
  
  startMeasurement(appId: string, metricName: string): void {
    this.metricTimers.set(`${appId}_${metricName}`, performance.now());
  }
  
  completeMeasurement(appId: string, metricName: string): number {
    const timerKey = `${appId}_${metricName}`;
    const start = this.metricTimers.get(timerKey);
    
    if (start) {
      const duration = performance.now() - start;
      this.metricTimers.delete(timerKey);
      this.logMetric(appId, metricName, duration);
      return duration;
    }
    return 0;
  }
  
  private logMetric(appId: string, metric: string, value: number): void {
    const metricData = {
      application: appId,
      metricType: metric,
      value: value,
      timestamp: Date.now()
    };
    
    if (navigator.sendBeacon) {
      navigator.sendBeacon('/api/perf-metrics', JSON.stringify(metricData));
    }
  }
}

태그: Web Components Micro Frontend Shadow DOM 커스텀 요소 프론트엔드 아키텍처

7월 12일 00:48에 게시됨