# 새 메뉴 탭 추가 가이드
새로운 메뉴 탭을 추가하는 전체 절차를 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/components/{탭명}/components/{TabName}View.tsx` | 뷰 컴포넌트 생성 |
| | `frontend/src/components/{탭명}/services/{tabName}Api.ts` | API 서비스 생성 |
| | `frontend/src/components/{탭명}/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/components/{탭명}/
components/
{TabName}View.tsx # 메인 뷰 컴포넌트
services/
{tabName}Api.ts # API 서비스
index.ts # re-export
```
### 1-2. 뷰 컴포넌트 (보일러플레이트)
서브탭이 **없는** 간단한 탭:
```tsx
// frontend/src/components/monitoring/components/MonitoringView.tsx
export function MonitoringView() {
return (
);
}
```
서브탭이 **있는** 탭 (board 패턴):
```tsx
// frontend/src/components/monitoring/components/MonitoringView.tsx
import { useSubMenu } from '@common/hooks/useSubMenu';
export function MonitoringView() {
const { activeSubTab } = useSubMenu('monitoring');
const renderContent = () => {
switch (activeSubTab) {
case 'dashboard':
return 대시보드 컨텐츠
;
case 'alerts':
return 알림 컨텐츠
;
default:
return 준비 중입니다.
;
}
};
return (
);
}
```
### 1-3. API 서비스 (보일러플레이트)
```ts
// frontend/src/components/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 {
const response = await api.get('/monitoring', { params });
return response.data;
}
export async function fetchMonitoringDetail(sn: number): Promise {
const response = await api.get(`/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/components/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 '@components/monitoring';
// 2. renderView switch에 case 추가
const renderView = () => {
switch (activeMainTab) {
// ... 기존 case들 ...
case 'monitoring':
return ;
// admin은 항상 마지막
case 'admin':
return ;
default:
return 준비 중입니다...
;
}
};
```
### 2-3. 서브메뉴 설정 (서브탭이 있는 경우)
서브탭이 있다면 `useSubMenu.ts`에 3곳을 수정한다:
```ts
// frontend/src/common/hooks/useSubMenu.ts
// 1. subMenuConfigs 에 서브탭 배열 추가
const subMenuConfigs: Record = {
// ... 기존 설정 ...
monitoring: [
{ id: 'dashboard', label: '대시보드', icon: '📊' },
{ id: 'alerts', label: '알림 관리', icon: '🔔' },
],
};
// 2. subMenuState 에 기본 서브탭 추가
const subMenuState: Record = {
// ... 기존 상태 ...
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 {
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) => ({
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 {
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/components/monitoring/components
mkdir -p frontend/src/components/monitoring/services
```
- `frontend/src/components/monitoring/components/MonitoringView.tsx` 생성
- `frontend/src/components/monitoring/services/monitoringApi.ts` 생성
- `frontend/src/components/monitoring/index.ts` 생성
### 2단계: 프론트엔드 기존 파일 수정
```diff
--- frontend/src/common/types/navigation.ts
+ export type MainTab = '...' | 'monitoring' | 'admin';
--- frontend/src/App.tsx
+ import { MonitoringView } from '@components/monitoring';
// renderView switch 내:
+ case 'monitoring':
+ return ;
--- 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/components/{탭명}/components/{TabName}View.tsx` 생성
- [ ] `frontend/src/components/{탭명}/services/{tabName}Api.ts` 생성
- [ ] `frontend/src/components/{탭명}/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 실행 검증
### 배포 후
- [ ] 관리자 로그인 -> 메뉴 관리에서 새 메뉴 표시 확인
- [ ] 메뉴 활성화/비활성화 토글 동작 확인
- [ ] 권한 미부여 사용자에게 메뉴가 보이지 않는지 확인