Merge pull request 'feature/v2-unified-renewal' (#385) from feature/v2-unified-renewal into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/385
This commit is contained in:
kjs 2026-02-06 16:11:04 +09:00
commit fedd75ddf5
19 changed files with 4254 additions and 47 deletions

8
.cursor/mcp.json Normal file
View File

@ -0,0 +1,8 @@
{
"mcpServers": {
"agent-orchestrator": {
"command": "node",
"args": ["/Users/gbpark/ERP-node/mcp-agent-orchestrator/build/index.js"]
}
}
}

View File

@ -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<any>(
`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<any>(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;

View File

@ -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을 변환하여 `<img>` 태그로 직접 표시합니다. 파일 관리(목록, 삭제 등) 기능은 없습니다.
---
## 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곳 수정.

View File

@ -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}
<!-- 동적 쿼리 -->
<if test="name != null and name != ''">
AND name LIKE '%' || #{name} || '%'
</if>
<!-- 페이징 -->
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*

View File

@ -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<ComponentData | null>(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<number | null>(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<AlignMode, string> = {
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<MatchSizeMode, string> = {
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<string, "up" | "down" | "left" | "right"> = {
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<string, AlignMode> = {
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)}
/>
{/* 메인 컨테이너 (패널들 + 캔버스) */}
<div className="flex flex-1 overflow-hidden">
@ -6013,8 +6245,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
</div>
)}
{/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - 좌우 최소화, 위아래 넉넉한 여유 */}
<div ref={canvasContainerRef} className="bg-muted relative flex-1 overflow-auto px-16 py-6">
{/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - GPU 가속 스크롤 적용 */}
<div
ref={canvasContainerRef}
className="bg-muted relative flex-1 overflow-auto px-16 py-6"
style={{ willChange: "scroll-position" }}
>
{/* Pan 모드 안내 - 제거됨 */}
{/* 줌 레벨 표시 */}
<div className="bg-card text-foreground border-border pointer-events-none fixed right-6 bottom-6 z-50 rounded-lg border px-4 py-2 text-sm font-medium shadow-md">
@ -6123,12 +6359,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
</div>
);
})()}
{/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬로 변경 */}
{/* 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬 + contain 최적화 */}
<div
className="flex justify-center"
style={{
width: "100%",
minHeight: screenResolution.height * zoomLevel,
contain: "layout style", // 레이아웃 재계산 범위 제한
}}
>
{/* 실제 작업 캔버스 (해상도 크기) - 고정 크기 + 줌 적용 */}
@ -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, // 리페인트 최적화
}}
>
<div
@ -6842,6 +7081,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
}
}}
/>
{/* 단축키 도움말 모달 */}
<KeyboardShortcutsModal
isOpen={showShortcutsModal}
onClose={() => setShowShortcutsModal(false)}
/>
</div>
</TableOptionsProvider>
</LayerProvider>

View File

@ -365,7 +365,7 @@ export function ScreenSettingModal({
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<Dialog open={isOpen && !showDesignerModal} onOpenChange={onClose}>
<DialogContent className="flex h-[90vh] max-h-[950px] w-[98vw] max-w-[1600px] flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2 text-lg">
@ -525,34 +525,30 @@ export function ScreenSettingModal({
</DialogContent>
</Dialog>
{/* ScreenDesigner 전체 화면 모달 */}
<Dialog open={showDesignerModal} onOpenChange={setShowDesignerModal}>
<DialogContent className="max-w-[98vw] h-[95vh] p-0 overflow-hidden">
<DialogTitle className="sr-only"> </DialogTitle>
<div className="flex flex-col h-full">
<ScreenDesigner
selectedScreen={{
screenId: currentScreenId,
screenCode: `screen_${currentScreenId}`,
screenName: currentScreenName,
tableName: currentMainTable || "",
companyCode: companyCode || "*",
description: "",
isActive: "Y" as const,
createdDate: new Date(),
updatedDate: new Date(),
}}
onBackToList={async () => {
setShowDesignerModal(false);
// 디자이너에서 저장 후 모달 닫으면 데이터 새로고침
await loadData();
// 데이터 로드 완료 후 iframe 갱신
setIframeKey(prev => prev + 1);
}}
/>
</div>
</DialogContent>
</Dialog>
{/* ScreenDesigner 전체 화면 - Dialog 중첩 시 오버레이 컴포지팅으로 깜빡임 발생하여 직접 렌더링 */}
{/* ScreenDesigner 자체 SlimToolbar에 "목록으로" 닫기 버튼이 있으므로 별도 X 버튼 불필요 */}
{showDesignerModal && (
<div className="bg-background fixed inset-0 z-[1000] flex flex-col">
<ScreenDesigner
selectedScreen={{
screenId: currentScreenId,
screenCode: `screen_${currentScreenId}`,
screenName: currentScreenName,
tableName: currentMainTable || "",
companyCode: companyCode || "*",
description: "",
isActive: "Y" as const,
createdDate: new Date(),
updatedDate: new Date(),
}}
onBackToList={async () => {
setShowDesignerModal(false);
await loadData();
setIframeKey(prev => prev + 1);
}}
/>
</div>
)}
{/* TableSettingModal */}
{tableSettingTarget && (

View File

@ -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<KeyboardShortcutsModalProps> = ({
isOpen,
onClose,
}) => {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px] max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
. Mac에서는 Ctrl Cmd를 .
</DialogDescription>
</DialogHeader>
<div className="space-y-5">
{shortcutGroups.map((group) => (
<div key={group.title}>
<h3 className="text-sm font-semibold text-foreground mb-2">
{group.title}
</h3>
<div className="space-y-1">
{group.shortcuts.map((shortcut, idx) => (
<div
key={idx}
className="flex items-center justify-between rounded-md px-3 py-1.5 hover:bg-muted/50 transition-colors"
>
<span className="text-sm text-muted-foreground">
{shortcut.description}
</span>
<div className="flex items-center gap-1">
{shortcut.keys.map((key, kidx) => (
<React.Fragment key={kidx}>
{kidx > 0 && (
<span className="text-xs text-muted-foreground">+</span>
)}
<kbd className="inline-flex h-6 min-w-[24px] items-center justify-center rounded border border-border bg-muted px-1.5 font-mono text-xs text-muted-foreground shadow-sm">
{key}
</kbd>
</React.Fragment>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -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<SlimToolbarProps> = ({
@ -85,6 +108,12 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
onOpenMultilangSettings,
isPanelOpen = false,
onTogglePanel,
selectedCount = 0,
onAlign,
onDistribute,
onMatchSize,
onToggleLabels,
onShowShortcuts,
}) => {
// 사용자 정의 해상도 상태
const [customWidth, setCustomWidth] = useState("");
@ -325,8 +354,100 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
)}
</div>
{/* 중앙: 정렬/배분 도구 (다중 선택 시 표시) */}
{selectedCount >= 2 && (onAlign || onDistribute || onMatchSize) && (
<div className="flex items-center space-x-1 rounded-md bg-blue-50 px-2 py-1">
{/* 정렬 */}
{onAlign && (
<>
<span className="mr-1 text-xs font-medium text-blue-700"></span>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("left")} title="좌측 정렬 (Alt+L)">
<AlignStartVertical className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("centerX")} title="가로 중앙 (Alt+C)">
<AlignCenterVertical className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("right")} title="우측 정렬 (Alt+R)">
<AlignEndVertical className="h-3.5 w-3.5" />
</Button>
<div className="mx-0.5 h-4 w-px bg-blue-200" />
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("top")} title="상단 정렬 (Alt+T)">
<AlignStartHorizontal className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("centerY")} title="세로 중앙 (Alt+M)">
<AlignCenterHorizontal className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("bottom")} title="하단 정렬 (Alt+B)">
<AlignEndHorizontal className="h-3.5 w-3.5" />
</Button>
</>
)}
{/* 배분 (3개 이상 선택 시) */}
{onDistribute && selectedCount >= 3 && (
<>
<div className="mx-1 h-4 w-px bg-blue-200" />
<span className="mr-1 text-xs font-medium text-blue-700"></span>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onDistribute("horizontal")} title="가로 균등 배분 (Alt+H)">
<AlignHorizontalSpaceAround className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onDistribute("vertical")} title="세로 균등 배분 (Alt+V)">
<AlignVerticalSpaceAround className="h-3.5 w-3.5" />
</Button>
</>
)}
{/* 크기 맞추기 */}
{onMatchSize && (
<>
<div className="mx-1 h-4 w-px bg-blue-200" />
<span className="mr-1 text-xs font-medium text-blue-700"></span>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onMatchSize("width")} title="너비 맞추기 (Alt+W)">
<RulerIcon className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onMatchSize("height")} title="높이 맞추기 (Alt+E)">
<RulerIcon className="h-3.5 w-3.5 rotate-90" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onMatchSize("both")} title="크기 모두 맞추기">
<Equal className="h-3.5 w-3.5" />
</Button>
</>
)}
<div className="mx-1 h-4 w-px bg-blue-200" />
<span className="text-xs text-blue-600">{selectedCount} </span>
</div>
)}
{/* 우측: 버튼들 */}
<div className="flex items-center space-x-2">
{/* 라벨 토글 버튼 */}
{onToggleLabels && (
<Button
variant="outline"
size="sm"
onClick={onToggleLabels}
className="flex items-center space-x-1"
title="라벨 일괄 표시/숨기기 (Alt+Shift+L)"
>
<Tag className="h-4 w-4" />
<span></span>
</Button>
)}
{/* 단축키 도움말 */}
{onShowShortcuts && (
<Button
variant="ghost"
size="icon"
onClick={onShowShortcuts}
className="h-9 w-9"
title="단축키 도움말 (?)"
>
<Keyboard className="h-4 w-4" />
</Button>
)}
{onPreview && (
<Button variant="outline" onClick={onPreview} className="flex items-center space-x-2">
<Smartphone className="h-4 w-4" />

View File

@ -122,7 +122,13 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn("w-full justify-between font-normal", className)}
className={cn(
"w-full justify-between font-normal",
"bg-transparent hover:bg-transparent", // 표준 Select와 동일한 투명 배경
"border-input shadow-xs", // 표준 Select와 동일한 테두리
"h-6 px-2 py-0 text-sm", // 표준 Select xs와 동일한 높이
className,
)}
style={style}
>
<span className="truncate flex-1 text-left">

View File

@ -0,0 +1,265 @@
/**
* //
*
* , , .
*/
import { ComponentData } from "@/types/screen";
// 정렬 모드 타입
export type AlignMode = "left" | "centerX" | "right" | "top" | "centerY" | "bottom";
// 배분 방향 타입
export type DistributeDirection = "horizontal" | "vertical";
// 크기 맞추기 모드 타입
export type MatchSizeMode = "width" | "height" | "both";
/**
*
* .
*/
export function alignComponents(
components: ComponentData[],
selectedIds: string[],
mode: AlignMode
): ComponentData[] {
const selected = components.filter((c) => selectedIds.includes(c.id));
if (selected.length < 2) return components;
let targetValue: number;
switch (mode) {
case "left":
// 가장 왼쪽 x값으로 정렬
targetValue = Math.min(...selected.map((c) => c.position.x));
return components.map((c) => {
if (!selectedIds.includes(c.id)) return c;
return { ...c, position: { ...c.position, x: targetValue } };
});
case "right":
// 가장 오른쪽 (x + width)로 정렬
targetValue = Math.max(...selected.map((c) => c.position.x + (c.size?.width || 100)));
return components.map((c) => {
if (!selectedIds.includes(c.id)) return c;
const width = c.size?.width || 100;
return { ...c, position: { ...c.position, x: targetValue - width } };
});
case "centerX":
// 가로 중앙 정렬 (전체 범위의 중앙)
{
const minX = Math.min(...selected.map((c) => c.position.x));
const maxX = Math.max(...selected.map((c) => c.position.x + (c.size?.width || 100)));
const centerX = (minX + maxX) / 2;
return components.map((c) => {
if (!selectedIds.includes(c.id)) return c;
const width = c.size?.width || 100;
return { ...c, position: { ...c.position, x: Math.round(centerX - width / 2) } };
});
}
case "top":
// 가장 위쪽 y값으로 정렬
targetValue = Math.min(...selected.map((c) => c.position.y));
return components.map((c) => {
if (!selectedIds.includes(c.id)) return c;
return { ...c, position: { ...c.position, y: targetValue } };
});
case "bottom":
// 가장 아래쪽 (y + height)로 정렬
targetValue = Math.max(...selected.map((c) => c.position.y + (c.size?.height || 40)));
return components.map((c) => {
if (!selectedIds.includes(c.id)) return c;
const height = c.size?.height || 40;
return { ...c, position: { ...c.position, y: targetValue - height } };
});
case "centerY":
// 세로 중앙 정렬 (전체 범위의 중앙)
{
const minY = Math.min(...selected.map((c) => c.position.y));
const maxY = Math.max(...selected.map((c) => c.position.y + (c.size?.height || 40)));
const centerY = (minY + maxY) / 2;
return components.map((c) => {
if (!selectedIds.includes(c.id)) return c;
const height = c.size?.height || 40;
return { ...c, position: { ...c.position, y: Math.round(centerY - height / 2) } };
});
}
default:
return components;
}
}
/**
*
* .
* 3 .
*/
export function distributeComponents(
components: ComponentData[],
selectedIds: string[],
direction: DistributeDirection
): ComponentData[] {
const selected = components.filter((c) => selectedIds.includes(c.id));
if (selected.length < 3) return components;
if (direction === "horizontal") {
// x 기준 정렬
const sorted = [...selected].sort((a, b) => a.position.x - b.position.x);
const first = sorted[0];
const last = sorted[sorted.length - 1];
// 첫 번째 ~ 마지막 컴포넌트 사이의 총 공간
const totalSpace = last.position.x + (last.size?.width || 100) - first.position.x;
// 컴포넌트들이 차지하는 총 너비
const totalComponentWidth = sorted.reduce((sum, c) => sum + (c.size?.width || 100), 0);
// 균등 간격
const gap = (totalSpace - totalComponentWidth) / (sorted.length - 1);
// ID -> 새 x 좌표 매핑
const newPositions = new Map<string, number>();
let currentX = first.position.x;
for (const comp of sorted) {
newPositions.set(comp.id, Math.round(currentX));
currentX += (comp.size?.width || 100) + gap;
}
return components.map((c) => {
if (!selectedIds.includes(c.id)) return c;
const newX = newPositions.get(c.id);
if (newX === undefined) return c;
return { ...c, position: { ...c.position, x: newX } };
});
} else {
// y 기준 정렬
const sorted = [...selected].sort((a, b) => a.position.y - b.position.y);
const first = sorted[0];
const last = sorted[sorted.length - 1];
const totalSpace = last.position.y + (last.size?.height || 40) - first.position.y;
const totalComponentHeight = sorted.reduce((sum, c) => sum + (c.size?.height || 40), 0);
const gap = (totalSpace - totalComponentHeight) / (sorted.length - 1);
const newPositions = new Map<string, number>();
let currentY = first.position.y;
for (const comp of sorted) {
newPositions.set(comp.id, Math.round(currentY));
currentY += (comp.size?.height || 40) + gap;
}
return components.map((c) => {
if (!selectedIds.includes(c.id)) return c;
const newY = newPositions.get(c.id);
if (newY === undefined) return c;
return { ...c, position: { ...c.position, y: newY } };
});
}
}
/**
*
* .
*/
export function matchComponentSize(
components: ComponentData[],
selectedIds: string[],
mode: MatchSizeMode,
referenceId?: string
): ComponentData[] {
const selected = components.filter((c) => selectedIds.includes(c.id));
if (selected.length < 2) return components;
// 기준 컴포넌트 (지정하지 않으면 첫 번째 선택된 컴포넌트)
const reference = referenceId
? selected.find((c) => c.id === referenceId) || selected[0]
: selected[0];
const refWidth = reference.size?.width || 100;
const refHeight = reference.size?.height || 40;
return components.map((c) => {
if (!selectedIds.includes(c.id)) return c;
const currentWidth = c.size?.width || 100;
const currentHeight = c.size?.height || 40;
let newWidth = currentWidth;
let newHeight = currentHeight;
if (mode === "width" || mode === "both") {
newWidth = refWidth;
}
if (mode === "height" || mode === "both") {
newHeight = refHeight;
}
return {
...c,
size: {
...c.size,
width: newWidth,
height: newHeight,
},
};
});
}
/**
*
* / .
* ,
*/
export function toggleAllLabels(components: ComponentData[], forceShow?: boolean): ComponentData[] {
// 현재 라벨이 숨겨진(labelDisplay === false) 컴포넌트가 있는지 확인
const hasHiddenLabel = components.some(
(c) => c.type === "widget" && (c.style as any)?.labelDisplay === false
);
// forceShow가 지정되면 그 값 사용, 아니면 자동 판단
// 숨겨진 라벨이 있으면 모두 표시, 아니면 모두 숨기기
const shouldShow = forceShow !== undefined ? forceShow : hasHiddenLabel;
return components.map((c) => {
// 위젯 타입만 라벨 토글 대상
if (c.type !== "widget") return c;
return {
...c,
style: {
...(c.style || {}),
labelDisplay: shouldShow,
} as any,
};
});
}
/**
* nudge ( )
* .
*/
export function nudgeComponents(
components: ComponentData[],
selectedIds: string[],
direction: "up" | "down" | "left" | "right",
distance: number = 1 // 기본 1px, Shift 누르면 10px
): ComponentData[] {
const dx = direction === "left" ? -distance : direction === "right" ? distance : 0;
const dy = direction === "up" ? -distance : direction === "down" ? distance : 0;
return components.map((c) => {
if (!selectedIds.includes(c.id)) return c;
return {
...c,
position: {
...c.position,
x: Math.max(0, c.position.x + dx),
y: Math.max(0, c.position.y + dy),
},
};
});
}

View File

@ -0,0 +1,189 @@
# Multi-Agent Orchestrator MCP Server v2.0
Cursor Agent CLI를 활용한 멀티에이전트 시스템입니다.
**Cursor Team Plan만으로 동작** - 외부 API 키 불필요!
## 아키텍처
```
┌─────────────────────────────────────────┐
│ Cursor IDE (PM Agent) │
│ Claude Opus 4.5 │
└────────────────────┬────────────────────┘
│ MCP Tools
┌────────────────┼────────────────┐
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│Backend │ │ DB │ │Frontend│
│ Agent │ │ Agent │ │ Agent │
│ via CLI│ │ via CLI│ │ via CLI│
│Sonnet │ │Sonnet │ │Sonnet │
└────────┘ └────────┘ └────────┘
↑ ↑ ↑
└──────────────┴───────────────┘
Cursor Agent CLI
(Team Plan 크레딧 사용)
```
## 특징
- **API 키 불필요**: Cursor Team Plan 크레딧만 사용
- **크로스 플랫폼**: Windows, Mac, Linux 지원
- **진짜 병렬 실행**: `parallel_ask`로 동시 작업
- **모델 티어링**: PM=Opus, Sub-agents=Sonnet
## 사전 요구사항
1. **Cursor Team/Pro Plan** 구독
2. **Cursor Agent CLI** 설치 및 로그인
```bash
# 설치 후 로그인 확인
agent status
```
## 설치
```bash
cd mcp-agent-orchestrator
npm install
npm run build
```
## Cursor 설정
### Windows
`.cursor/mcp.json`:
```json
{
"mcpServers": {
"agent-orchestrator": {
"command": "node",
"args": ["C:/Users/YOUR_USERNAME/ERP-node/mcp-agent-orchestrator/build/index.js"]
}
}
}
```
### Mac
`.cursor/mcp.json`:
```json
{
"mcpServers": {
"agent-orchestrator": {
"command": "node",
"args": ["/Users/YOUR_USERNAME/ERP-node/mcp-agent-orchestrator/build/index.js"]
}
}
}
```
**주의**: Mac에서 agent CLI가 PATH에 있어야 합니다.
```bash
# agent CLI 위치 확인
which agent
# 보통: ~/.cursor-agent/bin/agent 또는 /usr/local/bin/agent
# PATH에 없으면 추가 (.zshrc 또는 .bashrc)
export PATH="$HOME/.cursor-agent/bin:$PATH"
```
## 사용 가능한 도구
### ask_backend_agent
백엔드 전문가에게 질문/작업 요청
- API 설계, 서비스 로직, 라우팅
- 담당 폴더: `backend-node/src/`
### ask_db_agent
DB 전문가에게 질문/작업 요청
- 스키마, 쿼리, MyBatis 매퍼
- 담당 폴더: `src/com/pms/mapper/`, `db/`
### ask_frontend_agent
프론트엔드 전문가에게 질문/작업 요청
- React 컴포넌트, 페이지, 스타일
- 담당 폴더: `frontend/`
### parallel_ask
여러 전문가에게 동시에 질문 (진짜 병렬 실행!)
- 정보 수집 단계에서 유용
### get_agent_info
에이전트 시스템 정보 확인
## 워크플로우 예시
### 1단계: 정보 수집 (병렬)
```
parallel_ask([
{ agent: "backend", task: "현재 order 관련 API 구조 분석" },
{ agent: "db", task: "orders 테이블 스키마 분석" },
{ agent: "frontend", task: "주문 관련 컴포넌트 현황 분석" }
])
```
### 2단계: 개별 작업 (순차)
```
ask_db_agent("cursor 기반 페이징 쿼리 작성")
ask_backend_agent("GET /api/orders에 pagination 추가")
ask_frontend_agent("Pagination 컴포넌트 적용")
```
## 모델 설정
| Agent | Model | 역할 |
|-------|-------|------|
| PM (Cursor IDE) | Opus 4.5 | 전체 조율, 사용자 대화 |
| Backend | Sonnet 4.5 | API, 서비스 로직 |
| DB | Sonnet 4.5 | 스키마, 쿼리 |
| Frontend | Sonnet 4.5 | 컴포넌트, UI |
**비용 최적화**: PM만 Opus, 나머지는 Sonnet 사용
## 환경 변수
- `LOG_LEVEL`: 로그 레벨 (debug, info, warn, error)
## 트러블슈팅
### Windows: agent 명령어가 안 됨
```powershell
# PowerShell 실행 정책 확인
Get-ExecutionPolicy -List
# 필요시 변경
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
```
### Mac: agent 명령어를 찾을 수 없음
```bash
# agent CLI 위치 확인
ls -la ~/.cursor-agent/bin/
# PATH 추가
echo 'export PATH="$HOME/.cursor-agent/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc
```
### 응답이 오래 걸림
- 정상입니다! 각 에이전트 호출에 15-30초 소요
- `parallel_ask`로 병렬 처리하면 시간 절약
## 개발
```bash
# 개발 모드 (watch)
npm run dev
# 빌드
npm run build
# 테스트 실행
npm start
```
## 라이선스
MIT

1179
mcp-agent-orchestrator/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,29 @@
{
"name": "mcp-agent-orchestrator",
"version": "2.0.0",
"description": "Multi-Agent Orchestrator MCP Server using Cursor Agent CLI (Team Plan)",
"type": "module",
"main": "build/index.js",
"scripts": {
"build": "tsc",
"start": "node build/index.js",
"dev": "tsc --watch"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.0.0"
},
"engines": {
"node": ">=18.0.0"
},
"keywords": [
"cursor",
"mcp",
"multi-agent",
"ai",
"orchestrator"
]
}

View File

@ -0,0 +1,6 @@
/**
*
*/
export * from "./types.js";
export * from "./prompts.js";

View File

@ -0,0 +1,258 @@
/**
* Agent System Prompts v2.1
* All prompts in English for better token efficiency and model performance.
* Agents will respond in Korean based on user preferences.
*/
export const PM_PROMPT = `# Role
You are a PM (Project Manager) agent for ERP-node project.
Analyze user requests, distribute tasks to specialist agents (Backend, DB, Frontend),
and integrate results to create the final deliverable.
# Available Tools
- ask_backend_agent: Backend expert (API, services, routing)
- ask_db_agent: DB expert (schema, queries, migrations)
- ask_frontend_agent: Frontend expert (components, pages, styles)
- parallel_ask: Multiple experts simultaneously
# Work Process
1. Analyze request -> identify scope
2. If cross-domain (FE+BE+DB): use parallel_ask
3. If single domain: use specific agent
4. Integrate results -> report to user
# Task Distribution
- Backend Agent: backend-node/src/ (controllers, services, routes)
- DB Agent: db/, mapper/ (schema, migrations, queries)
- Frontend Agent: frontend/ (components, pages, lib)
# Response: Always concise! Summarize key findings only.`;
export const BACKEND_PROMPT = `# Role
You are a Backend specialist for ERP-node project.
Stack: Node.js + Express + TypeScript + PostgreSQL Raw Query.
# CRITICAL PROJECT RULES
## 1. Multi-tenancy (ABSOLUTE MUST!)
- ALL queries MUST include company_code filter
- Use req.user!.companyCode from auth middleware
- NEVER trust client-sent company_code
- Super Admin (company_code = "*") sees all data
- Regular users CANNOT see company_code = "*" data
## 2. Super Admin Visibility
- If req.user.companyCode !== "*", add: WHERE company_code != '*'
- Super admin users must be hidden from regular company users
## 3. Required Code Pattern
\`\`\`typescript
const companyCode = req.user!.companyCode;
if (companyCode === "*") {
query = "SELECT * FROM table ORDER BY company_code";
} else {
query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'";
params = [companyCode];
}
\`\`\`
# Your Domain (ONLY these!)
- backend-node/src/controllers/
- backend-node/src/services/
- backend-node/src/routes/
- backend-node/src/middleware/
- backend-node/src/utils/
# NOT Your Domain
- frontend/ -> Frontend Agent
- db/migrations/ -> DB Agent
- Direct SQL schema design -> DB Agent
# Code Rules
1. TypeScript strict mode
2. Error handling with try/catch
3. Comments in Korean
4. Follow existing code patterns
5. Use logger for important operations
# Response Format (JSON) - BE CONCISE!
{
"status": "success | partial | failed",
"confidence": "high | medium | low",
"result": {
"summary": "one line summary",
"details": "brief explanation",
"files_affected": ["paths"],
"code_changes": [{"file": "path", "action": "create|modify", "content": "code"}]
},
"needs_from_others": [],
"questions": []
}
# IMPORTANT: Keep responses SHORT. No unnecessary explanations.`;
export const DB_PROMPT = `# Role
You are a Database specialist for ERP-node project.
Stack: PostgreSQL + Raw Query (no ORM). Migrations in db/migrations/.
# CRITICAL PROJECT RULES
## 1. Multi-tenancy (ABSOLUTE MUST!)
- ALL tables MUST have company_code VARCHAR(20) NOT NULL
- ALL queries MUST filter by company_code
- JOINs MUST include company_code matching condition
- Subqueries MUST include company_code filter
- Aggregates (COUNT, SUM) MUST filter by company_code
- CREATE INDEX on company_code for every table
## 2. company_code = "*" Meaning
- NOT shared/common data!
- Super admin ONLY data
- Regular companies CANNOT see it: WHERE company_code != '*'
## 3. Required SQL Patterns
\`\`\`sql
-- Standard query pattern
SELECT * FROM table_name
WHERE company_code = $1
AND company_code != '*'
ORDER BY created_date DESC;
-- JOIN pattern (company_code matching required!)
SELECT a.*, b.name
FROM table_a a
LEFT JOIN table_b b ON a.ref_id = b.id
AND a.company_code = b.company_code
WHERE a.company_code = $1;
\`\`\`
## 4. Migration Rules
- File naming: NNN_description.sql (e.g., 034_add_new_table.sql)
- Always include company_code column
- Always create index on company_code
- Add foreign key to company_info(company_code) when possible
# Your Domain (ONLY these!)
- db/migrations/
- SQL schema design
- Query optimization
- Index strategy
# NOT Your Domain
- API logic -> Backend Agent
- Frontend -> Frontend Agent
- Business logic decisions -> PM Agent
# Code Rules
1. PostgreSQL syntax only
2. Parameter binding ($1, $2) - prevent SQL injection
3. Consider indexes for frequently queried columns
4. Use COALESCE for NULL handling
5. Use TIMESTAMPTZ for dates
# Response Format (JSON) - BE CONCISE!
{
"status": "success | partial | failed",
"confidence": "high | medium | low",
"result": {
"summary": "one line summary",
"details": "brief explanation",
"schema_info": {"tables": [], "columns": [], "indexes": []},
"code_changes": [{"file": "path", "action": "create|modify", "content": "sql"}]
},
"performance_notes": [],
"questions": []
}
# IMPORTANT: Keep responses SHORT. Focus on schema and queries only.`;
export const FRONTEND_PROMPT = `# Role
You are a Frontend specialist for ERP-node project.
Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui.
# CRITICAL PROJECT RULES
## 1. API Client (ABSOLUTE RULE!)
- NEVER use fetch() directly!
- ALWAYS use lib/api/ clients (Axios-based)
\`\`\`typescript
// FORBIDDEN
const res = await fetch('/api/flow/definitions');
// MUST USE
import { getFlowDefinitions } from '@/lib/api/flow';
const res = await getFlowDefinitions();
\`\`\`
## 2. shadcn/ui Style Rules
- Use CSS variables: bg-primary, text-muted-foreground (NOT bg-blue-500)
- No nested boxes: Card inside Card is FORBIDDEN
- Button variants: default, secondary, outline, ghost, destructive
- Responsive: mobile-first approach (sm:, md:, lg:)
- Modal standard: max-w-[95vw] sm:max-w-[500px]
## 3. Component Rules
- Functional components only
- Korean comments for code documentation
- Custom hooks for reusable logic
- TypeScript strict typing required
# Your Domain (ONLY these!)
- frontend/components/
- frontend/app/ or frontend/pages/
- frontend/lib/
- frontend/hooks/
- frontend/styles/
# NOT Your Domain
- backend-node/ -> Backend Agent
- DB schema -> DB Agent
- API endpoint decisions -> PM/Backend Agent
# Code Rules
1. TypeScript strict mode
2. React functional components with hooks
3. Prefer shadcn/ui components
4. Use cn() utility for conditional classes
5. Comments in Korean
# Response Format (JSON) - BE CONCISE!
{
"status": "success | partial | failed",
"confidence": "high | medium | low",
"result": {
"summary": "one line summary",
"details": "brief explanation",
"components_affected": ["list"],
"code_changes": [{"file": "path", "action": "create|modify", "content": "code"}]
},
"needs_from_others": [],
"ui_notes": [],
"questions": []
}
# IMPORTANT: Keep responses SHORT. No lengthy analysis unless explicitly asked.`;
// Agent configuration map
export const AGENT_CONFIGS = {
pm: {
model: 'claude-opus-4-5-20250214',
systemPrompt: PM_PROMPT,
maxTokens: 4096,
},
backend: {
model: 'claude-sonnet-4-20250514',
systemPrompt: BACKEND_PROMPT,
maxTokens: 4096,
},
db: {
model: 'claude-sonnet-4-20250514',
systemPrompt: DB_PROMPT,
maxTokens: 4096,
},
frontend: {
model: 'claude-sonnet-4-20250514',
systemPrompt: FRONTEND_PROMPT,
maxTokens: 4096,
},
} as const;

View File

@ -0,0 +1,63 @@
/**
* Multi-Agent System
*/
// 에이전트 타입
export type AgentType = 'pm' | 'backend' | 'db' | 'frontend';
// 에이전트 설정
export interface AgentConfig {
model: string;
systemPrompt: string;
maxTokens: number;
}
// 작업 요청
export interface TaskRequest {
agent: AgentType;
task: string;
context?: string;
}
// 작업 응답 상태
export type ResponseStatus = 'success' | 'partial' | 'failed' | 'need_clarification';
// 확신도
export type ConfidenceLevel = 'high' | 'medium' | 'low';
// 코드 변경
export interface CodeChange {
file: string;
action: 'create' | 'modify' | 'delete';
content?: string;
lineStart?: number;
lineEnd?: number;
}
// 에이전트 응답
export interface AgentResponse {
status: ResponseStatus;
confidence: ConfidenceLevel;
result?: {
summary: string;
details: string;
filesAffected?: string[];
codeChanges?: CodeChange[];
};
scopeViolations?: string[];
dependencies?: string[];
sideEffects?: string[];
alternatives?: string[];
questions?: string[];
needsFromOthers?: {
agent: AgentType;
request: string;
}[];
}
// 병렬 요청 결과
export interface ParallelResult {
agent: AgentType;
result: string;
error?: string;
}

View File

@ -0,0 +1,407 @@
#!/usr/bin/env node
/**
b * Multi-Agent Orchestrator MCP Server v2.0
*
* Cursor Agent CLI를
* - PM (Cursor IDE):
* - Sub-agents (agent CLI):
*
* AI Cursor Team Plan으로 !
* API !
*/
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { exec } from "child_process";
import { promisify } from "util";
import { platform } from "os";
import { AGENT_CONFIGS } from "./agents/prompts.js";
import { AgentType, ParallelResult } from "./agents/types.js";
import { logger } from "./utils/logger.js";
const execAsync = promisify(exec);
// OS 감지
const isWindows = platform() === "win32";
logger.info(`Platform detected: ${platform()} (isWindows: ${isWindows})`);
// MCP 서버 생성
const server = new Server(
{
name: "agent-orchestrator",
version: "2.0.0",
},
{
capabilities: {
tools: {},
},
}
);
/**
* Cursor Agent CLI를
* Cursor Team Plan - API !
*
* :
* - Windows: cmd /c "echo. | agent ..." (stdin )
* - Mac/Linux: ~/.local/bin/agent
*/
async function callAgentCLI(
agentType: AgentType,
task: string,
context?: string
): Promise<string> {
const config = AGENT_CONFIGS[agentType];
// 모델 선택: PM은 opus, 나머지는 sonnet
const model = agentType === 'pm' ? 'opus-4.5' : 'sonnet-4.5';
logger.info(`Calling ${agentType} agent via CLI`, { model, task: task.substring(0, 100) });
try {
const userMessage = context
? `${task}\n\n배경 정보:\n${context}`
: task;
// 프롬프트를 임시 파일에 저장하여 쉘 이스케이프 문제 회피
const fullPrompt = `${config.systemPrompt}\n\n---\n\n${userMessage}`;
// Base64 인코딩으로 특수문자 문제 해결
const encodedPrompt = Buffer.from(fullPrompt).toString('base64');
let cmd: string;
let shell: string;
const agentPath = isWindows ? 'agent' : `${process.env.HOME}/.local/bin/agent`;
if (isWindows) {
// Windows: PowerShell을 통해 Base64 디코딩 후 실행
cmd = `powershell -Command "$prompt = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${encodedPrompt}')); echo $prompt | ${agentPath} --model ${model} --print"`;
shell = 'powershell.exe';
} else {
// Mac/Linux: echo로 base64 디코딩 후 파이프
cmd = `echo "${encodedPrompt}" | base64 -d | ${agentPath} --model ${model} --print`;
shell = '/bin/bash';
}
logger.debug(`Executing: ${agentPath} --model ${model} --print`);
const { stdout, stderr } = await execAsync(cmd, {
cwd: process.cwd(),
maxBuffer: 10 * 1024 * 1024, // 10MB buffer
timeout: 300000, // 5분 타임아웃
shell,
env: {
...process.env,
PATH: `${process.env.HOME}/.local/bin:${process.env.PATH}`,
},
});
if (stderr && !stderr.includes('warning') && !stderr.includes('info')) {
logger.warn(`${agentType} agent stderr`, { stderr: stderr.substring(0, 500) });
}
logger.info(`${agentType} agent completed via CLI`);
return stdout.trim();
} catch (error) {
logger.error(`${agentType} agent CLI error`, error);
throw error;
}
}
/**
*
*/
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "ask_backend_agent",
description:
"백엔드 전문가에게 질문하거나 작업을 요청합니다. " +
"API 설계, 서비스 로직, 라우팅, 미들웨어 관련 작업에 사용하세요. " +
"담당 폴더: backend-node/src/ (Cursor Agent CLI, sonnet-4.5 모델)" +
"주의: 단순 파일 읽기/수정은 PM이 직접 처리하세요. 깊은 분석이 필요할 때만 호출!",
inputSchema: {
type: "object" as const,
properties: {
task: {
type: "string",
description: "백엔드 에이전트에게 요청할 작업 내용",
},
context: {
type: "string",
description: "작업에 필요한 배경 정보 (선택사항)",
},
},
required: ["task"],
},
},
{
name: "ask_db_agent",
description:
"DB 전문가에게 질문하거나 작업을 요청합니다. " +
"스키마 설계, SQL 쿼리, MyBatis 매퍼, 마이그레이션 관련 작업에 사용하세요. " +
"담당 폴더: src/com/pms/mapper/, db/ (Cursor Agent CLI, sonnet-4.5 모델)" +
"주의: 단순 스키마 확인은 PM이 직접 처리하세요. 복잡한 쿼리 설계/최적화 시에만 호출!",
inputSchema: {
type: "object" as const,
properties: {
task: {
type: "string",
description: "DB 에이전트에게 요청할 작업 내용",
},
context: {
type: "string",
description: "작업에 필요한 배경 정보 (선택사항)",
},
},
required: ["task"],
},
},
{
name: "ask_frontend_agent",
description:
"프론트엔드 전문가에게 질문하거나 작업을 요청합니다. " +
"React 컴포넌트, 페이지, 스타일링, 상태관리 관련 작업에 사용하세요. " +
"담당 폴더: frontend/ (Cursor Agent CLI, sonnet-4.5 모델)" +
"주의: 단순 컴포넌트 읽기/수정은 PM이 직접 처리하세요. 구조 분석이 필요할 때만 호출!",
inputSchema: {
type: "object" as const,
properties: {
task: {
type: "string",
description: "프론트엔드 에이전트에게 요청할 작업 내용",
},
context: {
type: "string",
description: "작업에 필요한 배경 정보 (선택사항)",
},
},
required: ["task"],
},
},
{
name: "parallel_ask",
description:
"여러 전문가에게 동시에 질문합니다 (진짜 병렬 실행!). " +
"3개 영역(FE+BE+DB) 크로스도메인 분석이 필요할 때만 사용하세요. " +
"주의: 호출 시간이 오래 걸림! 단순 작업은 PM이 직접 처리하는 게 훨씬 빠릅니다. " +
"적합한 경우: 전체 아키텍처 파악, 대규모 리팩토링 계획, 크로스도메인 영향 분석",
inputSchema: {
type: "object" as const,
properties: {
requests: {
type: "array",
description: "각 에이전트에게 보낼 요청 목록",
items: {
type: "object",
properties: {
agent: {
type: "string",
enum: ["backend", "db", "frontend"],
description: "요청할 에이전트 타입",
},
task: {
type: "string",
description: "해당 에이전트에게 요청할 작업",
},
context: {
type: "string",
description: "배경 정보 (선택사항)",
},
},
required: ["agent", "task"],
},
},
},
required: ["requests"],
},
},
{
name: "get_agent_info",
description:
"에이전트 시스템의 현재 상태와 사용 가능한 에이전트 정보를 확인합니다.",
inputSchema: {
type: "object" as const,
properties: {},
},
},
],
};
});
/**
*
*/
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
logger.info(`Tool called: ${name}`);
try {
switch (name) {
case "ask_backend_agent": {
const { task, context } = args as { task: string; context?: string };
const result = await callAgentCLI("backend", task, context);
return {
content: [{ type: "text" as const, text: result }],
};
}
case "ask_db_agent": {
const { task, context } = args as { task: string; context?: string };
const result = await callAgentCLI("db", task, context);
return {
content: [{ type: "text" as const, text: result }],
};
}
case "ask_frontend_agent": {
const { task, context } = args as { task: string; context?: string };
const result = await callAgentCLI("frontend", task, context);
return {
content: [{ type: "text" as const, text: result }],
};
}
case "parallel_ask": {
const { requests } = args as {
requests: Array<{
agent: "backend" | "db" | "frontend";
task: string;
context?: string;
}>;
};
logger.info(`Parallel ask to ${requests.length} agents (TRUE PARALLEL!)`);
// 진짜 병렬 실행! 모든 에이전트가 동시에 작업
const results: ParallelResult[] = await Promise.all(
requests.map(async (req) => {
try {
const result = await callAgentCLI(req.agent, req.task, req.context);
return { agent: req.agent, result };
} catch (error) {
return {
agent: req.agent,
result: "",
error: error instanceof Error ? error.message : "Unknown error",
};
}
})
);
// 결과를 보기 좋게 포맷팅
const formattedResults = results.map((r) => {
const header = `\n${"=".repeat(60)}\n## ${r.agent.toUpperCase()} Agent 응답\n${"=".repeat(60)}\n`;
if (r.error) {
return `${header}❌ 에러: ${r.error}`;
}
return `${header}${r.result}`;
});
return {
content: [
{
type: "text" as const,
text: formattedResults.join("\n"),
},
],
};
}
case "get_agent_info": {
const info = {
system: "Multi-Agent Orchestrator v2.0",
version: "2.0.0",
backend: "Cursor Agent CLI (Team Plan)",
cliPath: `${process.env.HOME}/.local/bin/agent`,
apiKey: "NOT REQUIRED! Using Cursor Team Plan credits",
agents: {
pm: {
role: "Project Manager",
model: "opus-4.5 (Cursor IDE에서 직접)",
description: "전체 조율, 사용자 의도 파악, 작업 분배",
},
backend: {
role: "Backend Specialist",
model: "sonnet-4.5 (via Agent CLI)",
description: "API, 서비스 로직, 라우팅 담당",
folder: "backend-node/src/",
},
db: {
role: "Database Specialist",
model: "sonnet-4.5 (via Agent CLI)",
description: "스키마, 쿼리, 마이그레이션 담당",
folder: "src/com/pms/mapper/, db/",
},
frontend: {
role: "Frontend Specialist",
model: "sonnet-4.5 (via Agent CLI)",
description: "컴포넌트, 페이지, 스타일링 담당",
folder: "frontend/",
},
},
features: {
parallel_execution: true,
cursor_team_plan: true,
cursor_agent_cli: true,
separate_api_key: false,
cross_platform: true,
},
usage: {
single_agent: "ask_backend_agent, ask_db_agent, ask_frontend_agent",
parallel: "parallel_ask로 여러 에이전트 동시 호출",
workflow: "1. parallel_ask로 정보 수집 → 2. 개별 에이전트로 작업 분배",
},
};
return {
content: [
{
type: "text" as const,
text: JSON.stringify(info, null, 2),
},
],
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (error) {
logger.error(`Tool error: ${name}`, error);
return {
content: [
{
type: "text" as const,
text: `❌ 에러 발생: ${error instanceof Error ? error.message : "Unknown error"}`,
},
],
isError: true,
};
}
});
/**
*
*/
async function main() {
logger.info("Starting Multi-Agent Orchestrator MCP Server v2.0...");
logger.info(`Backend: Cursor Agent CLI (${process.env.HOME}/.local/bin/agent)`);
logger.info("Credits: Cursor Team Plan - No API Key Required!");
const transport = new StdioServerTransport();
await server.connect(transport);
logger.info("MCP Server connected and ready!");
}
main().catch((error) => {
logger.error("Server failed to start", error);
process.exit(1);
});

View File

@ -0,0 +1,55 @@
/**
*
*/
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
const LOG_LEVELS: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
// 환경변수로 로그 레벨 설정 (기본: info)
const currentLevel = (process.env.LOG_LEVEL as LogLevel) || 'info';
function shouldLog(level: LogLevel): boolean {
return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
}
function formatMessage(level: LogLevel, message: string, data?: unknown): string {
const timestamp = new Date().toISOString();
const prefix = `[${timestamp}] [${level.toUpperCase()}]`;
if (data) {
return `${prefix} ${message} ${JSON.stringify(data, null, 2)}`;
}
return `${prefix} ${message}`;
}
export const logger = {
debug(message: string, data?: unknown): void {
if (shouldLog('debug')) {
console.error(formatMessage('debug', message, data));
}
},
info(message: string, data?: unknown): void {
if (shouldLog('info')) {
console.error(formatMessage('info', message, data));
}
},
warn(message: string, data?: unknown): void {
if (shouldLog('warn')) {
console.error(formatMessage('warn', message, data));
}
},
error(message: string, data?: unknown): void {
if (shouldLog('error')) {
console.error(formatMessage('error', message, data));
}
},
};

View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "build"]
}