# 메뉴 = 대시보드 통합 설계 (MENU_INFO 단일 본체화)

> 2026-04-19 / gbpark
> INVYONE 규격 "메뉴 = 대시보드"를 실제 DB 스키마/로직 레벨까지 일치시킴.
> 이중 테이블(DASHBOARDS + MENU_INFO) + MENU_DESC 문자열 태그 sync 방식 → 완전 폐기.

---

## 1. 현재 상태 (제거 대상)

### 1-1. 이중 테이블

**DASHBOARDS** (신규, 본체 취급)
```
DASHBOARD_ID    VARCHAR PK     -- "dash_xxx"
NAME            VARCHAR
ICON            VARCHAR
DISPLAY_ORDER   INT
COMPANY_CODE    VARCHAR
USER_ID         VARCHAR        -- 개인화용 (nullable=공용)
IS_ACTIVE       CHAR(1)        -- 'Y'/'D'
CREATED_BY, CREATED_DATE, UPDATED_BY, UPDATED_DATE
```

**MENU_INFO** (기존 VEX, 사이드바 렌더용)
```
OBJID              VARCHAR PK (System.currentTimeMillis() 문자열)
PARENT_OBJ_ID      BIGINT/VARCHAR
MENU_NAME_KOR      VARCHAR
MENU_URL           VARCHAR      -- '/dashboard/{DASHBOARD_ID}' 패턴
MENU_DESC          VARCHAR      -- 'dashboard:{DASHBOARD_ID}' 태그 악용 중
MENU_TYPE          VARCHAR      -- '0'=admin / '1'=user
SEQ, WRITER, CREATED_DATE, STATUS, COMPANY_CODE, LANG_KEY, MENU_ICON
+ REL_MENU_AUTH (권한), MULTI_LANG_KEY_MASTER (다국어)
```

**차이 필드**: `USER_ID` 단 하나만 DASHBOARDS 고유. 나머지는 전부 MENU_INFO에 대응 존재.

### 1-2. 문자열 태그 sync (백엔드)

`backend-spring/src/main/java/com/erp/service/DashboardService.java`
- L20: `MENU_DESC_PREFIX = "dashboard:"`
- L35~73: `insertDashboard` — DASHBOARDS INSERT → MENU_INFO INSERT(태그 부여)
- L75~95: `updateDashboard` — DASHBOARDS UPDATE → `updateMenuByDesc`로 메뉴 sync
- L97~114: `deleteDashboard` — DASHBOARDS soft delete → `deleteMenuByDesc`로 메뉴 sync

`backend-spring/src/main/resources/mapper/admin.xml`
- L385~416: 대시보드 역참조 쿼리 3개
  - `selectMenuByDesc` / `updateMenuByDesc` / `deleteMenuByDesc`

### 1-3. 중복 엔드포인트

- `/api/dashboards` (DASHBOARDS 기반)
- `/api/menus/user` (MENU_INFO 기반, 사이드바 실제 사용)
- `/api/dashboards/sidebar/menu` (안 쓰이는데 만들어둠)

### 1-4. 프론트 dashboard_id 참조 (15파일)
```
app/(main)/dashboard/[dashboardId]/page.tsx
app/(main)/dashboard/page.tsx
app/(main)/admin/screenMng/dashboardList/[id]/page.tsx
components/dash/DashboardLayout.tsx
components/dash/DashboardSidebar.tsx
components/dashboard/DashboardViewer.tsx
components/control/ControlMode.tsx, ControlToolbar.tsx, FlowViewer.tsx
components/admin/dashboard/MenuAssignmentModal.tsx
components/admin/MenuFormModal.tsx
components/layout/AdminPageRenderer.tsx
lib/api/dashMenu.ts, businessRule.ts
stores/dashboardStore.ts
```

---

## 2. 통합 설계 (최종 구조)

### 2-1. 스키마

**MENU_INFO** (단일 본체, USER_ID 1컬럼만 추가)
```sql
ALTER TABLE MENU_INFO ADD COLUMN USER_ID VARCHAR(64);
```
- **회사 공용 대시보드 (기본값)**: `USER_ID IS NULL` — COMPANY_CODE로 구분, 같은 회사 사용자 모두 공유
- **개인 대시보드 (옵션)**: `USER_ID = <로그인 사용자 ID>` — 본인만 노출

> UI 측면: 대시보드 생성 시 "공유 범위" 선택이 **필수**. 기본 선택은 "회사 전체 공용".
> - `insertDashboard({ name, icon, is_personal: boolean })`
> - `is_personal=false` → USER_ID=NULL / `is_personal=true` → USER_ID=로그인 사용자

**DASHBOARD_CARDS** (FK를 OBJID로)
```sql
ALTER TABLE DASHBOARD_CARDS ADD COLUMN MENU_OBJID VARCHAR(50);
-- 데이터 이관 후
ALTER TABLE DASHBOARD_CARDS DROP COLUMN DASHBOARD_ID;
ALTER TABLE DASHBOARD_CARDS ALTER COLUMN MENU_OBJID SET NOT NULL;
```
- 테이블명은 유지 (`DASHBOARD_CARDS`). MENU 아래 카드들이라는 의미상 자연스러움
- FK만 `DASHBOARD_ID` → `MENU_OBJID`로 교체

**DASHBOARDS** — DROP (전체 폐기)

**MENU_INFO.MENU_DESC** — `'dashboard:...'` 태그 전부 NULL 처리 (본체 합침 = 태그 불필요)

### 2-2. 대시보드 정체성

- 대시보드 = MENU_INFO row 중 `MENU_TYPE='1'` + `MENU_URL='/dashboard/<OBJID>'`
- OBJID가 곧 대시보드 식별자 (더 이상 별도 dashboard_id 없음)
- 라우트: `/dashboard/12345...` (MENU_INFO.OBJID)
- 대시보드 아닌 사용자 메뉴(외부링크 등)는 `MENU_URL='https://...'`로 공존

### 2-3. API 일원화

- `GET /api/dashboards` — 내부적으로 `SELECT FROM MENU_INFO WHERE MENU_TYPE='1' AND MENU_URL LIKE '/dashboard/%'`
- `POST /api/dashboards` — MENU_INFO INSERT만 수행
- `PUT /api/dashboards/{objid}` — MENU_INFO UPDATE
- `DELETE /api/dashboards/{objid}` — MENU_INFO DELETE (또는 STATUS)
- `GET /api/dashboards/{objid}/cards`, `POST`, `PUT`, `DELETE` — DASHBOARD_CARDS.MENU_OBJID 기준
- `/api/dashboards/sidebar/menu` — **제거**. 사이드바는 `/api/menus/user` 하나만 사용

---

## 3. 마이그레이션 DDL/DML (승인 후 실행)

```sql
-- ═══ 0. 백업 ═══
CREATE TABLE DASHBOARDS_BAK_20260419 AS SELECT * FROM DASHBOARDS;
CREATE TABLE DASHBOARD_CARDS_BAK_20260419 AS SELECT * FROM DASHBOARD_CARDS;
CREATE TABLE MENU_INFO_BAK_20260419 AS
  SELECT * FROM MENU_INFO WHERE MENU_DESC LIKE 'dashboard:%';

-- ═══ 1. MENU_INFO에 USER_ID 컬럼 추가 ═══
ALTER TABLE MENU_INFO ADD COLUMN USER_ID VARCHAR(64);

-- ═══ 2. DASHBOARDS 중 대응되는 MENU_INFO가 없는 것만 보충 INSERT ═══
-- (sync 로직 덕에 대부분은 이미 매칭 row 존재)
INSERT INTO MENU_INFO (
  OBJID, MENU_TYPE, PARENT_OBJ_ID, MENU_NAME_KOR, MENU_URL,
  MENU_DESC, SEQ, WRITER, CREATED_DATE, STATUS, COMPANY_CODE,
  MENU_ICON, USER_ID
)
SELECT
  (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT + ROW_NUMBER() OVER (ORDER BY D.CREATED_DATE),
  '1', '0', D.NAME, '/dashboard/' || D.DASHBOARD_ID,
  NULL, D.DISPLAY_ORDER, D.CREATED_BY, D.CREATED_DATE,
  CASE WHEN D.IS_ACTIVE='Y' THEN 'active' ELSE 'inactive' END,
  D.COMPANY_CODE, D.ICON, D.USER_ID
FROM DASHBOARDS D
WHERE D.IS_ACTIVE='Y'
  AND NOT EXISTS (
    SELECT 1 FROM MENU_INFO M WHERE M.MENU_DESC = 'dashboard:' || D.DASHBOARD_ID
  );

-- ═══ 3. 기존 MENU_INFO에 USER_ID 채우기 (DASHBOARDS에 저장돼있던 값) ═══
UPDATE MENU_INFO M
SET USER_ID = D.USER_ID
FROM DASHBOARDS D
WHERE M.MENU_DESC = 'dashboard:' || D.DASHBOARD_ID;

-- ═══ 4. DASHBOARD_CARDS에 MENU_OBJID 컬럼 추가 & 매핑 ═══
ALTER TABLE DASHBOARD_CARDS ADD COLUMN MENU_OBJID BIGINT;

-- MENU_INFO.MENU_URL 패턴으로 매핑 (가장 확실)
UPDATE DASHBOARD_CARDS C
SET MENU_OBJID = M.OBJID
FROM MENU_INFO M
WHERE M.MENU_URL = '/dashboard/' || C.DASHBOARD_ID;

-- (fallback: 태그 매핑)
UPDATE DASHBOARD_CARDS C
SET MENU_OBJID = M.OBJID
FROM MENU_INFO M
WHERE C.MENU_OBJID IS NULL
  AND M.MENU_DESC = 'dashboard:' || C.DASHBOARD_ID;

-- 매핑 실패 row 검증
SELECT COUNT(*) FROM DASHBOARD_CARDS WHERE MENU_OBJID IS NULL AND IS_ACTIVE='Y';
-- 0이 아니면 중단하고 조사

-- ═══ 5. MENU_URL 재생성 (DASHBOARD_ID → OBJID) ═══
UPDATE MENU_INFO
SET MENU_URL = '/dashboard/' || OBJID::TEXT
WHERE MENU_URL LIKE '/dashboard/dash_%';

-- ═══ 6. 태그 제거 ═══
UPDATE MENU_INFO SET MENU_DESC = NULL WHERE MENU_DESC LIKE 'dashboard:%';

-- ═══ 7. DASHBOARD_CARDS 제약 ═══
ALTER TABLE DASHBOARD_CARDS DROP COLUMN DASHBOARD_ID;
ALTER TABLE DASHBOARD_CARDS ALTER COLUMN MENU_OBJID SET NOT NULL;

-- ═══ 8. DASHBOARDS DROP ═══
DROP TABLE DASHBOARDS;
```

**롤백 전략**: `_BAK_20260419` 테이블들에서 복원 가능. 폐기는 1주일 모니터링 후.

---

## 4. 백엔드 변경 목록

### 4-1. `DashboardService.java`
- `MENU_DESC_PREFIX` 상수 제거
- `insertDashboard` — MENU_INFO INSERT만, dashboard_id 생성 로직 제거, OBJID 생성 + 반환
- `updateDashboard` — `admin.updateMenu`로 통일
- `deleteDashboard` — `admin.deleteMenu` 사용
- `getDashboardList`/`getDashboardInfo` — MENU_INFO 기반 새 쿼리
- `getSidebarMenu` 메서드 제거

### 4-2. `DashboardController.java`
- `@PathVariable String dashboardId` → `@PathVariable Long menuObjid` (또는 String 유지하되 내부 Long 변환)
- `/sidebar/menu` 엔드포인트 제거

### 4-3. `admin.xml`
- `selectMenuByDesc`, `updateMenuByDesc`, `deleteMenuByDesc` 3개 쿼리 삭제
- `insertMenu`에 `USER_ID` 컬럼 추가

### 4-4. `dashboard.xml`
- DASHBOARDS 관련 CRUD 전부 MENU_INFO 기반으로 재작성
  - `getDashboardList`: `FROM MENU_INFO WHERE MENU_TYPE='1' AND MENU_URL LIKE '/dashboard/%'`
  - `getDashboardInfo`: `WHERE OBJID = #{objid}`
  - `insertDashboard`, `updateDashboard`, `deleteDashboard` — MENU_INFO 상대 쿼리
- `DASHBOARD_CARDS` 쿼리: `DASHBOARD_ID` → `MENU_OBJID` 치환
- `getSidebarMenu` 쿼리 삭제

---

## 5. 프론트엔드 변경 목록

### 5-1. `lib/api/dashMenu.ts`
- 파라미터 `dashboardId` → `menuObjid` (변수명 유지해도 값은 OBJID)
- `getSidebarMenu` 함수 제거
- 엔드포인트 URL 그대로 (`/api/dashboards/...`)

### 5-2. `app/(main)/dashboard/[dashboardId]/page.tsx`
- 라우트 param 이름은 `[dashboardId]` 유지해도 되나, 실제 값은 OBJID
- 또는 `[menuObjid]`로 rename (가독성 ↑, diff 커짐)
- **선택**: rename 하지 않음 (URL만 안 바뀌면 리다이렉트 영향 ↓)

### 5-3. `app/(main)/dashboard/page.tsx` (진입점)
- 첫 `menu_url`에서 OBJID 추출해서 라우트
- 또는 기존 getDashboardList 응답의 `objid` 필드 사용

### 5-4. 기타 15개 참조 파일
- `dashboard_id` → `menu_objid` 변수명 치환 (프론트 내부 네이밍)
- DB 응답 키도 백엔드와 맞춰 `menu_objid`로 통일

### 5-5. `contexts/MenuContext.tsx`
- 변경 없음. 이미 `/api/menus/user`만 호출 중이라 통합의 수혜자

### 5-6. 대시보드 생성 모달 (신규)
- 현재 `prompt()`로 이름만 받는 방식을 모달로 교체
- 필드: 이름 / 아이콘 / **공유 범위 (회사 공용 ● / 나만 보기 ○)**
- 사용 위치: `dashboard/page.tsx`, `dash/DashboardLayout.tsx` — 공통 `CreateDashboardModal` 컴포넌트로
- 사이드바 구분 표시: 개인 대시보드는 이름 옆 작은 `👤` 또는 v5 토큰의 서브톤 텍스트 (세부는 구현 시 결정)

---

## 6. 실행 순서 (의존성)

```
[1] 설계 문서 확정 ← 지금 여기
      ↓
[2] DB 현황 확인 (psql SELECT, 사용자 승인)
      ↓
[3] DDL 스크립트 최종 검토 + 사용자 승인
      ↓
[4] 백업 + 마이그레이션 실행
      ↓
[5] 백엔드 수정 + 빌드 성공 확인
      ↓
[6] 프론트엔드 수정 + 빌드 성공 확인
      ↓
[7] 도커 재기동 + 사이드바에서 대시보드 렌더 확인
      ↓
[8] 1주일 모니터링 후 _BAK 테이블 DROP
```

---

## 7. 리스크 / 고려사항

1. **OBJID URL 노출**
   - BIGINT 값이 URL에 그대로 보임 (e.g. `/dashboard/1713456789012`). VEX 관례와 일치
   - 사용자 메뉴 라우트 전반에서 이미 같은 패턴 사용 중이라 이질감 없음

2. **기존 즐겨찾기 북마크**
   - 내부 사용자라 영향 미미 (재로그인 시 MenuContext가 새로 로드)
   - 걱정되면 MENU_URL 저장 시 기존 dash_ id를 SLUG 필드로 보관 가능 (선택)

3. **템플릿-카드 관계**
   - 변경 없음. `DASHBOARD_CARDS.TEMPLATE_ID` → `TEMPLATES.TEMPLATE_ID` 그대로

4. **관리자 메뉴**
   - 영향 없음. `MENU_TYPE='0'`으로 분리 유지

5. **권한 (REL_MENU_AUTH)**
   - 자동 계승. 대시보드 생성 시 권한 row 별도 insert 필요하면 MenuService 기존 로직 따라감

---

## 8. 승인 체크리스트 (사용자)

- [ ] 설계 방향 OK (MENU_INFO 단일 본체)
- [ ] MENU_INFO에 `USER_ID` 1컬럼 추가 OK
- [ ] DASHBOARDS 테이블 DROP OK (백업 테이블 _BAK_20260419는 1주일 유지)
- [ ] DASHBOARD_CARDS.DASHBOARD_ID → MENU_OBJID 전환 OK
- [ ] `/api/dashboards/sidebar/menu` 엔드포인트 제거 OK
- [ ] URL 패턴 `/dashboard/<OBJID>` OK (기존 `/dashboard/dash_xxx`는 마이그레이션 시 일괄 교체)
- [ ] 현재 DB 데이터 확인을 위한 psql SELECT 권한 허용 OK
