[agent-pipeline] pipe-20260310142919-l9ae round-1
This commit is contained in:
parent
b14e862cc3
commit
53ac875915
|
|
@ -0,0 +1,759 @@
|
|||
# WACE 시스템 문제점 분석 및 개선 계획
|
||||
|
||||
> **작성일**: 2026-03-01
|
||||
> **상태**: 분석 완료, 계획 수립
|
||||
> **목적**: 반복적으로 발생하는 시스템 문제의 근본 원인 분석 및 구조적 개선 방안
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [문제 요약](#1-문제-요약)
|
||||
2. [문제 1: AI(Cursor) 대화 길어질수록 정확도 저하](#2-문제-1-aicursor-대화-길어질수록-정확도-저하)
|
||||
3. [문제 2: 컴포넌트가 일관되지 않게 생성됨](#3-문제-2-컴포넌트가-일관되지-않게-생성됨)
|
||||
4. [문제 3: 코드 수정 시 다른 곳에 사이드 이펙트 발생](#4-문제-3-코드-수정-시-다른-곳에-사이드-이펙트-발생)
|
||||
5. [근본 원인 종합](#5-근본-원인-종합)
|
||||
6. [개선 계획](#6-개선-계획)
|
||||
7. [우선순위 로드맵](#7-우선순위-로드맵)
|
||||
|
||||
---
|
||||
|
||||
## 1. 문제 요약
|
||||
|
||||
| # | 증상 | 빈도 | 심각도 |
|
||||
|---|------|------|--------|
|
||||
| 1 | Cursor로 오래 작업하면 정확도 떨어짐 | 매 세션 | 중 |
|
||||
| 2 | 로우코드 컴포넌트 생성 시 오류, 비일관성 | 매 컴포넌트 | 높 |
|
||||
| 3 | 수정/신규 코드가 다른 곳에 영향 (저장 안됨, 특정 기능 깨짐) | 수시 | 높 |
|
||||
|
||||
세 문제는 독립적으로 보이지만, **하나의 구조적 원인**에서 파생된다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 문제 1: AI(Cursor) 대화 길어질수록 정확도 저하
|
||||
|
||||
### 2.1. 증상
|
||||
|
||||
- 대화 초반에는 정확한 코드를 생성하다가, 30분~1시간 이상 작업하면 엉뚱한 코드 생성
|
||||
- 이전 맥락을 잊고 같은 질문을 반복하거나, 이미 수정한 부분을 되돌림
|
||||
- 관련 없는 파일을 수정하거나, 존재하지 않는 함수/변수를 참조
|
||||
|
||||
### 2.2. 원인 분석
|
||||
|
||||
AI의 컨텍스트 윈도우는 유한하다. 우리 코드베이스의 핵심 파일들이 **비정상적으로 거대**해서, AI가 한 번에 파악해야 할 정보량이 폭발한다.
|
||||
|
||||
#### 거대 파일 목록 (상위 10개)
|
||||
|
||||
| 파일 | 줄 수 | 역할 |
|
||||
|------|-------|------|
|
||||
| `frontend/lib/utils/buttonActions.ts` | **7,609줄** | 버튼 액션 전체 로직 |
|
||||
| `frontend/components/screen/ScreenDesigner.tsx` | **7,559줄** | 화면 설계기 |
|
||||
| `frontend/lib/registry/components/v2-table-list/TableListComponent.tsx` | **6,867줄** | V2 테이블 컴포넌트 |
|
||||
| `frontend/lib/registry/components/table-list/TableListComponent.tsx` | **6,829줄** | 레거시 테이블 컴포넌트 |
|
||||
| `frontend/components/screen/EditModal.tsx` | **1,648줄** | 편집 모달 |
|
||||
| `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` | **1,524줄** | 버튼 컴포넌트 |
|
||||
| `frontend/components/v2/V2Repeater.tsx` | **1,442줄** | 리피터 컴포넌트 |
|
||||
| `frontend/components/screen/InteractiveScreenViewerDynamic.tsx` | **1,435줄** | 화면 뷰어 |
|
||||
| `frontend/lib/utils/improvedButtonActionExecutor.ts` | **1,063줄** | 버튼 실행기 |
|
||||
| `frontend/lib/registry/DynamicComponentRenderer.tsx` | **980줄** | 컴포넌트 렌더러 |
|
||||
|
||||
**상위 3개 파일만 합쳐도 22,035줄**이다. AI가 이 파일 하나를 읽는 것만으로도 컨텍스트의 상당 부분을 소모한다.
|
||||
|
||||
#### 타입 안전성 부재
|
||||
|
||||
```typescript
|
||||
// frontend/types/component.ts:37-39
|
||||
export interface ComponentConfig {
|
||||
[key: string]: any; // 사실상 타입 검증 없음
|
||||
}
|
||||
|
||||
// frontend/types/component.ts:56-78
|
||||
export interface ComponentRendererProps {
|
||||
component: any; // ComponentData인데 any로 선언
|
||||
// ... 중략 ...
|
||||
[key: string]: any; // 여기도 any
|
||||
}
|
||||
```
|
||||
|
||||
`any` 타입이 핵심 인터페이스에 사용되어, AI가 "이 prop에 뭘 넣어야 하는지" 추론 불가.
|
||||
사람이 봐도 모르는데 AI가 알 리가 없다.
|
||||
|
||||
#### 이벤트 이름이 문자열 상수
|
||||
|
||||
```typescript
|
||||
// 이 이벤트들이 코드 전체에 흩어져 있음
|
||||
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||
window.dispatchEvent(new CustomEvent("closeEditModal"));
|
||||
window.dispatchEvent(new CustomEvent("saveSuccessInModal"));
|
||||
window.dispatchEvent(new CustomEvent("repeaterSaveComplete"));
|
||||
window.dispatchEvent(new CustomEvent("refreshCardDisplay"));
|
||||
window.dispatchEvent(new CustomEvent("refreshTableData"));
|
||||
window.dispatchEvent(new CustomEvent("saveSuccess"));
|
||||
window.dispatchEvent(new CustomEvent("closeScreenModal"));
|
||||
```
|
||||
|
||||
문자열 기반이라 AI가 이벤트 흐름을 추적할 수 없다. 어떤 이벤트가 어디서 발생하고 어디서 수신되는지 **정적 분석이 불가능**하다.
|
||||
|
||||
### 2.3. 영향
|
||||
|
||||
- AI가 파일 하나를 읽으면 다른 파일의 맥락을 잊음
|
||||
- 함수 시그니처를 추론하지 못하고 잘못된 파라미터를 넣음
|
||||
- 이벤트 기반 로직을 이해하지 못해 부정확한 코드 생성
|
||||
|
||||
---
|
||||
|
||||
## 3. 문제 2: 컴포넌트가 일관되지 않게 생성됨
|
||||
|
||||
### 3.1. 증상
|
||||
|
||||
- 새 컴포넌트를 만들 때마다 구조가 다름
|
||||
- Config 패널의 UI 패턴이 컴포넌트마다 제각각
|
||||
- 같은 기능인데 어떤 컴포넌트는 동작하고 어떤 컴포넌트는 안 됨
|
||||
|
||||
### 3.2. 원인 분석
|
||||
|
||||
#### 컴포넌트 수량과 중복
|
||||
|
||||
현재 등록된 컴포넌트 디렉토리: **81개**
|
||||
|
||||
이 중 V2와 레거시가 병존하는 **중복 쌍**:
|
||||
|
||||
| V2 버전 | 레거시 버전 | 기능 |
|
||||
|---------|------------|------|
|
||||
| `v2-table-list` (6,867줄) | `table-list` (6,829줄) | 테이블 |
|
||||
| `v2-button-primary` (1,524줄) | `button-primary` | 버튼 |
|
||||
| `v2-card-display` | `card-display` | 카드 표시 |
|
||||
| `v2-aggregation-widget` | `aggregation-widget` | 집계 위젯 |
|
||||
| `v2-file-upload` | `file-upload` | 파일 업로드 |
|
||||
| `v2-split-panel-layout` | `split-panel-layout` | 분할 패널 |
|
||||
| `v2-section-card` | `section-card` | 섹션 카드 |
|
||||
| `v2-section-paper` | `section-paper` | 섹션 페이퍼 |
|
||||
| `v2-category-manager` | `category-manager` | 카테고리 |
|
||||
| `v2-repeater` | `repeater-field-group` | 리피터 |
|
||||
| `v2-pivot-grid` | `pivot-grid` | 피벗 그리드 |
|
||||
| `v2-rack-structure` | `rack-structure` | 랙 구조 |
|
||||
| `v2-repeat-container` | `repeat-container` | 반복 컨테이너 |
|
||||
|
||||
**13쌍이 중복** 존재. `v2-table-list`와 `table-list`는 각각 6,800줄 이상으로, 거의 같은 코드가 두 벌 있다.
|
||||
|
||||
#### 패턴은 있지만 강제되지 않음
|
||||
|
||||
컴포넌트 표준 구조:
|
||||
```
|
||||
v2-example/
|
||||
├── index.ts # createComponentDefinition()
|
||||
├── ExampleRenderer.tsx # AutoRegisteringComponentRenderer 상속
|
||||
├── ExampleComponent.tsx # 실제 UI
|
||||
├── ExampleConfigPanel.tsx # 설정 패널 (선택)
|
||||
└── types.ts # ExampleConfig extends ComponentConfig
|
||||
```
|
||||
|
||||
이 패턴을 **문서(`.cursor/rules/component-development-guide.mdc`)에서 설명**하고 있지만:
|
||||
|
||||
1. **런타임 검증 없음**: `createComponentDefinition()`이 ID 형식만 검증, 나머지는 자유
|
||||
2. **Config 타입이 `any`**: `ComponentConfig = { [key: string]: any }` → 아무 값이나 들어감
|
||||
3. **테스트 0개**: 전체 프론트엔드에 테스트 파일 **1개** (`buttonDataflowPerformance.test.ts`), 컴포넌트 테스트는 **0개**
|
||||
4. **스캐폴딩 도구 없음**: 수동으로 파일을 만들고 index.ts에 import를 추가해야 함
|
||||
|
||||
#### 컴포넌트 간 복잡도 격차
|
||||
|
||||
| 분류 | 예시 | 줄 수 | 외부 의존 | Error Boundary |
|
||||
|------|------|-------|-----------|----------------|
|
||||
| 단순 표시형 | `v2-text-display` | ~100줄 | 거의 없음 | 없음 |
|
||||
| 입력형 | `v2-input` | ~500줄 | formData, eventBus | 없음 |
|
||||
| 버튼 | `v2-button-primary` | 1,524줄 | buttonActions, apiClient, context, eventBus, modalDataStore | 있음 |
|
||||
| 테이블 | `v2-table-list` | 6,867줄 | 거의 모든 것 | 있음 |
|
||||
|
||||
100줄짜리와 6,867줄짜리가 같은 "컴포넌트"로 취급된다. AI에게 "컴포넌트 만들어"라고 하면 어떤 수준으로 만들어야 하는지 기준이 없다.
|
||||
|
||||
#### POP 컴포넌트는 완전 별도 시스템
|
||||
|
||||
```
|
||||
frontend/lib/registry/
|
||||
├── ComponentRegistry.ts # 웹 컴포넌트 레지스트리
|
||||
├── PopComponentRegistry.ts # POP 컴포넌트 레지스트리 (별도 인터페이스)
|
||||
```
|
||||
|
||||
같은 "컴포넌트"인데 등록 방식, 인터페이스, 설정 구조가 완전히 다르다.
|
||||
|
||||
### 3.3. 영향
|
||||
|
||||
- 새 컴포넌트를 만들 때 "어떤 컴포넌트를 참고해야 하는지" 불명확
|
||||
- AI가 참조하는 컴포넌트에 따라 결과물이 달라짐
|
||||
- Config 구조가 제각각이라 설정 패널 UI도 불일치
|
||||
|
||||
---
|
||||
|
||||
## 4. 문제 3: 코드 수정 시 다른 곳에 사이드 이펙트 발생
|
||||
|
||||
### 4.1. 증상
|
||||
|
||||
- 저장 로직 수정했더니 다른 화면에서 저장이 안 됨
|
||||
- 테이블 관련 코드 수정했더니 모달에서 특정 기능이 깨짐
|
||||
- 리피터 수정했더니 버튼 동작이 달라짐
|
||||
|
||||
### 4.2. 원인 분석
|
||||
|
||||
#### 원인 A: window 전역 상태 오염
|
||||
|
||||
코드베이스 전체에서 `window.__*` 패턴 사용: **8개 파일, 32회 참조**
|
||||
|
||||
| 전역 변수 | 정의 위치 | 사용 위치 | 위험도 |
|
||||
|-----------|-----------|-----------|--------|
|
||||
| `window.__v2RepeaterInstances` | `V2Repeater.tsx` (220줄) | `EditModal.tsx`, `buttonActions.ts` (4곳) | **높음** |
|
||||
| `window.__relatedButtonsTargetTables` | `RelatedDataButtonsComponent.tsx` (25줄) | `v2-table-list`, `table-list`, `buttonActions.ts` | **높음** |
|
||||
| `window.__relatedButtonsSelectedData` | `RelatedDataButtonsComponent.tsx` (51줄) | `buttonActions.ts` (3113줄) | **높음** |
|
||||
| `window.__unifiedRepeaterInstances` | `UnifiedRepeater.tsx` (110줄) | `UnifiedRepeater.tsx` | 중간 |
|
||||
| `window.__AUTH_LOG` | `authLogger.ts` | 디버깅용 | 낮음 |
|
||||
|
||||
**사이드 이펙트 시나리오 예시**:
|
||||
|
||||
```
|
||||
1. V2Repeater 마운트 → window.__v2RepeaterInstances에 등록
|
||||
2. EditModal이 저장 시 → window.__v2RepeaterInstances 체크
|
||||
3. 만약 Repeater가 언마운트 타이밍에 늦게 정리되면?
|
||||
→ EditModal은 "리피터가 있다"고 판단
|
||||
→ 리피터 저장 로직 실행
|
||||
→ 실제로는 리피터 데이터 없음
|
||||
→ 저장 실패 또는 빈 데이터 저장
|
||||
```
|
||||
|
||||
#### 원인 B: 이벤트 스파게티
|
||||
|
||||
`window.dispatchEvent(new CustomEvent(...))` 사용: **43개 파일, 총 120회 이상**
|
||||
|
||||
주요 이벤트와 발신/수신 관계:
|
||||
|
||||
```
|
||||
[refreshTable 이벤트]
|
||||
발신 (8곳):
|
||||
- buttonActions.ts (5회)
|
||||
- BomItemEditorComponent.tsx
|
||||
- SelectedItemsDetailInputComponent.tsx
|
||||
- BomTreeComponent.tsx (2회)
|
||||
- ButtonPrimaryComponent.tsx (레거시)
|
||||
- ScreenModal.tsx (2회)
|
||||
- InteractiveScreenViewerDynamic.tsx
|
||||
|
||||
수신 (5곳):
|
||||
- v2-table-list/TableListComponent.tsx
|
||||
- table-list/TableListComponent.tsx
|
||||
- SplitPanelLayoutComponent.tsx
|
||||
- InteractiveScreenViewerDynamic.tsx
|
||||
- InteractiveScreenViewer.tsx
|
||||
```
|
||||
|
||||
```
|
||||
[closeEditModal 이벤트]
|
||||
발신 (4곳):
|
||||
- buttonActions.ts (4회)
|
||||
|
||||
수신 (2곳):
|
||||
- EditModal.tsx
|
||||
- screens/[screenId]/page.tsx
|
||||
```
|
||||
|
||||
```
|
||||
[beforeFormSave 이벤트]
|
||||
수신 (6곳):
|
||||
- V2Input.tsx
|
||||
- V2Repeater.tsx
|
||||
- BomItemEditorComponent.tsx
|
||||
- SelectedItemsDetailInputComponent.tsx
|
||||
- UniversalFormModalComponent.tsx
|
||||
- V2FormContext.tsx
|
||||
```
|
||||
|
||||
**문제**: 이벤트 이름이 **문자열 상수**이고, 발신과 수신이 **타입으로 연결되지 않음**.
|
||||
`refreshTable` 이벤트를 `refreshTableData`로 오타내도 컴파일 에러 없이 런타임에서만 발견된다.
|
||||
|
||||
#### 원인 C: 이중/삼중 이벤트 시스템
|
||||
|
||||
동시에 3개의 이벤트 시스템이 공존:
|
||||
|
||||
| 시스템 | 위치 | 방식 | 타입 안전 |
|
||||
|--------|------|------|-----------|
|
||||
| `window.dispatchEvent` | 전역 | CustomEvent 문자열 | 없음 |
|
||||
| `v2EventBus` | `lib/v2-core/events/EventBus.ts` | 타입 기반 pub/sub | 있음 |
|
||||
| `LegacyEventAdapter` | `lib/v2-core/adapters/LegacyEventAdapter.ts` | 1번↔2번 브릿지 | 부분적 |
|
||||
|
||||
어떤 컴포넌트는 `window.dispatchEvent`를 쓰고, 어떤 컴포넌트는 `v2EventBus`를 쓰고, 또 어떤 컴포넌트는 둘 다 쓴다. **같은 이벤트가 두 시스템에서 동시에 발생**할 수 있어 예측 불가능한 동작이 발생한다.
|
||||
|
||||
#### 원인 D: SplitPanelContext 이름 충돌
|
||||
|
||||
같은 이름의 Context가 2개 존재:
|
||||
|
||||
| 위치 | 용도 | 제공하는 것 |
|
||||
|------|------|------------|
|
||||
| `frontend/contexts/SplitPanelContext.tsx` | 데이터 전달 | `selectedLeftData`, `transfer()`, `registerReceiver()` |
|
||||
| `frontend/lib/registry/components/split-panel-layout/SplitPanelContext.tsx` | 리사이즈/좌표 | `getAdjustedX()`, `dividerX`, `leftWidthPercent` |
|
||||
|
||||
import 경로에 따라 **완전히 다른 Context**를 가져온다. AI가 자동완성으로 잘못된 Context를 import하면 런타임에 `undefined` 에러가 발생한다.
|
||||
|
||||
#### 원인 E: buttonActions.ts - 7,609줄의 신(God) 파일
|
||||
|
||||
이 파일 하나가 다음 기능을 전부 담당:
|
||||
|
||||
- 저장 (INSERT/UPDATE/DELETE)
|
||||
- 모달 열기/닫기
|
||||
- 리피터 데이터 수집
|
||||
- 테이블 새로고침
|
||||
- 파일 업로드
|
||||
- 외부 API 호출
|
||||
- 화면 전환
|
||||
- 데이터 검증
|
||||
- 이벤트 발송 (33회)
|
||||
- window 전역 상태 읽기 (5회)
|
||||
|
||||
**이 파일의 한 줄을 수정하면, 위의 모든 기능이 영향을 받을 수 있다.**
|
||||
|
||||
#### 원인 F: 레거시-V2 코드 동시 존재
|
||||
|
||||
```
|
||||
v2-table-list/TableListComponent.tsx (6,867줄)
|
||||
table-list/TableListComponent.tsx (6,829줄)
|
||||
```
|
||||
|
||||
거의 같은 코드가 두 벌. 한쪽을 수정하면 다른 쪽은 수정 안 되어 동작이 달라진다.
|
||||
또한 두 컴포넌트가 **같은 전역 이벤트를 수신**하므로, 한 화면에 둘 다 있으면 이중으로 반응할 수 있다.
|
||||
|
||||
#### 원인 G: Error Boundary 미적용
|
||||
|
||||
| 컴포넌트 | Error Boundary |
|
||||
|----------|----------------|
|
||||
| `v2-button-primary` | 있음 |
|
||||
| `v2-table-list` | 있음 |
|
||||
| `v2-repeater` | 있음 |
|
||||
| `v2-input` | **없음** |
|
||||
| `v2-select` | **없음** |
|
||||
| `v2-card-display` | **없음** |
|
||||
| `v2-text-display` | **없음** |
|
||||
| 기타 대부분 | **없음** |
|
||||
|
||||
Error Boundary가 없는 컴포넌트에서 에러가 발생하면, **상위 컴포넌트까지 전파**되어 화면 전체가 깨진다.
|
||||
|
||||
### 4.3. 사이드 이펙트 발생 위험 지도
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ buttonActions.ts │
|
||||
│ (7,609줄) │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ 저장 로직 │ │ 모달 로직 │ │ 이벤트 │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||
└───────┼──────────────┼─────────────┼─────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────────┐ ┌──────────┐ ┌─────────────────┐
|
||||
│ window.__v2 │ │EditModal │ │ CustomEvent │
|
||||
│ RepeaterInst │ │(1,648줄) │ │ "refreshTable" │
|
||||
│ ances │ │ │ │ "closeEditModal" │
|
||||
└──────┬───────┘ └────┬─────┘ │ "saveSuccess" │
|
||||
│ │ └───────┬─────────┘
|
||||
▼ │ │
|
||||
┌──────────────┐ │ ┌──────▼───────┐
|
||||
│ V2Repeater │◄─────┘ │ TableList │
|
||||
│ (1,442줄) │ │ (6,867줄) │
|
||||
└──────────────┘ │ + 레거시 │
|
||||
│ (6,829줄) │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
**위 그래프에서 어디를 수정하든 화살표를 따라 다른 곳에 영향이 전파된다.**
|
||||
|
||||
---
|
||||
|
||||
## 5. 근본 원인 종합
|
||||
|
||||
세 가지 문제의 근본 원인은 하나다: **경계(Boundary)가 없는 아키텍처**
|
||||
|
||||
| 근본 원인 | 문제 1 영향 | 문제 2 영향 | 문제 3 영향 |
|
||||
|-----------|-------------|-------------|-------------|
|
||||
| 거대 파일 (God File) | AI 컨텍스트 소모 | 참조할 기준 불명확 | 수정 영향 범위 광범위 |
|
||||
| `any` 타입 남발 | AI 타입 추론 불가 | Config 검증 없음 | 런타임 에러 |
|
||||
| 문자열 이벤트 | AI 이벤트 흐름 추적 불가 | 이벤트 패턴 불일치 | 이벤트 누락/오타 |
|
||||
| window 전역 상태 | AI 상태 추적 불가 | 컴포넌트 간 의존 증가 | 상태 오염 |
|
||||
| 테스트 부재 (0개) | 변경 검증 불가 | 컴포넌트 계약 불명 | 사이드 이펙트 감지 불가 |
|
||||
| 레거시-V2 중복 (13쌍) | AI 혼동 | 어느 쪽을 기준으로? | 한쪽만 수정 시 불일치 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 개선 계획
|
||||
|
||||
### Phase 1: 즉시 효과 (1~2주) - 안전장치 설치
|
||||
|
||||
#### 1-1. 이벤트 이름 상수화
|
||||
|
||||
**현재**:
|
||||
```typescript
|
||||
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||
```
|
||||
|
||||
**개선**:
|
||||
```typescript
|
||||
// frontend/lib/constants/events.ts
|
||||
export const EVENTS = {
|
||||
REFRESH_TABLE: "refreshTable",
|
||||
CLOSE_EDIT_MODAL: "closeEditModal",
|
||||
SAVE_SUCCESS: "saveSuccess",
|
||||
SAVE_SUCCESS_IN_MODAL: "saveSuccessInModal",
|
||||
REPEATER_SAVE_COMPLETE: "repeaterSaveComplete",
|
||||
REFRESH_CARD_DISPLAY: "refreshCardDisplay",
|
||||
REFRESH_TABLE_DATA: "refreshTableData",
|
||||
CLOSE_SCREEN_MODAL: "closeScreenModal",
|
||||
BEFORE_FORM_SAVE: "beforeFormSave",
|
||||
} as const;
|
||||
|
||||
// 사용
|
||||
window.dispatchEvent(new CustomEvent(EVENTS.REFRESH_TABLE));
|
||||
```
|
||||
|
||||
**효과**: 오타 방지, AI가 이벤트 흐름 추적 가능, IDE 자동완성 지원
|
||||
**위험도**: 낮음 (기능 변경 없음, 리팩토링만)
|
||||
**소요 예상**: 2~3시간
|
||||
|
||||
#### 1-2. window 전역 변수 타입 선언
|
||||
|
||||
**현재**: `window.__v2RepeaterInstances`를 사용하지만 타입 선언 없음
|
||||
|
||||
**개선**:
|
||||
```typescript
|
||||
// frontend/types/global.d.ts
|
||||
declare global {
|
||||
interface Window {
|
||||
__v2RepeaterInstances?: Set<string>;
|
||||
__unifiedRepeaterInstances?: Set<string>;
|
||||
__relatedButtonsTargetTables?: Set<string>;
|
||||
__relatedButtonsSelectedData?: {
|
||||
tableName: string;
|
||||
selectedRows: any[];
|
||||
};
|
||||
__AUTH_LOG?: { show: () => void };
|
||||
__COMPONENT_REGISTRY__?: Map<string, any>;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**효과**: 타입 안전성 확보, AI가 전역 상태 구조 이해 가능
|
||||
**위험도**: 낮음 (타입 선언만, 런타임 변경 없음)
|
||||
**소요 예상**: 1시간
|
||||
|
||||
#### 1-3. ComponentConfig에 제네릭 타입 적용
|
||||
|
||||
**현재**:
|
||||
```typescript
|
||||
export interface ComponentConfig {
|
||||
[key: string]: any;
|
||||
}
|
||||
```
|
||||
|
||||
**개선**:
|
||||
```typescript
|
||||
export interface ComponentConfig {
|
||||
[key: string]: unknown; // any → unknown으로 변경하여 타입 체크 강제
|
||||
}
|
||||
|
||||
// 각 컴포넌트에서
|
||||
export interface ButtonPrimaryConfig extends ComponentConfig {
|
||||
text: string; // 구체적 타입
|
||||
action: ButtonAction; // 구체적 타입
|
||||
variant?: "default" | "destructive" | "outline";
|
||||
}
|
||||
```
|
||||
|
||||
**효과**: 잘못된 config 값 사전 차단
|
||||
**위험도**: 중간 (기존 `any` 사용처에서 타입 에러 발생 가능, 점진적 적용 필요)
|
||||
**소요 예상**: 3~5일 (점진적)
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 구조 개선 (2~4주) - 핵심 분리
|
||||
|
||||
#### 2-1. buttonActions.ts 분할
|
||||
|
||||
**현재**: 7,609줄, 1개 파일
|
||||
|
||||
**개선 목표**: 도메인별 분리
|
||||
|
||||
```
|
||||
frontend/lib/actions/
|
||||
├── index.ts # re-export
|
||||
├── types.ts # 공통 타입
|
||||
├── saveActions.ts # INSERT/UPDATE 저장 로직
|
||||
├── deleteActions.ts # DELETE 로직
|
||||
├── modalActions.ts # 모달 열기/닫기
|
||||
├── tableActions.ts # 테이블 새로고침, 데이터 조작
|
||||
├── repeaterActions.ts # 리피터 데이터 수집/저장
|
||||
├── fileActions.ts # 파일 업로드/다운로드
|
||||
├── navigationActions.ts # 화면 전환
|
||||
├── validationActions.ts # 데이터 검증
|
||||
└── externalActions.ts # 외부 API 호출
|
||||
```
|
||||
|
||||
**효과**:
|
||||
- 저장 로직 수정 시 `saveActions.ts`만 영향
|
||||
- AI가 관련 파일만 읽으면 됨 (7,600줄 → 평균 500줄)
|
||||
- import 관계로 의존성 명확화
|
||||
|
||||
**위험도**: 높음 (가장 많이 사용되는 파일, 신중한 분리 필요)
|
||||
**소요 예상**: 1~2주
|
||||
|
||||
#### 2-2. 이벤트 시스템 통일
|
||||
|
||||
**현재**: 3개 시스템 공존 (window CustomEvent, v2EventBus, LegacyEventAdapter)
|
||||
|
||||
**개선**:
|
||||
```typescript
|
||||
// v2EventBus로 통일, 타입 안전한 이벤트 정의
|
||||
interface EventMap {
|
||||
"table:refresh": { tableId?: string };
|
||||
"modal:close": { modalId: string };
|
||||
"form:save": { formData: Record<string, any> };
|
||||
"form:saveComplete": { success: boolean; message?: string };
|
||||
"repeater:saveComplete": { repeaterId: string };
|
||||
}
|
||||
|
||||
// 사용
|
||||
v2EventBus.emit("table:refresh", { tableId: "order_table" });
|
||||
v2EventBus.on("table:refresh", (data) => { /* data.tableId 타입 안전 */ });
|
||||
```
|
||||
|
||||
**마이그레이션 전략**:
|
||||
1. `v2EventBus`에 `EventMap` 타입 추가
|
||||
2. 새 코드는 반드시 `v2EventBus` 사용
|
||||
3. 기존 `window.dispatchEvent` → `v2EventBus`로 점진적 교체
|
||||
4. `LegacyEventAdapter`에서 양방향 브릿지 유지 (과도기)
|
||||
5. 모든 교체 완료 후 `LegacyEventAdapter` 제거
|
||||
|
||||
**효과**: 이벤트 흐름 추적 가능, 타입 안전, 디버깅 용이
|
||||
**위험도**: 중간 (과도기 브릿지로 안전하게 전환)
|
||||
**소요 예상**: 2~3주
|
||||
|
||||
#### 2-3. window 전역 상태 → Zustand 스토어 전환
|
||||
|
||||
**현재**:
|
||||
```typescript
|
||||
window.__v2RepeaterInstances = new Set();
|
||||
window.__relatedButtonsSelectedData = { tableName, selectedRows };
|
||||
```
|
||||
|
||||
**개선**:
|
||||
```typescript
|
||||
// frontend/lib/stores/componentInstanceStore.ts
|
||||
import { create } from "zustand";
|
||||
|
||||
interface ComponentInstanceState {
|
||||
repeaterInstances: Set<string>;
|
||||
relatedButtonsTargetTables: Set<string>;
|
||||
relatedButtonsSelectedData: {
|
||||
tableName: string;
|
||||
selectedRows: any[];
|
||||
} | null;
|
||||
|
||||
registerRepeater: (key: string) => void;
|
||||
unregisterRepeater: (key: string) => void;
|
||||
setRelatedData: (data: { tableName: string; selectedRows: any[] }) => void;
|
||||
clearRelatedData: () => void;
|
||||
}
|
||||
|
||||
export const useComponentInstanceStore = create<ComponentInstanceState>((set) => ({
|
||||
repeaterInstances: new Set(),
|
||||
relatedButtonsTargetTables: new Set(),
|
||||
relatedButtonsSelectedData: null,
|
||||
|
||||
registerRepeater: (key) =>
|
||||
set((state) => {
|
||||
const next = new Set(state.repeaterInstances);
|
||||
next.add(key);
|
||||
return { repeaterInstances: next };
|
||||
}),
|
||||
unregisterRepeater: (key) =>
|
||||
set((state) => {
|
||||
const next = new Set(state.repeaterInstances);
|
||||
next.delete(key);
|
||||
return { repeaterInstances: next };
|
||||
}),
|
||||
setRelatedData: (data) => set({ relatedButtonsSelectedData: data }),
|
||||
clearRelatedData: () => set({ relatedButtonsSelectedData: null }),
|
||||
}));
|
||||
```
|
||||
|
||||
**효과**:
|
||||
- 상태 변경 추적 가능 (Zustand devtools)
|
||||
- 컴포넌트 리렌더링 최적화 (selector 사용)
|
||||
- window 오염 제거
|
||||
|
||||
**위험도**: 중간
|
||||
**소요 예상**: 1주
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 품질 강화 (4~8주) - 예방 체계
|
||||
|
||||
#### 3-1. 레거시 컴포넌트 제거
|
||||
|
||||
**목표**: V2-레거시 중복 13쌍 → V2만 유지
|
||||
|
||||
**전략**:
|
||||
1. 각 중복 쌍에서 레거시 사용처 검색
|
||||
2. 사용처가 없는 레거시 컴포넌트 즉시 제거
|
||||
3. 사용처가 있는 경우 V2로 교체 후 제거
|
||||
4. `components/index.ts`에서 import 제거
|
||||
|
||||
**효과**: 코드베이스 ~15,000줄 감소, AI 혼동 제거
|
||||
**소요 예상**: 2~3주
|
||||
|
||||
#### 3-2. 컴포넌트 스캐폴딩 CLI
|
||||
|
||||
**목표**: `npx create-v2-component my-component` 실행 시 표준 구조 자동 생성
|
||||
|
||||
```bash
|
||||
$ npx create-v2-component my-widget --category data
|
||||
|
||||
생성 완료:
|
||||
frontend/lib/registry/components/v2-my-widget/
|
||||
├── index.ts # 자동 생성
|
||||
├── MyWidgetRenderer.tsx # 자동 생성
|
||||
├── MyWidgetComponent.tsx # 템플릿
|
||||
├── MyWidgetConfigPanel.tsx # 템플릿
|
||||
└── types.ts # Config 인터페이스 템플릿
|
||||
|
||||
components/index.ts에 import 자동 추가 완료
|
||||
```
|
||||
|
||||
**효과**: 컴포넌트 구조 100% 일관성 보장
|
||||
**소요 예상**: 3~5일
|
||||
|
||||
#### 3-3. 핵심 컴포넌트 통합 테스트
|
||||
|
||||
**목표**: 사이드 이펙트 감지용 테스트 작성
|
||||
|
||||
```typescript
|
||||
// __tests__/integration/save-flow.test.ts
|
||||
describe("저장 플로우", () => {
|
||||
it("버튼 저장 → refreshTable 이벤트 발생", async () => {
|
||||
const listener = vi.fn();
|
||||
v2EventBus.on("table:refresh", listener);
|
||||
|
||||
await executeSaveAction({ tableName: "test_table", data: mockData });
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("리피터가 있을 때 저장 → 리피터 데이터도 포함", async () => {
|
||||
useComponentInstanceStore.getState().registerRepeater("detail_table");
|
||||
|
||||
const result = await executeSaveAction({ tableName: "master_table", data: mockData });
|
||||
|
||||
expect(result.repeaterDataCollected).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**대상**: 저장/삭제/모달/리피터 흐름 (가장 빈번하게 깨지는 부분)
|
||||
**효과**: 코드 수정 후 즉시 사이드 이펙트 감지
|
||||
**소요 예상**: 2~3주
|
||||
|
||||
#### 3-4. SplitPanelContext 통합
|
||||
|
||||
**목표**: 이름이 같은 2개의 Context → 1개로 통합 또는 명확히 분리
|
||||
|
||||
**방안 A - 통합**:
|
||||
```typescript
|
||||
// frontend/contexts/SplitPanelContext.tsx에 통합
|
||||
interface SplitPanelContextValue {
|
||||
// 데이터 전달 (기존 contexts/ 버전)
|
||||
selectedLeftData: any;
|
||||
transfer: (data: any) => void;
|
||||
registerReceiver: (handler: (data: any) => void) => void;
|
||||
// 리사이즈 (기존 components/ 버전)
|
||||
getAdjustedX: (x: number) => number;
|
||||
dividerX: number;
|
||||
leftWidthPercent: number;
|
||||
}
|
||||
```
|
||||
|
||||
**방안 B - 명확 분리**:
|
||||
```typescript
|
||||
// SplitPanelDataContext.tsx → 데이터 전달용
|
||||
// SplitPanelResizeContext.tsx → 리사이즈용
|
||||
```
|
||||
|
||||
**효과**: import 혼동 제거
|
||||
**소요 예상**: 2~3일
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: 장기 개선 (8주+) - 아키텍처 전환
|
||||
|
||||
#### 4-1. 거대 컴포넌트 분할
|
||||
|
||||
| 대상 파일 | 현재 줄 수 | 분할 목표 |
|
||||
|-----------|-----------|-----------|
|
||||
| `v2-table-list/TableListComponent.tsx` | 6,867줄 | 훅 분리, 렌더링 분리 → 각 1,000줄 이하 |
|
||||
| `ScreenDesigner.tsx` | 7,559줄 | 패널별 분리 → 각 1,500줄 이하 |
|
||||
| `EditModal.tsx` | 1,648줄 | 저장/폼/UI 분리 → 각 500줄 이하 |
|
||||
| `ButtonPrimaryComponent.tsx` | 1,524줄 | 액션 실행 분리 → 각 500줄 이하 |
|
||||
|
||||
#### 4-2. Config 스키마 검증 (Zod)
|
||||
|
||||
```typescript
|
||||
// v2-button-primary/types.ts
|
||||
import { z } from "zod";
|
||||
|
||||
export const ButtonPrimaryConfigSchema = z.object({
|
||||
text: z.string().default("버튼"),
|
||||
variant: z.enum(["default", "destructive", "outline", "secondary", "ghost"]).default("default"),
|
||||
action: z.object({
|
||||
type: z.enum(["save", "delete", "navigate", "custom"]),
|
||||
targetTable: z.string().optional(),
|
||||
// ...
|
||||
}),
|
||||
});
|
||||
|
||||
export type ButtonPrimaryConfig = z.infer<typeof ButtonPrimaryConfigSchema>;
|
||||
```
|
||||
|
||||
`createComponentDefinition()`에서 스키마 검증을 강제하여 잘못된 config가 등록 시점에 차단되도록 한다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 우선순위 로드맵
|
||||
|
||||
### 즉시 (이번 주)
|
||||
|
||||
- [ ] **1-1**: 이벤트 이름 상수 파일 생성 (`frontend/lib/constants/events.ts`)
|
||||
- [ ] **1-2**: window 전역 변수 타입 선언 (`frontend/types/global.d.ts`)
|
||||
|
||||
### 단기 (1~2주)
|
||||
|
||||
- [ ] **2-3**: window 전역 상태 → Zustand 스토어 전환
|
||||
- [ ] **1-3**: ComponentConfig `any` → `unknown` 점진적 적용
|
||||
|
||||
### 중기 (2~4주)
|
||||
|
||||
- [ ] **2-1**: buttonActions.ts 분할 (7,609줄 → 도메인별)
|
||||
- [ ] **2-2**: 이벤트 시스템 통일 (v2EventBus 기반)
|
||||
- [ ] **3-4**: SplitPanelContext 통합/분리
|
||||
|
||||
### 장기 (4~8주)
|
||||
|
||||
- [ ] **3-1**: 레거시 컴포넌트 13쌍 제거
|
||||
- [ ] **3-2**: 컴포넌트 스캐폴딩 CLI
|
||||
- [ ] **3-3**: 핵심 플로우 통합 테스트
|
||||
- [ ] **4-1**: 거대 컴포넌트 분할
|
||||
- [ ] **4-2**: Config 스키마 Zod 검증
|
||||
|
||||
---
|
||||
|
||||
## 부록: 수치 요약
|
||||
|
||||
| 지표 | 현재 | 목표 |
|
||||
|------|------|------|
|
||||
| 최대 파일 크기 | 7,609줄 | 1,500줄 이하 |
|
||||
| 컴포넌트 수 | 81개 (13쌍 중복) | ~55개 (중복 제거) |
|
||||
| window 전역 변수 | 5개 | 0개 |
|
||||
| 이벤트 시스템 | 3개 공존 | 1개 (v2EventBus) |
|
||||
| 테스트 파일 | 1개 | 핵심 플로우 최소 10개 |
|
||||
| `any` 타입 사용 (핵심 인터페이스) | 3곳 | 0곳 |
|
||||
| SplitPanelContext 중복 | 2개 | 1개 (또는 명확 분리) |
|
||||
|
|
@ -21,6 +21,7 @@ import {
|
|||
ChevronsUpDown,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { ResponsiveSplitPanel } from "@/components/common/ResponsiveSplitPanel";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -1463,9 +1464,8 @@ export default function TableManagementPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full flex-1 gap-6 overflow-hidden">
|
||||
{/* 좌측 사이드바: 테이블 목록 (20%) */}
|
||||
<div className="flex h-full w-[20%] flex-col border-r pr-4">
|
||||
<ResponsiveSplitPanel
|
||||
left={
|
||||
<div className="flex h-full flex-col space-y-4">
|
||||
{/* 검색 */}
|
||||
<div className="flex-shrink-0">
|
||||
|
|
@ -1584,10 +1584,8 @@ export default function TableManagementPage() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측 메인 영역: 컬럼 타입 관리 (80%) */}
|
||||
<div className="flex h-full w-[80%] flex-col overflow-hidden pl-0">
|
||||
}
|
||||
right={
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{!selectedTable ? (
|
||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border">
|
||||
|
|
@ -2171,8 +2169,14 @@ export default function TableManagementPage() {
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
leftTitle="테이블 목록"
|
||||
leftWidth={20}
|
||||
minLeftWidth={10}
|
||||
maxLeftWidth={35}
|
||||
height="100%"
|
||||
className="flex-1 overflow-hidden"
|
||||
/>
|
||||
|
||||
{/* DDL 모달 컴포넌트들 */}
|
||||
{isSuperAdmin && (
|
||||
|
|
|
|||
|
|
@ -8,10 +8,11 @@
|
|||
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useMemo } from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { EmbeddedScreen } from "./EmbeddedScreen";
|
||||
import { Columns2 } from "lucide-react";
|
||||
import { SplitPanelProvider } from "@/contexts/SplitPanelContext";
|
||||
import { ResponsiveSplitPanel } from "@/components/common/ResponsiveSplitPanel";
|
||||
|
||||
interface ScreenSplitPanelProps {
|
||||
screenId?: number;
|
||||
|
|
@ -27,15 +28,6 @@ interface ScreenSplitPanelProps {
|
|||
export function ScreenSplitPanel({ screenId, config, initialFormData, groupedData }: ScreenSplitPanelProps) {
|
||||
// config에서 splitRatio 추출 (기본값 50)
|
||||
const configSplitRatio = config?.splitRatio ?? 50;
|
||||
|
||||
|
||||
// 드래그로 조절 가능한 splitRatio 상태
|
||||
const [splitRatio, setSplitRatio] = useState(configSplitRatio);
|
||||
|
||||
// config.splitRatio가 변경되면 동기화 (설정 패널에서 변경 시)
|
||||
React.useEffect(() => {
|
||||
setSplitRatio(configSplitRatio);
|
||||
}, [configSplitRatio]);
|
||||
|
||||
// 설정 패널에서 오는 간단한 config를 임베딩 설정으로 변환
|
||||
const leftEmbedding = config?.leftScreenId
|
||||
|
|
@ -66,13 +58,6 @@ export function ScreenSplitPanel({ screenId, config, initialFormData, groupedDat
|
|||
}
|
||||
: null;
|
||||
|
||||
/**
|
||||
* 리사이저 드래그 핸들러
|
||||
*/
|
||||
const handleResize = useCallback((newRatio: number) => {
|
||||
setSplitRatio(Math.max(20, Math.min(80, newRatio)));
|
||||
}, []);
|
||||
|
||||
// config가 없는 경우 (디자이너 모드 또는 초기 상태)
|
||||
if (!config) {
|
||||
return (
|
||||
|
|
@ -114,58 +99,32 @@ export function ScreenSplitPanel({ screenId, config, initialFormData, groupedDat
|
|||
linkedFilters={config?.linkedFilters || []}
|
||||
disableAutoDataTransfer={config?.disableAutoDataTransfer ?? false}
|
||||
>
|
||||
<div className="flex h-full">
|
||||
{/* 좌측 패널 */}
|
||||
<div style={{ width: `${splitRatio}%` }} className="h-full flex-shrink-0 overflow-hidden border-r">
|
||||
{hasLeftScreen ? (
|
||||
<ResponsiveSplitPanel
|
||||
left={
|
||||
hasLeftScreen ? (
|
||||
<EmbeddedScreen embedding={leftEmbedding!} position="left" initialFormData={initialFormData} groupedData={groupedData} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center bg-muted/30">
|
||||
<p className="text-muted-foreground text-sm">좌측 화면을 선택하세요</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 리사이저 */}
|
||||
{config?.resizable !== false && (
|
||||
<div
|
||||
className="group bg-border hover:bg-primary/20 relative w-1 flex-shrink-0 cursor-col-resize transition-colors"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startRatio = splitRatio;
|
||||
const containerWidth = e.currentTarget.parentElement!.offsetWidth;
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const deltaX = moveEvent.clientX - startX;
|
||||
const deltaRatio = (deltaX / containerWidth) * 100;
|
||||
handleResize(startRatio + deltaRatio);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
}}
|
||||
>
|
||||
<div className="bg-primary absolute inset-y-0 left-1/2 w-1 -translate-x-1/2 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 우측 패널 */}
|
||||
<div style={{ width: `${100 - splitRatio}%` }} className="h-full flex-shrink-0 overflow-hidden">
|
||||
{hasRightScreen ? (
|
||||
)
|
||||
}
|
||||
right={
|
||||
hasRightScreen ? (
|
||||
<EmbeddedScreen embedding={rightEmbedding!} position="right" initialFormData={initialFormData} groupedData={groupedData} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center bg-muted/30">
|
||||
<p className="text-muted-foreground text-sm">우측 화면을 선택하세요</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
leftTitle="좌측 패널"
|
||||
leftWidth={configSplitRatio}
|
||||
minLeftWidth={20}
|
||||
maxLeftWidth={80}
|
||||
showResizer={config?.resizable !== false}
|
||||
collapsedOnMobile={true}
|
||||
/>
|
||||
</SplitPanelProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
|
||||
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
|
||||
import { GripVertical } from "lucide-react";
|
||||
import { ResponsiveSplitPanel } from "@/components/common/ResponsiveSplitPanel";
|
||||
|
||||
interface CategoryWidgetProps {
|
||||
widgetId?: string;
|
||||
|
|
@ -54,74 +54,24 @@ export function CategoryWidget({ widgetId, tableName, menuObjid, component, ...p
|
|||
columnLabel: string;
|
||||
tableName: string;
|
||||
} | null>(null);
|
||||
|
||||
const [leftWidth, setLeftWidth] = useState(15); // 초기값 15%
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
const handleMouseDown = useCallback(() => {
|
||||
isDraggingRef.current = true;
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (!isDraggingRef.current || !containerRef.current) return;
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const newLeftWidth = ((e.clientX - containerRect.left) / containerRect.width) * 100;
|
||||
|
||||
// 최소 10%, 최대 40%로 제한
|
||||
if (newLeftWidth >= 10 && newLeftWidth <= 40) {
|
||||
setLeftWidth(newLeftWidth);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
isDraggingRef.current = false;
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [handleMouseMove, handleMouseUp]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex h-full min-h-[10px] gap-0">
|
||||
{/* 좌측: 카테고리 컬럼 리스트 */}
|
||||
<div style={{ width: `${leftWidth}%` }} className="pr-3">
|
||||
<ResponsiveSplitPanel
|
||||
left={
|
||||
<CategoryColumnList
|
||||
tableName={tableName}
|
||||
selectedColumn={selectedColumn?.uniqueKey || null}
|
||||
onColumnSelect={(uniqueKey, columnLabel, tableName) => {
|
||||
// uniqueKey는 "테이블명.컬럼명" 형식
|
||||
const columnName = uniqueKey.split('.')[1];
|
||||
setSelectedColumn({ uniqueKey, columnName, columnLabel, tableName });
|
||||
}}
|
||||
menuObjid={effectiveMenuObjid}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 리사이저 */}
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className="group relative flex w-3 cursor-col-resize items-center justify-center border-r hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
</div>
|
||||
|
||||
{/* 우측: 카테고리 값 관리 */}
|
||||
<div style={{ width: `${100 - leftWidth - 1}%` }} className="pl-3">
|
||||
{selectedColumn ? (
|
||||
}
|
||||
right={
|
||||
selectedColumn ? (
|
||||
<CategoryValueManager
|
||||
key={selectedColumn.uniqueKey} // 테이블명.컬럼명으로 컴포넌트 재생성
|
||||
key={selectedColumn.uniqueKey}
|
||||
tableName={selectedColumn.tableName}
|
||||
columnName={selectedColumn.columnName}
|
||||
columnLabel={selectedColumn.columnLabel}
|
||||
|
|
@ -135,9 +85,12 @@ export function CategoryWidget({ widgetId, tableName, menuObjid, component, ...p
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
leftTitle="카테고리 컬럼"
|
||||
leftWidth={15}
|
||||
minLeftWidth={10}
|
||||
maxLeftWidth={40}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue