feat: V2 Core 및 이벤트 시스템 추가
- V2 Core 라이브러리를 추가하여 느슨한 결합 아키텍처를 지원합니다. - V2 EventBus를 통해 타입 안전한 이벤트 발행 및 구독 기능을 구현하였습니다. - V2ErrorBoundary 컴포넌트를 추가하여 각 컴포넌트의 에러를 격리하고, 사용자 정의 폴백 UI 및 재시도 기능을 제공합니다. - UnifiedRepeater 및 ButtonPrimaryComponent에서 V2 EventBus를 활용하여 이벤트 처리 로직을 개선하였습니다. - 레거시 이벤트와의 호환성을 위해 LegacyEventAdapter를 추가하여 점진적 마이그레이션을 지원합니다. - V2 컴포넌트 간의 통신을 위한 이벤트 타입을 정의하였습니다.
This commit is contained in:
parent
b1fba586cb
commit
b39c98c73f
|
|
@ -0,0 +1,568 @@
|
|||
# V2 컴포넌트 및 Unified 폼 컴포넌트 결합도 분석 보고서
|
||||
|
||||
> 작성일: 2026-01-26
|
||||
> 목적: 컴포넌트 간 결합도 분석 및 느슨한 결합 전환 가능성 평가
|
||||
|
||||
---
|
||||
|
||||
## 1. 분석 대상 컴포넌트 목록
|
||||
|
||||
### 1.1 V2 컴포넌트 (18개)
|
||||
|
||||
| # | 컴포넌트 | 경로 | 주요 용도 |
|
||||
|---|---------|------|----------|
|
||||
| 1 | v2-aggregation-widget | `v2-aggregation-widget/` | 데이터 집계 표시 |
|
||||
| 2 | v2-button-primary | `v2-button-primary/` | 기본 버튼 (저장/삭제/모달 등) |
|
||||
| 3 | v2-card-display | `v2-card-display/` | 카드 형태 데이터 표시 |
|
||||
| 4 | v2-category-manager | `v2-category-manager/` | 카테고리 트리 관리 |
|
||||
| 5 | v2-divider-line | `v2-divider-line/` | 구분선 |
|
||||
| 6 | v2-location-swap-selector | `v2-location-swap-selector/` | 출발지/도착지 선택 |
|
||||
| 7 | v2-numbering-rule | `v2-numbering-rule/` | 채번 규칙 표시 |
|
||||
| 8 | v2-pivot-grid | `v2-pivot-grid/` | 피벗 테이블 |
|
||||
| 9 | v2-rack-structure | `v2-rack-structure/` | 렉 구조 표시 |
|
||||
| 10 | v2-repeat-container | `v2-repeat-container/` | 리피터 컨테이너 |
|
||||
| 11 | v2-repeat-screen-modal | `v2-repeat-screen-modal/` | 반복 화면 모달 |
|
||||
| 12 | v2-section-card | `v2-section-card/` | 섹션 카드 |
|
||||
| 13 | v2-section-paper | `v2-section-paper/` | 섹션 페이퍼 |
|
||||
| 14 | v2-split-panel-layout | `v2-split-panel-layout/` | 분할 패널 레이아웃 |
|
||||
| 15 | v2-table-list | `v2-table-list/` | 테이블 리스트 |
|
||||
| 16 | v2-table-search-widget | `v2-table-search-widget/` | 테이블 검색 위젯 |
|
||||
| 17 | v2-tabs-widget | `v2-tabs-widget/` | 탭 위젯 |
|
||||
| 18 | v2-text-display | `v2-text-display/` | 텍스트 표시 |
|
||||
| 19 | v2-unified-repeater | `v2-unified-repeater/` | 통합 리피터 |
|
||||
|
||||
### 1.2 Unified 폼 컴포넌트 (11개)
|
||||
|
||||
| # | 컴포넌트 | 파일 | 주요 용도 |
|
||||
|---|---------|------|----------|
|
||||
| 1 | UnifiedInput | `UnifiedInput.tsx` | 텍스트/숫자/이메일 등 입력 |
|
||||
| 2 | UnifiedSelect | `UnifiedSelect.tsx` | 선택박스/라디오/체크박스 |
|
||||
| 3 | UnifiedDate | `UnifiedDate.tsx` | 날짜/시간 입력 |
|
||||
| 4 | UnifiedRepeater | `UnifiedRepeater.tsx` | 리피터 (테이블 형태) |
|
||||
| 5 | UnifiedLayout | `UnifiedLayout.tsx` | 레이아웃 컨테이너 |
|
||||
| 6 | UnifiedGroup | `UnifiedGroup.tsx` | 그룹 컨테이너 (카드/탭/접기) |
|
||||
| 7 | UnifiedHierarchy | `UnifiedHierarchy.tsx` | 계층 구조 표시 |
|
||||
| 8 | UnifiedList | `UnifiedList.tsx` | 리스트 표시 |
|
||||
| 9 | UnifiedMedia | `UnifiedMedia.tsx` | 파일/이미지/비디오 업로드 |
|
||||
| 10 | UnifiedBiz | `UnifiedBiz.tsx` | 비즈니스 컴포넌트 |
|
||||
| 11 | UnifiedFormContext | `UnifiedFormContext.tsx` | 폼 상태 관리 컨텍스트 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 결합도 분석 결과
|
||||
|
||||
### 2.1 결합도 유형 분류
|
||||
|
||||
| 유형 | 설명 | 문제점 |
|
||||
|------|------|--------|
|
||||
| **직접 Import** | 다른 모듈을 직접 import하여 사용 | 변경 시 영향 범위 큼 |
|
||||
| **CustomEvent** | window.dispatchEvent로 이벤트 발생/수신 | 암묵적 의존성, 타입 안전성 부족 |
|
||||
| **전역 상태 (window.__)** | window 객체에 전역 변수 저장 | 네임스페이스 충돌, 테스트 어려움 |
|
||||
| **Context API** | React Context로 상태 공유 | 상대적으로 안전하지만 범위 확장 시 주의 |
|
||||
|
||||
### 2.2 V2 컴포넌트 결합도 상세
|
||||
|
||||
#### 2.2.1 높은 결합도 (High Coupling) - 우선 개선 대상
|
||||
|
||||
| 컴포넌트 | buttonActions Import | CustomEvent 사용 | window.__ 사용 | 결합도 점수 |
|
||||
|---------|---------------------|------------------|----------------|------------|
|
||||
| **v2-button-primary** | ✅ 직접 Import | 4개 발생 | ❌ | 🔴 8/10 |
|
||||
| **v2-table-list** | ❌ | 16개 수신/발생 | 4개 사용 | 🔴 9/10 |
|
||||
|
||||
**v2-button-primary 상세:**
|
||||
```typescript
|
||||
// 직접 의존
|
||||
import { ButtonActionExecutor, ButtonActionContext } from "@/lib/utils/buttonActions";
|
||||
|
||||
// CustomEvent 발생
|
||||
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||
window.dispatchEvent(new CustomEvent("closeEditModal"));
|
||||
window.dispatchEvent(new CustomEvent("saveSuccessInModal"));
|
||||
```
|
||||
|
||||
**v2-table-list 상세:**
|
||||
```typescript
|
||||
// 전역 상태 사용
|
||||
window.__relatedButtonsTargetTables
|
||||
window.__relatedButtonsSelectedData
|
||||
|
||||
// CustomEvent 발생
|
||||
window.dispatchEvent(new CustomEvent("tableListDataChange", { ... }));
|
||||
|
||||
// CustomEvent 수신
|
||||
window.addEventListener("refreshTable", handleRefreshTable);
|
||||
window.addEventListener("related-button-register", ...);
|
||||
window.addEventListener("related-button-unregister", ...);
|
||||
window.addEventListener("related-button-select", ...);
|
||||
```
|
||||
|
||||
#### 2.2.2 중간 결합도 (Medium Coupling)
|
||||
|
||||
| 컴포넌트 | buttonActions Import | CustomEvent 사용 | window.__ 사용 | 결합도 점수 |
|
||||
|---------|---------------------|------------------|----------------|------------|
|
||||
| **v2-repeat-container** | ❌ | 5개 수신/발생 | ❌ | 🟠 6/10 |
|
||||
| **v2-split-panel-layout** | ❌ | 3개 수신/발생 | ❌ | 🟠 5/10 |
|
||||
| **v2-aggregation-widget** | ❌ | 14개 수신 | ❌ | 🟠 6/10 |
|
||||
| **v2-tabs-widget** | ❌ | 2개 | ❌ | 🟠 4/10 |
|
||||
|
||||
**v2-repeat-container 상세:**
|
||||
```typescript
|
||||
// CustomEvent 수신
|
||||
window.addEventListener("beforeFormSave", handleBeforeFormSave);
|
||||
window.addEventListener("repeaterDataChange", handleDataChange);
|
||||
window.addEventListener("tableListDataChange", handleDataChange);
|
||||
```
|
||||
|
||||
**v2-aggregation-widget 상세:**
|
||||
```typescript
|
||||
// CustomEvent 수신 (다수)
|
||||
window.addEventListener("tableListDataChange", handleTableListDataChange);
|
||||
window.addEventListener("repeaterDataChange", handleRepeaterDataChange);
|
||||
window.addEventListener("selectionChange", handleSelectionChange);
|
||||
window.addEventListener("tableSelectionChange", handleSelectionChange);
|
||||
window.addEventListener("rowSelectionChange", handleSelectionChange);
|
||||
window.addEventListener("checkboxSelectionChange", handleSelectionChange);
|
||||
```
|
||||
|
||||
#### 2.2.3 낮은 결합도 (Low Coupling) - 독립적
|
||||
|
||||
| 컴포넌트 | buttonActions Import | CustomEvent 사용 | window.__ 사용 | 결합도 점수 |
|
||||
|---------|---------------------|------------------|----------------|------------|
|
||||
| v2-pivot-grid | ❌ | 0개 | window.open만 | 🟢 2/10 |
|
||||
| v2-card-display | ❌ | 1개 수신 | ❌ | 🟢 2/10 |
|
||||
| v2-category-manager | ❌ | 2개 (ConfigPanel) | ❌ | 🟢 2/10 |
|
||||
| v2-divider-line | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
| v2-location-swap-selector | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
| v2-numbering-rule | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
| v2-rack-structure | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
| v2-section-card | ❌ | 1개 (ConfigPanel) | ❌ | 🟢 1/10 |
|
||||
| v2-section-paper | ❌ | 1개 (ConfigPanel) | ❌ | 🟢 1/10 |
|
||||
| v2-table-search-widget | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
| v2-text-display | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
| v2-repeat-screen-modal | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
| v2-unified-repeater | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
|
||||
### 2.3 Unified 폼 컴포넌트 결합도 상세
|
||||
|
||||
| 컴포넌트 | buttonActions Import | CustomEvent 사용 | window.__ 사용 | 결합도 점수 |
|
||||
|---------|---------------------|------------------|----------------|------------|
|
||||
| **UnifiedRepeater** | ❌ | 7개 수신/발생 | 2개 사용 | 🔴 8/10 |
|
||||
| **UnifiedFormContext** | ❌ | 3개 발생 | ❌ | 🟠 4/10 |
|
||||
| UnifiedInput | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
| UnifiedSelect | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
| UnifiedDate | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
| UnifiedLayout | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
| UnifiedGroup | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
| UnifiedHierarchy | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
| UnifiedList | ❌ | 0개 (TableList 래핑) | ❌ | 🟢 2/10 |
|
||||
| UnifiedMedia | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
| UnifiedBiz | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
|
||||
**UnifiedRepeater 상세:**
|
||||
```typescript
|
||||
// 전역 상태 사용
|
||||
window.__unifiedRepeaterInstances = new Set();
|
||||
window.__unifiedRepeaterInstances.add(targetTableName);
|
||||
|
||||
// CustomEvent 수신
|
||||
window.addEventListener("repeaterSave", handleSaveEvent);
|
||||
window.addEventListener("beforeFormSave", handleBeforeFormSave);
|
||||
window.addEventListener("componentDataTransfer", handleComponentDataTransfer);
|
||||
window.addEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer);
|
||||
```
|
||||
|
||||
**UnifiedFormContext 상세:**
|
||||
```typescript
|
||||
// CustomEvent 발생 (레거시 호환)
|
||||
window.dispatchEvent(new CustomEvent("beforeFormSave", { detail: eventDetail }));
|
||||
window.dispatchEvent(new CustomEvent("afterFormSave", { detail: { ... } }));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 주요 결합 지점 시각화
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ buttonActions.ts (7,145줄) │
|
||||
│ ⬇️ 직접 Import │
|
||||
│ v2-button-primary ───────────────────────────────────────────────┐
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ CustomEvent
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Event Bus (현재: window) │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ refreshTable │ │beforeFormSave│ │tableListData │ │
|
||||
│ │ │ │ │ │ Change │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
│ │ │ │ │
|
||||
└─────────│──────────────────│──────────────────│─────────────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌───────────┐ ┌───────────┐ ┌───────────────┐
|
||||
│v2-table │ │v2-repeat │ │v2-aggregation │
|
||||
│ -list │ │-container │ │ -widget │
|
||||
└───────────┘ └───────────┘ └───────────────┘
|
||||
│ │
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────────┐ ┌───────────┐
|
||||
│Unified │ │Unified │
|
||||
│Repeater │ │FormContext│
|
||||
└───────────┘ └───────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 이벤트 매트릭스
|
||||
|
||||
### 4.1 이벤트 발생 컴포넌트
|
||||
|
||||
| 이벤트명 | 발생 컴포넌트 | 용도 |
|
||||
|---------|-------------|------|
|
||||
| `refreshTable` | v2-button-primary, buttonActions | 테이블 데이터 새로고침 |
|
||||
| `closeEditModal` | v2-button-primary, buttonActions | 수정 모달 닫기 |
|
||||
| `saveSuccessInModal` | v2-button-primary, buttonActions | 저장 성공 알림 (연속 등록) |
|
||||
| `beforeFormSave` | UnifiedFormContext, buttonActions | 저장 전 데이터 수집 |
|
||||
| `afterFormSave` | UnifiedFormContext | 저장 완료 알림 |
|
||||
| `tableListDataChange` | v2-table-list | 테이블 데이터 변경 알림 |
|
||||
| `repeaterDataChange` | UnifiedRepeater | 리피터 데이터 변경 알림 |
|
||||
| `repeaterSave` | buttonActions | 리피터 저장 요청 |
|
||||
| `openScreenModal` | v2-split-panel-layout | 화면 모달 열기 |
|
||||
| `refreshCardDisplay` | buttonActions | 카드 디스플레이 새로고침 |
|
||||
|
||||
### 4.2 이벤트 수신 컴포넌트
|
||||
|
||||
| 이벤트명 | 수신 컴포넌트 | 처리 내용 |
|
||||
|---------|-------------|----------|
|
||||
| `refreshTable` | v2-table-list, v2-split-panel-layout | 데이터 재조회 |
|
||||
| `beforeFormSave` | v2-repeat-container, UnifiedRepeater | formData에 섹션 데이터 추가 |
|
||||
| `tableListDataChange` | v2-aggregation-widget, v2-repeat-container | 집계 재계산, 데이터 동기화 |
|
||||
| `repeaterDataChange` | v2-aggregation-widget, v2-repeat-container | 집계 재계산, 데이터 동기화 |
|
||||
| `repeaterSave` | UnifiedRepeater | 리피터 데이터 저장 실행 |
|
||||
| `selectionChange` | v2-aggregation-widget | 선택 기반 집계 |
|
||||
| `componentDataTransfer` | UnifiedRepeater | 컴포넌트 간 데이터 전달 |
|
||||
| `splitPanelDataTransfer` | UnifiedRepeater | 분할 패널 데이터 전달 |
|
||||
| `refreshCardDisplay` | v2-card-display | 카드 데이터 재조회 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 전역 상태 사용 현황
|
||||
|
||||
| 전역 변수 | 사용 컴포넌트 | 용도 | 위험도 |
|
||||
|----------|-------------|------|--------|
|
||||
| `window.__unifiedRepeaterInstances` | UnifiedRepeater, buttonActions | 리피터 인스턴스 추적 | 🟠 중간 |
|
||||
| `window.__relatedButtonsTargetTables` | v2-table-list | 관련 버튼 대상 테이블 | 🟠 중간 |
|
||||
| `window.__relatedButtonsSelectedData` | v2-table-list, buttonActions | 관련 버튼 선택 데이터 | 🟠 중간 |
|
||||
| `window.__dataRegistry` | v2-table-list (v1/v2) | 테이블 데이터 레지스트리 | 🟠 중간 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 결합도 요약 점수
|
||||
|
||||
### 6.1 V2 컴포넌트 (18개)
|
||||
|
||||
| 결합도 수준 | 개수 | 컴포넌트 |
|
||||
|------------|------|---------|
|
||||
| 🔴 높음 (7-10점) | 2개 | v2-button-primary, v2-table-list |
|
||||
| 🟠 중간 (4-6점) | 4개 | v2-repeat-container, v2-split-panel-layout, v2-aggregation-widget, v2-tabs-widget |
|
||||
| 🟢 낮음 (1-3점) | 12개 | 나머지 |
|
||||
|
||||
### 6.2 Unified 컴포넌트 (11개)
|
||||
|
||||
| 결합도 수준 | 개수 | 컴포넌트 |
|
||||
|------------|------|---------|
|
||||
| 🔴 높음 (7-10점) | 1개 | UnifiedRepeater |
|
||||
| 🟠 중간 (4-6점) | 1개 | UnifiedFormContext |
|
||||
| 🟢 낮음 (1-3점) | 9개 | 나머지 |
|
||||
|
||||
### 6.3 전체 결합도 분포
|
||||
|
||||
```
|
||||
전체 29개 컴포넌트
|
||||
|
||||
높은 결합도 (🔴): 3개 (10.3%)
|
||||
├── v2-button-primary
|
||||
├── v2-table-list
|
||||
└── UnifiedRepeater
|
||||
|
||||
중간 결합도 (🟠): 5개 (17.2%)
|
||||
├── v2-repeat-container
|
||||
├── v2-split-panel-layout
|
||||
├── v2-aggregation-widget
|
||||
├── v2-tabs-widget
|
||||
└── UnifiedFormContext
|
||||
|
||||
낮은 결합도 (🟢): 21개 (72.5%)
|
||||
└── 나머지 모든 컴포넌트
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 장애 영향 분석
|
||||
|
||||
### 7.1 현재 구조에서의 장애 전파 경로
|
||||
|
||||
```
|
||||
v2-button-primary 오류 발생 시:
|
||||
├── buttonActions.ts 영향 → 모든 저장/삭제 기능 중단
|
||||
├── refreshTable 이벤트 미발생 → 테이블 갱신 안됨
|
||||
└── closeEditModal 이벤트 미발생 → 모달 닫기 안됨
|
||||
|
||||
v2-table-list 오류 발생 시:
|
||||
├── tableListDataChange 미발생 → 집계 위젯 업데이트 안됨
|
||||
├── related-button 이벤트 미발생 → 관련 버튼 비활성화
|
||||
└── 전역 상태 오염 가능성
|
||||
|
||||
UnifiedRepeater 오류 발생 시:
|
||||
├── beforeFormSave 처리 실패 → 리피터 데이터 저장 누락
|
||||
├── repeaterSave 수신 실패 → 저장 요청 무시
|
||||
└── 전역 인스턴스 레지스트리 오류
|
||||
```
|
||||
|
||||
### 7.2 장애 격리 현황
|
||||
|
||||
| 컴포넌트 | 장애 시 영향 범위 | 격리 수준 |
|
||||
|---------|-----------------|----------|
|
||||
| v2-button-primary | 저장/삭제 전체 | ❌ 격리 안됨 |
|
||||
| v2-table-list | 집계/관련버튼 | ❌ 격리 안됨 |
|
||||
| UnifiedRepeater | 리피터 저장 | ❌ 격리 안됨 |
|
||||
| v2-aggregation-widget | 자신만 | ✅ 부분 격리 |
|
||||
| v2-repeat-container | 자신만 | ✅ 부분 격리 |
|
||||
| 나머지 21개 | 자신만 | ✅ 완전 격리 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 느슨한 결합 전환 권장사항
|
||||
|
||||
### 8.1 1단계: 인프라 구축 (1-2일)
|
||||
|
||||
1. **V2 EventBus 생성**
|
||||
- 타입 안전한 이벤트 시스템
|
||||
- 에러 격리 (Promise.allSettled)
|
||||
- 구독/발행 패턴
|
||||
|
||||
2. **V2 ErrorBoundary 생성**
|
||||
- 컴포넌트별 장애 격리
|
||||
- 폴백 UI 제공
|
||||
- 재시도 기능
|
||||
|
||||
### 8.2 2단계: 핵심 컴포넌트 분리 (3-4일)
|
||||
|
||||
| 우선순위 | 컴포넌트 | 작업 내용 |
|
||||
|---------|---------|----------|
|
||||
| 1 | v2-button-primary | buttonActions 의존성 제거, 독립 저장 서비스 |
|
||||
| 2 | v2-table-list | 전역 상태 제거, EventBus 전환 |
|
||||
| 3 | UnifiedRepeater | 전역 상태 제거, EventBus 전환 |
|
||||
|
||||
### 8.3 3단계: 이벤트 통합 (2-3일)
|
||||
|
||||
| 기존 이벤트 | 신규 이벤트 | 변환 방식 |
|
||||
|------------|------------|----------|
|
||||
| `refreshTable` | `v2:table:refresh` | EventBus 발행 |
|
||||
| `beforeFormSave` | `v2:form:save:before` | EventBus 발행 |
|
||||
| `tableListDataChange` | `v2:table:data:change` | EventBus 발행 |
|
||||
| `repeaterSave` | `v2:repeater:save` | EventBus 발행 |
|
||||
|
||||
### 8.4 4단계: 레거시 제거 (1-2일)
|
||||
|
||||
- `window.__` 전역 변수 → Context API 또는 Zustand
|
||||
- 기존 CustomEvent → V2 EventBus로 완전 전환
|
||||
- buttonActions.ts 경량화 (7,145줄 → 분할)
|
||||
|
||||
---
|
||||
|
||||
## 9. 예상 효과
|
||||
|
||||
### 9.1 장애 격리
|
||||
|
||||
| 현재 | 전환 후 |
|
||||
|------|--------|
|
||||
| 한 컴포넌트 오류 → 연쇄 실패 | 한 컴포넌트 오류 → 해당만 실패 표시 |
|
||||
| 저장 실패 → 전체 중단 | 저장 실패 → 부분 저장 + 에러 표시 |
|
||||
|
||||
### 9.2 유지보수성
|
||||
|
||||
| 현재 | 전환 후 |
|
||||
|------|--------|
|
||||
| buttonActions.ts 7,145줄 | 여러 서비스로 분리 (각 500줄 이하) |
|
||||
| 암묵적 이벤트 계약 | 타입 정의된 이벤트 |
|
||||
| 전역 상태 오염 위험 | Context/Store로 관리 |
|
||||
|
||||
### 9.3 테스트 용이성
|
||||
|
||||
| 현재 | 전환 후 |
|
||||
|------|--------|
|
||||
| 통합 테스트만 가능 | 단위 테스트 가능 |
|
||||
| 모킹 어려움 | EventBus 모킹 용이 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 구현 현황 (2026-01-26 업데이트)
|
||||
|
||||
### 10.1 V2 Core 인프라 (✅ 완료)
|
||||
|
||||
다음 핵심 인프라가 구현되었습니다:
|
||||
|
||||
| 모듈 | 경로 | 설명 | 상태 |
|
||||
|------|------|------|------|
|
||||
| **V2 EventBus** | `lib/v2-core/events/EventBus.ts` | 타입 안전한 이벤트 시스템 | ✅ 완료 |
|
||||
| **V2 이벤트 타입** | `lib/v2-core/events/types.ts` | 모든 이벤트 타입 정의 | ✅ 완료 |
|
||||
| **V2 ErrorBoundary** | `lib/v2-core/components/V2ErrorBoundary.tsx` | 컴포넌트별 에러 격리 | ✅ 완료 |
|
||||
| **레거시 어댑터** | `lib/v2-core/adapters/LegacyEventAdapter.ts` | CustomEvent ↔ EventBus 브릿지 | ✅ 완료 |
|
||||
| **V2 Core 초기화** | `lib/v2-core/init.ts` | 앱 시작 시 초기화 | ✅ 완료 |
|
||||
|
||||
### 10.2 컴포넌트 마이그레이션 현황
|
||||
|
||||
| 컴포넌트 | V2 EventBus 적용 | ErrorBoundary 적용 | 레거시 지원 | 상태 |
|
||||
|---------|-----------------|-------------------|-------------|------|
|
||||
| **v2-button-primary** | ✅ | ✅ | ✅ | 완료 |
|
||||
| **v2-table-list** | ✅ | - | ✅ | 완료 |
|
||||
| **UnifiedRepeater** | ✅ | - | ✅ | 완료 |
|
||||
|
||||
### 10.3 아키텍처 특징
|
||||
|
||||
**점진적 마이그레이션 지원:**
|
||||
- 레거시 `window.dispatchEvent` 이벤트와 V2 EventBus 이벤트가 **양방향 브릿지**로 연결됨
|
||||
- 기존 코드 수정 없이 새 시스템 도입 가능
|
||||
- 모든 V2 이벤트는 자동으로 레거시 CustomEvent로도 발행됨
|
||||
|
||||
**에러 격리:**
|
||||
- V2ErrorBoundary로 감싼 컴포넌트는 에러 발생 시 해당 컴포넌트만 에러 UI 표시
|
||||
- 다른 컴포넌트는 정상 작동 유지
|
||||
- 재시도 버튼으로 복구 가능
|
||||
|
||||
### 10.4 사용 방법
|
||||
|
||||
```typescript
|
||||
// 이벤트 발행
|
||||
import { v2EventBus, V2_EVENTS } from "@/lib/v2-core";
|
||||
|
||||
v2EventBus.emit(V2_EVENTS.TABLE_REFRESH, {
|
||||
tableName: "item_info",
|
||||
target: "single",
|
||||
});
|
||||
|
||||
// 이벤트 구독
|
||||
const unsubscribe = v2EventBus.subscribe(
|
||||
V2_EVENTS.TABLE_REFRESH,
|
||||
(payload) => {
|
||||
console.log("테이블 새로고침:", payload.tableName);
|
||||
},
|
||||
{ componentId: "my-component" }
|
||||
);
|
||||
|
||||
// 정리
|
||||
useEffect(() => {
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 결론
|
||||
|
||||
### 11.1 현재 상태 요약
|
||||
|
||||
- **전체 29개 컴포넌트 중 72.5%(21개)는 이미 낮은 결합도**를 가지고 있어 독립적으로 동작
|
||||
- **핵심 문제 컴포넌트 3개 (v2-button-primary, v2-table-list, UnifiedRepeater) 마이그레이션 완료**
|
||||
- **buttonActions.ts (7,145줄)**는 추후 분할 예정 (현재는 동작 유지)
|
||||
|
||||
### 11.2 달성 목표
|
||||
|
||||
✅ **V2 Core 인프라 구축 완료**
|
||||
|
||||
- 타입 안전한 EventBus
|
||||
- 컴포넌트별 ErrorBoundary
|
||||
- 레거시 호환 어댑터
|
||||
- 앱 초기화 연동
|
||||
|
||||
### 11.3 다음 단계
|
||||
|
||||
1. **buttonActions.ts 분할** - 서비스별 모듈 분리
|
||||
2. **나머지 중간 결합도 컴포넌트 마이그레이션** (v2-repeat-container, v2-split-panel-layout 등)
|
||||
3. **전역 상태 (window.__) 제거** - Context API 또는 Zustand로 전환
|
||||
|
||||
---
|
||||
|
||||
## 부록 A: 파일 위치 참조
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── lib/
|
||||
│ ├── registry/
|
||||
│ │ └── components/
|
||||
│ │ ├── v2-aggregation-widget/
|
||||
│ │ ├── v2-button-primary/
|
||||
│ │ ├── v2-card-display/
|
||||
│ │ ├── v2-category-manager/
|
||||
│ │ ├── v2-divider-line/
|
||||
│ │ ├── v2-location-swap-selector/
|
||||
│ │ ├── v2-numbering-rule/
|
||||
│ │ ├── v2-pivot-grid/
|
||||
│ │ ├── v2-rack-structure/
|
||||
│ │ ├── v2-repeat-container/
|
||||
│ │ ├── v2-repeat-screen-modal/
|
||||
│ │ ├── v2-section-card/
|
||||
│ │ ├── v2-section-paper/
|
||||
│ │ ├── v2-split-panel-layout/
|
||||
│ │ ├── v2-table-list/
|
||||
│ │ ├── v2-table-search-widget/
|
||||
│ │ ├── v2-tabs-widget/
|
||||
│ │ ├── v2-text-display/
|
||||
│ │ └── v2-unified-repeater/
|
||||
│ └── utils/
|
||||
│ └── buttonActions.ts (7,145줄)
|
||||
└── components/
|
||||
└── unified/
|
||||
├── UnifiedInput.tsx
|
||||
├── UnifiedSelect.tsx
|
||||
├── UnifiedDate.tsx
|
||||
├── UnifiedRepeater.tsx
|
||||
├── UnifiedLayout.tsx
|
||||
├── UnifiedGroup.tsx
|
||||
├── UnifiedHierarchy.tsx
|
||||
├── UnifiedList.tsx
|
||||
├── UnifiedMedia.tsx
|
||||
├── UnifiedBiz.tsx
|
||||
└── UnifiedFormContext.tsx
|
||||
```
|
||||
|
||||
## 부록 B: V2 Core 파일 구조 (구현됨)
|
||||
|
||||
```
|
||||
frontend/lib/v2-core/
|
||||
├── index.ts # 메인 내보내기
|
||||
├── init.ts # 앱 초기화
|
||||
├── events/
|
||||
│ ├── index.ts
|
||||
│ ├── types.ts # 이벤트 타입 정의
|
||||
│ └── EventBus.ts # 이벤트 버스 구현
|
||||
├── components/
|
||||
│ ├── index.ts
|
||||
│ └── V2ErrorBoundary.tsx # 에러 바운더리
|
||||
└── adapters/
|
||||
├── index.ts
|
||||
└── LegacyEventAdapter.ts # 레거시 브릿지
|
||||
```
|
||||
|
||||
## 부록 C: 이벤트 타입 정의 (구현됨)
|
||||
|
||||
전체 이벤트 타입은 `frontend/lib/v2-core/events/types.ts`에 정의되어 있습니다.
|
||||
|
||||
주요 이벤트:
|
||||
|
||||
| 이벤트 | 설명 |
|
||||
|--------|------|
|
||||
| `v2:table:refresh` | 테이블 새로고침 |
|
||||
| `v2:table:data:change` | 테이블 데이터 변경 |
|
||||
| `v2:form:save:collect` | 폼 저장 전 데이터 수집 |
|
||||
| `v2:modal:close` | 모달 닫기 |
|
||||
| `v2:modal:save:success` | 모달 저장 성공 |
|
||||
| `v2:repeater:save` | 리피터 저장 |
|
||||
| `v2:component:error` | 컴포넌트 에러 |
|
||||
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { initializeRegistries } from "@/lib/registry/init";
|
||||
import { initV2Core, cleanupV2Core } from "@/lib/v2-core";
|
||||
|
||||
interface RegistryProviderProps {
|
||||
children: React.ReactNode;
|
||||
|
|
@ -18,11 +19,26 @@ export function RegistryProvider({ children }: RegistryProviderProps) {
|
|||
// 레지스트리 초기화
|
||||
try {
|
||||
initializeRegistries();
|
||||
|
||||
// V2 Core 초기화 (느슨한 결합 아키텍처)
|
||||
initV2Core({
|
||||
debug: process.env.NODE_ENV === "development",
|
||||
legacyBridge: {
|
||||
legacyToV2: true,
|
||||
v2ToLegacy: true,
|
||||
},
|
||||
});
|
||||
|
||||
setIsInitialized(true);
|
||||
} catch (error) {
|
||||
console.error("❌ 레지스트리 초기화 실패:", error);
|
||||
setIsInitialized(true); // 오류가 있어도 앱은 계속 실행
|
||||
}
|
||||
|
||||
// 정리 함수
|
||||
return () => {
|
||||
cleanupV2Core();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 초기화 중 로딩 표시 (선택사항)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
} from "@/types/unified-repeater";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||
import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";
|
||||
|
||||
// modal-repeater-table 컴포넌트 재사용
|
||||
import { RepeaterTable } from "@/lib/registry/components/modal-repeater-table/RepeaterTable";
|
||||
|
|
@ -201,8 +202,24 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// V2 EventBus 구독
|
||||
const unsubscribe = v2EventBus.subscribe(
|
||||
V2_EVENTS.REPEATER_SAVE,
|
||||
async (payload) => {
|
||||
const tableName = config.useCustomTable && config.mainTableName
|
||||
? config.mainTableName
|
||||
: config.dataSource?.tableName;
|
||||
if (payload.tableName === tableName) {
|
||||
await handleSaveEvent({ detail: payload } as CustomEvent);
|
||||
}
|
||||
},
|
||||
{ componentId: `unified-repeater-${config.dataSource?.tableName}` }
|
||||
);
|
||||
|
||||
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
|
||||
window.addEventListener("repeaterSave" as any, handleSaveEvent);
|
||||
return () => {
|
||||
unsubscribe();
|
||||
window.removeEventListener("repeaterSave" as any, handleSaveEvent);
|
||||
};
|
||||
}, [data, config.dataSource?.tableName, config.useCustomTable, config.mainTableName, config.foreignKeyColumn, parentId]);
|
||||
|
|
@ -696,9 +713,24 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
|||
formData[fieldName] = processedData;
|
||||
};
|
||||
|
||||
// V2 EventBus 구독
|
||||
const unsubscribe = v2EventBus.subscribe(
|
||||
V2_EVENTS.FORM_SAVE_COLLECT,
|
||||
async (payload) => {
|
||||
// formData 객체가 있으면 데이터 수집
|
||||
const fakeEvent = {
|
||||
detail: { formData: payload.formData },
|
||||
} as CustomEvent;
|
||||
await handleBeforeFormSave(fakeEvent);
|
||||
},
|
||||
{ componentId: `unified-repeater-${config.dataSource?.tableName}` }
|
||||
);
|
||||
|
||||
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
|
||||
window.addEventListener("beforeFormSave", handleBeforeFormSave);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
window.removeEventListener("beforeFormSave", handleBeforeFormSave);
|
||||
};
|
||||
}, [config.fieldName]);
|
||||
|
|
@ -782,10 +814,45 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// V2 EventBus 구독
|
||||
const unsubscribeComponent = v2EventBus.subscribe(
|
||||
V2_EVENTS.COMPONENT_DATA_TRANSFER,
|
||||
(payload) => {
|
||||
const fakeEvent = {
|
||||
detail: {
|
||||
targetComponentId: payload.targetComponentId,
|
||||
transferData: [payload.data],
|
||||
mappingRules: [],
|
||||
mode: "append",
|
||||
},
|
||||
} as CustomEvent;
|
||||
handleComponentDataTransfer(fakeEvent);
|
||||
},
|
||||
{ componentId: `unified-repeater-${config.dataSource?.tableName}` }
|
||||
);
|
||||
|
||||
const unsubscribeSplitPanel = v2EventBus.subscribe(
|
||||
V2_EVENTS.SPLIT_PANEL_DATA_TRANSFER,
|
||||
(payload) => {
|
||||
const fakeEvent = {
|
||||
detail: {
|
||||
transferData: [payload.data],
|
||||
mappingRules: [],
|
||||
mode: "append",
|
||||
},
|
||||
} as CustomEvent;
|
||||
handleSplitPanelDataTransfer(fakeEvent);
|
||||
},
|
||||
{ componentId: `unified-repeater-${config.dataSource?.tableName}` }
|
||||
);
|
||||
|
||||
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
|
||||
window.addEventListener("componentDataTransfer", handleComponentDataTransfer as EventListener);
|
||||
window.addEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
|
||||
|
||||
return () => {
|
||||
unsubscribeComponent();
|
||||
unsubscribeSplitPanel();
|
||||
window.removeEventListener("componentDataTransfer", handleComponentDataTransfer as EventListener);
|
||||
window.removeEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
|
||||
};
|
||||
|
|
@ -855,4 +922,17 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
|||
|
||||
UnifiedRepeater.displayName = "UnifiedRepeater";
|
||||
|
||||
// V2ErrorBoundary로 래핑된 안전한 버전 export
|
||||
export const SafeUnifiedRepeater: React.FC<UnifiedRepeaterProps> = (props) => {
|
||||
return (
|
||||
<V2ErrorBoundary
|
||||
componentId={props.parentId || "unified-repeater"}
|
||||
componentType="UnifiedRepeater"
|
||||
fallbackStyle="compact"
|
||||
>
|
||||
<UnifiedRepeater {...props} />
|
||||
</V2ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnifiedRepeater;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
|||
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
||||
import { applyMappingRules } from "@/lib/utils/dataMapping";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { V2ErrorBoundary, v2EventBus, V2_EVENTS } from "@/lib/v2-core";
|
||||
|
||||
export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
||||
config?: ButtonPrimaryConfig;
|
||||
|
|
@ -668,23 +669,32 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
|
||||
// 저장/수정 성공 시 자동 처리
|
||||
if (actionConfig.type === "save" || actionConfig.type === "edit") {
|
||||
if (typeof window !== "undefined") {
|
||||
// 1. 테이블 새로고침 이벤트 먼저 발송 (모달이 닫히기 전에)
|
||||
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||
// 1. 테이블 새로고침 이벤트 먼저 발송 (모달이 닫히기 전에)
|
||||
// V2 EventBus 사용 (레거시 어댑터가 자동으로 window 이벤트로도 브릿지)
|
||||
v2EventBus.emitSync(V2_EVENTS.TABLE_REFRESH, {
|
||||
tableName: context.tableName,
|
||||
target: "all",
|
||||
});
|
||||
|
||||
// 2. 모달 닫기 (약간의 딜레이)
|
||||
setTimeout(() => {
|
||||
// EditModal 내부인지 확인 (isInModal prop 사용)
|
||||
const isInEditModal = (props as any).isInModal;
|
||||
// 2. 모달 닫기 (약간의 딜레이)
|
||||
setTimeout(() => {
|
||||
// EditModal 내부인지 확인 (isInModal prop 사용)
|
||||
const isInEditModal = (props as any).isInModal;
|
||||
|
||||
if (isInEditModal) {
|
||||
window.dispatchEvent(new CustomEvent("closeEditModal"));
|
||||
}
|
||||
if (isInEditModal) {
|
||||
v2EventBus.emitSync(V2_EVENTS.MODAL_CLOSE, {
|
||||
modalId: "edit-modal",
|
||||
reason: "save",
|
||||
});
|
||||
}
|
||||
|
||||
// ScreenModal은 연속 등록 모드를 지원하므로 saveSuccessInModal 이벤트 발생
|
||||
window.dispatchEvent(new CustomEvent("saveSuccessInModal"));
|
||||
}, 100);
|
||||
}
|
||||
// ScreenModal은 연속 등록 모드를 지원하므로 saveSuccessInModal 이벤트 발생
|
||||
v2EventBus.emitSync(V2_EVENTS.MODAL_SAVE_SUCCESS, {
|
||||
modalId: "screen-modal",
|
||||
savedData: context.formData || {},
|
||||
tableName: context.tableName || "",
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
} catch (error) {
|
||||
// 로딩 토스트 제거
|
||||
|
|
@ -1355,8 +1365,17 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
|
||||
/**
|
||||
* ButtonPrimary 래퍼 컴포넌트
|
||||
* 추가적인 로직이나 상태 관리가 필요한 경우 사용
|
||||
* V2 ErrorBoundary로 감싸서 에러 격리 제공
|
||||
*/
|
||||
export const ButtonPrimaryWrapper: React.FC<ButtonPrimaryComponentProps> = (props) => {
|
||||
return <ButtonPrimaryComponent {...props} />;
|
||||
return (
|
||||
<V2ErrorBoundary
|
||||
componentId={props.component?.id || `button-${Date.now()}`}
|
||||
componentType="v2-button-primary"
|
||||
fallbackStyle="minimal"
|
||||
recoverable={true}
|
||||
>
|
||||
<ButtonPrimaryComponent {...props} />
|
||||
</V2ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { codeCache } from "@/lib/caching/codeCache";
|
|||
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
|
||||
import { getFullImageUrl } from "@/lib/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";
|
||||
|
||||
// 🆕 RelatedDataButtons 전역 레지스트리 타입 선언
|
||||
declare global {
|
||||
|
|
@ -2060,18 +2061,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
});
|
||||
}
|
||||
|
||||
// 🆕 리피터 컨테이너/집계 위젯 연동용 커스텀 이벤트 발생
|
||||
if (typeof window !== "undefined") {
|
||||
const event = new CustomEvent("tableListDataChange", {
|
||||
detail: {
|
||||
componentId: component.id,
|
||||
tableName: tableConfig.selectedTable,
|
||||
data: selectedRowsData,
|
||||
selectedRows: Array.from(newSelectedRows),
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
// 🆕 리피터 컨테이너/집계 위젯 연동용 V2 이벤트 발생
|
||||
v2EventBus.emitSync(V2_EVENTS.TABLE_DATA_CHANGE, {
|
||||
tableName: tableConfig.selectedTable || "",
|
||||
data: selectedRowsData,
|
||||
totalCount: selectedRowsData.length,
|
||||
source: component.id || "table-list",
|
||||
});
|
||||
|
||||
// 🆕 modalDataStore에 선택된 데이터 자동 저장 (테이블명 기반 dataSourceId)
|
||||
if (tableConfig.selectedTable && selectedRowsData.length > 0) {
|
||||
|
|
@ -2111,18 +2107,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
});
|
||||
}
|
||||
|
||||
// 🆕 리피터 컨테이너/집계 위젯 연동용 커스텀 이벤트 발생
|
||||
if (typeof window !== "undefined") {
|
||||
const event = new CustomEvent("tableListDataChange", {
|
||||
detail: {
|
||||
componentId: component.id,
|
||||
tableName: tableConfig.selectedTable,
|
||||
data: filteredData,
|
||||
selectedRows: Array.from(newSelectedRows),
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
// 🆕 리피터 컨테이너/집계 위젯 연동용 V2 이벤트 발생
|
||||
v2EventBus.emitSync(V2_EVENTS.TABLE_DATA_CHANGE, {
|
||||
tableName: tableConfig.selectedTable || "",
|
||||
data: filteredData,
|
||||
totalCount: filteredData.length,
|
||||
source: component.id || "table-list",
|
||||
});
|
||||
|
||||
// 🆕 modalDataStore에 전체 데이터 저장
|
||||
if (tableConfig.selectedTable && filteredData.length > 0) {
|
||||
|
|
@ -2147,18 +2138,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
onFormDataChange({ selectedRows: [], selectedRowsData: [] });
|
||||
}
|
||||
|
||||
// 🆕 리피터 컨테이너/집계 위젯 연동용 커스텀 이벤트 발생 (선택 해제)
|
||||
if (typeof window !== "undefined") {
|
||||
const event = new CustomEvent("tableListDataChange", {
|
||||
detail: {
|
||||
componentId: component.id,
|
||||
tableName: tableConfig.selectedTable,
|
||||
data: [],
|
||||
selectedRows: [],
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
// 🆕 리피터 컨테이너/집계 위젯 연동용 V2 이벤트 발생 (선택 해제)
|
||||
v2EventBus.emitSync(V2_EVENTS.TABLE_DATA_CHANGE, {
|
||||
tableName: tableConfig.selectedTable || "",
|
||||
data: [],
|
||||
totalCount: 0,
|
||||
source: component.id || "table-list",
|
||||
});
|
||||
|
||||
// 🆕 modalDataStore 데이터 제거
|
||||
if (tableConfig.selectedTable) {
|
||||
|
|
@ -4897,12 +4883,26 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// V2 EventBus 구독 (레거시 어댑터가 window 이벤트도 브릿지)
|
||||
const unsubscribe = v2EventBus.subscribe(
|
||||
V2_EVENTS.TABLE_REFRESH,
|
||||
(payload) => {
|
||||
// 특정 테이블만 새로고침하거나 전체 새로고침
|
||||
if (!payload.tableName || payload.tableName === tableConfig.selectedTable) {
|
||||
handleRefreshTable();
|
||||
}
|
||||
},
|
||||
{ componentId: component.id }
|
||||
);
|
||||
|
||||
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
|
||||
window.addEventListener("refreshTable", handleRefreshTable);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
window.removeEventListener("refreshTable", handleRefreshTable);
|
||||
};
|
||||
}, [tableConfig.selectedTable, isDesignMode]);
|
||||
}, [tableConfig.selectedTable, isDesignMode, component.id]);
|
||||
|
||||
// 🆕 테이블명 변경 시 전역 레지스트리에서 확인
|
||||
useEffect(() => {
|
||||
|
|
@ -4934,14 +4934,41 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// V2 EventBus 구독
|
||||
const unsubscribeRegister = v2EventBus.subscribe(
|
||||
V2_EVENTS.RELATED_BUTTON_REGISTER,
|
||||
(payload) => {
|
||||
if (payload.targetTables.includes(tableConfig.selectedTable || "")) {
|
||||
console.log("📝 [TableList] RelatedDataButtons 대상으로 등록됨:", tableConfig.selectedTable);
|
||||
setIsRelatedButtonTarget(true);
|
||||
}
|
||||
},
|
||||
{ componentId: component.id }
|
||||
);
|
||||
|
||||
const unsubscribeUnregister = v2EventBus.subscribe(
|
||||
V2_EVENTS.RELATED_BUTTON_UNREGISTER,
|
||||
(payload) => {
|
||||
if (payload.buttonId) {
|
||||
console.log("📝 [TableList] RelatedDataButtons 대상에서 해제됨:", tableConfig.selectedTable);
|
||||
setIsRelatedButtonTarget(false);
|
||||
setRelatedButtonFilter(null);
|
||||
}
|
||||
},
|
||||
{ componentId: component.id }
|
||||
);
|
||||
|
||||
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
|
||||
window.addEventListener("related-button-register" as any, handleRelatedButtonRegister);
|
||||
window.addEventListener("related-button-unregister" as any, handleRelatedButtonUnregister);
|
||||
|
||||
return () => {
|
||||
unsubscribeRegister();
|
||||
unsubscribeUnregister();
|
||||
window.removeEventListener("related-button-register" as any, handleRelatedButtonRegister);
|
||||
window.removeEventListener("related-button-unregister" as any, handleRelatedButtonUnregister);
|
||||
};
|
||||
}, [tableConfig.selectedTable]);
|
||||
}, [tableConfig.selectedTable, component.id]);
|
||||
|
||||
// 🆕 RelatedDataButtons 선택 이벤트 리스너 (버튼 선택 시 테이블 필터링)
|
||||
useEffect(() => {
|
||||
|
|
@ -4967,12 +4994,40 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// V2 EventBus 구독
|
||||
const unsubscribeSelect = v2EventBus.subscribe(
|
||||
V2_EVENTS.RELATED_BUTTON_SELECT,
|
||||
(payload) => {
|
||||
if (payload.tableName === tableConfig.selectedTable) {
|
||||
if (!payload.selectedData || payload.selectedData.length === 0) {
|
||||
console.log("📌 [TableList] RelatedDataButtons 선택 해제 (빈 상태):", tableConfig.selectedTable);
|
||||
setRelatedButtonFilter(null);
|
||||
setIsRelatedButtonTarget(true);
|
||||
} else {
|
||||
console.log("📌 [TableList] RelatedDataButtons 필터 적용:", {
|
||||
tableName: tableConfig.selectedTable,
|
||||
selectedData: payload.selectedData,
|
||||
});
|
||||
// 첫 번째 선택된 데이터의 ID를 필터로 사용
|
||||
const firstItem = payload.selectedData[0];
|
||||
if (firstItem?.id) {
|
||||
setRelatedButtonFilter({ filterColumn: "id", filterValue: firstItem.id });
|
||||
}
|
||||
setIsRelatedButtonTarget(true);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ componentId: component.id }
|
||||
);
|
||||
|
||||
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
|
||||
window.addEventListener("related-button-select" as any, handleRelatedButtonSelect);
|
||||
|
||||
return () => {
|
||||
unsubscribeSelect();
|
||||
window.removeEventListener("related-button-select" as any, handleRelatedButtonSelect);
|
||||
};
|
||||
}, [tableConfig.selectedTable]);
|
||||
}, [tableConfig.selectedTable, component.id]);
|
||||
|
||||
// 🆕 relatedButtonFilter 변경 시 데이터 다시 로드
|
||||
useEffect(() => {
|
||||
|
|
@ -6808,5 +6863,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
};
|
||||
|
||||
export const TableListWrapper: React.FC<TableListComponentProps> = (props) => {
|
||||
return <TableListComponent {...props} />;
|
||||
return (
|
||||
<V2ErrorBoundary
|
||||
componentId={props.component?.id || "table-list"}
|
||||
componentType="v2-table-list"
|
||||
fallbackStyle="compact"
|
||||
>
|
||||
<TableListComponent {...props} />
|
||||
</V2ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,443 @@
|
|||
/**
|
||||
* 레거시 CustomEvent ↔ V2 EventBus 어댑터
|
||||
*
|
||||
* 기존 window.dispatchEvent/addEventListener 기반 이벤트를
|
||||
* V2 EventBus로 브릿지하여 점진적 마이그레이션을 지원합니다.
|
||||
*
|
||||
* 특징:
|
||||
* - 양방향 브릿지 (레거시 → V2, V2 → 레거시)
|
||||
* - 이벤트 중복 방지
|
||||
* - 선택적 브릿지 활성화/비활성화
|
||||
*/
|
||||
|
||||
import { v2EventBus, V2_EVENTS, V2EventName, V2EventPayloadMap } from "../events";
|
||||
|
||||
// ============================================================================
|
||||
// 이벤트 매핑 정의
|
||||
// ============================================================================
|
||||
|
||||
interface EventMapping {
|
||||
legacy: string;
|
||||
v2: V2EventName;
|
||||
/** 레거시 → V2 페이로드 변환 함수 */
|
||||
toV2?: (legacyDetail: any) => any;
|
||||
/** V2 → 레거시 페이로드 변환 함수 */
|
||||
toLegacy?: (v2Payload: any) => any;
|
||||
}
|
||||
|
||||
const EVENT_MAPPINGS: EventMapping[] = [
|
||||
// 테이블 관련
|
||||
{
|
||||
legacy: "refreshTable",
|
||||
v2: V2_EVENTS.TABLE_REFRESH,
|
||||
toV2: (detail) => ({
|
||||
tableName: detail?.tableName,
|
||||
target: detail?.target ?? "all",
|
||||
screenCode: detail?.screenCode,
|
||||
}),
|
||||
toLegacy: (payload) => ({
|
||||
tableName: payload.tableName,
|
||||
target: payload.target,
|
||||
screenCode: payload.screenCode,
|
||||
}),
|
||||
},
|
||||
{
|
||||
legacy: "tableListDataChange",
|
||||
v2: V2_EVENTS.TABLE_DATA_CHANGE,
|
||||
toV2: (detail) => ({
|
||||
tableName: detail?.tableName,
|
||||
data: detail?.data ?? [],
|
||||
totalCount: detail?.totalCount ?? 0,
|
||||
source: detail?.source ?? "legacy",
|
||||
}),
|
||||
toLegacy: (payload) => ({
|
||||
tableName: payload.tableName,
|
||||
data: payload.data,
|
||||
totalCount: payload.totalCount,
|
||||
source: payload.source,
|
||||
}),
|
||||
},
|
||||
{
|
||||
legacy: "tableSelectionChange",
|
||||
v2: V2_EVENTS.TABLE_SELECTION_CHANGE,
|
||||
toV2: (detail) => ({
|
||||
tableName: detail?.tableName,
|
||||
selectedRows: detail?.selectedRows ?? [],
|
||||
selectedRowIds: detail?.selectedRowIds ?? [],
|
||||
source: detail?.source ?? "legacy",
|
||||
}),
|
||||
toLegacy: (payload) => ({
|
||||
tableName: payload.tableName,
|
||||
selectedRows: payload.selectedRows,
|
||||
selectedRowIds: payload.selectedRowIds,
|
||||
source: payload.source,
|
||||
}),
|
||||
},
|
||||
{
|
||||
legacy: "selectionChange",
|
||||
v2: V2_EVENTS.TABLE_SELECTION_CHANGE,
|
||||
toV2: (detail) => ({
|
||||
tableName: detail?.tableName ?? "",
|
||||
selectedRows: detail?.selectedRows ?? [],
|
||||
selectedRowIds: detail?.selectedRowIds ?? [],
|
||||
source: "legacy-selectionChange",
|
||||
}),
|
||||
toLegacy: (payload) => ({
|
||||
tableName: payload.tableName,
|
||||
selectedRows: payload.selectedRows,
|
||||
selectedRowIds: payload.selectedRowIds,
|
||||
source: payload.source,
|
||||
}),
|
||||
},
|
||||
|
||||
// 폼 저장 관련
|
||||
{
|
||||
legacy: "beforeFormSave",
|
||||
v2: V2_EVENTS.FORM_SAVE_COLLECT,
|
||||
toV2: (detail) => ({
|
||||
requestId: detail?.requestId ?? `req_${Date.now()}`,
|
||||
formData: detail?.formData ?? {},
|
||||
componentId: detail?.componentId ?? "legacy",
|
||||
}),
|
||||
toLegacy: (payload) => ({
|
||||
requestId: payload.requestId,
|
||||
formData: payload.formData,
|
||||
componentId: payload.componentId,
|
||||
}),
|
||||
},
|
||||
{
|
||||
legacy: "saveSuccess",
|
||||
v2: V2_EVENTS.FORM_SAVE_COMPLETE,
|
||||
toV2: (detail) => ({
|
||||
requestId: detail?.requestId ?? "",
|
||||
success: true,
|
||||
savedData: detail?.savedData ?? detail,
|
||||
tableName: detail?.tableName ?? "",
|
||||
}),
|
||||
toLegacy: (payload) => ({
|
||||
requestId: payload.requestId,
|
||||
savedData: payload.savedData,
|
||||
tableName: payload.tableName,
|
||||
}),
|
||||
},
|
||||
|
||||
// 리피터 관련
|
||||
{
|
||||
legacy: "repeaterSave",
|
||||
v2: V2_EVENTS.REPEATER_SAVE,
|
||||
toV2: (detail) => ({
|
||||
repeaterId: detail?.repeaterId ?? "",
|
||||
tableName: detail?.tableName ?? "",
|
||||
items: detail?.items ?? [],
|
||||
joinData: detail?.joinData,
|
||||
}),
|
||||
toLegacy: (payload) => ({
|
||||
repeaterId: payload.repeaterId,
|
||||
tableName: payload.tableName,
|
||||
items: payload.items,
|
||||
joinData: payload.joinData,
|
||||
}),
|
||||
},
|
||||
{
|
||||
legacy: "repeaterDataChange",
|
||||
v2: V2_EVENTS.REPEATER_DATA_CHANGE,
|
||||
toV2: (detail) => ({
|
||||
repeaterId: detail?.repeaterId ?? "",
|
||||
tableName: detail?.tableName ?? "",
|
||||
data: detail?.data ?? [],
|
||||
action: detail?.action ?? "update",
|
||||
}),
|
||||
toLegacy: (payload) => ({
|
||||
repeaterId: payload.repeaterId,
|
||||
tableName: payload.tableName,
|
||||
data: payload.data,
|
||||
action: payload.action,
|
||||
}),
|
||||
},
|
||||
|
||||
// 모달 관련
|
||||
{
|
||||
legacy: "closeEditModal",
|
||||
v2: V2_EVENTS.MODAL_CLOSE,
|
||||
toV2: (detail) => ({
|
||||
modalId: detail?.modalId ?? "edit-modal",
|
||||
reason: detail?.reason ?? "close",
|
||||
}),
|
||||
toLegacy: (payload) => ({
|
||||
modalId: payload.modalId,
|
||||
reason: payload.reason,
|
||||
}),
|
||||
},
|
||||
{
|
||||
legacy: "saveSuccessInModal",
|
||||
v2: V2_EVENTS.MODAL_SAVE_SUCCESS,
|
||||
toV2: (detail) => ({
|
||||
modalId: detail?.modalId ?? "edit-modal",
|
||||
savedData: detail?.savedData ?? {},
|
||||
tableName: detail?.tableName ?? "",
|
||||
}),
|
||||
toLegacy: (payload) => ({
|
||||
modalId: payload.modalId,
|
||||
savedData: payload.savedData,
|
||||
tableName: payload.tableName,
|
||||
}),
|
||||
},
|
||||
{
|
||||
legacy: "openScreenModal",
|
||||
v2: V2_EVENTS.MODAL_OPEN,
|
||||
toV2: (detail) => ({
|
||||
modalId: detail?.modalId ?? "",
|
||||
screenCode: detail?.screenCode,
|
||||
data: detail?.data,
|
||||
mode: detail?.mode ?? "view",
|
||||
}),
|
||||
toLegacy: (payload) => ({
|
||||
modalId: payload.modalId,
|
||||
screenCode: payload.screenCode,
|
||||
data: payload.data,
|
||||
mode: payload.mode,
|
||||
}),
|
||||
},
|
||||
|
||||
// 카드 디스플레이
|
||||
{
|
||||
legacy: "refreshCardDisplay",
|
||||
v2: V2_EVENTS.CARD_REFRESH,
|
||||
toV2: (detail) => ({
|
||||
cardId: detail?.cardId,
|
||||
tableName: detail?.tableName,
|
||||
}),
|
||||
toLegacy: (payload) => ({
|
||||
cardId: payload.cardId,
|
||||
tableName: payload.tableName,
|
||||
}),
|
||||
},
|
||||
|
||||
// 분할 패널
|
||||
{
|
||||
legacy: "splitPanelDataTransfer",
|
||||
v2: V2_EVENTS.SPLIT_PANEL_DATA_TRANSFER,
|
||||
toV2: (detail) => ({
|
||||
sourcePanel: detail?.sourcePanel ?? "left",
|
||||
targetPanel: detail?.targetPanel ?? "right",
|
||||
data: detail?.data ?? {},
|
||||
tableName: detail?.tableName ?? "",
|
||||
}),
|
||||
toLegacy: (payload) => ({
|
||||
sourcePanel: payload.sourcePanel,
|
||||
targetPanel: payload.targetPanel,
|
||||
data: payload.data,
|
||||
tableName: payload.tableName,
|
||||
}),
|
||||
},
|
||||
|
||||
// 컴포넌트 데이터 전송
|
||||
{
|
||||
legacy: "componentDataTransfer",
|
||||
v2: V2_EVENTS.COMPONENT_DATA_TRANSFER,
|
||||
toV2: (detail) => ({
|
||||
sourceComponentId: detail?.sourceComponentId ?? "",
|
||||
targetComponentId: detail?.targetComponentId,
|
||||
data: detail?.data ?? {},
|
||||
tableName: detail?.tableName,
|
||||
}),
|
||||
toLegacy: (payload) => ({
|
||||
sourceComponentId: payload.sourceComponentId,
|
||||
targetComponentId: payload.targetComponentId,
|
||||
data: payload.data,
|
||||
tableName: payload.tableName,
|
||||
}),
|
||||
},
|
||||
|
||||
// 관련 버튼
|
||||
{
|
||||
legacy: "related-button-register",
|
||||
v2: V2_EVENTS.RELATED_BUTTON_REGISTER,
|
||||
toV2: (detail) => ({
|
||||
buttonId: detail?.buttonId ?? "",
|
||||
targetTables: detail?.targetTables ?? [],
|
||||
}),
|
||||
toLegacy: (payload) => ({
|
||||
buttonId: payload.buttonId,
|
||||
targetTables: payload.targetTables,
|
||||
}),
|
||||
},
|
||||
{
|
||||
legacy: "related-button-unregister",
|
||||
v2: V2_EVENTS.RELATED_BUTTON_UNREGISTER,
|
||||
toV2: (detail) => ({
|
||||
buttonId: detail?.buttonId ?? "",
|
||||
}),
|
||||
toLegacy: (payload) => ({
|
||||
buttonId: payload.buttonId,
|
||||
}),
|
||||
},
|
||||
{
|
||||
legacy: "related-button-select",
|
||||
v2: V2_EVENTS.RELATED_BUTTON_SELECT,
|
||||
toV2: (detail) => ({
|
||||
tableName: detail?.tableName ?? "",
|
||||
selectedData: detail?.selectedData ?? [],
|
||||
}),
|
||||
toLegacy: (payload) => ({
|
||||
tableName: payload.tableName,
|
||||
selectedData: payload.selectedData,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// 어댑터 클래스
|
||||
// ============================================================================
|
||||
|
||||
class LegacyEventAdapter {
|
||||
private isActive = false;
|
||||
private legacyListeners: Map<string, (e: Event) => void> = new Map();
|
||||
private v2Unsubscribes: Map<string, () => void> = new Map();
|
||||
|
||||
/** 브릿지에서 발생한 이벤트 추적 (무한 루프 방지) */
|
||||
private bridgedEvents: Set<string> = new Set();
|
||||
|
||||
/** 브릿지 방향 설정 */
|
||||
private config = {
|
||||
legacyToV2: true,
|
||||
v2ToLegacy: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* 어댑터 초기화 및 브릿지 시작
|
||||
*
|
||||
* @param options - 브릿지 설정
|
||||
*/
|
||||
init(options?: { legacyToV2?: boolean; v2ToLegacy?: boolean }): void {
|
||||
if (this.isActive) {
|
||||
console.warn("[LegacyEventAdapter] 이미 초기화되어 있습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (options) {
|
||||
this.config = { ...this.config, ...options };
|
||||
}
|
||||
|
||||
console.log("[LegacyEventAdapter] 초기화 시작", this.config);
|
||||
|
||||
EVENT_MAPPINGS.forEach((mapping) => {
|
||||
// 레거시 → V2 브릿지
|
||||
if (this.config.legacyToV2) {
|
||||
this.setupLegacyToV2Bridge(mapping);
|
||||
}
|
||||
|
||||
// V2 → 레거시 브릿지
|
||||
if (this.config.v2ToLegacy) {
|
||||
this.setupV2ToLegacyBridge(mapping);
|
||||
}
|
||||
});
|
||||
|
||||
this.isActive = true;
|
||||
console.log(
|
||||
`[LegacyEventAdapter] 초기화 완료 (${EVENT_MAPPINGS.length}개 매핑)`
|
||||
);
|
||||
}
|
||||
|
||||
private setupLegacyToV2Bridge(mapping: EventMapping): void {
|
||||
const listener = (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const bridgeKey = `${mapping.legacy}-${Date.now()}`;
|
||||
|
||||
// 무한 루프 방지: 브릿지에서 발생한 이벤트인지 확인
|
||||
if (customEvent.detail?.__v2Bridged) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.bridgedEvents.add(bridgeKey);
|
||||
|
||||
// 페이로드 변환
|
||||
const v2Payload = mapping.toV2
|
||||
? mapping.toV2(customEvent.detail)
|
||||
: customEvent.detail;
|
||||
|
||||
// V2 EventBus로 발행
|
||||
v2EventBus.emitSync(mapping.v2, v2Payload);
|
||||
|
||||
// 잠시 후 브릿지 키 정리
|
||||
setTimeout(() => {
|
||||
this.bridgedEvents.delete(bridgeKey);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
window.addEventListener(mapping.legacy, listener);
|
||||
this.legacyListeners.set(mapping.legacy, listener);
|
||||
}
|
||||
|
||||
private setupV2ToLegacyBridge(mapping: EventMapping): void {
|
||||
const unsubscribe = v2EventBus.subscribe(
|
||||
mapping.v2,
|
||||
(payload) => {
|
||||
// 무한 루프 방지 표시 추가
|
||||
const legacyPayload = mapping.toLegacy
|
||||
? { ...mapping.toLegacy(payload), __v2Bridged: true }
|
||||
: { ...payload, __v2Bridged: true };
|
||||
|
||||
// 레거시 이벤트 발행
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(mapping.legacy, { detail: legacyPayload })
|
||||
);
|
||||
},
|
||||
{ componentId: "legacy-adapter" }
|
||||
);
|
||||
|
||||
this.v2Unsubscribes.set(mapping.v2, unsubscribe);
|
||||
}
|
||||
|
||||
/**
|
||||
* 어댑터 정지 및 정리
|
||||
*/
|
||||
destroy(): void {
|
||||
if (!this.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 레거시 리스너 정리
|
||||
this.legacyListeners.forEach((listener, eventName) => {
|
||||
window.removeEventListener(eventName, listener);
|
||||
});
|
||||
this.legacyListeners.clear();
|
||||
|
||||
// V2 구독 정리
|
||||
this.v2Unsubscribes.forEach((unsubscribe) => {
|
||||
unsubscribe();
|
||||
});
|
||||
this.v2Unsubscribes.clear();
|
||||
|
||||
this.bridgedEvents.clear();
|
||||
this.isActive = false;
|
||||
|
||||
console.log("[LegacyEventAdapter] 정리 완료");
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 활성 상태 확인
|
||||
*/
|
||||
get active(): boolean {
|
||||
return this.isActive;
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑된 이벤트 목록 조회
|
||||
*/
|
||||
getMappings(): Array<{ legacy: string; v2: string }> {
|
||||
return EVENT_MAPPINGS.map((m) => ({
|
||||
legacy: m.legacy,
|
||||
v2: m.v2,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스
|
||||
export const legacyEventAdapter = new LegacyEventAdapter();
|
||||
|
||||
// 개발 환경에서 window에 노출 (디버깅용)
|
||||
if (typeof window !== "undefined" && process.env.NODE_ENV === "development") {
|
||||
(window as any).__legacyEventAdapter = legacyEventAdapter;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* V2 어댑터 내보내기
|
||||
*/
|
||||
|
||||
export * from "./LegacyEventAdapter";
|
||||
|
||||
|
|
@ -0,0 +1,360 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* V2 ErrorBoundary - 컴포넌트별 에러 격리
|
||||
*
|
||||
* 특징:
|
||||
* - 각 컴포넌트의 에러가 다른 컴포넌트에 영향을 주지 않음
|
||||
* - 폴백 UI 제공
|
||||
* - 재시도 기능
|
||||
* - 에러 로깅 및 이벤트 발행
|
||||
*/
|
||||
|
||||
import React, { Component, ErrorInfo, ReactNode } from "react";
|
||||
import { v2EventBus, V2_EVENTS } from "../events";
|
||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
|
||||
interface V2ErrorBoundaryProps {
|
||||
/** 자식 컴포넌트 */
|
||||
children: ReactNode;
|
||||
/** 컴포넌트 ID (에러 추적용) */
|
||||
componentId: string;
|
||||
/** 컴포넌트 타입 (에러 추적용) */
|
||||
componentType: string;
|
||||
/** 사용자 정의 폴백 UI */
|
||||
fallback?: ReactNode | ((error: Error, retry: () => void) => ReactNode);
|
||||
/** 폴백 UI 표시 방식 */
|
||||
fallbackStyle?: "minimal" | "compact" | "full";
|
||||
/** 에러 발생 시 콜백 */
|
||||
onError?: (error: Error, errorInfo: ErrorInfo) => void;
|
||||
/** 복구 가능 여부 */
|
||||
recoverable?: boolean;
|
||||
/** 자동 재시도 횟수 (0이면 자동 재시도 안 함) */
|
||||
autoRetryCount?: number;
|
||||
/** 자동 재시도 간격 (ms) */
|
||||
autoRetryDelay?: number;
|
||||
}
|
||||
|
||||
interface V2ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: ErrorInfo | null;
|
||||
retryCount: number;
|
||||
}
|
||||
|
||||
export class V2ErrorBoundary extends Component<
|
||||
V2ErrorBoundaryProps,
|
||||
V2ErrorBoundaryState
|
||||
> {
|
||||
private retryTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
static defaultProps = {
|
||||
fallbackStyle: "compact" as const,
|
||||
recoverable: true,
|
||||
autoRetryCount: 0,
|
||||
autoRetryDelay: 3000,
|
||||
};
|
||||
|
||||
constructor(props: V2ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
retryCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<V2ErrorBoundaryState> {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
const { componentId, componentType, onError, autoRetryCount = 0 } = this.props;
|
||||
const { retryCount } = this.state;
|
||||
|
||||
// 상태 업데이트
|
||||
this.setState({ errorInfo });
|
||||
|
||||
// 에러 로깅
|
||||
console.error(
|
||||
`[V2ErrorBoundary] 컴포넌트 에러 - ${componentType}(${componentId}):`,
|
||||
error
|
||||
);
|
||||
console.error("Component Stack:", errorInfo.componentStack);
|
||||
|
||||
// 에러 이벤트 발행
|
||||
v2EventBus.emitSync(V2_EVENTS.COMPONENT_ERROR, {
|
||||
componentId,
|
||||
componentType,
|
||||
error,
|
||||
recoverable: this.props.recoverable ?? true,
|
||||
});
|
||||
|
||||
// 사용자 정의 에러 핸들러 호출
|
||||
onError?.(error, errorInfo);
|
||||
|
||||
// 자동 재시도
|
||||
if (autoRetryCount > 0 && retryCount < autoRetryCount) {
|
||||
this.scheduleAutoRetry();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
if (this.retryTimeoutId) {
|
||||
clearTimeout(this.retryTimeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleAutoRetry = (): void => {
|
||||
const { autoRetryDelay = 3000 } = this.props;
|
||||
|
||||
this.retryTimeoutId = setTimeout(() => {
|
||||
this.handleRetry();
|
||||
}, autoRetryDelay);
|
||||
};
|
||||
|
||||
private handleRetry = (): void => {
|
||||
const { componentId, componentType } = this.props;
|
||||
|
||||
// 복구 이벤트 발행
|
||||
v2EventBus.emitSync(V2_EVENTS.COMPONENT_RECOVER, {
|
||||
componentId,
|
||||
componentType,
|
||||
});
|
||||
|
||||
this.setState((prev) => ({
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
retryCount: prev.retryCount + 1,
|
||||
}));
|
||||
};
|
||||
|
||||
private renderMinimalFallback(): ReactNode {
|
||||
const { recoverable = true } = this.props;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center p-2 text-xs text-destructive">
|
||||
<AlertCircle className="mr-1 h-3 w-3" />
|
||||
<span>오류 발생</span>
|
||||
{recoverable && (
|
||||
<button
|
||||
onClick={this.handleRetry}
|
||||
className="ml-2 underline hover:no-underline"
|
||||
>
|
||||
재시도
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private renderCompactFallback(): ReactNode {
|
||||
const { componentType, recoverable = true } = this.props;
|
||||
const { error } = this.state;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/5 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||
<span className="text-sm font-medium text-destructive">
|
||||
{componentType} 로드 실패
|
||||
</span>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{error.message.substring(0, 100)}
|
||||
{error.message.length > 100 ? "..." : ""}
|
||||
</p>
|
||||
)}
|
||||
{recoverable && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={this.handleRetry}
|
||||
className="mt-2"
|
||||
>
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
재시도
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private renderFullFallback(): ReactNode {
|
||||
const { componentId, componentType, recoverable = true } = this.props;
|
||||
const { error, errorInfo, retryCount } = this.state;
|
||||
|
||||
return (
|
||||
<Alert variant="destructive" className="my-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{componentType} 컴포넌트 오류
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<div className="mt-2 space-y-2">
|
||||
<p className="text-sm">
|
||||
<strong>컴포넌트 ID:</strong> {componentId}
|
||||
</p>
|
||||
{error && (
|
||||
<p className="text-sm">
|
||||
<strong>에러 메시지:</strong> {error.message}
|
||||
</p>
|
||||
)}
|
||||
{retryCount > 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
재시도 횟수: {retryCount}회
|
||||
</p>
|
||||
)}
|
||||
{process.env.NODE_ENV === "development" && errorInfo && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-xs">
|
||||
스택 트레이스 보기
|
||||
</summary>
|
||||
<pre className="mt-1 max-h-32 overflow-auto whitespace-pre-wrap rounded bg-muted p-2 text-xs">
|
||||
{errorInfo.componentStack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
{recoverable && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={this.handleRetry}
|
||||
className="mt-2"
|
||||
>
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
다시 시도
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
const { children, fallback, fallbackStyle = "compact" } = this.props;
|
||||
const { hasError, error } = this.state;
|
||||
|
||||
if (!hasError) {
|
||||
return children;
|
||||
}
|
||||
|
||||
// 사용자 정의 폴백
|
||||
if (fallback) {
|
||||
if (typeof fallback === "function") {
|
||||
return fallback(error!, this.handleRetry);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// 기본 폴백 스타일별 렌더링
|
||||
switch (fallbackStyle) {
|
||||
case "minimal":
|
||||
return this.renderMinimalFallback();
|
||||
case "full":
|
||||
return this.renderFullFallback();
|
||||
case "compact":
|
||||
default:
|
||||
return this.renderCompactFallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 함수형 래퍼 (HOC)
|
||||
// ============================================================================
|
||||
|
||||
interface WithV2ErrorBoundaryOptions {
|
||||
componentType: string;
|
||||
fallbackStyle?: "minimal" | "compact" | "full";
|
||||
recoverable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 에러 바운더리 HOC
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const SafeComponent = withV2ErrorBoundary(MyComponent, {
|
||||
* componentType: "MyComponent",
|
||||
* fallbackStyle: "compact",
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function withV2ErrorBoundary<P extends object>(
|
||||
WrappedComponent: React.ComponentType<P>,
|
||||
options: WithV2ErrorBoundaryOptions
|
||||
): React.FC<P & { componentId?: string }> {
|
||||
const { componentType, fallbackStyle, recoverable } = options;
|
||||
|
||||
const WithErrorBoundary: React.FC<P & { componentId?: string }> = (props) => {
|
||||
const componentId =
|
||||
props.componentId ?? `${componentType}_${Date.now()}`;
|
||||
|
||||
return (
|
||||
<V2ErrorBoundary
|
||||
componentId={componentId}
|
||||
componentType={componentType}
|
||||
fallbackStyle={fallbackStyle}
|
||||
recoverable={recoverable}
|
||||
>
|
||||
<WrappedComponent {...props} />
|
||||
</V2ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
WithErrorBoundary.displayName = `WithV2ErrorBoundary(${componentType})`;
|
||||
|
||||
return WithErrorBoundary;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 훅 기반 에러 리포팅 (ErrorBoundary 외부에서 에러 보고용)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* V2 에러 리포팅 훅
|
||||
*
|
||||
* ErrorBoundary가 잡지 못하는 비동기 에러 등을 보고할 때 사용
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const reportError = useV2ErrorReporter("my-component", "MyComponent");
|
||||
*
|
||||
* const handleClick = async () => {
|
||||
* try {
|
||||
* await someAsyncOperation();
|
||||
* } catch (error) {
|
||||
* reportError(error as Error);
|
||||
* }
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
export function useV2ErrorReporter(
|
||||
componentId: string,
|
||||
componentType: string
|
||||
): (error: Error, recoverable?: boolean) => void {
|
||||
return React.useCallback(
|
||||
(error: Error, recoverable = true) => {
|
||||
console.error(
|
||||
`[V2ErrorReporter] ${componentType}(${componentId}):`,
|
||||
error
|
||||
);
|
||||
|
||||
v2EventBus.emitSync(V2_EVENTS.COMPONENT_ERROR, {
|
||||
componentId,
|
||||
componentType,
|
||||
error,
|
||||
recoverable,
|
||||
});
|
||||
},
|
||||
[componentId, componentType]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* V2 코어 컴포넌트 내보내기
|
||||
*/
|
||||
|
||||
export * from "./V2ErrorBoundary";
|
||||
|
||||
|
|
@ -0,0 +1,344 @@
|
|||
/**
|
||||
* V2 EventBus - 타입 안전한 이벤트 버스
|
||||
*
|
||||
* 특징:
|
||||
* - 타입 안전한 이벤트 발행/구독
|
||||
* - 에러 격리 (하나의 핸들러 실패가 다른 핸들러에 영향 없음)
|
||||
* - 병렬/순차 실행 지원
|
||||
* - 디버그 모드 지원
|
||||
* - 구독 자동 정리 (컴포넌트 언마운트 시)
|
||||
*/
|
||||
|
||||
import {
|
||||
V2_EVENTS,
|
||||
V2EventName,
|
||||
V2EventPayloadMap,
|
||||
V2EventHandler,
|
||||
V2Unsubscribe,
|
||||
} from "./types";
|
||||
|
||||
interface SubscriberInfo<T extends V2EventName> {
|
||||
id: string;
|
||||
handler: V2EventHandler<T>;
|
||||
componentId?: string;
|
||||
once: boolean;
|
||||
}
|
||||
|
||||
interface EmitOptions {
|
||||
/** 병렬 실행 여부 (기본값: true) */
|
||||
parallel?: boolean;
|
||||
/** 타임아웃 (ms, 기본값: 5000) */
|
||||
timeout?: number;
|
||||
/** 실패 시 재시도 횟수 (기본값: 0) */
|
||||
retryCount?: number;
|
||||
}
|
||||
|
||||
interface EmitResult {
|
||||
success: boolean;
|
||||
handlerCount: number;
|
||||
errors: Array<{ subscriberId: string; error: Error }>;
|
||||
}
|
||||
|
||||
class V2EventBus {
|
||||
private subscribers: Map<V2EventName, Map<string, SubscriberInfo<any>>> =
|
||||
new Map();
|
||||
private subscriberIdCounter = 0;
|
||||
|
||||
/** 디버그 모드 활성화 시 모든 이벤트 로깅 */
|
||||
public debug = false;
|
||||
|
||||
/** 디버그용 로거 */
|
||||
private log(message: string, ...args: any[]) {
|
||||
if (this.debug) {
|
||||
console.log(`[V2EventBus] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/** 에러 로거 */
|
||||
private logError(message: string, error: any) {
|
||||
console.error(`[V2EventBus] ${message}`, error);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 구독
|
||||
*
|
||||
* @param eventName - 이벤트 이름
|
||||
* @param handler - 이벤트 핸들러
|
||||
* @param options - 구독 옵션
|
||||
* @returns 구독 해제 함수
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const unsubscribe = v2EventBus.subscribe(
|
||||
* V2_EVENTS.TABLE_REFRESH,
|
||||
* (payload) => {
|
||||
* console.log("테이블 새로고침:", payload.tableName);
|
||||
* },
|
||||
* { componentId: "my-table" }
|
||||
* );
|
||||
*
|
||||
* // 컴포넌트 언마운트 시
|
||||
* unsubscribe();
|
||||
* ```
|
||||
*/
|
||||
subscribe<T extends V2EventName>(
|
||||
eventName: T,
|
||||
handler: V2EventHandler<T>,
|
||||
options?: { componentId?: string; once?: boolean }
|
||||
): V2Unsubscribe {
|
||||
const subscriberId = `sub_${++this.subscriberIdCounter}`;
|
||||
|
||||
if (!this.subscribers.has(eventName)) {
|
||||
this.subscribers.set(eventName, new Map());
|
||||
}
|
||||
|
||||
const eventSubscribers = this.subscribers.get(eventName)!;
|
||||
eventSubscribers.set(subscriberId, {
|
||||
id: subscriberId,
|
||||
handler,
|
||||
componentId: options?.componentId,
|
||||
once: options?.once ?? false,
|
||||
});
|
||||
|
||||
this.log(
|
||||
`구독 등록: ${eventName} (${subscriberId})`,
|
||||
options?.componentId ? `컴포넌트: ${options.componentId}` : ""
|
||||
);
|
||||
|
||||
// 구독 해제 함수 반환
|
||||
return () => {
|
||||
this.unsubscribe(eventName, subscriberId);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 일회성 이벤트 구독 (한 번 실행 후 자동 해제)
|
||||
*/
|
||||
once<T extends V2EventName>(
|
||||
eventName: T,
|
||||
handler: V2EventHandler<T>,
|
||||
options?: { componentId?: string }
|
||||
): V2Unsubscribe {
|
||||
return this.subscribe(eventName, handler, { ...options, once: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 구독 해제
|
||||
*/
|
||||
private unsubscribe(eventName: V2EventName, subscriberId: string): void {
|
||||
const eventSubscribers = this.subscribers.get(eventName);
|
||||
if (eventSubscribers) {
|
||||
eventSubscribers.delete(subscriberId);
|
||||
this.log(`구독 해제: ${eventName} (${subscriberId})`);
|
||||
|
||||
// 구독자가 없으면 Map 정리
|
||||
if (eventSubscribers.size === 0) {
|
||||
this.subscribers.delete(eventName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 컴포넌트의 모든 구독 해제
|
||||
*
|
||||
* @param componentId - 컴포넌트 ID
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // useEffect cleanup에서 사용
|
||||
* useEffect(() => {
|
||||
* return () => {
|
||||
* v2EventBus.unsubscribeByComponent("my-table-component");
|
||||
* };
|
||||
* }, []);
|
||||
* ```
|
||||
*/
|
||||
unsubscribeByComponent(componentId: string): void {
|
||||
let unsubscribedCount = 0;
|
||||
|
||||
this.subscribers.forEach((eventSubscribers, eventName) => {
|
||||
eventSubscribers.forEach((subscriber, subscriberId) => {
|
||||
if (subscriber.componentId === componentId) {
|
||||
eventSubscribers.delete(subscriberId);
|
||||
unsubscribedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
// 구독자가 없으면 Map 정리
|
||||
if (eventSubscribers.size === 0) {
|
||||
this.subscribers.delete(eventName);
|
||||
}
|
||||
});
|
||||
|
||||
this.log(
|
||||
`컴포넌트 구독 해제: ${componentId} (${unsubscribedCount}개 해제)`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 발행
|
||||
*
|
||||
* @param eventName - 이벤트 이름
|
||||
* @param payload - 이벤트 데이터
|
||||
* @param options - 발행 옵션
|
||||
* @returns 발행 결과
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await v2EventBus.emit(V2_EVENTS.TABLE_REFRESH, {
|
||||
* tableName: "item_info",
|
||||
* target: "single",
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
async emit<T extends V2EventName>(
|
||||
eventName: T,
|
||||
payload: V2EventPayloadMap[T],
|
||||
options: EmitOptions = {}
|
||||
): Promise<EmitResult> {
|
||||
const { parallel = true, timeout = 5000, retryCount = 0 } = options;
|
||||
|
||||
const eventSubscribers = this.subscribers.get(eventName);
|
||||
|
||||
if (!eventSubscribers || eventSubscribers.size === 0) {
|
||||
this.log(`이벤트 발행 (구독자 없음): ${eventName}`);
|
||||
return { success: true, handlerCount: 0, errors: [] };
|
||||
}
|
||||
|
||||
this.log(`이벤트 발행: ${eventName} → ${eventSubscribers.size}개 구독자`);
|
||||
|
||||
const errors: Array<{ subscriberId: string; error: Error }> = [];
|
||||
const subscribersToRemove: string[] = [];
|
||||
|
||||
// 핸들러 실행 함수
|
||||
const executeHandler = async (
|
||||
subscriber: SubscriberInfo<T>
|
||||
): Promise<void> => {
|
||||
const executeWithRetry = async (retriesLeft: number): Promise<void> => {
|
||||
try {
|
||||
// 타임아웃 적용
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(
|
||||
() => reject(new Error(`Handler timeout after ${timeout}ms`)),
|
||||
timeout
|
||||
);
|
||||
});
|
||||
|
||||
const handlerPromise = Promise.resolve(subscriber.handler(payload));
|
||||
await Promise.race([handlerPromise, timeoutPromise]);
|
||||
|
||||
if (subscriber.once) {
|
||||
subscribersToRemove.push(subscriber.id);
|
||||
}
|
||||
} catch (error) {
|
||||
if (retriesLeft > 0) {
|
||||
this.log(
|
||||
`핸들러 재시도: ${subscriber.id} (남은 횟수: ${retriesLeft})`
|
||||
);
|
||||
await executeWithRetry(retriesLeft - 1);
|
||||
} else {
|
||||
const err =
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
this.logError(`핸들러 실행 실패: ${subscriber.id}`, err);
|
||||
errors.push({ subscriberId: subscriber.id, error: err });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await executeWithRetry(retryCount);
|
||||
};
|
||||
|
||||
// 병렬 또는 순차 실행
|
||||
const subscriberArray = Array.from(eventSubscribers.values());
|
||||
|
||||
if (parallel) {
|
||||
// 병렬 실행 (Promise.allSettled로 에러 격리)
|
||||
await Promise.allSettled(
|
||||
subscriberArray.map((subscriber) => executeHandler(subscriber))
|
||||
);
|
||||
} else {
|
||||
// 순차 실행
|
||||
for (const subscriber of subscriberArray) {
|
||||
await executeHandler(subscriber);
|
||||
}
|
||||
}
|
||||
|
||||
// 일회성 구독자 정리
|
||||
subscribersToRemove.forEach((id) => {
|
||||
eventSubscribers.delete(id);
|
||||
});
|
||||
|
||||
if (eventSubscribers.size === 0) {
|
||||
this.subscribers.delete(eventName);
|
||||
}
|
||||
|
||||
const success = errors.length === 0;
|
||||
|
||||
if (errors.length > 0) {
|
||||
this.log(
|
||||
`이벤트 완료: ${eventName} (성공: ${subscriberArray.length - errors.length}, 실패: ${errors.length})`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success,
|
||||
handlerCount: subscriberArray.length,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 동기적 이벤트 발행 (결과 대기 없음)
|
||||
*
|
||||
* 빠른 발행이 필요하고 결과를 기다릴 필요 없을 때 사용
|
||||
*/
|
||||
emitSync<T extends V2EventName>(
|
||||
eventName: T,
|
||||
payload: V2EventPayloadMap[T]
|
||||
): void {
|
||||
this.emit(eventName, payload).catch((error) => {
|
||||
this.logError(`동기 이벤트 발행 실패: ${eventName}`, error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 구독자 수 조회
|
||||
*/
|
||||
getSubscriberCount(eventName: V2EventName): number {
|
||||
return this.subscribers.get(eventName)?.size ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 구독 해제 (테스트용)
|
||||
*/
|
||||
clear(): void {
|
||||
this.subscribers.clear();
|
||||
this.log("모든 구독 해제됨");
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 구독 상태 출력 (디버그용)
|
||||
*/
|
||||
printState(): void {
|
||||
console.log("=== V2EventBus 상태 ===");
|
||||
this.subscribers.forEach((subscribers, eventName) => {
|
||||
console.log(`${eventName}: ${subscribers.size}개 구독자`);
|
||||
subscribers.forEach((sub) => {
|
||||
console.log(` - ${sub.id} (컴포넌트: ${sub.componentId ?? "없음"})`);
|
||||
});
|
||||
});
|
||||
console.log("======================");
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스 생성 및 내보내기
|
||||
export const v2EventBus = new V2EventBus();
|
||||
|
||||
// 개발 환경에서 window에 노출 (디버깅용)
|
||||
if (typeof window !== "undefined" && process.env.NODE_ENV === "development") {
|
||||
(window as any).__v2EventBus = v2EventBus;
|
||||
}
|
||||
|
||||
// 클래스도 내보내기 (테스트용)
|
||||
export { V2EventBus };
|
||||
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* V2 이벤트 시스템 내보내기
|
||||
*/
|
||||
|
||||
export * from "./types";
|
||||
export * from "./EventBus";
|
||||
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
/**
|
||||
* V2 이벤트 타입 정의
|
||||
*
|
||||
* 모든 V2 컴포넌트 간의 통신에 사용되는 이벤트 타입을 정의합니다.
|
||||
* 타입 안전성과 IDE 자동완성을 위해 명시적으로 정의합니다.
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// 이벤트 이름 상수
|
||||
// ============================================================================
|
||||
|
||||
export const V2_EVENTS = {
|
||||
// 폼 저장 흐름
|
||||
FORM_SAVE_REQUEST: "v2:form:save:request",
|
||||
FORM_SAVE_COLLECT: "v2:form:save:collect",
|
||||
FORM_SAVE_COMPLETE: "v2:form:save:complete",
|
||||
FORM_SAVE_ERROR: "v2:form:save:error",
|
||||
|
||||
// 테이블
|
||||
TABLE_REFRESH: "v2:table:refresh",
|
||||
TABLE_DATA_CHANGE: "v2:table:data:change",
|
||||
TABLE_SELECTION_CHANGE: "v2:table:selection:change",
|
||||
TABLE_ROW_CLICK: "v2:table:row:click",
|
||||
TABLE_ROW_DOUBLE_CLICK: "v2:table:row:doubleclick",
|
||||
|
||||
// 리피터
|
||||
REPEATER_DATA_COLLECT: "v2:repeater:data:collect",
|
||||
REPEATER_SAVE: "v2:repeater:save",
|
||||
REPEATER_DATA_CHANGE: "v2:repeater:data:change",
|
||||
|
||||
// 모달
|
||||
MODAL_OPEN: "v2:modal:open",
|
||||
MODAL_CLOSE: "v2:modal:close",
|
||||
MODAL_SAVE_SUCCESS: "v2:modal:save:success",
|
||||
|
||||
// 카드 디스플레이
|
||||
CARD_REFRESH: "v2:card:refresh",
|
||||
|
||||
// 집계 위젯
|
||||
AGGREGATION_UPDATE: "v2:aggregation:update",
|
||||
|
||||
// 분할 패널
|
||||
SPLIT_PANEL_DATA_TRANSFER: "v2:splitpanel:data:transfer",
|
||||
|
||||
// 컴포넌트 데이터 전송
|
||||
COMPONENT_DATA_TRANSFER: "v2:component:data:transfer",
|
||||
|
||||
// 컴포넌트 에러
|
||||
COMPONENT_ERROR: "v2:component:error",
|
||||
COMPONENT_RECOVER: "v2:component:recover",
|
||||
|
||||
// 관련 버튼 (Related Button)
|
||||
RELATED_BUTTON_REGISTER: "v2:related-button:register",
|
||||
RELATED_BUTTON_UNREGISTER: "v2:related-button:unregister",
|
||||
RELATED_BUTTON_SELECT: "v2:related-button:select",
|
||||
} as const;
|
||||
|
||||
export type V2EventName = (typeof V2_EVENTS)[keyof typeof V2_EVENTS];
|
||||
|
||||
// ============================================================================
|
||||
// 이벤트 페이로드 인터페이스
|
||||
// ============================================================================
|
||||
|
||||
/** 폼 저장 요청 이벤트 */
|
||||
export interface V2FormSaveRequestEvent {
|
||||
requestId: string;
|
||||
tableName: string;
|
||||
formData: Record<string, any>;
|
||||
originalData?: Record<string, any>;
|
||||
source: string; // 요청 발생 컴포넌트
|
||||
}
|
||||
|
||||
/** 폼 저장 데이터 수집 이벤트 */
|
||||
export interface V2FormSaveCollectEvent {
|
||||
requestId: string;
|
||||
formData: Record<string, any>;
|
||||
componentId: string;
|
||||
}
|
||||
|
||||
/** 폼 저장 완료 이벤트 */
|
||||
export interface V2FormSaveCompleteEvent {
|
||||
requestId: string;
|
||||
success: boolean;
|
||||
savedData?: Record<string, any>;
|
||||
tableName: string;
|
||||
}
|
||||
|
||||
/** 폼 저장 에러 이벤트 */
|
||||
export interface V2FormSaveErrorEvent {
|
||||
requestId: string;
|
||||
error: string;
|
||||
componentId?: string;
|
||||
tableName?: string;
|
||||
}
|
||||
|
||||
/** 테이블 새로고침 이벤트 */
|
||||
export interface V2TableRefreshEvent {
|
||||
tableName?: string;
|
||||
target?: "all" | "single";
|
||||
screenCode?: string;
|
||||
}
|
||||
|
||||
/** 테이블 데이터 변경 이벤트 */
|
||||
export interface V2TableDataChangeEvent {
|
||||
tableName: string;
|
||||
data: any[];
|
||||
totalCount: number;
|
||||
source: string;
|
||||
}
|
||||
|
||||
/** 테이블 선택 변경 이벤트 */
|
||||
export interface V2TableSelectionChangeEvent {
|
||||
tableName: string;
|
||||
selectedRows: any[];
|
||||
selectedRowIds: (string | number)[];
|
||||
source: string;
|
||||
}
|
||||
|
||||
/** 테이블 행 클릭 이벤트 */
|
||||
export interface V2TableRowClickEvent {
|
||||
tableName: string;
|
||||
row: any;
|
||||
rowIndex: number;
|
||||
}
|
||||
|
||||
/** 리피터 데이터 수집 이벤트 */
|
||||
export interface V2RepeaterDataCollectEvent {
|
||||
requestId: string;
|
||||
repeaterId: string;
|
||||
tableName: string;
|
||||
}
|
||||
|
||||
/** 리피터 저장 이벤트 */
|
||||
export interface V2RepeaterSaveEvent {
|
||||
repeaterId: string;
|
||||
tableName: string;
|
||||
items: any[];
|
||||
joinData?: {
|
||||
column: string;
|
||||
value: any;
|
||||
};
|
||||
}
|
||||
|
||||
/** 리피터 데이터 변경 이벤트 */
|
||||
export interface V2RepeaterDataChangeEvent {
|
||||
repeaterId: string;
|
||||
tableName: string;
|
||||
data: any[];
|
||||
action: "add" | "update" | "delete" | "init";
|
||||
}
|
||||
|
||||
/** 모달 열기 이벤트 */
|
||||
export interface V2ModalOpenEvent {
|
||||
modalId: string;
|
||||
screenCode?: string;
|
||||
data?: Record<string, any>;
|
||||
mode?: "create" | "edit" | "view";
|
||||
}
|
||||
|
||||
/** 모달 닫기 이벤트 */
|
||||
export interface V2ModalCloseEvent {
|
||||
modalId: string;
|
||||
reason?: "save" | "cancel" | "close";
|
||||
}
|
||||
|
||||
/** 모달 저장 성공 이벤트 */
|
||||
export interface V2ModalSaveSuccessEvent {
|
||||
modalId: string;
|
||||
savedData: Record<string, any>;
|
||||
tableName: string;
|
||||
}
|
||||
|
||||
/** 카드 새로고침 이벤트 */
|
||||
export interface V2CardRefreshEvent {
|
||||
cardId?: string;
|
||||
tableName?: string;
|
||||
}
|
||||
|
||||
/** 집계 업데이트 이벤트 */
|
||||
export interface V2AggregationUpdateEvent {
|
||||
source: string;
|
||||
tableName: string;
|
||||
data: any[];
|
||||
}
|
||||
|
||||
/** 분할 패널 데이터 전송 이벤트 */
|
||||
export interface V2SplitPanelDataTransferEvent {
|
||||
sourcePanel: "left" | "right";
|
||||
targetPanel: "left" | "right";
|
||||
data: Record<string, any>;
|
||||
tableName: string;
|
||||
}
|
||||
|
||||
/** 컴포넌트 데이터 전송 이벤트 */
|
||||
export interface V2ComponentDataTransferEvent {
|
||||
sourceComponentId: string;
|
||||
targetComponentId?: string;
|
||||
data: Record<string, any>;
|
||||
tableName?: string;
|
||||
}
|
||||
|
||||
/** 컴포넌트 에러 이벤트 */
|
||||
export interface V2ComponentErrorEvent {
|
||||
componentId: string;
|
||||
componentType: string;
|
||||
error: Error | string;
|
||||
recoverable: boolean;
|
||||
}
|
||||
|
||||
/** 컴포넌트 복구 이벤트 */
|
||||
export interface V2ComponentRecoverEvent {
|
||||
componentId: string;
|
||||
componentType: string;
|
||||
}
|
||||
|
||||
/** 관련 버튼 등록 이벤트 */
|
||||
export interface V2RelatedButtonRegisterEvent {
|
||||
buttonId: string;
|
||||
targetTables: string[];
|
||||
}
|
||||
|
||||
/** 관련 버튼 해제 이벤트 */
|
||||
export interface V2RelatedButtonUnregisterEvent {
|
||||
buttonId: string;
|
||||
}
|
||||
|
||||
/** 관련 버튼 선택 이벤트 */
|
||||
export interface V2RelatedButtonSelectEvent {
|
||||
tableName: string;
|
||||
selectedData: any[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 이벤트 타입 맵핑 (타입 안전성을 위한)
|
||||
// ============================================================================
|
||||
|
||||
export interface V2EventPayloadMap {
|
||||
[V2_EVENTS.FORM_SAVE_REQUEST]: V2FormSaveRequestEvent;
|
||||
[V2_EVENTS.FORM_SAVE_COLLECT]: V2FormSaveCollectEvent;
|
||||
[V2_EVENTS.FORM_SAVE_COMPLETE]: V2FormSaveCompleteEvent;
|
||||
[V2_EVENTS.FORM_SAVE_ERROR]: V2FormSaveErrorEvent;
|
||||
|
||||
[V2_EVENTS.TABLE_REFRESH]: V2TableRefreshEvent;
|
||||
[V2_EVENTS.TABLE_DATA_CHANGE]: V2TableDataChangeEvent;
|
||||
[V2_EVENTS.TABLE_SELECTION_CHANGE]: V2TableSelectionChangeEvent;
|
||||
[V2_EVENTS.TABLE_ROW_CLICK]: V2TableRowClickEvent;
|
||||
[V2_EVENTS.TABLE_ROW_DOUBLE_CLICK]: V2TableRowClickEvent;
|
||||
|
||||
[V2_EVENTS.REPEATER_DATA_COLLECT]: V2RepeaterDataCollectEvent;
|
||||
[V2_EVENTS.REPEATER_SAVE]: V2RepeaterSaveEvent;
|
||||
[V2_EVENTS.REPEATER_DATA_CHANGE]: V2RepeaterDataChangeEvent;
|
||||
|
||||
[V2_EVENTS.MODAL_OPEN]: V2ModalOpenEvent;
|
||||
[V2_EVENTS.MODAL_CLOSE]: V2ModalCloseEvent;
|
||||
[V2_EVENTS.MODAL_SAVE_SUCCESS]: V2ModalSaveSuccessEvent;
|
||||
|
||||
[V2_EVENTS.CARD_REFRESH]: V2CardRefreshEvent;
|
||||
|
||||
[V2_EVENTS.AGGREGATION_UPDATE]: V2AggregationUpdateEvent;
|
||||
|
||||
[V2_EVENTS.SPLIT_PANEL_DATA_TRANSFER]: V2SplitPanelDataTransferEvent;
|
||||
|
||||
[V2_EVENTS.COMPONENT_DATA_TRANSFER]: V2ComponentDataTransferEvent;
|
||||
|
||||
[V2_EVENTS.COMPONENT_ERROR]: V2ComponentErrorEvent;
|
||||
[V2_EVENTS.COMPONENT_RECOVER]: V2ComponentRecoverEvent;
|
||||
|
||||
[V2_EVENTS.RELATED_BUTTON_REGISTER]: V2RelatedButtonRegisterEvent;
|
||||
[V2_EVENTS.RELATED_BUTTON_UNREGISTER]: V2RelatedButtonUnregisterEvent;
|
||||
[V2_EVENTS.RELATED_BUTTON_SELECT]: V2RelatedButtonSelectEvent;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 유틸리티 타입
|
||||
// ============================================================================
|
||||
|
||||
/** 이벤트 핸들러 타입 */
|
||||
export type V2EventHandler<T extends V2EventName> = (
|
||||
payload: V2EventPayloadMap[T]
|
||||
) => void | Promise<void>;
|
||||
|
||||
/** 구독 해제 함수 타입 */
|
||||
export type V2Unsubscribe = () => void;
|
||||
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* V2 Core - 느슨한 결합 아키텍처를 위한 코어 라이브러리
|
||||
*
|
||||
* 사용법:
|
||||
* ```typescript
|
||||
* import {
|
||||
* v2EventBus,
|
||||
* V2_EVENTS,
|
||||
* V2ErrorBoundary,
|
||||
* initV2Core,
|
||||
* } from "@/lib/v2-core";
|
||||
*
|
||||
* // 앱 시작 시 초기화
|
||||
* initV2Core();
|
||||
*
|
||||
* // 이벤트 발행
|
||||
* v2EventBus.emit(V2_EVENTS.TABLE_REFRESH, { tableName: "item_info" });
|
||||
*
|
||||
* // 이벤트 구독
|
||||
* const unsubscribe = v2EventBus.subscribe(V2_EVENTS.TABLE_REFRESH, (payload) => {
|
||||
* console.log("테이블 새로고침:", payload.tableName);
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
// 이벤트 시스템
|
||||
export * from "./events";
|
||||
|
||||
// 컴포넌트
|
||||
export * from "./components";
|
||||
|
||||
// 어댑터
|
||||
export * from "./adapters";
|
||||
|
||||
// 초기화
|
||||
export { initV2Core, cleanupV2Core } from "./init";
|
||||
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* V2 Core 초기화
|
||||
*
|
||||
* 앱 시작 시 한 번 호출하여 V2 시스템을 초기화합니다.
|
||||
*/
|
||||
|
||||
import { v2EventBus } from "./events";
|
||||
import { legacyEventAdapter } from "./adapters";
|
||||
|
||||
let isInitialized = false;
|
||||
|
||||
export interface V2CoreOptions {
|
||||
/** 디버그 모드 활성화 */
|
||||
debug?: boolean;
|
||||
/** 레거시 이벤트 브릿지 설정 */
|
||||
legacyBridge?: {
|
||||
/** 레거시 → V2 브릿지 활성화 (기본값: true) */
|
||||
legacyToV2?: boolean;
|
||||
/** V2 → 레거시 브릿지 활성화 (기본값: true) */
|
||||
v2ToLegacy?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 Core 초기화
|
||||
*
|
||||
* @param options - 초기화 옵션
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // app/layout.tsx 또는 진입점에서 호출
|
||||
* import { initV2Core } from "@/lib/v2-core";
|
||||
*
|
||||
* // 기본 초기화
|
||||
* initV2Core();
|
||||
*
|
||||
* // 디버그 모드 및 커스텀 설정
|
||||
* initV2Core({
|
||||
* debug: process.env.NODE_ENV === "development",
|
||||
* legacyBridge: {
|
||||
* legacyToV2: true,
|
||||
* v2ToLegacy: true,
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function initV2Core(options?: V2CoreOptions): void {
|
||||
if (isInitialized) {
|
||||
console.warn("[V2Core] 이미 초기화되어 있습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
debug = process.env.NODE_ENV === "development",
|
||||
legacyBridge = { legacyToV2: true, v2ToLegacy: true },
|
||||
} = options ?? {};
|
||||
|
||||
console.log("[V2Core] 초기화 시작...");
|
||||
|
||||
// 디버그 모드 설정
|
||||
v2EventBus.debug = debug;
|
||||
|
||||
// 레거시 이벤트 브릿지 초기화
|
||||
legacyEventAdapter.init(legacyBridge);
|
||||
|
||||
isInitialized = true;
|
||||
|
||||
console.log("[V2Core] 초기화 완료", {
|
||||
debug,
|
||||
legacyBridge: legacyEventAdapter.active,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 Core 정리
|
||||
*
|
||||
* 테스트 또는 특수 상황에서 V2 시스템을 정리할 때 사용
|
||||
*/
|
||||
export function cleanupV2Core(): void {
|
||||
if (!isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[V2Core] 정리 시작...");
|
||||
|
||||
// 레거시 어댑터 정리
|
||||
legacyEventAdapter.destroy();
|
||||
|
||||
// 이벤트 버스 정리
|
||||
v2EventBus.clear();
|
||||
|
||||
isInitialized = false;
|
||||
|
||||
console.log("[V2Core] 정리 완료");
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 Core 초기화 상태 확인
|
||||
*/
|
||||
export function isV2CoreInitialized(): boolean {
|
||||
return isInitialized;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue