@context-action/lit-ui 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/LICENSE +201 -0
- package/README.md +110 -2
- package/dist/hydrate.cjs +139 -0
- package/dist/hydrate.cjs.map +1 -0
- package/dist/hydrate.d.cts +18 -0
- package/dist/hydrate.d.cts.map +1 -0
- package/dist/hydrate.d.ts +18 -0
- package/dist/hydrate.d.ts.map +1 -0
- package/dist/hydrate.js +127 -0
- package/dist/hydrate.js.map +1 -0
- package/dist/index.cjs +1636 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +644 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +644 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1571 -0
- package/dist/index.js.map +1 -0
- package/dist/ssr.cjs +116 -0
- package/dist/ssr.cjs.map +1 -0
- package/dist/ssr.d.cts +53 -0
- package/dist/ssr.d.cts.map +1 -0
- package/dist/ssr.d.ts +53 -0
- package/dist/ssr.d.ts.map +1 -0
- package/dist/ssr.js +111 -0
- package/dist/ssr.js.map +1 -0
- package/docs/01-dom-ownership.md +133 -0
- package/docs/02-reactive-controller-vs-hooks.md +246 -0
- package/docs/03-form-associated-elements.md +585 -0
- package/docs/04-micro-frontend-communication.md +682 -0
- package/docs/05-interface-and-state-management-standards.md +210 -0
- package/docs/06-ssr-hydration.md +134 -0
- package/docs/07-release-and-verification.md +150 -0
- package/package.json +105 -4
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
# 마이크로 프론트엔드 통신 및 W3C Context 프로토콜 아키텍처
|
|
2
|
+
|
|
3
|
+
이 문서는 서로 다른 프레임워크(React 18/19, Vue, Angular, Svelte, Lit, Vanilla JS)로 작성된 마이크로 프론트엔드(Micro-Frontend, MFE) 환경에서 **Context-Action 상태 및 액션 파이프라인을 전역 싱글톤 없이, DOM 트리 기반으로 안전하게 공유하고 통신**하는 아키텍처 규격과 코딩 가이드를 정의합니다.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. 마이크로 프론트엔드 통신의 고질적 문제와 패러다임 전환
|
|
8
|
+
|
|
9
|
+
### 1.1 기존 MFE 통신 방식의 치명적 한계
|
|
10
|
+
|
|
11
|
+
마이크로 프론트엔드 아키텍처를 도입한 많은 대규모 조직이 다음과 같은 방식의 컴포넌트 간 통신을 시도하다가 유지보수 실패와 시스템 불안정성을 겪습니다:
|
|
12
|
+
|
|
13
|
+
1. **전역 `window` 싱글톤 오염 (`window.__GLOBAL_STORE__`, `window.eventBus`)**:
|
|
14
|
+
- 브라우저 전역 객체에 상태나 이벤트 버스를 두면, 여러 MFE 팀이 동일한 키를 덮어쓰거나 원인 모를 상태 변조가 일어납니다.
|
|
15
|
+
- 단일 페이지에 동일 MFE가 두 번 마운트(Multi-instance)되거나, 독립 테스트(Vitest/Jest) 환경을 구축할 때 테스트 격리가 완전히 깨집니다.
|
|
16
|
+
- 번들 버전 불일치로 인해 서로 다른 버전의 라이브러리가 전역 객체를 오염시키는 프로토타입 오염(Prototype Pollution)이 발생합니다.
|
|
17
|
+
|
|
18
|
+
2. **프레임워크 전용 Context API의 폐쇄성**:
|
|
19
|
+
- React의 `React.createContext`, Vue의 `provide/inject`, Angular의 DI는 각자의 버추얼 DOM/컴포넌트 트리 내부에서만 동작합니다.
|
|
20
|
+
- React 쉘 아래에 Lit 웹 컴포넌트가 임베드되거나, Vue 마이크로 앱이 삽입되면 프레임워크 경계에서 컨텍스트 전달이 완전히 단절됩니다.
|
|
21
|
+
|
|
22
|
+
3. **속성(Props) 드릴링과 문자열 직렬화 손실**:
|
|
23
|
+
- 상위 프레임워크에서 하위 커스텀 엘리먼트로 상태나 스토어를 전달할 때, HTML 어트리뷰트로 넘기면 React 18 등에서 `"[object Object]"`로 문자열화되어 데이터가 유실됩니다.
|
|
24
|
+
|
|
25
|
+
4. **수명주기(Lifecycle) 누수와 좀비 리스너**:
|
|
26
|
+
- 탭 이동, BFCache(Back-Forward Cache), 라우트 전환으로 MFE 아일랜드가 언마운트되었음에도 전역 이벤트 버스나 스토어 구독을 해제하지 않아 심각한 메모리 누수가 발생합니다.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
### 1.2 Context-Action이 제시하는 해결책
|
|
31
|
+
|
|
32
|
+
`@context-action/lit-ui`는 웹 표준과 3계층 아키텍처(Action, Store, View)를 결합하여 완벽한 해법을 제공합니다:
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
┌────────────────────────────────────────────────────────────────────────┐
|
|
36
|
+
│ Host Shell (React 18/19, Vue, or Light DOM) │
|
|
37
|
+
│ ├── provideActionRegister(host, actionRegister) │
|
|
38
|
+
│ └── provideStore(host, cartStoreContext, cartStore) │
|
|
39
|
+
└───────────────────────────────────┬────────────────────────────────────┘
|
|
40
|
+
│ (W3C Context Protocol: context-request)
|
|
41
|
+
┌───────────────────────┴───────────────────────┐
|
|
42
|
+
│ bubbles: true, composed: true │
|
|
43
|
+
▼ ▼
|
|
44
|
+
┌───────────────────────────────┐ ┌───────────────────────────────┐
|
|
45
|
+
│ MFE Island A (Header Badge) │ │ MFE Island B (Product Stepper)│
|
|
46
|
+
│ <lit-cart-badge> │ │ <lit-quantity-stepper> │
|
|
47
|
+
│ ├── #shadow-root │ │ ├── #shadow-root (FACE) │
|
|
48
|
+
│ └── ContextStoreController │ │ └── ContextActionController │
|
|
49
|
+
│ (Selective Projection) │ │ (Typed Pipeline) │
|
|
50
|
+
└───────────────────────────────┘ └───────────────────────────────┘
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
1. **W3C Context Community Protocol 표준 준수**:
|
|
54
|
+
- DOM 트리의 이벤트 버블링 메커니즘(`context-request`)을 활용하여 조상 노드가 공급(Provide)하고 자손 노드가 소비(Consume)합니다.
|
|
55
|
+
- `composed: true` 플래그를 통해 **Shadow DOM 캡슐화 경계를 자유롭게 관통**합니다.
|
|
56
|
+
- 전역 `window` 객체에 단 하나의 변수도 남기지 않습니다.
|
|
57
|
+
|
|
58
|
+
2. **`Symbol.for` 기반의 번들 간 무손실 토큰 일치**:
|
|
59
|
+
- 서로 다른 팀이 서로 다른 번들러(Vite, Webpack, Rollup)로 빌드하더라도, 자바스크립트 런타임의 글로벌 심볼 레지스트리를 통해 정확히 동일한 토큰 식별자를 참조합니다.
|
|
60
|
+
|
|
61
|
+
3. **React 18/19 상호운용성 브릿지 (`createLitElementBridge`)**:
|
|
62
|
+
- 복합 객체 및 스토어 인스턴스를 DOM 프로퍼티로 직결 대입하고, 커스텀 이벤트를 리액트 콜백 prop에 매핑하며 완벽한 클린업을 보장합니다.
|
|
63
|
+
|
|
64
|
+
4. **결정론적 제로-누수(Zero-Leak) 수명주기**:
|
|
65
|
+
- 엘리먼트가 DOM에서 분리(`hostDisconnected`)되는 즉시 스토어 구독을 해제하고 진행 중인 비동기 액션을 AbortSignal로 취소합니다.
|
|
66
|
+
- DOM에 재연결(`hostConnected`)되면 자동으로 재구독하고 최신 스냅샷을 동기화합니다.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## 2. W3C Context Protocol 심층 분석 및 동작 원리
|
|
71
|
+
|
|
72
|
+
### 2.1 `context-request` 이벤트의 섀도우 DOM 관통 메커니즘
|
|
73
|
+
|
|
74
|
+
W3C Web Components Community Group의 [Context Protocol](https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md) 규격은 다음과 같은 표준 이벤트 구조를 정의합니다:
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
export class ContextRequestEvent<C extends Context<unknown, unknown>> extends Event {
|
|
78
|
+
readonly context: C;
|
|
79
|
+
readonly callback: (value: ContextType<C>, unsubscribe?: () => void) => void;
|
|
80
|
+
readonly subscribe?: boolean;
|
|
81
|
+
|
|
82
|
+
constructor(
|
|
83
|
+
context: C,
|
|
84
|
+
callback: (value: ContextType<C>, unsubscribe?: () => void) => void,
|
|
85
|
+
options?: { subscribe?: boolean }
|
|
86
|
+
) {
|
|
87
|
+
super('context-request', {
|
|
88
|
+
bubbles: true, // DOM 트리를 따라 상위로 전파
|
|
89
|
+
composed: true, // Shadow DOM 경계를 넘어 상위 Light DOM으로 탈출!
|
|
90
|
+
});
|
|
91
|
+
this.context = context;
|
|
92
|
+
this.callback = callback;
|
|
93
|
+
this.subscribe = options?.subscribe ?? false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
#### 왜 `composed: true`가 결정적인가?
|
|
99
|
+
기본 브라우저 이벤트는 `ShadowRoot` 경계를 만나면 전파가 중단됩니다. 하지만 `composed: true`가 지정되면, 이벤트는 내부 섀도우 루트를 빠져나와 호스트 엘리먼트로 전달되고, 다시 그 상위 DOM 트리로 버블링됩니다.
|
|
100
|
+
|
|
101
|
+
```mermaid
|
|
102
|
+
sequenceDiagram
|
|
103
|
+
participant Consumer as <leaf-consumer> (Inside ShadowRoot B)
|
|
104
|
+
participant ShadowRootB as ShadowRoot B
|
|
105
|
+
participant MidContainer as <mid-container> (Inside ShadowRoot A)
|
|
106
|
+
participant Provider as <provider-host> (Root Shell)
|
|
107
|
+
|
|
108
|
+
Note over Consumer: ContextConsumer 초기화
|
|
109
|
+
Consumer->>ShadowRootB: dispatchEvent(context-request) [composed:true]
|
|
110
|
+
ShadowRootB->>MidContainer: 경계 탈출 및 버블링
|
|
111
|
+
MidContainer->>Provider: 버블링 도달
|
|
112
|
+
Note over Provider: ContextProvider가 이벤트 포착
|
|
113
|
+
Provider->>Consumer: callback(storeInstance, unsubscribeFn) 동기 실행
|
|
114
|
+
Note over Consumer: store.subscribe() 및 초기 렌더링 동기화
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
### 2.2 `Symbol.for` 글로벌 심볼 레지스트리를 통한 크로스 번들 무손실 일치
|
|
120
|
+
|
|
121
|
+
독립적으로 배포되는 마이크로 프론트엔드는 각자의 `node_modules`와 번들러 환경을 가집니다. 일반 `Symbol('name')`을 사용하면 번들이 분리될 때마다 서로 다른 심볼 메모리 주소를 생성하므로 의존성 주입이 실패합니다.
|
|
122
|
+
|
|
123
|
+
`@context-action/lit-ui`는 **자바스크립트 런타임의 전역 심볼 테이블(`Symbol.for`)**을 사용하여 토큰을 생성합니다:
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
// packages/lit-ui/src/context.ts
|
|
127
|
+
|
|
128
|
+
// 1. 단일 루트 액션 레지스터 토큰
|
|
129
|
+
export const actionRegisterContext: Context<symbol, ActionRegister<any>> =
|
|
130
|
+
createContext<ActionRegister<any>, symbol>(Symbol.for('context-action.register'));
|
|
131
|
+
|
|
132
|
+
// 2. 단일 루트 액션 디스패처 토큰
|
|
133
|
+
export const actionDispatcherContext: Context<symbol, ActionDispatcher<any>> =
|
|
134
|
+
createContext<ActionDispatcher<any>, symbol>(Symbol.for('context-action.dispatcher'));
|
|
135
|
+
|
|
136
|
+
// 3. 네임드 스토어 도메인 토큰 팩토리
|
|
137
|
+
export function createStoreContext<T = unknown>(name: string): Context<symbol, ReadableStore<T>> {
|
|
138
|
+
if (!name || typeof name !== 'string') {
|
|
139
|
+
throw new TypeError('createStoreContext: "name" must be a non-empty string.');
|
|
140
|
+
}
|
|
141
|
+
return createContext<ReadableStore<T>, symbol>(
|
|
142
|
+
Symbol.for(`context-action.store.${name}`)
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
#### 번들 분리 격리 증명:
|
|
148
|
+
- Team A (Header MFE, Webpack 5): `createStoreContext('cart')` 호출 → `Symbol.for('context-action.store.cart')` 반환.
|
|
149
|
+
- Team B (Checkout MFE, Vite 6): `createStoreContext('cart')` 호출 → `Symbol.for('context-action.store.cart')` 반환.
|
|
150
|
+
- `storeContextA === storeContextB`는 **항상 `true`**로 평가되며, 런타임에 어떤 공유 라이브러리 싱글톤도 요구하지 않습니다.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## 3. ActionRegister 및 Store 의존성 주입 패턴
|
|
155
|
+
|
|
156
|
+
### 3.1 공급자(Provider) 설정 패턴
|
|
157
|
+
|
|
158
|
+
호스트 엘리먼트 또는 상위 MFE 컨테이너는 명령형 헬퍼나 선언적 클래스 필드를 통해 컨텍스트를 공급할 수 있습니다:
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
import { LitElement, html } from 'lit';
|
|
162
|
+
import { ActionRegister } from '@context-action/core';
|
|
163
|
+
import {
|
|
164
|
+
provideStore,
|
|
165
|
+
provideActionRegister,
|
|
166
|
+
createStoreContext
|
|
167
|
+
} from '@context-action/lit-ui';
|
|
168
|
+
|
|
169
|
+
export interface CartState {
|
|
170
|
+
items: readonly { id: string; name: string; quantity: number }[];
|
|
171
|
+
total: number;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const cartStoreContext = createStoreContext<CartState>('cart');
|
|
175
|
+
|
|
176
|
+
export class AppShellElement extends LitElement {
|
|
177
|
+
// 도메인 스토어 및 액션 레지스터 초기화
|
|
178
|
+
readonly cartStore = createCartStore();
|
|
179
|
+
readonly actionRegister = new ActionRegister({ name: 'app-actions' });
|
|
180
|
+
|
|
181
|
+
constructor() {
|
|
182
|
+
super();
|
|
183
|
+
// 호스트 엘리먼트에 W3C Context Provider 등록
|
|
184
|
+
provideStore(this, cartStoreContext, this.cartStore);
|
|
185
|
+
provideActionRegister(this, this.actionRegister);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
override render() {
|
|
189
|
+
return html`
|
|
190
|
+
<header><slot name="header"></slot></header>
|
|
191
|
+
<main><slot></slot></main>
|
|
192
|
+
`;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
customElements.define('app-shell', AppShellElement);
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
### 3.2 소비자(Consumer) 리액티브 컨트롤러 패턴
|
|
201
|
+
|
|
202
|
+
하위 컴포넌트는 `@context-action/lit-ui`가 제공하는 두 가지 강력한 컨트롤러를 사용하여 컨텍스트를 주입받습니다:
|
|
203
|
+
|
|
204
|
+
#### 1. `ContextStoreController`: 스토어 구독 및 선택적 프로젝션 (Projection)
|
|
205
|
+
- 상위 조상 중 일치하는 `Context` 토큰을 가진 프로바이더를 자동으로 탐색합니다.
|
|
206
|
+
- 프로젝션(`selector`)과 동등성 비교(`equalityFn`)를 지원하여 불필요한 리렌더링을 완벽히 차단합니다.
|
|
207
|
+
- 호스트가 DOM에서 분리되면 즉시 구독을 해제하고, 다시 붙으면 최신 상태로 재구독합니다.
|
|
208
|
+
|
|
209
|
+
```typescript
|
|
210
|
+
import { LitElement, html, css } from 'lit';
|
|
211
|
+
import { ContextStoreController } from '@context-action/lit-ui';
|
|
212
|
+
import { cartStoreContext, type CartState } from './shell-element.js';
|
|
213
|
+
|
|
214
|
+
export class CartBadgeElement extends LitElement {
|
|
215
|
+
static override styles = css`
|
|
216
|
+
:host { display: inline-block; font-weight: bold; }
|
|
217
|
+
.badge { background: #e11d48; color: white; border-radius: 9999px; padding: 2px 8px; }
|
|
218
|
+
`;
|
|
219
|
+
|
|
220
|
+
// 장바구니 전체 객체가 아닌 totalCount 파생 값만 선택적으로 구독!
|
|
221
|
+
readonly #cartCount = new ContextStoreController(this, {
|
|
222
|
+
context: cartStoreContext,
|
|
223
|
+
selector: (s: CartState) => s.items.reduce((sum, item) => sum + item.quantity, 0),
|
|
224
|
+
equalityFn: (prev, next) => prev === next,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
override render() {
|
|
228
|
+
// 프로바이더가 아직 로드되지 않은 경우 안전하게 fallback 처리
|
|
229
|
+
if (!this.#cartCount.isResolved) {
|
|
230
|
+
return html`<span class="badge loading">...</span>`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return html`<span class="badge">${this.#cartCount.value}</span>`;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
customElements.define('lit-cart-badge', CartBadgeElement);
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
#### 2. `ContextActionController`: 타입 안전 액션 디스패치 및 수명주기 취소
|
|
240
|
+
- 상위에서 주입된 `ActionRegister` 파이프라인으로 액션을 디스패치합니다.
|
|
241
|
+
- `isPending` 상태를 자동으로 추적하여 로딩 스피너나 버튼 비활성화를 처리합니다.
|
|
242
|
+
- 컴포넌트가 언마운트되면 진행 중인 비동기 파이프라인을 `AbortController`를 통해 즉시 취소합니다.
|
|
243
|
+
|
|
244
|
+
```typescript
|
|
245
|
+
import { LitElement, html } from 'lit';
|
|
246
|
+
import { ContextActionController, actionRegisterContext } from '@context-action/lit-ui';
|
|
247
|
+
|
|
248
|
+
export interface CartActionMap {
|
|
249
|
+
addToCart: { id: string; quantity: number };
|
|
250
|
+
clearCart: void;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export class AddToCartButtonElement extends LitElement {
|
|
254
|
+
readonly #actions = new ContextActionController<CartActionMap>(this, {
|
|
255
|
+
context: actionRegisterContext,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
async #handleClick() {
|
|
259
|
+
try {
|
|
260
|
+
await this.#actions.dispatch('addToCart', { id: 'prod_99', quantity: 1 });
|
|
261
|
+
} catch (err) {
|
|
262
|
+
console.error('장바구니 담기 실패:', err);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
override render() {
|
|
267
|
+
return html`
|
|
268
|
+
<button
|
|
269
|
+
type="button"
|
|
270
|
+
?disabled=${this.#actions.isPending}
|
|
271
|
+
@click=${this.#handleClick}
|
|
272
|
+
>
|
|
273
|
+
${this.#actions.isPending ? '담는 중...' : '장바구니 담기'}
|
|
274
|
+
</button>
|
|
275
|
+
`;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
customElements.define('add-to-cart-btn', AddToCartButtonElement);
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
## 4. 멀티 아일랜드 조율 및 React 18/19 호스트 브릿지
|
|
284
|
+
|
|
285
|
+
대규모 서비스에서 가장 빈번한 시나리오는 **메인 애플리케이션(React 18/19 Shell) 내에 독립된 팀들이 개발한 Lit 커스텀 엘리먼트 아일랜드들을 배치**하는 것입니다.
|
|
286
|
+
|
|
287
|
+
### 4.1 React와 커스텀 엘리먼트 연동 시의 3대 난제 해결
|
|
288
|
+
|
|
289
|
+
| React 18/19 순수 JSX 작성 시 문제점 | `createLitElementBridge`의 해결책 |
|
|
290
|
+
|---|---|
|
|
291
|
+
| **복합 객체 프로퍼티의 문자열 직렬화**: `<lit-stepper items={items} />` 실행 시 DOM 어트리뷰트로 `setAttribute('items', '[object Object]')`가 호출되어 데이터가 파괴됨. | **Direct Property Assignment**: `properties: ['items', 'metadata']` 옵션에 등록된 키를 DOM 엘리먼트 인스턴스에 `el.items = items`로 직접 대입. |
|
|
292
|
+
| **Dash-cased 커스텀 이벤트 수신 불가**: React 합성 이벤트(SyntheticEvent)는 `onquantity-change`와 같은 대시형 네이티브 이벤트를 정상 감지하지 못함. | **Automated Event Mapping**: `events: { onQuantityChange: 'quantity-change' }` 설정을 통해 DOM `addEventListener`를 자동 등록하고 언마운트 시 클린업. |
|
|
293
|
+
| **Ref 전달 불일치**: `useRef()`를 전달했을 때 내부 DOM 노드를 안정적으로 가리키지 못하거나 타입 추론이 상실됨. | **Bidirectional Ref Forwarding**: Callback Ref와 ObjectRef를 모두 완벽히 지원하여 네이티브 커스텀 엘리먼트 인스턴스를 정확히 전달. |
|
|
294
|
+
|
|
295
|
+
---
|
|
296
|
+
|
|
297
|
+
### 4.2 `createLitElementBridge`를 사용한 React 래핑 컴포넌트 선언
|
|
298
|
+
|
|
299
|
+
```tsx
|
|
300
|
+
// islands/react-stepper-bridge.tsx
|
|
301
|
+
import * as React from 'react';
|
|
302
|
+
import { createLitElementBridge } from '@context-action/lit-ui';
|
|
303
|
+
import type { LitQuantityStepper } from './lit-quantity-stepper.js';
|
|
304
|
+
|
|
305
|
+
export interface StepperProps {
|
|
306
|
+
id?: string;
|
|
307
|
+
name?: string;
|
|
308
|
+
min?: number;
|
|
309
|
+
max?: number;
|
|
310
|
+
value?: number;
|
|
311
|
+
metadata?: Record<string, any>;
|
|
312
|
+
onQuantityChange?: (event: CustomEvent<{ value: number }>) => void;
|
|
313
|
+
className?: string;
|
|
314
|
+
children?: React.ReactNode;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// React 컴포넌트로 브릿지 생성
|
|
318
|
+
export const ReactQuantityStepper = createLitElementBridge<StepperProps, LitQuantityStepper>(
|
|
319
|
+
'lit-quantity-stepper',
|
|
320
|
+
{
|
|
321
|
+
properties: ['metadata', 'value'], // DOM 프로퍼티로 직결 대입
|
|
322
|
+
events: {
|
|
323
|
+
onQuantityChange: 'quantity-change', // DOM 커스텀 이벤트를 React prop에 매핑
|
|
324
|
+
},
|
|
325
|
+
}
|
|
326
|
+
);
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
---
|
|
330
|
+
|
|
331
|
+
### 4.3 멀티 아일랜드 크로스 통신 시나리오 워크스루
|
|
332
|
+
|
|
333
|
+
하나의 React 메인 화면에서 헤더 뱃지 아일랜드와 상품 상세 스텝퍼 아일랜드가 스토어를 공유하는 전체 흐름입니다:
|
|
334
|
+
|
|
335
|
+
```tsx
|
|
336
|
+
// App.tsx (React 18/19 Host Application)
|
|
337
|
+
import React, { useRef, useState } from 'react';
|
|
338
|
+
import { ReactQuantityStepper } from './islands/react-stepper-bridge.js';
|
|
339
|
+
|
|
340
|
+
export function ShopApp() {
|
|
341
|
+
const [lastValue, setLastValue] = useState(1);
|
|
342
|
+
const stepperRef = useRef<HTMLElement>(null);
|
|
343
|
+
|
|
344
|
+
return (
|
|
345
|
+
<div className="ecommerce-container">
|
|
346
|
+
<header className="navbar">
|
|
347
|
+
<h1>쇼핑몰 호스트 (React 18/19)</h1>
|
|
348
|
+
{/* 아일랜드 A: Lit 커스텀 엘리먼트 (헤더 장바구니 뱃지) */}
|
|
349
|
+
<lit-cart-badge></lit-cart-badge>
|
|
350
|
+
</header>
|
|
351
|
+
|
|
352
|
+
<main className="product-view">
|
|
353
|
+
<h2>프리미엄 무선 키보드</h2>
|
|
354
|
+
<p>선택된 수량: {lastValue}개</p>
|
|
355
|
+
|
|
356
|
+
{/* 아일랜드 B: React 브릿지로 감싸진 Lit 수량 스텝퍼 */}
|
|
357
|
+
<ReactQuantityStepper
|
|
358
|
+
ref={stepperRef}
|
|
359
|
+
name="order_quantity"
|
|
360
|
+
min={1}
|
|
361
|
+
max={10}
|
|
362
|
+
value={lastValue}
|
|
363
|
+
metadata={{ source: 'react-pdp-banner' }}
|
|
364
|
+
onQuantityChange={(e) => {
|
|
365
|
+
console.log('Lit 컴포넌트에서 발생한 변경 수신:', e.detail.value);
|
|
366
|
+
setLastValue(e.detail.value);
|
|
367
|
+
}}
|
|
368
|
+
/>
|
|
369
|
+
</main>
|
|
370
|
+
</div>
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
#### 상태 동기화 및 렌더링 독립성:
|
|
376
|
+
1. 사용자가 `<ReactQuantityStepper>`의 `+` 버튼을 누르면 내부 액션 디스패치가 발생하여 `cartStore`의 수량이 증가합니다.
|
|
377
|
+
2. `cartStore`의 구독자 목록에 등록된 `<lit-cart-badge>`의 `ContextStoreController`가 즉시 반응하여 뱃지 텍스트를 업데이트합니다 (0.1ms 이내).
|
|
378
|
+
3. **React 메인 트리의 불필요한 전체 리렌더링 없이**, 오직 변경이 발생한 두 개의 Web Component 아일랜드만 미세 갱신(Fine-grained Update)됩니다!
|
|
379
|
+
|
|
380
|
+
---
|
|
381
|
+
|
|
382
|
+
## 5. 제로-누수(Zero-Leak) 수명주기 및 재연결 탄력성
|
|
383
|
+
|
|
384
|
+
마이크로 프론트엔드는 SPA 라우팅, 모달 팝업, 탭 UI, 가상 스크롤(Virtual Scroll) 등으로 인해 DOM 엘리먼트가 매우 빈번하게 붙었다가 떨어집니다. 이 과정에서 리스너가 누수되면 브라우저 메모리가 고갈되고 성능이 급격히 저하됩니다.
|
|
385
|
+
|
|
386
|
+
### 5.1 분리(Disconnect)와 파괴(Dispose)의 엄격한 분리
|
|
387
|
+
|
|
388
|
+
일반적인 치명적 안티패턴은 `disconnectedCallback`에서 스토어 자체나 도메인 모델을 파괴해버리는 것입니다. 탭을 전환하거나 DOM 요소를 다른 부모 아래로 이동(`appendChild`)할 때 사용자가 입력 중이던 상태가 전부 날아갑니다.
|
|
389
|
+
|
|
390
|
+
`@context-action/lit-ui`는 **렌더러 구독 해제**와 **도메인 상태 보존**을 철저히 분리합니다:
|
|
391
|
+
|
|
392
|
+
```typescript
|
|
393
|
+
// ContextStoreController 내부 수명주기 구현 분석
|
|
394
|
+
export class ContextStoreController<T, Selected> implements ReactiveController {
|
|
395
|
+
// DOM 연결 시 호출:
|
|
396
|
+
hostConnected(): void {
|
|
397
|
+
// 1. ContextConsumer가 자동으로 상위 context-request 재전송
|
|
398
|
+
// 2. 스토어가 이미 존재하면 즉시 재구독 및 최신 스냅샷 동기화
|
|
399
|
+
if (this.#currentStore && !this.#unsubscribeStore) {
|
|
400
|
+
this.#syncStateAndSubscribe();
|
|
401
|
+
this.#host.requestUpdate();
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// DOM 분리 시 호출:
|
|
406
|
+
hostDisconnected(): void {
|
|
407
|
+
// 스토어 구독만 즉각 해제! (도메인 모델이나 엘리먼트 자체는 보존)
|
|
408
|
+
this.#cleanupStoreSubscription();
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
```mermaid
|
|
414
|
+
stateDiagram-v2
|
|
415
|
+
[*] --> Connected: element.append(container)
|
|
416
|
+
Connected --> Subscribed: ContextStoreController.subscribe()
|
|
417
|
+
note right of Subscribed: Store.getListenerCount() == 1\n액티브 상태 반영
|
|
418
|
+
|
|
419
|
+
Subscribed --> Disconnected: element.remove()
|
|
420
|
+
Disconnected --> Unsubscribed: ContextStoreController.unsubscribe()
|
|
421
|
+
note right of Unsubscribed: Store.getListenerCount() == 0\n완벽한 제로 메모리 누수!
|
|
422
|
+
|
|
423
|
+
Unsubscribed --> Connected: 다시 DOM에 append
|
|
424
|
+
Connected --> Subscribed: fresh snapshot sync & resubscribe
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
---
|
|
428
|
+
|
|
429
|
+
### 5.2 엄격한 누수 방지 테스트 검증 (Adversarial Evidence)
|
|
430
|
+
|
|
431
|
+
`packages/lit-ui/test/adversarial-stress.test.ts`에 의해 검증된 실제 스트레스 테스트 결과:
|
|
432
|
+
|
|
433
|
+
1. **50회 연속 고속 Attach / Detach 사이클**:
|
|
434
|
+
- 엘리먼트를 50회 동안 DOM에 추가했다가 제거하는 과정을 반복했을 때, 스토어의 리스너 카운트는 항상 `1 → 0 → 1 → 0`으로 정확히 복귀하며, 최종 분리 상태에서 **정확히 0개**의 리스너를 유지합니다.
|
|
435
|
+
2. **React Bridge 100회 연속 인스턴스 마운트/언마운트**:
|
|
436
|
+
- `packages/lit-ui/test/react-bridge-stress.test.tsx`에서 100개의 인스턴스를 연속으로 마운트하고 언마운트했을 때, `totalAddCalls === totalRemoveCalls`가 성립하여 단 하나의 핸들러도 누수되지 않음을 입증했습니다.
|
|
437
|
+
3. **진행 중인 비동기 액션의 AbortSignal 연동**:
|
|
438
|
+
- `ActionController`는 `hostDisconnected()`가 호출되는 순간 내부 `AbortController`를 트리거하여, 네트워크 응답이 뒤늦게 도착해 언마운트된 엘리먼트의 상태를 변경하려는 비동기 경쟁 상태(Race Condition)를 원천 차단합니다.
|
|
439
|
+
|
|
440
|
+
---
|
|
441
|
+
|
|
442
|
+
## 6. 안티패턴 vs 황금률 (Golden Rules)
|
|
443
|
+
|
|
444
|
+
| 영역 | ❌ 안티패턴 (Bad Practice) | ✅ 올바른 패턴 (Best Practice) | 이유 및 영향 |
|
|
445
|
+
|---|---|---|---|
|
|
446
|
+
| **상태 공유** | `window.__STORE__` 또는 글로벌 `EventEmitter`에 의존 | W3C Context Protocol (`createStoreContext`, `provideStore`) 사용 | 전역 충돌 방지, 멀티 인스턴스 지원, 테스트 격리 보장 |
|
|
447
|
+
| **토큰 정의** | 로컬 모듈에서 `createContext(Symbol('my-key'))` 사용 | `createStoreContext('my-key')` (`Symbol.for`) 사용 | 독립 빌드/배포된 MFE 간 동일 토큰 참조 보장 |
|
|
448
|
+
| **속성 전달** | React JSX에서 `<lit-comp items={[1, 2, 3]} />` 직접 전달 | `createLitElementBridge`의 `properties: ['items']` 사용 | React 18의 `"[object Object]"` 문자열 직렬화 방지 |
|
|
449
|
+
| **이벤트 처리** | 전역 버스 발행 또는 Shadow DOM 내부 쿼리 (`el.shadowRoot.querySelector`) | `bubbles: true, composed: true`를 가진 표준 `CustomEvent` 디스패치 | 캡슐화 원칙 준수, 상위 프레임워크와의 자연스러운 연동 |
|
|
450
|
+
| **수명 관리** | 컴포넌트 disconnect 시 아무런 정리도 하지 않거나 반대로 Store 인스턴스를 파괴 | `hostDisconnected()`에서 리스너만 해제하고 상태는 보존 (재연결 탄력성) | 메모리 누수 방지 및 탭 이동/BFCache 복원 시 데이터 보존 |
|
|
451
|
+
| **리렌더링** | 스토어 전체 상태를 매번 구독하여 불필요한 렌더링 폭포수 유발 | `ContextStoreController`의 `selector`와 `equalityFn` 활용 | 불필요한 `requestUpdate()` 차단 및 120fps 고성능 유지 |
|
|
452
|
+
|
|
453
|
+
---
|
|
454
|
+
|
|
455
|
+
## 7. 완전한 엔드투엔드 통합 레시피 (E2E Integration Recipe)
|
|
456
|
+
|
|
457
|
+
아래 예제는 독립된 세 개의 마이크로 프론트엔드가 동일한 쇼핑 카트 도메인을 공유하고 협업하는 완전한 코드입니다.
|
|
458
|
+
|
|
459
|
+
### 7.1 공유 도메인 스펙 (`shared-cart-domain.ts`)
|
|
460
|
+
|
|
461
|
+
```typescript
|
|
462
|
+
import { ActionRegister } from '@context-action/core';
|
|
463
|
+
import type { ReadableStore } from '@context-action/lit';
|
|
464
|
+
import { createStoreContext } from '@context-action/lit-ui';
|
|
465
|
+
|
|
466
|
+
export interface CartItem {
|
|
467
|
+
id: string;
|
|
468
|
+
name: string;
|
|
469
|
+
price: number;
|
|
470
|
+
quantity: number;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export interface CartDomainState {
|
|
474
|
+
items: readonly CartItem[];
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export interface CartActionMap {
|
|
478
|
+
addItem: { product: Omit<CartItem, 'quantity'>; quantity: number };
|
|
479
|
+
removeItem: { id: string };
|
|
480
|
+
clear: void;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// 1. 번들 간 무손실 일치 W3C Context 토큰
|
|
484
|
+
export const cartStoreContext = createStoreContext<CartDomainState>('ecommerce-cart');
|
|
485
|
+
|
|
486
|
+
// 2. 순수 상태 스토어 생성기
|
|
487
|
+
export function createCartStore(initialItems: CartItem[] = []): ReadableStore<CartDomainState> & {
|
|
488
|
+
update(fn: (prev: CartDomainState) => CartDomainState): void;
|
|
489
|
+
} {
|
|
490
|
+
let state: CartDomainState = { items: initialItems };
|
|
491
|
+
const listeners = new Set<() => void>();
|
|
492
|
+
|
|
493
|
+
return {
|
|
494
|
+
getValue: () => state,
|
|
495
|
+
getSnapshot: () => state,
|
|
496
|
+
subscribe: (listener: () => void) => {
|
|
497
|
+
listeners.add(listener);
|
|
498
|
+
return () => listeners.delete(listener);
|
|
499
|
+
},
|
|
500
|
+
update: (fn) => {
|
|
501
|
+
state = fn(state);
|
|
502
|
+
listeners.forEach((l) => l());
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
---
|
|
509
|
+
|
|
510
|
+
### 7.2 MFE 1: Lit 기반 카트 뱃지 엘리먼트 (`lit-cart-badge.ts`)
|
|
511
|
+
|
|
512
|
+
```typescript
|
|
513
|
+
import { LitElement, html, css } from 'lit';
|
|
514
|
+
import { customElement } from 'lit/decorators.js';
|
|
515
|
+
import { ContextStoreController } from '@context-action/lit-ui';
|
|
516
|
+
import { cartStoreContext, type CartDomainState } from './shared-cart-domain.js';
|
|
517
|
+
|
|
518
|
+
@customElement('lit-cart-badge')
|
|
519
|
+
export class LitCartBadge extends LitElement {
|
|
520
|
+
static override styles = css`
|
|
521
|
+
:host { display: inline-flex; align-items: center; }
|
|
522
|
+
.badge {
|
|
523
|
+
background-color: #2563eb;
|
|
524
|
+
color: white;
|
|
525
|
+
padding: 0.25rem 0.6rem;
|
|
526
|
+
border-radius: 9999px;
|
|
527
|
+
font-size: 0.875rem;
|
|
528
|
+
font-weight: 700;
|
|
529
|
+
}
|
|
530
|
+
`;
|
|
531
|
+
|
|
532
|
+
// 총 아이템 개수만 프로젝션하여 선택적 구독
|
|
533
|
+
readonly count = new ContextStoreController(this, {
|
|
534
|
+
context: cartStoreContext,
|
|
535
|
+
selector: (state: CartDomainState) =>
|
|
536
|
+
state.items.reduce((acc, item) => acc + item.quantity, 0),
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
override render() {
|
|
540
|
+
return html`
|
|
541
|
+
<div class="badge" aria-label="장바구니 담긴 수량">
|
|
542
|
+
🛒 <span>${this.count.value ?? 0}</span>
|
|
543
|
+
</div>
|
|
544
|
+
`;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
---
|
|
550
|
+
|
|
551
|
+
### 7.3 MFE 2: React 18/19 상품 주문 위젯 (`ReactProductCard.tsx`)
|
|
552
|
+
|
|
553
|
+
```tsx
|
|
554
|
+
import * as React from 'react';
|
|
555
|
+
import { createLitElementBridge } from '@context-action/lit-ui';
|
|
556
|
+
import type { CartActionMap } from './shared-cart-domain.js';
|
|
557
|
+
|
|
558
|
+
// 수량 선택 Lit 커스텀 엘리먼트를 React 컴포넌트로 브릿지
|
|
559
|
+
const ReactLitStepper = createLitElementBridge({
|
|
560
|
+
tagName: 'lit-quantity-stepper',
|
|
561
|
+
properties: ['value', 'min', 'max'],
|
|
562
|
+
events: {
|
|
563
|
+
onQuantityChange: 'quantity-change',
|
|
564
|
+
},
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
interface ProductCardProps {
|
|
568
|
+
productId: string;
|
|
569
|
+
productName: string;
|
|
570
|
+
price: number;
|
|
571
|
+
onAddToCart: (quantity: number) => void;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
export function ReactProductCard({ productId, productName, price, onAddToCart }: ProductCardProps) {
|
|
575
|
+
const [selectedQty, setSelectedQty] = React.useState(1);
|
|
576
|
+
|
|
577
|
+
return (
|
|
578
|
+
<div className="product-card">
|
|
579
|
+
<h3>{productName}</h3>
|
|
580
|
+
<p className="price">₩{price.toLocaleString()}</p>
|
|
581
|
+
|
|
582
|
+
<div className="stepper-row">
|
|
583
|
+
<label>수량 선택:</label>
|
|
584
|
+
<ReactLitStepper
|
|
585
|
+
min={1}
|
|
586
|
+
max={10}
|
|
587
|
+
value={selectedQty}
|
|
588
|
+
onQuantityChange={(e: CustomEvent<{ value: number }>) => {
|
|
589
|
+
setSelectedQty(e.detail.value);
|
|
590
|
+
}}
|
|
591
|
+
/>
|
|
592
|
+
</div>
|
|
593
|
+
|
|
594
|
+
<button
|
|
595
|
+
type="button"
|
|
596
|
+
className="add-btn"
|
|
597
|
+
onClick={() => onAddToCart(selectedQty)}
|
|
598
|
+
>
|
|
599
|
+
{selectedQty}개 장바구니에 담기
|
|
600
|
+
</button>
|
|
601
|
+
</div>
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
---
|
|
607
|
+
|
|
608
|
+
### 7.4 최상위 애플리케이션 통합 (`AppShell.ts`)
|
|
609
|
+
|
|
610
|
+
```typescript
|
|
611
|
+
import { LitElement, html, css } from 'lit';
|
|
612
|
+
import { customElement } from 'lit/decorators.js';
|
|
613
|
+
import { ActionRegister } from '@context-action/core';
|
|
614
|
+
import { provideStore, provideActionRegister } from '@context-action/lit-ui';
|
|
615
|
+
import {
|
|
616
|
+
cartStoreContext,
|
|
617
|
+
createCartStore,
|
|
618
|
+
type CartActionMap
|
|
619
|
+
} from './shared-cart-domain.js';
|
|
620
|
+
|
|
621
|
+
@customElement('mfe-app-shell')
|
|
622
|
+
export class MfeAppShell extends LitElement {
|
|
623
|
+
static override styles = css`
|
|
624
|
+
:host { display: block; max-width: 960px; margin: 0 auto; font-family: sans-serif; }
|
|
625
|
+
header { display: flex; justify-content: space-between; align-items: center; padding: 1rem 0; border-bottom: 1px solid #ccc; }
|
|
626
|
+
`;
|
|
627
|
+
|
|
628
|
+
readonly cartStore = createCartStore();
|
|
629
|
+
readonly actionRegister = new ActionRegister<CartActionMap>({ name: 'cart-pipeline' });
|
|
630
|
+
|
|
631
|
+
constructor() {
|
|
632
|
+
super();
|
|
633
|
+
|
|
634
|
+
// 1. W3C Context 프로토콜을 통해 하위 MFE 아일랜드에 주입
|
|
635
|
+
provideStore(this, cartStoreContext, this.cartStore);
|
|
636
|
+
provideActionRegister(this, this.actionRegister);
|
|
637
|
+
|
|
638
|
+
// 2. 비즈니스 액션 핸들러 등록
|
|
639
|
+
this.actionRegister.register('addItem', async (payload) => {
|
|
640
|
+
this.cartStore.update((prev) => {
|
|
641
|
+
const existing = prev.items.find((item) => item.id === payload.product.id);
|
|
642
|
+
if (existing) {
|
|
643
|
+
return {
|
|
644
|
+
items: prev.items.map((item) =>
|
|
645
|
+
item.id === payload.product.id
|
|
646
|
+
? { ...item, quantity: item.quantity + payload.quantity }
|
|
647
|
+
: item
|
|
648
|
+
),
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
return {
|
|
652
|
+
items: [...prev.items, { ...payload.product, quantity: payload.quantity }],
|
|
653
|
+
};
|
|
654
|
+
});
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
override render() {
|
|
659
|
+
return html`
|
|
660
|
+
<header>
|
|
661
|
+
<h2>멀티 프레임워크 쇼핑몰 쉘</h2>
|
|
662
|
+
<!-- Lit 배지 아일랜드 -->
|
|
663
|
+
<lit-cart-badge></lit-cart-badge>
|
|
664
|
+
</header>
|
|
665
|
+
|
|
666
|
+
<main>
|
|
667
|
+
<!-- React 또는 다른 프레임워크 컴포넌트가 슬롯에 임베드됨 -->
|
|
668
|
+
<slot></slot>
|
|
669
|
+
</main>
|
|
670
|
+
`;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
---
|
|
676
|
+
|
|
677
|
+
## 8. 요약 및 권장 가이드라인
|
|
678
|
+
|
|
679
|
+
1. **DOM 트리를 신뢰하십시오**: 전역 `window` 객체에 상태나 이벤트 버스를 두지 말고, W3C Context Protocol (`@lit/context`)과 DOM 이벤트 버블링(`context-request`)을 활용하십시오.
|
|
680
|
+
2. **`Symbol.for`로 번들 간 호환성을 확보하십시오**: 마이크로 프론트엔드가 독립 빌드되더라도 `createStoreContext`를 사용하면 런타임에 완벽히 동일한 컨텍스트 토큰을 공유합니다.
|
|
681
|
+
3. **React 18/19 호스트에는 `createLitElementBridge`를 적용하십시오**: 복합 프로퍼티의 문자열 직렬화 방지와 커스텀 이벤트 클린업이 자동으로 처리됩니다.
|
|
682
|
+
4. **수명주기 해제를 철저히 검증하십시오**: `hostDisconnected` 시 스토어 구독 해제를 보장하고, 테스트 작성 시 `getListenerCount()`를 통해 제로 메모리 누수를 검증하십시오.
|