테마
커스텀 엘리먼트 인터페이스 - Web Components API 활용
Web Components의 Custom Elements API로 마이크로앱 간 일관된 HTML 인터페이스를 구현하고 레거시 통합을 최종 정리한다
학습 목표
- Custom Elements API의 핵심 개념(connectedCallback, disconnectedCallback, attributeChangedCallback)을 이해한다
- 마이크로 커스텀 컴포넌트를 구현하여 HTML 태그처럼 마이크로앱을 사용할 수 있게 한다
- 어트리뷰트 관찰(observedAttributes)과 Props 변환 패턴을 익힌다
- 레거시 통합 전체 아키텍처를 종합적으로 정리한다
본문
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-01 | toString Loader | Dev/Prod CSS 불일치 |
| Ch.19-02 | Redux + ANTD | 독립 스토어 + Shadow DOM 스타일 |
| Ch.19-03 | Zustand | 경량 상태관리 + 기술 독립성 |
| Ch.19-04 | Tailwind 공유 컴포넌트 | 프레임워크 없는 UI 구현 |
| Ch.19-05 | 라우팅 + baseName | 프로젝트 간 네비게이션 충돌 |
| Ch.20-01 | MicroComponent | 소비 코드 반복 제거 |
| Ch.20-02 | 레거시 프로젝트 | webpack 없는 환경 구축 |
| Ch.20-03 | 크로스 프로젝트 통합 | asset-manifest + CSS 격리 |
| Ch.20-04 | Custom Elements | 프레임워크 무관 인터페이스 |
핵심 정리
- Custom Elements API:
HTMLElement를 상속하여connectedCallback,disconnectedCallback,attributeChangedCallback으로 생명주기를 관리하는 표준 Web API이다 - 프레임워크 독립적 인터페이스: Custom Elements는 React, Vue, Angular, 순수 HTML 등 어떤 환경에서든 HTML 태그로 마이크로앱을 소비할 수 있게 한다
- 어트리뷰트 -> Props 변환: HTML 어트리뷰트는 kebab-case로 전달되므로 camelCase로 변환하여 마이크로앱 props로 전달한다
- 레거시 통합 완성: loadScript(modern) + loadLegacyScript(legacy) + MicroComponent(React) + Custom Element(표준 HTML)의 조합으로 어떤 환경의 마이크로앱도 소비할 수 있는 완전한 인터페이스를 제공한다