feat: V2 Core 및 이벤트 시스템 추가

- V2 Core 라이브러리를 추가하여 느슨한 결합 아키텍처를 지원합니다.
- V2 EventBus를 통해 타입 안전한 이벤트 발행 및 구독 기능을 구현하였습니다.
- V2ErrorBoundary 컴포넌트를 추가하여 각 컴포넌트의 에러를 격리하고, 사용자 정의 폴백 UI 및 재시도 기능을 제공합니다.
- UnifiedRepeater 및 ButtonPrimaryComponent에서 V2 EventBus를 활용하여 이벤트 처리 로직을 개선하였습니다.
- 레거시 이벤트와의 호환성을 위해 LegacyEventAdapter를 추가하여 점진적 마이그레이션을 지원합니다.
- V2 컴포넌트 간의 통신을 위한 이벤트 타입을 정의하였습니다.
This commit is contained in:
kjs 2026-01-26 11:34:31 +09:00
parent b1fba586cb
commit b39c98c73f
14 changed files with 2392 additions and 56 deletions

View File

@ -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` | 컴포넌트 에러 |

View File

@ -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();
};
}, []);
// 초기화 중 로딩 표시 (선택사항)

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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;
}

View File

@ -0,0 +1,6 @@
/**
* V2
*/
export * from "./LegacyEventAdapter";

View File

@ -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]
);
}

View File

@ -0,0 +1,6 @@
/**
* V2
*/
export * from "./V2ErrorBoundary";

View File

@ -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 };

View File

@ -0,0 +1,7 @@
/**
* V2
*/
export * from "./types";
export * from "./EventBus";

View File

@ -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;

View File

@ -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";

View File

@ -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;
}