diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 00000000..7a87d1a0 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "agent-orchestrator": { + "command": "node", + "args": ["/Users/gbpark/ERP-node/mcp-agent-orchestrator/build/index.js"] + } + } +} diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 6e62a541..db5f32ed 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -2266,6 +2266,9 @@ export class TableManagementService { ? `WHERE ${whereConditions.join(" AND ")}` : ""; + // 안전한 테이블명 검증 + const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, ""); + // ORDER BY 조건 구성 let orderClause = ""; if (sortBy) { @@ -2274,13 +2277,16 @@ export class TableManagementService { sortOrder.toLowerCase() === "desc" ? "DESC" : "ASC"; orderClause = `ORDER BY ${safeSortBy} ${safeSortOrder}`; } else { - // sortBy가 없으면 created_date DESC를 기본 정렬로 사용 (신규 데이터가 최상단에 표시) - orderClause = `ORDER BY main.created_date DESC`; + // sortBy가 없으면 created_date 컬럼이 있는 경우에만 기본 정렬 적용 + const hasCreatedDate = await query( + `SELECT 1 FROM information_schema.columns WHERE table_name = $1 AND column_name = 'created_date' LIMIT 1`, + [safeTableName] + ); + if (hasCreatedDate.length > 0) { + orderClause = `ORDER BY main.created_date DESC`; + } } - // 안전한 테이블명 검증 - const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, ""); - // 전체 개수 조회 (main 별칭 추가 - buildWhereClause가 main. 접두사를 사용하므로 필요) const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} main ${whereClause}`; const countResult = await query(countQuery, searchValues); @@ -3188,10 +3194,13 @@ export class TableManagementService { } // ORDER BY 절 구성 - // sortBy가 없으면 created_date DESC를 기본 정렬로 사용 (신규 데이터가 최상단에 표시) + // sortBy가 없으면 created_date 컬럼이 있는 경우에만 기본 정렬 적용 + const hasCreatedDateColumn = selectColumns.includes("created_date"); const orderBy = options.sortBy ? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}` - : `main."created_date" DESC`; + : hasCreatedDateColumn + ? `main."created_date" DESC` + : ""; // 페이징 계산 const offset = (options.page - 1) * options.size; @@ -3401,6 +3410,7 @@ export class TableManagementService { const entitySearchColumns: string[] = []; // Entity 조인 쿼리 생성하여 별칭 매핑 얻기 + const hasCreatedDateForSearch = selectColumns.includes("created_date"); const joinQueryResult = entityJoinService.buildJoinQuery( tableName, joinConfigs, @@ -3408,7 +3418,9 @@ export class TableManagementService { "", // WHERE 절은 나중에 추가 options.sortBy ? `main."${options.sortBy}" ${options.sortOrder || "ASC"}` - : `main."created_date" DESC`, + : hasCreatedDateForSearch + ? `main."created_date" DESC` + : undefined, options.size, (options.page - 1) * options.size ); @@ -3594,9 +3606,12 @@ export class TableManagementService { } const whereClause = whereConditions.join(" AND "); + const hasCreatedDateForOrder = selectColumns.includes("created_date"); const orderBy = options.sortBy ? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}` - : `main."created_date" DESC`; + : hasCreatedDateForOrder + ? `main."created_date" DESC` + : ""; // 페이징 계산 const offset = (options.page - 1) * options.size; diff --git a/docs/image-file-storage-guide.md b/docs/image-file-storage-guide.md new file mode 100644 index 00000000..73c99baa --- /dev/null +++ b/docs/image-file-storage-guide.md @@ -0,0 +1,214 @@ +# 이미지/파일 저장 방식 가이드 + +## 개요 + +WACE 솔루션에서 이미지 및 파일은 **attach_file_info 테이블**에 메타데이터를 저장하고, 실제 파일은 **서버 디스크**에 저장하는 이중 구조를 사용합니다. + +--- + +## 1. 데이터 흐름 + +``` +[사용자 업로드] → [백엔드 API] → [디스크 저장] + [DB 메타데이터 저장] + ↓ ↓ + /uploads/COMPANY_7/ attach_file_info 테이블 + 2026/02/06/ (objid, file_path, ...) + 1770346704685_5.png +``` + +### 저장 과정 + +1. 사용자가 파일 업로드 → `POST /api/files/upload` +2. 백엔드가 파일을 디스크에 저장: `/uploads/{company_code}/{YYYY}/{MM}/{DD}/{timestamp}_{filename}` +3. `attach_file_info` 테이블에 메타데이터 INSERT (objid, file_path, target_objid 등) +4. 비즈니스 테이블의 이미지 컬럼에 **파일 objid** 저장 (예: `item_info.image = '433765011963536400'`) + +### 조회 과정 + +1. 비즈니스 테이블에서 이미지 컬럼 값(objid) 로드 +2. `GET /api/files/preview/{objid}` 로 이미지 프리뷰 요청 +3. 백엔드가 `attach_file_info`에서 objid로 파일 정보 조회 +4. 디스크에서 실제 파일을 읽어 응답 + +--- + +## 2. 테이블 구조 + +### attach_file_info (파일 메타데이터) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| objid | numeric | 파일 고유 ID (PK, 큰 숫자) | +| real_file_name | varchar | 원본 파일명 | +| saved_file_name | varchar | 저장된 파일명 (timestamp_원본명) | +| file_path | varchar | 저장 경로 (/uploads/COMPANY_7/2026/02/06/...) | +| file_ext | varchar | 파일 확장자 | +| file_size | numeric | 파일 크기 (bytes) | +| target_objid | varchar | 연결 대상 (아래 패턴 참조) | +| company_code | varchar | 회사 코드 (멀티테넌시) | +| status | varchar | 상태 (ACTIVE, DELETED) | +| writer | varchar | 업로더 ID | +| regdate | timestamp | 등록일시 | +| is_representative | boolean | 대표 이미지 여부 | + +### 비즈니스 테이블 (예: item_info, company_mng) + +이미지 컬럼에 `attach_file_info.objid` 값을 문자열로 저장합니다. + +```sql +-- item_info.image = '433765011963536400' +-- company_mng.company_image = '413276787660035200' +``` + +--- + +## 3. target_objid 패턴 + +`attach_file_info.target_objid`는 파일이 어디에 연결되어 있는지를 나타냅니다. + +| 패턴 | 예시 | 설명 | +|------|------|------| +| 템플릿 모드 | `screen_files:140:comp_z4yffowb:image` | 화면 설계 시 업로드 (screenId:componentId:columnName) | +| 레코드 모드 | `item_info:uuid-xxx:image` | 특정 레코드에 연결 (tableName:recordId:columnName) | + +--- + +## 4. 파일 조회 API + +### GET /api/files/preview/{objid} + +이미지 프리뷰 (공개 접근 허용). + +``` +GET /api/files/preview/433765011963536400 +→ 200 OK (이미지 바이너리) +``` + +**주의: objid를 parseInt()로 변환하면 안 됩니다.** JavaScript의 `Number.MAX_SAFE_INTEGER`(9007199254740991)를 초과하는 큰 숫자이므로 **정밀도 손실**이 발생합니다. 반드시 **문자열**로 전달해야 합니다. + +```typescript +// 잘못된 방법 +const fileRecord = await query("SELECT * FROM attach_file_info WHERE objid = $1", [parseInt(objid)]); +// → parseInt("433765011963536400") = 433765011963536416 (16 차이!) +// → DB에서 찾을 수 없음 → 404 + +// 올바른 방법 +const fileRecord = await query("SELECT * FROM attach_file_info WHERE objid = $1", [objid]); +// → PostgreSQL이 문자열 → numeric 자동 캐스팅 +``` + +### GET /api/files/component-files + +컴포넌트별 파일 목록 조회 (인증 필요). + +``` +GET /api/files/component-files?screenId=149&componentId=comp_z4yffowb&tableName=item_info&recordId=uuid-xxx&columnName=image +``` + +**조회 우선순위:** +1. **데이터 파일**: `target_objid = '{tableName}:{recordId}:{columnName}'` 패턴으로 조회 +2. **템플릿 파일**: `target_objid = 'screen_files:{screenId}:{componentId}:{columnName}'` 패턴으로 조회 +3. **레코드 컬럼 값 조회 (fallback)**: 위 두 방법으로 파일을 찾지 못하면, 비즈니스 테이블의 레코드에서 해당 컬럼 값(파일 objid)을 읽어 직접 조회 + +```sql +-- fallback: 레코드의 image 컬럼에 저장된 objid로 직접 조회 +SELECT "image" FROM "item_info" WHERE id = $1; +-- → '433765011963536400' +SELECT * FROM attach_file_info WHERE objid = '433765011963536400' AND status = 'ACTIVE'; +``` + +--- + +## 5. 프론트엔드 컴포넌트 + +### v2-file-upload (FileUploadComponent.tsx) + +현재 사용되는 V2 파일 업로드 컴포넌트입니다. + +**파일 경로**: `frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx` + +#### 이미지 로드 방식 + +1. **formData의 컬럼 값으로 로드**: `formData[columnName]`에 파일 objid가 있으면 `/api/files/preview/{objid}`로 이미지 표시 +2. **getComponentFiles API로 로드**: target_objid 패턴으로 서버에서 파일 목록 조회 + +#### 상태 관리 + +- `uploadedFiles` state: 현재 표시 중인 파일 목록 +- `localStorage` 백업: `fileUpload_{componentId}_{columnName}` 키로 저장 +- `window.globalFileState`: 전역 파일 상태 (컴포넌트 간 동기화) + +#### 등록/수정 모드 구분 + +- **수정 모드** (isRecordMode=true, recordId 있음): localStorage/서버에서 기존 파일 복원 +- **등록 모드** (isRecordMode=false, recordId 없음): localStorage 복원 스킵, 빈 상태로 시작 +- **단일 폼 화면** (회사정보 등): `formData[columnName]`의 objid 값으로 이미지 자동 로드 + +### file-upload (레거시) + +**파일 경로**: `frontend/lib/registry/components/file-upload/FileUploadComponent.tsx` + +V2MediaRenderer에서 사용하는 레거시 컴포넌트. v2-file-upload와 유사하지만 별도 파일입니다. + +### ImageWidget + +**파일 경로**: `frontend/components/screen/widgets/types/ImageWidget.tsx` + +단순 이미지 표시용 위젯. 파일 업로드 기능은 있으나, `getFullImageUrl()`로 URL을 변환하여 `` 태그로 직접 표시합니다. 파일 관리(목록, 삭제 등) 기능은 없습니다. + +--- + +## 6. 디스크 저장 구조 + +``` +backend-node/uploads/ +├── COMPANY_7/ # 회사별 격리 +│ ├── 2026/ +│ │ ├── 01/ +│ │ │ └── 08/ +│ │ │ └── 1767863580718_img.jpg +│ │ └── 02/ +│ │ └── 06/ +│ │ ├── 1770346704685_5.png +│ │ └── 1770352493105_5.png +├── COMPANY_9/ +│ └── ... +└── company_*/ # 최고 관리자 전용 + └── ... +``` + +--- + +## 7. 수정 이력 (2026-02-06) + +### parseInt 정밀도 손실 수정 + +**파일**: `backend-node/src/controllers/fileController.ts` + +`attach_file_info.objid`는 `numeric` 타입으로 `433765011963536400` 같은 매우 큰 숫자입니다. JavaScript의 `parseInt()`는 `Number.MAX_SAFE_INTEGER`(약 9 * 10^15)를 초과하면 정밀도 손실이 발생합니다. + +| objid (원본) | parseInt 결과 | 차이 | +|:---|:---|:---:| +| 396361999644927100 | 396361999644927104 | -4 | +| 433765011963536400 | 433765011963536384 | +16 | +| 1128460590844245000 | 1128460590844244992 | +8 | + +**수정**: `parseInt(objid)` → `objid` (문자열 직접 전달, 8곳) + +### getComponentFiles fallback 추가 + +**파일**: `backend-node/src/controllers/fileController.ts` + +수정 모달에서 이미지가 안 보이는 문제. `target_objid` 패턴이 일치하지 않을 때, 비즈니스 테이블의 레코드 컬럼 값으로 파일을 직접 조회하는 fallback 로직 추가. + +### v2-file-upload 등록 모드 파일 잔존 방지 + +**파일**: `frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx` + +연속 등록 시 이전 등록의 이미지가 남아있는 문제. `loadComponentFiles`와 fallback 로직에서 등록 모드(recordId 없음)일 때 파일 복원을 스킵하도록 수정. + +### ORDER BY 기본 정렬 추가 + +**파일**: `backend-node/src/services/tableManagementService.ts` + +`sortBy` 파라미터가 없을 때 `ORDER BY created_date DESC`를 기본값으로 적용. 4곳 수정. diff --git a/docs/multi-agent-system-plan.md b/docs/multi-agent-system-plan.md new file mode 100644 index 00000000..46a1df3c --- /dev/null +++ b/docs/multi-agent-system-plan.md @@ -0,0 +1,989 @@ +# Multi-Agent 협업 시스템 설계서 + +> Cursor 에이전트 간 협업을 통한 효율적인 개발 시스템 + +## 목차 + +1. [개요](#개요) +2. [아키텍처](#아키텍처) +3. [에이전트 역할 정의](#에이전트-역할-정의) +4. [통신 프로토콜](#통신-프로토콜) +5. [워크플로우](#워크플로우) +6. [프롬프트 템플릿](#프롬프트-템플릿) +7. [MCP 서버 구현](#mcp-서버-구현) +8. [비용 분석](#비용-분석) +9. [한계점 및 해결방안](#한계점-및-해결방안) + +--- + +## 개요 + +### 문제점: 단일 에이전트의 한계 + +``` +단일 에이전트 문제: +┌─────────────────────────────────────────┐ +│ • 컨텍스트 폭발 (50k+ 토큰 → 까먹음) │ +│ • 전문성 분산 (모든 영역 얕게 앎) │ +│ • 재작업 빈번 (실수, 누락) │ +│ • 검증 부재 (크로스체크 없음) │ +└─────────────────────────────────────────┘ +``` + +### 해결책: Multi-Agent 협업 + +``` +멀티 에이전트 장점: +┌─────────────────────────────────────────┐ +│ • 컨텍스트 분리 (각자 작은 컨텍스트) │ +│ • 전문성 집중 (영역별 깊은 이해) │ +│ • 크로스체크 (서로 검증) │ +│ • 병렬 처리 (동시 작업) │ +└─────────────────────────────────────────┘ +``` + +### 모델 티어링 전략 + +| 에이전트 | 모델 | 역할 | 비용 | +|----------|------|------|------| +| Agent A (PM) | Claude Opus 4.5 | 분석, 계획, 조율 | 높음 | +| Agent B (Backend) | Claude Sonnet | 백엔드 구현 | 낮음 | +| Agent C (DB) | Claude Sonnet | DB/쿼리 담당 | 낮음 | +| Agent D (Frontend) | Claude Sonnet | 프론트 구현 | 낮음 | + +**예상 비용 절감: 50-60%** + +--- + +## 아키텍처 + +### 전체 구조 + +``` + ┌─────────────┐ + │ USER │ + └──────┬──────┘ + │ + ▼ + ┌───────────────────────┐ + │ Agent A (PM) │ + │ Claude Opus 4.5 │ + │ │ + │ • 사용자 의도 파악 │ + │ • 작업 분배 │ + │ • 결과 통합 │ + │ • 품질 검증 │ + └───────────┬───────────┘ + │ + ┌─────────────────┼─────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ + │ Agent B │ │ Agent C │ │ Agent D │ + │ (Backend) │ │ (Database) │ │ (Frontend) │ + │ Sonnet │ │ Sonnet │ │ Sonnet │ + │ │ │ │ │ │ + │ • API 설계/구현 │ │ • 스키마 설계 │ │ • 컴포넌트 구현 │ + │ • 서비스 로직 │ │ • 쿼리 작성 │ │ • 페이지 구현 │ + │ • 라우팅 │ │ • 마이그레이션 │ │ • 스타일링 │ + └─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ │ + └─────────────────┴─────────────────┘ + │ + ▼ + ┌───────────────────────┐ + │ MCP Orchestrator │ + │ │ + │ • 메시지 라우팅 │ + │ • 병렬 실행 │ + │ • 결과 수집 │ + └───────────────────────┘ +``` + +### 폴더별 담당 영역 + +| 에이전트 | 담당 폴더 | 파일 유형 | +|----------|-----------|-----------| +| Agent B (Backend) | `backend-node/src/` | `.ts`, `.js` | +| Agent C (DB) | `src/com/pms/mapper/`, `db/` | `.xml`, `.sql` | +| Agent D (Frontend) | `frontend/` | `.tsx`, `.ts`, `.css` | +| Agent A (PM) | 전체 조율 | 모든 파일 (읽기 위주) | + +--- + +## 에이전트 역할 정의 + +### Agent A (PM) - 프로젝트 매니저 + +```yaml +역할: 전체 조율 및 사용자 인터페이스 +모델: Claude Opus 4.5 + +핵심 책임: + 의도 파악: + - 사용자 요청 분석 + - 모호한 요청 명확화 + - 숨겨진 요구사항 발굴 + + 작업 분배: + - 작업을 세부 태스크로 분해 + - 적절한 에이전트에게 할당 + - 우선순위 및 의존성 결정 + + 품질 관리: + - 결과물 검증 + - 일관성 체크 + - 충돌 해결 + + 통합: + - 개별 결과물 취합 + - 최종 결과 생성 + - 사용자에게 보고 + +하지 않는 것: + - 직접 코드 구현 (전문가에게 위임) + - 특정 영역 깊이 분석 (전문가에게 요청) +``` + +### Agent B (Backend) - 백엔드 전문가 + +```yaml +역할: API 및 서버 로직 담당 +모델: Claude Sonnet + +담당 영역: + 폴더: + - backend-node/src/controllers/ + - backend-node/src/services/ + - backend-node/src/routes/ + - backend-node/src/middleware/ + - backend-node/src/utils/ + + 작업: + - REST API 엔드포인트 설계/구현 + - 비즈니스 로직 구현 + - 미들웨어 작성 + - 에러 핸들링 + - 인증/인가 로직 + +담당 아닌 것: + - frontend/ 폴더 (Agent D 담당) + - SQL 쿼리 직접 작성 (Agent C에게 요청) + - DB 스키마 변경 (Agent C 담당) + +협업 필요 시: + - DB 쿼리 필요 → Agent C에게 요청 + - 프론트 연동 문제 → Agent D와 협의 +``` + +### Agent C (Database) - DB 전문가 + +```yaml +역할: 데이터베이스 및 쿼리 담당 +모델: Claude Sonnet + +담당 영역: + 폴더: + - src/com/pms/mapper/ + - db/ + - backend-node/src/database/ + + 작업: + - 테이블 스키마 설계 + - MyBatis 매퍼 XML 작성 + - SQL 쿼리 최적화 + - 인덱스 설계 + - 마이그레이션 스크립트 + +담당 아닌 것: + - API 로직 (Agent B 담당) + - 프론트엔드 (Agent D 담당) + - 비즈니스 로직 판단 (Agent A에게 확인) + +협업 필요 시: + - API에서 필요한 데이터 구조 → Agent B와 협의 + - 쿼리 결과 사용법 → Agent B에게 전달 +``` + +### Agent D (Frontend) - 프론트엔드 전문가 + +```yaml +역할: UI/UX 및 클라이언트 로직 담당 +모델: Claude Sonnet + +담당 영역: + 폴더: + - frontend/components/ + - frontend/pages/ + - frontend/lib/ + - frontend/hooks/ + - frontend/styles/ + + 작업: + - React 컴포넌트 구현 + - 페이지 레이아웃 + - 상태 관리 + - API 연동 (호출) + - 스타일링 + +담당 아닌 것: + - API 구현 (Agent B 담당) + - DB 쿼리 (Agent C 담당) + - API 스펙 결정 (Agent A/B와 협의) + +협업 필요 시: + - API 엔드포인트 필요 → Agent B에게 요청 + - 데이터 구조 확인 → Agent C에게 문의 +``` + +--- + +## 통신 프로토콜 + +### 메시지 포맷 + +```typescript +// 요청 메시지 +interface TaskRequest { + id: string; // 고유 ID (예: "task-001") + from: 'A' | 'B' | 'C' | 'D'; // 발신자 + to: 'A' | 'B' | 'C' | 'D'; // 수신자 + type: 'info_request' | 'work_request' | 'question'; + priority: 'high' | 'medium' | 'low'; + content: { + task: string; // 작업 내용 + context?: string; // 배경 정보 + expected_output?: string; // 기대 결과 + depends_on?: string[]; // 선행 작업 ID + }; + timestamp: string; +} + +// 응답 메시지 +interface TaskResponse { + id: string; // 요청 ID와 매칭 + from: 'A' | 'B' | 'C' | 'D'; + to: 'A' | 'B' | 'C' | 'D'; + status: 'success' | 'partial' | 'failed' | 'need_clarification'; + confidence: 'high' | 'medium' | 'low'; + + result?: { + summary: string; // 한 줄 요약 + details: string; // 상세 내용 + files_affected?: string[]; // 영향받는 파일 + code_changes?: CodeChange[]; // 코드 변경사항 + }; + + // 메타 정보 + scope_violations?: string[]; // 스코프 벗어난 요청 + dependencies?: string[]; // 필요한 선행 작업 + side_effects?: string[]; // 부작용 + alternatives?: string[]; // 대안 + + // 추가 요청 + questions?: string[]; // 명확화 필요 + needs_from_others?: { + agent: 'A' | 'B' | 'C' | 'D'; + request: string; + }[]; + + timestamp: string; +} + +// 코드 변경 +interface CodeChange { + file: string; + action: 'create' | 'modify' | 'delete'; + content?: string; // 전체 코드 또는 diff + line_start?: number; + line_end?: number; +} +``` + +### 상태 코드 정의 + +| 상태 | 의미 | 후속 조치 | +|------|------|-----------| +| `success` | 완전히 완료 | 결과 사용 가능 | +| `partial` | 부분 완료 | 추가 작업 필요 | +| `failed` | 실패 | 에러 확인 후 재시도 | +| `need_clarification` | 명확화 필요 | 질문에 답변 후 재요청 | + +### 확신도 정의 + +| 확신도 | 의미 | 권장 조치 | +|--------|------|-----------| +| `high` | 확실함 | 바로 적용 가능 | +| `medium` | 대체로 맞음 | 검토 후 적용 | +| `low` | 추측임 | 반드시 검증 필요 | + +--- + +## 워크플로우 + +### Phase 1: 정보 수집 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Phase 1: 정보 수집 │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ 1. User → Agent A: "주문 관리 기능 만들어줘" │ +│ │ +│ 2. Agent A 분석: │ +│ - 기능 범위 파악 │ +│ - 필요한 정보 식별 │ +│ - 정보 수집 요청 생성 │ +│ │ +│ 3. Agent A → B, C, D (병렬): │ +│ - B에게: "현재 order 관련 API 구조 분석해줘" │ +│ - C에게: "orders 테이블 스키마 알려줘" │ +│ - D에게: "주문 관련 컴포넌트 현황 알려줘" │ +│ │ +│ 4. B, C, D → Agent A (응답): │ +│ - B: API 현황 보고 │ +│ - C: 스키마 정보 보고 │ +│ - D: 컴포넌트 현황 보고 │ +│ │ +│ 5. Agent A: 정보 취합 및 계획 수립 │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Phase 2: 작업 분배 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Phase 2: 작업 분배 │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ 1. Agent A: 종합 계획 수립 │ +│ ┌─────────────────────────────────────────┐ │ +│ │ 분석 결과: │ │ +│ │ - API에 pagination 추가 필요 │ │ +│ │ - DB는 현재 구조 유지 │ │ +│ │ - 프론트 무한스크롤 → 페이지네이션 │ │ +│ │ │ │ +│ │ 작업 순서: │ │ +│ │ 1. C: 페이징 쿼리 준비 │ │ +│ │ 2. B: API 수정 (C 결과 의존) │ │ +│ │ 3. D: 프론트 수정 (B 결과 의존) │ │ +│ └─────────────────────────────────────────┘ │ +│ │ +│ 2. Agent A → B, C, D: 작업 할당 │ +│ - C에게: "cursor 기반 페이징 쿼리 작성" │ +│ - B에게: "GET /api/orders에 pagination 추가" (C 대기) │ +│ - D에게: "Pagination 컴포넌트 적용" (B 대기) │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Phase 3: 실행 및 통합 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Phase 3: 실행 및 통합 │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ 1. 순차/병렬 실행: │ +│ - C: 쿼리 작성 → 완료 보고 │ +│ - B: API 수정 (C 완료 후) → 완료 보고 │ +│ - D: 프론트 수정 (B 완료 후) → 완료 보고 │ +│ │ +│ 2. Agent A: 결과 검증 │ +│ - 일관성 체크 │ +│ - 누락 확인 │ +│ - 충돌 해결 │ +│ │ +│ 3. Agent A → User: 최종 보고 │ +│ ┌─────────────────────────────────────────┐ │ +│ │ 완료된 작업: │ │ +│ │ ✅ orders.xml - 페이징 쿼리 추가 │ │ +│ │ ✅ OrderController.ts - pagination 적용 │ │ +│ │ ✅ OrderListPage.tsx - UI 수정 │ │ +│ │ │ │ +│ │ 테스트 필요: │ │ +│ │ - GET /api/orders?page=1&limit=10 │ │ +│ └─────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 프롬프트 템플릿 + +### Agent A (PM) 시스템 프롬프트 + +```markdown +# 역할 +너는 PM(Project Manager) 에이전트야. +사용자 요청을 분석하고, 전문가 에이전트들(Backend, DB, Frontend)에게 +작업을 분배하고, 결과를 통합해서 최종 결과물을 만들어. + +# 사용 가능한 도구 +- ask_backend_agent: 백엔드 전문가에게 질문/작업 요청 +- ask_db_agent: DB 전문가에게 질문/작업 요청 +- ask_frontend_agent: 프론트 전문가에게 질문/작업 요청 +- parallel_ask: 여러 전문가에게 동시에 요청 + +# 작업 프로세스 + +## Phase 1: 분석 +1. 사용자 요청 분석 +2. 필요한 정보 식별 +3. 정보 수집 요청 (parallel_ask 활용) + +## Phase 2: 계획 +1. 수집된 정보 분석 +2. 작업 분해 및 의존성 파악 +3. 우선순위 결정 +4. 작업 분배 계획 수립 + +## Phase 3: 실행 +1. 의존성 순서대로 작업 요청 +2. 결과 검증 +3. 필요시 재요청 + +## Phase 4: 통합 +1. 모든 결과 취합 +2. 일관성 검증 +3. 사용자에게 보고 + +# 작업 분배 기준 +- Backend Agent: API, 서비스 로직, 라우팅 (backend-node/) +- DB Agent: 스키마, 쿼리, 마이그레이션 (mapper/, db/) +- Frontend Agent: 컴포넌트, 페이지, 스타일 (frontend/) + +# 판단 기준 +- 불확실하면 사용자에게 물어봐 +- 에이전트 결과가 이상하면 재요청 +- 영향 범위 크면 사용자 확인 +- 충돌 시 더 안전한 방향 선택 + +# 응답 형식 +작업 분배 시: +```json +{ + "phase": "info_gathering | work_distribution | integration", + "reasoning": "왜 이렇게 분배하는지", + "tasks": [ + { + "agent": "backend | db | frontend", + "priority": 1, + "task": "구체적인 작업 내용", + "depends_on": [], + "expected_output": "기대 결과" + } + ] +} +``` + +최종 보고 시: +```json +{ + "summary": "한 줄 요약", + "completed_tasks": ["완료된 작업들"], + "files_changed": ["변경된 파일들"], + "next_steps": ["다음 단계 (있다면)"], + "test_instructions": ["테스트 방법"] +} +``` +``` + +### Agent B (Backend) 시스템 프롬프트 + +```markdown +# 역할 +너는 Backend 전문가 에이전트야. +backend-node/ 폴더의 API, 서비스, 라우팅을 담당해. + +# 담당 영역 (이것만!) +- backend-node/src/controllers/ +- backend-node/src/services/ +- backend-node/src/routes/ +- backend-node/src/middleware/ +- backend-node/src/utils/ + +# 담당 아닌 것 (절대 건들지 마) +- frontend/ → Frontend Agent 담당 +- src/com/pms/mapper/ → DB Agent 담당 +- SQL 쿼리 직접 작성 → DB Agent에게 요청 + +# 코드 작성 규칙 +1. TypeScript 사용 +2. 에러 핸들링 필수 +3. 주석은 한글로 +4. 기존 코드 스타일 따르기 +5. ... 생략 없이 완전한 코드 + +# 응답 형식 +```json +{ + "status": "success | partial | failed | need_clarification", + "confidence": "high | medium | low", + "result": { + "summary": "한 줄 요약", + "details": "상세 설명", + "files_affected": ["파일 경로들"], + "code_changes": [ + { + "file": "경로", + "action": "create | modify | delete", + "content": "전체 코드" + } + ] + }, + "needs_from_others": [ + {"agent": "db", "request": "필요한 것"} + ], + "side_effects": ["영향받는 것들"], + "questions": ["명확하지 않은 것들"] +} +``` + +# 협업 규칙 +1. 내 영역 아니면 즉시 보고 (scope_violation) +2. 확실하지 않으면 confidence: "low" +3. 다른 에이전트 필요하면 needs_from_others에 명시 +4. 부작용 있으면 반드시 보고 +``` + +### Agent C (Database) 시스템 프롬프트 + +```markdown +# 역할 +너는 Database 전문가 에이전트야. +DB 스키마, 쿼리, 마이그레이션을 담당해. + +# 담당 영역 (이것만!) +- src/com/pms/mapper/ (MyBatis XML) +- db/ (스키마, 마이그레이션) +- backend-node/src/database/ + +# 담당 아닌 것 (절대 건들지 마) +- API 로직 → Backend Agent 담당 +- 프론트엔드 → Frontend Agent 담당 +- 비즈니스 로직 판단 → PM에게 확인 + +# 코드 작성 규칙 +1. PostgreSQL 문법 사용 +2. 파라미터 바인딩 (#{}) 필수 - SQL 인젝션 방지 +3. 인덱스 고려 +4. 성능 최적화 (EXPLAIN 결과 고려) + +# MyBatis 매퍼 규칙 +```xml + +WHERE id = #{id} + + + + AND name LIKE '%' || #{name} || '%' + + + +LIMIT #{limit} OFFSET #{offset} +``` + +# 응답 형식 +```json +{ + "status": "success | partial | failed | need_clarification", + "confidence": "high | medium | low", + "result": { + "summary": "한 줄 요약", + "details": "상세 설명", + "schema_info": { + "tables": ["관련 테이블"], + "columns": ["주요 컬럼"], + "indexes": ["인덱스"] + }, + "code_changes": [ + { + "file": "경로", + "action": "create | modify", + "content": "쿼리/스키마" + } + ] + }, + "performance_notes": ["성능 관련 참고사항"], + "questions": ["명확하지 않은 것들"] +} +``` +``` + +### Agent D (Frontend) 시스템 프롬프트 + +```markdown +# 역할 +너는 Frontend 전문가 에이전트야. +React/Next.js 기반 UI 구현을 담당해. + +# 담당 영역 (이것만!) +- frontend/components/ +- frontend/pages/ (또는 app/) +- frontend/lib/ +- frontend/hooks/ +- frontend/styles/ + +# 담당 아닌 것 (절대 건들지 마) +- backend-node/ → Backend Agent 담당 +- DB 관련 → DB Agent 담당 +- API 스펙 결정 → PM/Backend와 협의 + +# 코드 작성 규칙 +1. TypeScript 사용 +2. React 함수형 컴포넌트 +3. 커스텀 훅 활용 +4. 주석은 한글로 +5. Tailwind CSS 또는 기존 스타일 시스템 따르기 + +# API 호출 규칙 +- 절대 fetch 직접 사용 금지 +- lib/api/ 클라이언트 사용 +- 에러 핸들링 필수 + +# 응답 형식 +```json +{ + "status": "success | partial | failed | need_clarification", + "confidence": "high | medium | low", + "result": { + "summary": "한 줄 요약", + "details": "상세 설명", + "components_affected": ["컴포넌트 목록"], + "code_changes": [ + { + "file": "경로", + "action": "create | modify", + "content": "전체 코드" + } + ] + }, + "needs_from_others": [ + {"agent": "backend", "request": "필요한 API"} + ], + "ui_notes": ["UX 관련 참고사항"], + "questions": ["명확하지 않은 것들"] +} +``` +``` + +--- + +## MCP 서버 구현 + +### 프로젝트 구조 + +``` +mcp-agent-orchestrator/ +├── package.json +├── tsconfig.json +├── src/ +│ ├── index.ts # 메인 서버 +│ ├── agents/ +│ │ ├── types.ts # 타입 정의 +│ │ ├── pm.ts # PM 에이전트 프롬프트 +│ │ ├── backend.ts # Backend 에이전트 프롬프트 +│ │ ├── database.ts # DB 에이전트 프롬프트 +│ │ └── frontend.ts # Frontend 에이전트 프롬프트 +│ └── utils/ +│ └── logger.ts # 로깅 +└── build/ + └── index.js # 컴파일된 파일 +``` + +### 핵심 코드 + +```typescript +// src/index.ts +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import Anthropic from "@anthropic-ai/sdk"; +import { PM_PROMPT, BACKEND_PROMPT, DB_PROMPT, FRONTEND_PROMPT } from "./agents"; + +const server = new Server({ + name: "agent-orchestrator", + version: "1.0.0", +}); + +const anthropic = new Anthropic(); + +// 에이전트별 설정 +const AGENT_CONFIG = { + pm: { model: "claude-opus-4-5-20250214", prompt: PM_PROMPT }, + backend: { model: "claude-sonnet-4-20250514", prompt: BACKEND_PROMPT }, + db: { model: "claude-sonnet-4-20250514", prompt: DB_PROMPT }, + frontend: { model: "claude-sonnet-4-20250514", prompt: FRONTEND_PROMPT }, +}; + +// 도구 목록 +server.setRequestHandler("tools/list", async () => ({ + tools: [ + { + name: "ask_backend_agent", + description: "백엔드 전문가에게 질문하거나 작업 요청", + inputSchema: { + type: "object", + properties: { + task: { type: "string", description: "작업 내용" }, + context: { type: "string", description: "배경 정보 (선택)" }, + }, + required: ["task"], + }, + }, + { + name: "ask_db_agent", + description: "DB 전문가에게 질문하거나 작업 요청", + inputSchema: { + type: "object", + properties: { + task: { type: "string", description: "작업 내용" }, + context: { type: "string", description: "배경 정보 (선택)" }, + }, + required: ["task"], + }, + }, + { + name: "ask_frontend_agent", + description: "프론트엔드 전문가에게 질문하거나 작업 요청", + inputSchema: { + type: "object", + properties: { + task: { type: "string", description: "작업 내용" }, + context: { type: "string", description: "배경 정보 (선택)" }, + }, + required: ["task"], + }, + }, + { + name: "parallel_ask", + description: "여러 전문가에게 동시에 질문 (병렬 실행)", + inputSchema: { + type: "object", + properties: { + requests: { + type: "array", + items: { + type: "object", + properties: { + agent: { + type: "string", + enum: ["backend", "db", "frontend"] + }, + task: { type: "string" }, + context: { type: "string" }, + }, + required: ["agent", "task"], + }, + }, + }, + required: ["requests"], + }, + }, + ], +})); + +// 도구 실행 +server.setRequestHandler("tools/call", async (request) => { + const { name, arguments: args } = request.params; + + const callAgent = async (agentType: string, task: string, context?: string) => { + const config = AGENT_CONFIG[agentType]; + const response = await anthropic.messages.create({ + model: config.model, + max_tokens: 8192, + system: config.prompt, + messages: [ + { + role: "user", + content: context ? `${task}\n\n배경 정보:\n${context}` : task, + }, + ], + }); + return response.content[0].text; + }; + + switch (name) { + case "ask_backend_agent": + return { + content: [ + { type: "text", text: await callAgent("backend", args.task, args.context) }, + ], + }; + + case "ask_db_agent": + return { + content: [ + { type: "text", text: await callAgent("db", args.task, args.context) }, + ], + }; + + case "ask_frontend_agent": + return { + content: [ + { type: "text", text: await callAgent("frontend", args.task, args.context) }, + ], + }; + + case "parallel_ask": + const results = await Promise.all( + args.requests.map(async (req) => ({ + agent: req.agent, + result: await callAgent(req.agent, req.task, req.context), + })) + ); + return { + content: [ + { type: "text", text: JSON.stringify(results, null, 2) }, + ], + }; + + default: + throw new Error(`Unknown tool: ${name}`); + } +}); + +// 서버 시작 +const transport = new StdioServerTransport(); +await server.connect(transport); +``` + +### Cursor 설정 + +```json +// .cursor/mcp.json +{ + "mcpServers": { + "agent-orchestrator": { + "command": "node", + "args": ["C:/Users/defaultuser0/mcp-agent-orchestrator/build/index.js"], + "env": { + "ANTHROPIC_API_KEY": "your-api-key-here" + } + } + } +} +``` + +--- + +## 비용 분석 + +### 토큰 사용량 비교 + +| 시나리오 | 단일 에이전트 | 멀티 에이전트 | 절감 | +|----------|--------------|--------------|------| +| 기능 1개 추가 | 100,000 토큰 | 60,000 토큰 | 40% | +| 시스템 리팩토링 | 300,000 토큰 | 150,000 토큰 | 50% | +| 새 모듈 개발 | 500,000 토큰 | 200,000 토큰 | 60% | + +### 비용 계산 (예시) + +``` +단일 에이전트 (전부 Opus): +- 300,000 토큰 × $15/M = $4.50 + +멀티 에이전트 (Opus PM + Sonnet Workers): +- PM (Opus): 50,000 토큰 × $15/M = $0.75 +- Workers (Sonnet): 100,000 토큰 × $3/M = $0.30 +- 총: $1.05 + +절감: $4.50 - $1.05 = $3.45 (76% 절감!) +``` + +### ROI 분석 + +``` +초기 투자: +- MCP 서버 개발: 4-6시간 +- 프롬프트 튜닝: 2-4시간 +- 테스트: 2시간 +- 총: 8-12시간 + +회수: +- 대규모 작업당 $3-5 절감 +- 재작업 시간 70% 감소 +- 품질 30% 향상 + +손익분기점: 대규모 작업 3-5회 +``` + +--- + +## 한계점 및 해결방안 + +### 현재 한계 + +| 한계 | 설명 | 해결방안 | +|------|------|----------| +| 완전 자동화 불가 | Cursor 에이전트 간 직접 통신 없음 | MCP 서버로 우회 | +| 파일 읽기 제한 | 각 에이전트가 모든 파일 접근 어려움 | 컨텍스트에 필요한 정보 전달 | +| 실시간 동기화 | 변경사항 즉시 반영 어려움 | 명시적 갱신 요청 | +| 에러 복구 | 자동 롤백 메커니즘 없음 | 수동 복구 또는 git 활용 | + +### 향후 개선 방향 + +1. **파일 시스템 연동** + - MCP 서버에 파일 읽기/쓰기 도구 추가 + - 에이전트가 직접 코드 확인 가능 + +2. **결과 자동 적용** + - 코드 변경사항 자동 적용 + - git 커밋 자동화 + +3. **피드백 루프** + - 테스트 자동 실행 + - 실패 시 자동 재시도 + +4. **히스토리 관리** + - 대화 이력 저장 + - 컨텍스트 캐싱 + +--- + +## 체크리스트 + +### 구현 전 준비 + +- [ ] Node.js 18+ 설치 +- [ ] Anthropic API 키 발급 +- [ ] 프로젝트 폴더 생성 + +### MCP 서버 구현 + +- [ ] package.json 설정 +- [ ] TypeScript 설정 +- [ ] 기본 서버 구조 +- [ ] 도구 정의 (4개) +- [ ] 에이전트 프롬프트 작성 +- [ ] 빌드 및 테스트 + +### Cursor 연동 + +- [ ] mcp.json 설정 +- [ ] Cursor 재시작 +- [ ] 도구 호출 테스트 +- [ ] 실제 작업 테스트 + +### 튜닝 + +- [ ] 프롬프트 개선 +- [ ] 에러 핸들링 강화 +- [ ] 로깅 추가 +- [ ] 성능 최적화 + +--- + +## 참고 자료 + +- [MCP SDK 문서](https://modelcontextprotocol.io/) +- [Anthropic API 문서](https://docs.anthropic.com/) +- [CrewAI](https://github.com/joaomdmoura/crewAI) - 멀티에이전트 프레임워크 참고 +- [AutoGen](https://github.com/microsoft/autogen) - Microsoft 멀티에이전트 참고 + +--- + +*작성일: 2026-02-05* +*버전: 1.0* diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index df88cb04..429f91f8 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -35,6 +35,17 @@ import { snapSizeToGrid, snapToGrid, } from "@/lib/utils/gridUtils"; +import { + alignComponents, + distributeComponents, + matchComponentSize, + toggleAllLabels, + nudgeComponents, + AlignMode, + DistributeDirection, + MatchSizeMode, +} from "@/lib/utils/alignmentUtils"; +import { KeyboardShortcutsModal } from "./modals/KeyboardShortcutsModal"; // 10px 단위 스냅 함수 const snapTo10px = (value: number): number => { @@ -170,6 +181,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU // 메뉴 할당 모달 상태 const [showMenuAssignmentModal, setShowMenuAssignmentModal] = useState(false); + // 단축키 도움말 모달 상태 + const [showShortcutsModal, setShowShortcutsModal] = useState(false); + // 파일첨부 상세 모달 상태 const [showFileAttachmentModal, setShowFileAttachmentModal] = useState(false); const [selectedFileComponent, setSelectedFileComponent] = useState(null); @@ -360,6 +374,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU const [zoomLevel, setZoomLevel] = useState(1); // 1 = 100% const MIN_ZOOM = 0.1; // 10% const MAX_ZOOM = 3; // 300% + const zoomRafRef = useRef(null); // 줌 RAF throttle용 // 전역 파일 상태 변경 시 강제 리렌더링을 위한 상태 const [forceRenderTrigger, setForceRenderTrigger] = useState(0); @@ -1647,7 +1662,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU panState.innerScrollTop, ]); - // 마우스 휠로 줌 제어 + // 마우스 휠로 줌 제어 (RAF throttle 적용으로 깜빡임 방지) useEffect(() => { const handleWheel = (e: WheelEvent) => { // 캔버스 컨테이너 내에서만 동작 @@ -1660,9 +1675,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU const delta = e.deltaY; const zoomFactor = 0.001; // 줌 속도 조절 - setZoomLevel((prevZoom) => { - const newZoom = prevZoom - delta * zoomFactor; - return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, newZoom)); + // RAF throttle: 프레임당 한 번만 상태 업데이트 + if (zoomRafRef.current !== null) { + cancelAnimationFrame(zoomRafRef.current); + } + zoomRafRef.current = requestAnimationFrame(() => { + setZoomLevel((prevZoom) => { + const newZoom = prevZoom - delta * zoomFactor; + return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, newZoom)); + }); + zoomRafRef.current = null; }); } } @@ -1674,6 +1696,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU const containerRef = canvasContainerRef.current; return () => { containerRef?.removeEventListener("wheel", handleWheel); + if (zoomRafRef.current !== null) { + cancelAnimationFrame(zoomRafRef.current); + } }; }, [MIN_ZOOM, MAX_ZOOM]); @@ -1785,6 +1810,103 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU toast.success(`${adjustedComponents.length}개 컴포넌트가 격자에 맞게 재정렬되었습니다.`); }, [layout, screenResolution, saveToHistory]); + // === 정렬/배분/동일크기/라벨토글/Nudge 핸들러 === + + // 컴포넌트 정렬 + const handleGroupAlign = useCallback( + (mode: AlignMode) => { + if (groupState.selectedComponents.length < 2) { + toast.warning("2개 이상의 컴포넌트를 선택해주세요."); + return; + } + saveToHistory(layout); + const newComponents = alignComponents(layout.components, groupState.selectedComponents, mode); + setLayout((prev) => ({ ...prev, components: newComponents })); + + const modeNames: Record = { + left: "좌측", right: "우측", centerX: "가로 중앙", + top: "상단", bottom: "하단", centerY: "세로 중앙", + }; + toast.success(`${modeNames[mode]} 정렬 완료`); + }, + [groupState.selectedComponents, layout, saveToHistory] + ); + + // 컴포넌트 균등 배분 + const handleGroupDistribute = useCallback( + (direction: DistributeDirection) => { + if (groupState.selectedComponents.length < 3) { + toast.warning("3개 이상의 컴포넌트를 선택해주세요."); + return; + } + saveToHistory(layout); + const newComponents = distributeComponents(layout.components, groupState.selectedComponents, direction); + setLayout((prev) => ({ ...prev, components: newComponents })); + toast.success(`${direction === "horizontal" ? "가로" : "세로"} 균등 배분 완료`); + }, + [groupState.selectedComponents, layout, saveToHistory] + ); + + // 동일 크기 맞추기 + const handleMatchSize = useCallback( + (mode: MatchSizeMode) => { + if (groupState.selectedComponents.length < 2) { + toast.warning("2개 이상의 컴포넌트를 선택해주세요."); + return; + } + saveToHistory(layout); + const newComponents = matchComponentSize( + layout.components, + groupState.selectedComponents, + mode, + selectedComponent?.id + ); + setLayout((prev) => ({ ...prev, components: newComponents })); + + const modeNames: Record = { + width: "너비", height: "높이", both: "크기", + }; + toast.success(`${modeNames[mode]} 맞추기 완료`); + }, + [groupState.selectedComponents, layout, selectedComponent?.id, saveToHistory] + ); + + // 라벨 일괄 토글 + const handleToggleAllLabels = useCallback(() => { + saveToHistory(layout); + const newComponents = toggleAllLabels(layout.components); + setLayout((prev) => ({ ...prev, components: newComponents })); + + const hasHidden = layout.components.some( + (c) => c.type === "widget" && (c.style as any)?.labelDisplay === false + ); + toast.success(hasHidden ? "모든 라벨 표시" : "모든 라벨 숨기기"); + }, [layout, saveToHistory]); + + // Nudge (화살표 키 이동) + const handleNudge = useCallback( + (direction: "up" | "down" | "left" | "right", distance: number) => { + const targetIds = + groupState.selectedComponents.length > 0 + ? groupState.selectedComponents + : selectedComponent + ? [selectedComponent.id] + : []; + + if (targetIds.length === 0) return; + + const newComponents = nudgeComponents(layout.components, targetIds, direction, distance); + setLayout((prev) => ({ ...prev, components: newComponents })); + + // 선택된 컴포넌트 업데이트 + if (selectedComponent && targetIds.includes(selectedComponent.id)) { + const updated = newComponents.find((c) => c.id === selectedComponent.id); + if (updated) setSelectedComponent(updated); + } + }, + [groupState.selectedComponents, selectedComponent, layout.components] + ); + // 저장 const handleSave = useCallback(async () => { if (!selectedScreen?.screenId) { @@ -5359,6 +5481,105 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU } return false; } + + // === 9. 화살표 키 Nudge (컴포넌트 미세 이동) === + if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) { + // 입력 필드에서는 무시 + const active = document.activeElement; + if ( + active instanceof HTMLInputElement || + active instanceof HTMLTextAreaElement || + active?.getAttribute("contenteditable") === "true" + ) { + return; + } + + if (selectedComponent || groupState.selectedComponents.length > 0) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + const distance = e.shiftKey ? 10 : 1; // Shift 누르면 10px + const dirMap: Record = { + ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right", + }; + handleNudge(dirMap[e.key], distance); + return false; + } + } + + // === 10. 정렬 단축키 (Alt + 키) - 다중 선택 시 === + if (e.altKey && !e.ctrlKey && !e.metaKey) { + const alignKey = e.key?.toLowerCase(); + const alignMap: Record = { + l: "left", r: "right", c: "centerX", + t: "top", b: "bottom", m: "centerY", + }; + + if (alignMap[alignKey] && groupState.selectedComponents.length >= 2) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + handleGroupAlign(alignMap[alignKey]); + return false; + } + + // 균등 배분 (Alt+H: 가로, Alt+V: 세로) + if (alignKey === "h" && groupState.selectedComponents.length >= 3) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + handleGroupDistribute("horizontal"); + return false; + } + if (alignKey === "v" && groupState.selectedComponents.length >= 3) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + handleGroupDistribute("vertical"); + return false; + } + + // 동일 크기 맞추기 (Alt+W: 너비, Alt+E: 높이) + if (alignKey === "w" && groupState.selectedComponents.length >= 2) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + handleMatchSize("width"); + return false; + } + if (alignKey === "e" && groupState.selectedComponents.length >= 2) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + handleMatchSize("height"); + return false; + } + } + + // === 11. 라벨 일괄 토글 (Alt+Shift+L) === + if (e.altKey && e.shiftKey && e.key?.toLowerCase() === "l") { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + handleToggleAllLabels(); + return false; + } + + // === 12. 단축키 도움말 (? 키) === + if (e.key === "?" && !e.ctrlKey && !e.metaKey && !e.altKey) { + // 입력 필드에서는 무시 + const active = document.activeElement; + if ( + active instanceof HTMLInputElement || + active instanceof HTMLTextAreaElement || + active?.getAttribute("contenteditable") === "true" + ) { + return; + } + e.preventDefault(); + setShowShortcutsModal(true); + return false; + } }; // window 레벨에서 캡처 단계에서 가장 먼저 처리 @@ -5376,6 +5597,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU groupState.selectedComponents, layout, selectedScreen, + handleNudge, + handleGroupAlign, + handleGroupDistribute, + handleMatchSize, + handleToggleAllLabels, ]); // 플로우 위젯 높이 자동 업데이트 이벤트 리스너 @@ -5503,6 +5729,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU onOpenMultilangSettings={() => setShowMultilangSettingsModal(true)} isPanelOpen={panelStates.v2?.isOpen || false} onTogglePanel={() => togglePanel("v2")} + selectedCount={groupState.selectedComponents.length} + onAlign={handleGroupAlign} + onDistribute={handleGroupDistribute} + onMatchSize={handleMatchSize} + onToggleLabels={handleToggleAllLabels} + onShowShortcuts={() => setShowShortcutsModal(true)} /> {/* 메인 컨테이너 (패널들 + 캔버스) */}
@@ -6013,8 +6245,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
)} - {/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - 좌우 최소화, 위아래 넉넉한 여유 */} -
+ {/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - GPU 가속 스크롤 적용 */} +
{/* Pan 모드 안내 - 제거됨 */} {/* 줌 레벨 표시 */}
@@ -6123,12 +6359,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
); })()} - {/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬로 변경 */} + {/* 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬 + contain 최적화 */}
{/* 실제 작업 캔버스 (해상도 크기) - 고정 크기 + 줌 적용 */} @@ -6141,8 +6378,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU maxWidth: `${screenResolution.width}px`, minHeight: `${screenResolution.height}px`, flexShrink: 0, - transform: `scale(${zoomLevel})`, + transform: `scale3d(${zoomLevel}, ${zoomLevel}, 1)`, transformOrigin: "top center", // 중앙 기준으로 스케일 + willChange: "transform", // GPU 가속 레이어 생성 + backfaceVisibility: "hidden" as const, // 리페인트 최적화 }} >
+ {/* 단축키 도움말 모달 */} + setShowShortcutsModal(false)} + />
diff --git a/frontend/components/screen/ScreenSettingModal.tsx b/frontend/components/screen/ScreenSettingModal.tsx index 88ee9ece..fa802893 100644 --- a/frontend/components/screen/ScreenSettingModal.tsx +++ b/frontend/components/screen/ScreenSettingModal.tsx @@ -365,7 +365,7 @@ export function ScreenSettingModal({ return ( <> - + @@ -525,34 +525,30 @@ export function ScreenSettingModal({ - {/* ScreenDesigner 전체 화면 모달 */} - - - 화면 디자이너 -
- { - setShowDesignerModal(false); - // 디자이너에서 저장 후 모달 닫으면 데이터 새로고침 - await loadData(); - // 데이터 로드 완료 후 iframe 갱신 - setIframeKey(prev => prev + 1); - }} - /> -
-
-
+ {/* ScreenDesigner 전체 화면 - Dialog 중첩 시 오버레이 컴포지팅으로 깜빡임 발생하여 직접 렌더링 */} + {/* ScreenDesigner 자체 SlimToolbar에 "목록으로" 닫기 버튼이 있으므로 별도 X 버튼 불필요 */} + {showDesignerModal && ( +
+ { + setShowDesignerModal(false); + await loadData(); + setIframeKey(prev => prev + 1); + }} + /> +
+ )} {/* TableSettingModal */} {tableSettingTarget && ( diff --git a/frontend/components/screen/modals/KeyboardShortcutsModal.tsx b/frontend/components/screen/modals/KeyboardShortcutsModal.tsx new file mode 100644 index 00000000..0f122c53 --- /dev/null +++ b/frontend/components/screen/modals/KeyboardShortcutsModal.tsx @@ -0,0 +1,144 @@ +"use client"; + +import React from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; + +interface ShortcutItem { + keys: string[]; + description: string; +} + +interface ShortcutGroup { + title: string; + shortcuts: ShortcutItem[]; +} + +const shortcutGroups: ShortcutGroup[] = [ + { + title: "기본 조작", + shortcuts: [ + { keys: ["Ctrl", "S"], description: "레이아웃 저장" }, + { keys: ["Ctrl", "Z"], description: "실행취소" }, + { keys: ["Ctrl", "Y"], description: "다시실행" }, + { keys: ["Ctrl", "A"], description: "전체 선택" }, + { keys: ["Delete"], description: "선택 삭제" }, + { keys: ["Esc"], description: "선택 해제" }, + ], + }, + { + title: "복사/붙여넣기", + shortcuts: [ + { keys: ["Ctrl", "C"], description: "컴포넌트 복사" }, + { keys: ["Ctrl", "V"], description: "컴포넌트 붙여넣기" }, + ], + }, + { + title: "그룹 관리", + shortcuts: [ + { keys: ["Ctrl", "G"], description: "그룹 생성" }, + { keys: ["Ctrl", "Shift", "G"], description: "그룹 해제" }, + ], + }, + { + title: "이동 (Nudge)", + shortcuts: [ + { keys: ["Arrow"], description: "1px 이동" }, + { keys: ["Shift", "Arrow"], description: "10px 이동" }, + ], + }, + { + title: "정렬 (다중 선택 시)", + shortcuts: [ + { keys: ["Alt", "L"], description: "좌측 정렬" }, + { keys: ["Alt", "R"], description: "우측 정렬" }, + { keys: ["Alt", "C"], description: "가로 중앙 정렬" }, + { keys: ["Alt", "T"], description: "상단 정렬" }, + { keys: ["Alt", "B"], description: "하단 정렬" }, + { keys: ["Alt", "M"], description: "세로 중앙 정렬" }, + ], + }, + { + title: "배분/크기 (다중 선택 시)", + shortcuts: [ + { keys: ["Alt", "H"], description: "가로 균등 배분" }, + { keys: ["Alt", "V"], description: "세로 균등 배분" }, + { keys: ["Alt", "W"], description: "너비 맞추기" }, + { keys: ["Alt", "E"], description: "높이 맞추기" }, + ], + }, + { + title: "보기/탐색", + shortcuts: [ + { keys: ["Space", "Drag"], description: "캔버스 팬(이동)" }, + { keys: ["Wheel"], description: "줌 인/아웃" }, + { keys: ["P"], description: "패널 열기/닫기" }, + { keys: ["Alt", "Shift", "L"], description: "라벨 일괄 표시/숨기기" }, + { keys: ["?"], description: "단축키 도움말" }, + ], + }, +]; + +interface KeyboardShortcutsModalProps { + isOpen: boolean; + onClose: () => void; +} + +export const KeyboardShortcutsModal: React.FC = ({ + isOpen, + onClose, +}) => { + return ( + + + + + 키보드 단축키 + + + 화면 디자이너에서 사용할 수 있는 단축키 목록입니다. Mac에서는 Ctrl 대신 Cmd를 사용합니다. + + + +
+ {shortcutGroups.map((group) => ( +
+

+ {group.title} +

+
+ {group.shortcuts.map((shortcut, idx) => ( +
+ + {shortcut.description} + +
+ {shortcut.keys.map((key, kidx) => ( + + {kidx > 0 && ( + + + )} + + {key} + + + ))} +
+
+ ))} +
+
+ ))} +
+
+
+ ); +}; diff --git a/frontend/components/screen/toolbar/SlimToolbar.tsx b/frontend/components/screen/toolbar/SlimToolbar.tsx index d71ed93a..2dbd7129 100644 --- a/frontend/components/screen/toolbar/SlimToolbar.tsx +++ b/frontend/components/screen/toolbar/SlimToolbar.tsx @@ -22,6 +22,18 @@ import { Settings2, PanelLeft, PanelLeftClose, + AlignStartVertical, + AlignCenterVertical, + AlignEndVertical, + AlignStartHorizontal, + AlignCenterHorizontal, + AlignEndHorizontal, + AlignHorizontalSpaceAround, + AlignVerticalSpaceAround, + RulerIcon, + Tag, + Keyboard, + Equal, } from "lucide-react"; import { ScreenResolution, SCREEN_RESOLUTIONS } from "@/types/screen"; import { @@ -50,6 +62,10 @@ interface GridSettings { gridOpacity?: number; } +type AlignMode = "left" | "centerX" | "right" | "top" | "centerY" | "bottom"; +type DistributeDirection = "horizontal" | "vertical"; +type MatchSizeMode = "width" | "height" | "both"; + interface SlimToolbarProps { screenName?: string; tableName?: string; @@ -67,6 +83,13 @@ interface SlimToolbarProps { // 패널 토글 기능 isPanelOpen?: boolean; onTogglePanel?: () => void; + // 정렬/배분/크기 기능 + selectedCount?: number; + onAlign?: (mode: AlignMode) => void; + onDistribute?: (direction: DistributeDirection) => void; + onMatchSize?: (mode: MatchSizeMode) => void; + onToggleLabels?: () => void; + onShowShortcuts?: () => void; } export const SlimToolbar: React.FC = ({ @@ -85,6 +108,12 @@ export const SlimToolbar: React.FC = ({ onOpenMultilangSettings, isPanelOpen = false, onTogglePanel, + selectedCount = 0, + onAlign, + onDistribute, + onMatchSize, + onToggleLabels, + onShowShortcuts, }) => { // 사용자 정의 해상도 상태 const [customWidth, setCustomWidth] = useState(""); @@ -325,8 +354,100 @@ export const SlimToolbar: React.FC = ({ )}
+ {/* 중앙: 정렬/배분 도구 (다중 선택 시 표시) */} + {selectedCount >= 2 && (onAlign || onDistribute || onMatchSize) && ( +
+ {/* 정렬 */} + {onAlign && ( + <> + 정렬 + + + +
+ + + + + )} + + {/* 배분 (3개 이상 선택 시) */} + {onDistribute && selectedCount >= 3 && ( + <> +
+ 배분 + + + + )} + + {/* 크기 맞추기 */} + {onMatchSize && ( + <> +
+ 크기 + + + + + )} + +
+ {selectedCount}개 선택 +
+ )} + {/* 우측: 버튼들 */}
+ {/* 라벨 토글 버튼 */} + {onToggleLabels && ( + + )} + + {/* 단축키 도움말 */} + {onShowShortcuts && ( + + )} + {onPreview && (