From b39c98c73fb7b487d4387b3f66421a3a1382b5d9 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 26 Jan 2026 11:34:31 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20V2=20Core=20=EB=B0=8F=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - V2 Core 라이브러리를 추가하여 느슨한 결합 아키텍처를 지원합니다. - V2 EventBus를 통해 타입 안전한 이벤트 발행 및 구독 기능을 구현하였습니다. - V2ErrorBoundary 컴포넌트를 추가하여 각 컴포넌트의 에러를 격리하고, 사용자 정의 폴백 UI 및 재시도 기능을 제공합니다. - UnifiedRepeater 및 ButtonPrimaryComponent에서 V2 EventBus를 활용하여 이벤트 처리 로직을 개선하였습니다. - 레거시 이벤트와의 호환성을 위해 LegacyEventAdapter를 추가하여 점진적 마이그레이션을 지원합니다. - V2 컴포넌트 간의 통신을 위한 이벤트 타입을 정의하였습니다. --- docs/V2_COMPONENT_COUPLING_ANALYSIS.md | 568 ++++++++++++++++++ frontend/app/registry-provider.tsx | 16 + .../components/unified/UnifiedRepeater.tsx | 80 +++ .../ButtonPrimaryComponent.tsx | 51 +- .../v2-table-list/TableListComponent.tsx | 143 +++-- .../v2-core/adapters/LegacyEventAdapter.ts | 443 ++++++++++++++ frontend/lib/v2-core/adapters/index.ts | 6 + .../v2-core/components/V2ErrorBoundary.tsx | 360 +++++++++++ frontend/lib/v2-core/components/index.ts | 6 + frontend/lib/v2-core/events/EventBus.ts | 344 +++++++++++ frontend/lib/v2-core/events/index.ts | 7 + frontend/lib/v2-core/events/types.ts | 284 +++++++++ frontend/lib/v2-core/index.ts | 37 ++ frontend/lib/v2-core/init.ts | 103 ++++ 14 files changed, 2392 insertions(+), 56 deletions(-) create mode 100644 docs/V2_COMPONENT_COUPLING_ANALYSIS.md create mode 100644 frontend/lib/v2-core/adapters/LegacyEventAdapter.ts create mode 100644 frontend/lib/v2-core/adapters/index.ts create mode 100644 frontend/lib/v2-core/components/V2ErrorBoundary.tsx create mode 100644 frontend/lib/v2-core/components/index.ts create mode 100644 frontend/lib/v2-core/events/EventBus.ts create mode 100644 frontend/lib/v2-core/events/index.ts create mode 100644 frontend/lib/v2-core/events/types.ts create mode 100644 frontend/lib/v2-core/index.ts create mode 100644 frontend/lib/v2-core/init.ts diff --git a/docs/V2_COMPONENT_COUPLING_ANALYSIS.md b/docs/V2_COMPONENT_COUPLING_ANALYSIS.md new file mode 100644 index 00000000..47ec2bec --- /dev/null +++ b/docs/V2_COMPONENT_COUPLING_ANALYSIS.md @@ -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` | 컴포넌트 에러 | + diff --git a/frontend/app/registry-provider.tsx b/frontend/app/registry-provider.tsx index 0be1d572..d2bd3e32 100644 --- a/frontend/app/registry-provider.tsx +++ b/frontend/app/registry-provider.tsx @@ -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(); + }; }, []); // 초기화 중 로딩 표시 (선택사항) diff --git a/frontend/components/unified/UnifiedRepeater.tsx b/frontend/components/unified/UnifiedRepeater.tsx index 487ad190..d97c6850 100644 --- a/frontend/components/unified/UnifiedRepeater.tsx +++ b/frontend/components/unified/UnifiedRepeater.tsx @@ -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 = ({ } }; + // 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 = ({ 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 = ({ } }; + // 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 = ({ UnifiedRepeater.displayName = "UnifiedRepeater"; +// V2ErrorBoundary로 래핑된 안전한 버전 export +export const SafeUnifiedRepeater: React.FC = (props) => { + return ( + + + + ); +}; + export default UnifiedRepeater; diff --git a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx index 173a67ad..1ac3f547 100644 --- a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx @@ -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 = ({ // 저장/수정 성공 시 자동 처리 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 = ({ /** * ButtonPrimary 래퍼 컴포넌트 - * 추가적인 로직이나 상태 관리가 필요한 경우 사용 + * V2 ErrorBoundary로 감싸서 에러 격리 제공 */ export const ButtonPrimaryWrapper: React.FC = (props) => { - return ; + return ( + + + + ); }; diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index e24f5a2f..9b34876c 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -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 = ({ }); } - // 🆕 리피터 컨테이너/집계 위젯 연동용 커스텀 이벤트 발생 - 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 = ({ }); } - // 🆕 리피터 컨테이너/집계 위젯 연동용 커스텀 이벤트 발생 - 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 = ({ 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 = ({ } }; + // 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 = ({ } }; + // 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 = ({ } }; + // 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 = ({ }; export const TableListWrapper: React.FC = (props) => { - return ; + return ( + + + + ); }; diff --git a/frontend/lib/v2-core/adapters/LegacyEventAdapter.ts b/frontend/lib/v2-core/adapters/LegacyEventAdapter.ts new file mode 100644 index 00000000..b1779b5f --- /dev/null +++ b/frontend/lib/v2-core/adapters/LegacyEventAdapter.ts @@ -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 void> = new Map(); + private v2Unsubscribes: Map void> = new Map(); + + /** 브릿지에서 발생한 이벤트 추적 (무한 루프 방지) */ + private bridgedEvents: Set = 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; +} + diff --git a/frontend/lib/v2-core/adapters/index.ts b/frontend/lib/v2-core/adapters/index.ts new file mode 100644 index 00000000..6ad71271 --- /dev/null +++ b/frontend/lib/v2-core/adapters/index.ts @@ -0,0 +1,6 @@ +/** + * V2 어댑터 내보내기 + */ + +export * from "./LegacyEventAdapter"; + diff --git a/frontend/lib/v2-core/components/V2ErrorBoundary.tsx b/frontend/lib/v2-core/components/V2ErrorBoundary.tsx new file mode 100644 index 00000000..d411f9c9 --- /dev/null +++ b/frontend/lib/v2-core/components/V2ErrorBoundary.tsx @@ -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 | 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 { + 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 ( +
+ + 오류 발생 + {recoverable && ( + + )} +
+ ); + } + + private renderCompactFallback(): ReactNode { + const { componentType, recoverable = true } = this.props; + const { error } = this.state; + + return ( +
+
+ + + {componentType} 로드 실패 + +
+ {error && ( +

+ {error.message.substring(0, 100)} + {error.message.length > 100 ? "..." : ""} +

+ )} + {recoverable && ( + + )} +
+ ); + } + + private renderFullFallback(): ReactNode { + const { componentId, componentType, recoverable = true } = this.props; + const { error, errorInfo, retryCount } = this.state; + + return ( + + + + {componentType} 컴포넌트 오류 + + +
+

+ 컴포넌트 ID: {componentId} +

+ {error && ( +

+ 에러 메시지: {error.message} +

+ )} + {retryCount > 0 && ( +

+ 재시도 횟수: {retryCount}회 +

+ )} + {process.env.NODE_ENV === "development" && errorInfo && ( +
+ + 스택 트레이스 보기 + +
+                  {errorInfo.componentStack}
+                
+
+ )} + {recoverable && ( + + )} +
+
+
+ ); + } + + 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

( + WrappedComponent: React.ComponentType

, + options: WithV2ErrorBoundaryOptions +): React.FC

{ + const { componentType, fallbackStyle, recoverable } = options; + + const WithErrorBoundary: React.FC

= (props) => { + const componentId = + props.componentId ?? `${componentType}_${Date.now()}`; + + return ( + + + + ); + }; + + 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] + ); +} + diff --git a/frontend/lib/v2-core/components/index.ts b/frontend/lib/v2-core/components/index.ts new file mode 100644 index 00000000..1ea21b2f --- /dev/null +++ b/frontend/lib/v2-core/components/index.ts @@ -0,0 +1,6 @@ +/** + * V2 코어 컴포넌트 내보내기 + */ + +export * from "./V2ErrorBoundary"; + diff --git a/frontend/lib/v2-core/events/EventBus.ts b/frontend/lib/v2-core/events/EventBus.ts new file mode 100644 index 00000000..a63f781d --- /dev/null +++ b/frontend/lib/v2-core/events/EventBus.ts @@ -0,0 +1,344 @@ +/** + * V2 EventBus - 타입 안전한 이벤트 버스 + * + * 특징: + * - 타입 안전한 이벤트 발행/구독 + * - 에러 격리 (하나의 핸들러 실패가 다른 핸들러에 영향 없음) + * - 병렬/순차 실행 지원 + * - 디버그 모드 지원 + * - 구독 자동 정리 (컴포넌트 언마운트 시) + */ + +import { + V2_EVENTS, + V2EventName, + V2EventPayloadMap, + V2EventHandler, + V2Unsubscribe, +} from "./types"; + +interface SubscriberInfo { + id: string; + handler: V2EventHandler; + 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>> = + 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( + eventName: T, + handler: V2EventHandler, + 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( + eventName: T, + handler: V2EventHandler, + 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( + eventName: T, + payload: V2EventPayloadMap[T], + options: EmitOptions = {} + ): Promise { + 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 + ): Promise => { + const executeWithRetry = async (retriesLeft: number): Promise => { + try { + // 타임아웃 적용 + const timeoutPromise = new Promise((_, 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( + 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 }; + diff --git a/frontend/lib/v2-core/events/index.ts b/frontend/lib/v2-core/events/index.ts new file mode 100644 index 00000000..27981c07 --- /dev/null +++ b/frontend/lib/v2-core/events/index.ts @@ -0,0 +1,7 @@ +/** + * V2 이벤트 시스템 내보내기 + */ + +export * from "./types"; +export * from "./EventBus"; + diff --git a/frontend/lib/v2-core/events/types.ts b/frontend/lib/v2-core/events/types.ts new file mode 100644 index 00000000..8d0075c5 --- /dev/null +++ b/frontend/lib/v2-core/events/types.ts @@ -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; + originalData?: Record; + source: string; // 요청 발생 컴포넌트 +} + +/** 폼 저장 데이터 수집 이벤트 */ +export interface V2FormSaveCollectEvent { + requestId: string; + formData: Record; + componentId: string; +} + +/** 폼 저장 완료 이벤트 */ +export interface V2FormSaveCompleteEvent { + requestId: string; + success: boolean; + savedData?: Record; + 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; + mode?: "create" | "edit" | "view"; +} + +/** 모달 닫기 이벤트 */ +export interface V2ModalCloseEvent { + modalId: string; + reason?: "save" | "cancel" | "close"; +} + +/** 모달 저장 성공 이벤트 */ +export interface V2ModalSaveSuccessEvent { + modalId: string; + savedData: Record; + 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; + tableName: string; +} + +/** 컴포넌트 데이터 전송 이벤트 */ +export interface V2ComponentDataTransferEvent { + sourceComponentId: string; + targetComponentId?: string; + data: Record; + 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 = ( + payload: V2EventPayloadMap[T] +) => void | Promise; + +/** 구독 해제 함수 타입 */ +export type V2Unsubscribe = () => void; + diff --git a/frontend/lib/v2-core/index.ts b/frontend/lib/v2-core/index.ts new file mode 100644 index 00000000..2721b509 --- /dev/null +++ b/frontend/lib/v2-core/index.ts @@ -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"; + diff --git a/frontend/lib/v2-core/init.ts b/frontend/lib/v2-core/init.ts new file mode 100644 index 00000000..93eabb80 --- /dev/null +++ b/frontend/lib/v2-core/init.ts @@ -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; +} +