# 새 메뉴 탭 추가 가이드

새로운 메뉴 탭을 추가하는 전체 절차를 5단계로 설명한다.
board 탭을 기준 템플릿으로 사용하며, 각 단계별 실제 코드 예시를 제공한다.

> **소요 시간**: 약 20~30분 (기본 CRUD 탭 기준)

---

## 메뉴 시스템 아키텍처

```
[DB] AUTH_SETTING (menu.config JSON)
  |
  v  GET /api/menus
[Backend] settingsService.ts (DEFAULT_MENU_CONFIG, VALID_MENU_IDS)
  |
  v  API
[Frontend] menuStore.ts  -->  TopBar.tsx (탭 렌더링, enabled && hasPermission 필터링)
                         -->  App.tsx (renderView 라우팅)
```

- **DB**가 메뉴 정의의 단일 소스 (id, label, icon, enabled, order)
- **TopBar**는 `enabled && hasPermission` 조건으로 탭을 필터링하고 `order` 순 정렬
- **App.tsx**의 `renderView`가 탭 ID에 따라 뷰 컴포넌트를 매핑
- **admin** 탭은 메뉴 관리 대상에서 제외 (TopBar에서 별도 아이콘 버튼으로 접근)

---

## 수정 파일 요약

| 단계 | 파일 | 작업 |
|------|------|------|
| **Step 1** | `frontend/src/tabs/{탭명}/components/{TabName}View.tsx` | 뷰 컴포넌트 생성 |
| | `frontend/src/tabs/{탭명}/services/{tabName}Api.ts` | API 서비스 생성 |
| | `frontend/src/tabs/{탭명}/index.ts` | re-export |
| **Step 2** | `frontend/src/common/types/navigation.ts` | MainTab 타입 추가 |
| | `frontend/src/App.tsx` | import + renderView case 추가 |
| | `frontend/src/common/hooks/useSubMenu.ts` | 서브메뉴 설정 (서브탭이 있는 경우) |
| **Step 3** | `frontend/src/common/constants/featureIds.ts` | FEATURE_ID 등록 |
| **Step 4** | `backend/src/{도메인}/{domain}Router.ts` | 라우터 생성 |
| | `backend/src/{도메인}/{domain}Service.ts` | 서비스 생성 |
| **Step 5** | `backend/src/server.ts` | 라우트 등록 |
| | `backend/src/settings/settingsService.ts` | DEFAULT_MENU_CONFIG 추가 |
| | `database/auth_init.sql` | menu.config 초기 JSON 추가 |
| | `database/migration/NNN_{domain}.sql` | DB 마이그레이션 |

---

## Step 1: 프론트엔드 탭 패키지 생성

### 1-1. 디렉토리 구조

```
frontend/src/tabs/{탭명}/
  components/
    {TabName}View.tsx      # 메인 뷰 컴포넌트
  services/
    {tabName}Api.ts        # API 서비스
  index.ts                 # re-export
```

### 1-2. 뷰 컴포넌트 (보일러플레이트)

서브탭이 **없는** 간단한 탭:

```tsx
// frontend/src/tabs/monitoring/components/MonitoringView.tsx

export function MonitoringView() {
  return (
    <div className="flex flex-1 overflow-hidden">
      <div className="flex-1 relative overflow-hidden">
        <div className="flex flex-col h-full bg-bg-0">
          {/* 헤더 */}
          <div className="flex items-center justify-between px-8 py-4 border-b border-border bg-bg-1">
            <div className="text-sm font-bold text-text-1">실시간 모니터링</div>
          </div>

          {/* 본문 */}
          <div className="flex-1 overflow-auto px-8 py-6">
            <p className="text-text-3 text-sm">준비 중입니다.</p>
          </div>
        </div>
      </div>
    </div>
  );
}
```

서브탭이 **있는** 탭 (board 패턴):

```tsx
// frontend/src/tabs/monitoring/components/MonitoringView.tsx

import { useSubMenu } from '@common/hooks/useSubMenu';

export function MonitoringView() {
  const { activeSubTab } = useSubMenu('monitoring');

  const renderContent = () => {
    switch (activeSubTab) {
      case 'dashboard':
        return <div>대시보드 컨텐츠</div>;
      case 'alerts':
        return <div>알림 컨텐츠</div>;
      default:
        return <div>준비 중입니다.</div>;
    }
  };

  return (
    <div className="flex flex-1 overflow-hidden">
      <div className="flex-1 relative overflow-hidden">
        {renderContent()}
      </div>
    </div>
  );
}
```

### 1-3. API 서비스 (보일러플레이트)

```ts
// frontend/src/tabs/monitoring/services/monitoringApi.ts

import { api } from '@common/services/api';

// ============================================================
// 인터페이스
// ============================================================

export interface MonitoringItem {
  sn: number;
  title: string;
  status: string;
  regDtm: string;
}

export interface MonitoringListResponse {
  items: MonitoringItem[];
  totalCount: number;
  page: number;
  size: number;
}

export interface MonitoringListParams {
  search?: string;
  page?: number;
  size?: number;
}

export interface CreateMonitoringInput {
  title: string;
  status?: string;
}

// ============================================================
// API 함수
// ============================================================

export async function fetchMonitoringList(
  params?: MonitoringListParams,
): Promise<MonitoringListResponse> {
  const response = await api.get<MonitoringListResponse>('/monitoring', { params });
  return response.data;
}

export async function fetchMonitoringDetail(sn: number): Promise<MonitoringItem> {
  const response = await api.get<MonitoringItem>(`/monitoring/${sn}`);
  return response.data;
}

export async function createMonitoring(input: CreateMonitoringInput): Promise<{ sn: number }> {
  const response = await api.post<{ sn: number }>('/monitoring', input);
  return response.data;
}
```

### 1-4. index.ts (re-export)

```ts
// frontend/src/tabs/monitoring/index.ts

export { MonitoringView } from './components/MonitoringView';
```

> **참고**: 기존 탭의 index.ts 패턴과 동일하다. 모든 탭은 index.ts에서 메인 뷰만 export한다.

---

## Step 2: navigation.ts에 MainTab 추가 + App.tsx 라우팅

### 2-1. MainTab 타입에 ID 추가

```ts
// frontend/src/common/types/navigation.ts

// Before
export type MainTab = 'prediction' | 'hns' | 'rescue' | ... | 'admin';

// After (새 탭 ID를 admin 앞에 추가)
export type MainTab = 'prediction' | 'hns' | 'rescue' | ... | 'monitoring' | 'admin';
```

### 2-2. App.tsx에 import + renderView case 추가

```tsx
// frontend/src/App.tsx

// 1. import 추가
import { MonitoringView } from '@tabs/monitoring';

// 2. renderView switch에 case 추가
const renderView = () => {
  switch (activeMainTab) {
    // ... 기존 case들 ...
    case 'monitoring':
      return <MonitoringView />;
    // admin은 항상 마지막
    case 'admin':
      return <AdminView />;
    default:
      return <div className="flex items-center justify-center h-full text-text-3">준비 중입니다...</div>;
  }
};
```

### 2-3. 서브메뉴 설정 (서브탭이 있는 경우)

서브탭이 있다면 `useSubMenu.ts`에 3곳을 수정한다:

```ts
// frontend/src/common/hooks/useSubMenu.ts

// 1. subMenuConfigs 에 서브탭 배열 추가
const subMenuConfigs: Record<MainTab, SubMenuItem[] | null> = {
  // ... 기존 설정 ...
  monitoring: [
    { id: 'dashboard', label: '대시보드', icon: '📊' },
    { id: 'alerts', label: '알림 관리', icon: '🔔' },
  ],
};

// 2. subMenuState 에 기본 서브탭 추가
const subMenuState: Record<MainTab, string> = {
  // ... 기존 상태 ...
  monitoring: 'dashboard',
};
```

서브탭이 **없으면** `null`과 빈 문자열을 설정한다:

```ts
monitoring: null,    // subMenuConfigs
monitoring: '',      // subMenuState
```

---

## Step 3: featureIds.ts에 FEATURE_ID 등록

FEATURE_ID는 RBAC 권한 검사와 감사 로그에 사용된다.
형식: `'{메인탭}:{서브탭}'`

```ts
// frontend/src/common/constants/featureIds.ts

export const FEATURE_IDS = {
  // ... 기존 항목 ...

  // monitoring
  'monitoring:dashboard': '모니터링 대시보드',
  'monitoring:alerts': '알림 관리',
} as const;
```

> **동기화 필수**: 여기에 등록한 키는 백엔드의 `AUTH_PERM.RSRC_CD`와 일치해야 한다.
> 서브탭이 없는 탭은 `'{탭명}:main'` 형태로 하나만 등록한다.

---

## Step 4: 백엔드 모듈 생성

### 4-1. 디렉토리 구조

```
backend/src/{도메인}/
  {domain}Router.ts      # Express 라우터 (요청 파싱, 응답 포맷)
  {domain}Service.ts     # 비즈니스 로직 + DB 쿼리
```

### 4-2. 라우터 (보일러플레이트)

```ts
// backend/src/monitoring/monitoringRouter.ts

import { Router } from 'express';
import { requireAuth, requirePermission } from '../auth/authMiddleware.js';
import { AuthError } from '../auth/authService.js';
import { listItems, getItem, createItem } from './monitoringService.js';

const router = Router();

// GET /api/monitoring -- 목록 조회
router.get('/', requireAuth, requirePermission('monitoring', 'READ'), async (req, res) => {
  try {
    const { search, page, size } = req.query;
    const result = await listItems({
      search: search as string | undefined,
      page: page ? parseInt(page as string, 10) : undefined,
      size: size ? parseInt(size as string, 10) : undefined,
    });
    res.json(result);
  } catch (err) {
    console.error('[monitoring] 목록 조회 오류:', err);
    res.status(500).json({ error: '목록 조회 중 오류가 발생했습니다.' });
  }
});

// GET /api/monitoring/:sn -- 상세 조회
router.get('/:sn', requireAuth, requirePermission('monitoring', 'READ'), async (req, res) => {
  try {
    const sn = parseInt(req.params.sn as string, 10);
    if (isNaN(sn)) {
      res.status(400).json({ error: '유효하지 않은 번호입니다.' });
      return;
    }
    const item = await getItem(sn);
    res.json(item);
  } catch (err) {
    if (err instanceof AuthError) {
      res.status(err.status).json({ error: err.message });
      return;
    }
    console.error('[monitoring] 상세 조회 오류:', err);
    res.status(500).json({ error: '조회 중 오류가 발생했습니다.' });
  }
});

// POST /api/monitoring -- 등록
router.post('/', requireAuth, requirePermission('monitoring', 'CREATE'), async (req, res) => {
  try {
    const { title, status } = req.body;
    if (!title) {
      res.status(400).json({ error: '제목은 필수입니다.' });
      return;
    }
    const result = await createItem({
      title,
      status,
      authorId: req.user!.sub,
    });
    res.status(201).json(result);
  } catch (err) {
    if (err instanceof AuthError) {
      res.status(err.status).json({ error: err.message });
      return;
    }
    console.error('[monitoring] 등록 오류:', err);
    res.status(500).json({ error: '등록 중 오류가 발생했습니다.' });
  }
});

export default router;
```

### 4-3. 서비스 (보일러플레이트)

```ts
// backend/src/monitoring/monitoringService.ts

import { wingPool } from '../db/wingDb.js';
import { AuthError } from '../auth/authService.js';

// ============================================================
// 인터페이스
// ============================================================

interface MonitoringItem {
  sn: number;
  title: string;
  status: string;
  authorId: string;
  regDtm: string;
}

interface ListItemsInput {
  search?: string;
  page?: number;
  size?: number;
}

interface ListItemsResult {
  items: MonitoringItem[];
  totalCount: number;
  page: number;
  size: number;
}

interface CreateItemInput {
  title: string;
  status?: string;
  authorId: string;
}

// ============================================================
// CRUD 함수
// ============================================================

export async function listItems(input: ListItemsInput): Promise<ListItemsResult> {
  const page = input.page && input.page > 0 ? input.page : 1;
  const size = input.size && input.size > 0 ? Math.min(input.size, 100) : 20;
  const offset = (page - 1) * size;

  let whereClause = "WHERE USE_YN = 'Y'";
  const params: (string | number)[] = [];
  let paramIdx = 1;

  if (input.search) {
    whereClause += ` AND TITLE ILIKE $${paramIdx}`;
    params.push(`%${input.search}%`);
    paramIdx++;
  }

  // 전체 건수
  const countResult = await wingPool.query(
    `SELECT COUNT(*) as cnt FROM MONITORING ${whereClause}`,
    params,
  );
  const totalCount = parseInt(countResult.rows[0].cnt, 10);

  // 목록
  const listParams = [...params, size, offset];
  const listResult = await wingPool.query(
    `SELECT SN, TITLE, STATUS, AUTHOR_ID, REG_DTM
     FROM MONITORING
     ${whereClause}
     ORDER BY REG_DTM DESC
     LIMIT $${paramIdx++} OFFSET $${paramIdx}`,
    listParams,
  );

  const items: MonitoringItem[] = listResult.rows.map((r: Record<string, unknown>) => ({
    sn: r.sn as number,
    title: r.title as string,
    status: r.status as string,
    authorId: r.author_id as string,
    regDtm: r.reg_dtm as string,
  }));

  return { items, totalCount, page, size };
}

export async function getItem(sn: number): Promise<MonitoringItem> {
  const result = await wingPool.query(
    `SELECT SN, TITLE, STATUS, AUTHOR_ID, REG_DTM
     FROM MONITORING
     WHERE SN = $1 AND USE_YN = 'Y'`,
    [sn],
  );

  if (result.rows.length === 0) {
    throw new AuthError('데이터를 찾을 수 없습니다.', 404);
  }

  const r = result.rows[0];
  return {
    sn: r.sn,
    title: r.title,
    status: r.status,
    authorId: r.author_id,
    regDtm: r.reg_dtm,
  };
}

export async function createItem(input: CreateItemInput): Promise<{ sn: number }> {
  if (!input.title || input.title.trim().length === 0) {
    throw new AuthError('제목은 필수입니다.', 400);
  }

  const result = await wingPool.query(
    `INSERT INTO MONITORING (TITLE, STATUS, AUTHOR_ID)
     VALUES ($1, $2, $3)
     RETURNING SN`,
    [input.title.trim(), input.status || 'ACTIVE', input.authorId],
  );

  return { sn: result.rows[0].sn };
}
```

### 주요 패턴 요약

| 항목 | 패턴 |
|------|------|
| DB Pool | `wingPool` (wing DB) 또는 `authPool` (wing_auth DB) |
| 에러 처리 | `AuthError(message, status)` 활용 |
| 논리 삭제 | `USE_YN = 'Y'/'N'` 컬럼 사용, DELETE 대신 UPDATE |
| 페이징 | `LIMIT $N OFFSET $M`, 기본 size 20, 최대 100 |
| 인증 | `requireAuth` (JWT 검증) + `requirePermission(resource, operation)` |
| 작성자 | `req.user!.sub` (JWT payload에서 USER_ID 추출) |

---

## Step 5: server.ts 라우트 등록 + DB 마이그레이션

### 5-1. server.ts에 라우트 등록

```ts
// backend/src/server.ts

// 1. import 추가
import monitoringRouter from './monitoring/monitoringRouter.js';

// 2. 업무 API 라우트 등록 (기존 라우트 아래에)
app.use('/api/monitoring', monitoringRouter);
```

> **참고**: import 경로에 `.js` 확장자가 필요하다 (TypeScript ESM 빌드).

### 5-2. DEFAULT_MENU_CONFIG에 메뉴 항목 추가

```ts
// backend/src/settings/settingsService.ts

const DEFAULT_MENU_CONFIG: MenuConfigItem[] = [
  // ... 기존 10개 메뉴 ...
  { id: 'incidents', label: '통합조회', icon: '🔍', enabled: true, order: 10 },
  { id: 'monitoring', label: '실시간 모니터링', icon: '📡', enabled: true, order: 11 },
];
```

### 5-3. auth_init.sql에 menu.config 초기 JSON 추가

```sql
-- database/auth_init.sql 의 menu.config INSERT 문에 새 항목 추가
-- (신규 설치 시에만 적용. 기존 운영 DB는 관리자 UI에서 관리)
```

### 5-4. DB 마이그레이션 작성

```sql
-- database/migration/017_monitoring.sql

-- ============================================================
-- 마이그레이션 017: 모니터링 (MONITORING)
-- ============================================================

CREATE TABLE IF NOT EXISTS MONITORING (
  SN           SERIAL       PRIMARY KEY,
  TITLE        VARCHAR(200) NOT NULL,
  STATUS       VARCHAR(20)  NOT NULL DEFAULT 'ACTIVE',
  AUTHOR_ID    UUID         NOT NULL,
  USE_YN       CHAR(1)      NOT NULL DEFAULT 'Y',
  REG_DTM      TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
  MDFCN_DTM    TIMESTAMPTZ,

  CONSTRAINT FK_MONITORING_AUTHOR FOREIGN KEY (AUTHOR_ID)
    REFERENCES auth.AUTH_USER(USER_ID),
  CONSTRAINT CK_MONITORING_USE CHECK (USE_YN IN ('Y','N'))
);

COMMENT ON TABLE MONITORING IS '모니터링';
COMMENT ON COLUMN MONITORING.USE_YN IS '사용여부 (N=논리삭제)';

CREATE INDEX IF NOT EXISTS IDX_MONITORING_REG_DTM ON MONITORING(REG_DTM DESC);
```

> 마이그레이션 파일 네이밍: `NNN_{도메인}.sql` (NNN은 다음 순번).
> 자세한 마이그레이션 패턴은 `CRUD-API-GUIDE.md`를 참고한다.

---

## 실전 예시: "monitoring" 탭 추가 전체 흐름

### 1단계: 프론트엔드 파일 생성

```bash
mkdir -p frontend/src/tabs/monitoring/components
mkdir -p frontend/src/tabs/monitoring/services
```

- `frontend/src/tabs/monitoring/components/MonitoringView.tsx` 생성
- `frontend/src/tabs/monitoring/services/monitoringApi.ts` 생성
- `frontend/src/tabs/monitoring/index.ts` 생성

### 2단계: 프론트엔드 기존 파일 수정

```diff
--- frontend/src/common/types/navigation.ts
+ export type MainTab = '...' | 'monitoring' | 'admin';

--- frontend/src/App.tsx
+ import { MonitoringView } from '@tabs/monitoring';
  // renderView switch 내:
+     case 'monitoring':
+       return <MonitoringView />;

--- frontend/src/common/hooks/useSubMenu.ts
  // subMenuConfigs:
+   monitoring: null,
  // subMenuState:
+   monitoring: '',
```

### 3단계: FEATURE_ID 등록

```diff
--- frontend/src/common/constants/featureIds.ts
+ // monitoring
+ 'monitoring:main': '모니터링',
```

### 4단계: 백엔드 파일 생성

- `backend/src/monitoring/monitoringRouter.ts` 생성
- `backend/src/monitoring/monitoringService.ts` 생성

### 5단계: 백엔드 기존 파일 수정 + DB

```diff
--- backend/src/server.ts
+ import monitoringRouter from './monitoring/monitoringRouter.js';
+ app.use('/api/monitoring', monitoringRouter);

--- backend/src/settings/settingsService.ts
+ { id: 'monitoring', label: '실시간 모니터링', icon: '📡', enabled: true, order: 11 },
```

- `database/migration/017_monitoring.sql` 생성
- `database/auth_init.sql` 의 menu.config JSON에 항목 추가

### 6단계: 검증

```bash
cd frontend && npx tsc --noEmit    # TypeScript 컴파일 검증
cd frontend && npx eslint .         # ESLint 검증
cd backend && npx tsc --noEmit      # 백엔드 컴파일 검증
```

---

## 체크리스트

### 프론트엔드
- [ ] `frontend/src/tabs/{탭명}/components/{TabName}View.tsx` 생성
- [ ] `frontend/src/tabs/{탭명}/services/{tabName}Api.ts` 생성
- [ ] `frontend/src/tabs/{탭명}/index.ts` re-export 생성
- [ ] `navigation.ts` MainTab 타입에 새 ID 추가
- [ ] `App.tsx` import + renderView switch case 추가
- [ ] `useSubMenu.ts` subMenuConfigs + subMenuState 추가 (서브탭 있는 경우)
- [ ] `featureIds.ts` FEATURE_ID 등록
- [ ] `npx tsc --noEmit` 통과
- [ ] `npx eslint .` 통과

### 백엔드
- [ ] `backend/src/{도메인}/{domain}Router.ts` 생성
- [ ] `backend/src/{도메인}/{domain}Service.ts` 생성
- [ ] `server.ts` import + `app.use()` 등록
- [ ] `settingsService.ts` DEFAULT_MENU_CONFIG에 항목 추가
- [ ] `npx tsc --noEmit` 통과

### DB
- [ ] `database/migration/NNN_{domain}.sql` 마이그레이션 작성
- [ ] `database/auth_init.sql` menu.config 초기 JSON 업데이트
- [ ] SQL 실행 검증

### 배포 후
- [ ] 관리자 로그인 -> 메뉴 관리에서 새 메뉴 표시 확인
- [ ] 메뉴 활성화/비활성화 토글 동작 확인
- [ ] 권한 미부여 사용자에게 메뉴가 보이지 않는지 확인
