Skip to content

커스텀 엘리먼트 인터페이스 - Web Components API 활용

Web Components의 Custom Elements API로 마이크로앱 간 일관된 HTML 인터페이스를 구현하고 레거시 통합을 최종 정리한다

학습 목표

  1. Custom Elements API의 핵심 개념(connectedCallback, disconnectedCallback, attributeChangedCallback)을 이해한다
  2. 마이크로 커스텀 컴포넌트를 구현하여 HTML 태그처럼 마이크로앱을 사용할 수 있게 한다
  3. 어트리뷰트 관찰(observedAttributes)과 Props 변환 패턴을 익힌다
  4. 레거시 통합 전체 아키텍처를 종합적으로 정리한다

본문

1. Custom Elements가 제공하는 가치

지금까지 마이크로 컴포넌트를 소비하는 방법은 React 클래스 컴포넌트(MicroComponent)였다. Custom Elements를 사용하면 프레임워크에 독립적인 HTML 태그 인터페이스를 제공할 수 있다.

2. Custom Elements 생명주기

콜백호출 시점주요 역할
constructor()엘리먼트 인스턴스 생성 시Shadow Root 생성, 초기 변수 설정
connectedCallback()DOM 트리에 삽입될 때마이크로앱 로딩, 최초 렌더링
disconnectedCallback()DOM 트리에서 제거될 때마이크로앱 언마운트, 정리 작업
attributeChangedCallback()관찰 대상 속성 변경 시변경된 값으로 재렌더링

3. MicroCustomComponent 구현

typescript
// micro-custom-component.ts
import { loadScript, loadLegacyScript, MicroApp } from "../utils";

class MicroCustomComponent extends HTMLElement {
  private shadowRootRef: ShadowRoot;
  private container: HTMLDivElement;
  private unmountFn: (() => void) | undefined;

  // 관찰할 어트리뷰트 목록
  static get observedAttributes(): string[] {
    return ["url", "app-name", "component-name", "is-legacy", "base-name", "timestamp"];
  }

  constructor() {
    super();
    // Shadow Root 생성
    this.shadowRootRef = this.attachShadow({ mode: "open" });
    this.container = document.createElement("div");
    this.shadowRootRef.appendChild(this.container);
  }

  // DOM에 연결될 때 호출
  connectedCallback(): void {
    this.renderMicroApp();
  }

  // DOM에서 제거될 때 호출
  disconnectedCallback(): void {
    this.cleanup();
  }

  // 어트리뷰트 변경 시 호출
  attributeChangedCallback(
    name: string,
    oldValue: string | null,
    newValue: string | null
  ): void {
    if (oldValue !== newValue) {
      this.cleanup();
      this.renderMicroApp();
    }
  }

  private cleanup(): void {
    if (this.unmountFn) {
      this.unmountFn();
      this.unmountFn = undefined;
    }
  }

  private async renderMicroApp(): Promise<void> {
    const url = this.getAttribute("url");
    const appName = this.getAttribute("app-name");
    const componentName = this.getAttribute("component-name") || "default";
    const isLegacy = this.getAttribute("is-legacy") === "true";

    if (!url || !appName) return;

    try {
      let microApp: MicroApp;
      let cssPaths: string[] = [];

      if (isLegacy) {
        const result = await loadLegacyScript({ url, appName });
        microApp = result.microApp;
        cssPaths = result.cssPaths;
      } else {
        microApp = await loadScript({ url, appName });
      }

      const component = microApp[componentName] || microApp.default;
      if (!component?.render) return;

      // CSS를 Shadow DOM에 주입
      cssPaths.forEach((path) => {
        const link = document.createElement("link");
        link.rel = "stylesheet";
        link.href = path;
        this.shadowRootRef.appendChild(link);
      });

      // 마이크로앱 렌더링
      const props = this.getProps();
      this.unmountFn = component.render(this.container, {
        ...props,
        cssPaths,
        shadowRoot: this.shadowRootRef,
      });
    } catch (error) {
      console.error(`마이크로앱 로딩 실패: ${appName}`, error);
    }
  }

  // 어트리뷰트에서 Props 추출
  private getProps(): Record<string, string> {
    const reserved = ["url", "app-name", "component-name", "is-legacy"];
    const props: Record<string, string> = {};

    Array.from(this.attributes).forEach((attr) => {
      if (!reserved.includes(attr.name)) {
        // kebab-case를 camelCase로 변환
        const camelKey = attr.name.replace(/-([a-z])/g, (_, c) =>
          c.toUpperCase()
        );
        props[camelKey] = attr.value;
      }
    });

    return props;
  }
}

4. 커스텀 엘리먼트 등록

typescript
// register-custom-element.ts
interface RegisterOptions {
  name: string;
  observeAttributes?: string[];
}

export function registerCustomElement({
  name,
  observeAttributes = [],
}: RegisterOptions): void {
  // 이미 등록된 경우 중복 등록 방지
  if (customElements.get(name)) return;

  // 관찰할 어트리뷰트를 확장한 서브클래스 정의
  const CustomClass = class extends MicroCustomComponent {
    static get observedAttributes(): string[] {
      return [
        ...MicroCustomComponent.observedAttributes,
        ...observeAttributes,
      ];
    }
  };

  customElements.define(name, CustomClass);
}

5. 사용 예시

tsx
// Web 프로젝트에서 커스텀 엘리먼트 등록 및 사용
import { registerCustomElement } from "@packages/ui";

// 등록 (앱 초기화 시 1회)
registerCustomElement({ name: "micro-sns" });
registerCustomElement({ name: "micro-mail-list" });
registerCustomElement({ name: "micro-legacy-app" });

// JSX에서 HTML 태그처럼 사용
<micro-mail-list
  url="http://localhost:7001"
  app-name="docs"
  component-name="MailList"
/>

<micro-sns
  url="http://localhost:7003"
  app-name="legacy"
  component-name="SNS"
  is-legacy="true"
/>

<micro-legacy-app
  url="http://localhost:7003"
  app-name="legacy"
  is-legacy="true"
  base-name="/legacy"
  timestamp={Date.now().toString()}
/>

TypeScript에서 커스텀 엘리먼트를 JSX로 사용하려면 JSX.IntrinsicElements에 타입을 선언해야 한다. 각 커스텀 엘리먼트의 어트리뷰트를 React.DetailedHTMLProps로 정의하면 된다.

6. timestamp를 활용한 강제 재렌더링

레거시 앱의 라우팅 변경 시 커스텀 엘리먼트가 재렌더링되지 않는 문제를 timestamp 어트리뷰트로 해결한다.

tsx
// 라우팅 변경 시 timestamp를 업데이트하면
// attributeChangedCallback이 호출되어 재렌더링
<micro-legacy-app
  url="http://localhost:7003"
  app-name="legacy"
  is-legacy="true"
  base-name="/legacy"
  timestamp={Date.now().toString()}  // 변경될 때마다 재렌더링
/>

7. 레거시 통합 전체 아키텍처 종합

8. 전체 학습 내용 종합 정리

챕터핵심 주제해결한 문제
Ch.19-01toString LoaderDev/Prod CSS 불일치
Ch.19-02Redux + ANTD독립 스토어 + Shadow DOM 스타일
Ch.19-03Zustand경량 상태관리 + 기술 독립성
Ch.19-04Tailwind 공유 컴포넌트프레임워크 없는 UI 구현
Ch.19-05라우팅 + baseName프로젝트 간 네비게이션 충돌
Ch.20-01MicroComponent소비 코드 반복 제거
Ch.20-02레거시 프로젝트webpack 없는 환경 구축
Ch.20-03크로스 프로젝트 통합asset-manifest + CSS 격리
Ch.20-04Custom Elements프레임워크 무관 인터페이스

핵심 정리

  1. Custom Elements API: HTMLElement를 상속하여 connectedCallback, disconnectedCallback, attributeChangedCallback으로 생명주기를 관리하는 표준 Web API이다
  2. 프레임워크 독립적 인터페이스: Custom Elements는 React, Vue, Angular, 순수 HTML 등 어떤 환경에서든 HTML 태그로 마이크로앱을 소비할 수 있게 한다
  3. 어트리뷰트 -> Props 변환: HTML 어트리뷰트는 kebab-case로 전달되므로 camelCase로 변환하여 마이크로앱 props로 전달한다
  4. 레거시 통합 완성: loadScript(modern) + loadLegacyScript(legacy) + MicroComponent(React) + Custom Element(표준 HTML)의 조합으로 어떤 환경의 마이크로앱도 소비할 수 있는 완전한 인터페이스를 제공한다

다음 단계

전체 학습 완료! README로 돌아가기 ->