# 새 메뉴 탭 추가 가이드 새로운 메뉴 탭을 추가하는 전체 절차를 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 (
{/* 헤더 */}
실시간 모니터링
{/* 본문 */}

준비 중입니다.

); } ``` 서브탭이 **있는** 탭 (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
대시보드 컨텐츠
; case 'alerts': return
알림 컨텐츠
; default: return
준비 중입니다.
; } }; return (
{renderContent()}
); } ``` ### 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 { 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/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 ; // 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/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 ; --- 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 실행 검증 ### 배포 후 - [ ] 관리자 로그인 -> 메뉴 관리에서 새 메뉴 표시 확인 - [ ] 메뉴 활성화/비활성화 토글 동작 확인 - [ ] 권한 미부여 사용자에게 메뉴가 보이지 않는지 확인