wing-ops/docs/MENU-TAB-GUIDE.md
htlee 13d6ca69e2 refactor(db): DDL 스크립트 현행화 + wing_auth→auth 스키마 문서 전면 수정
- database/schema/ 14개 DDL 파일 신규 생성 (운영 DB pg_dump 기반)
- database/seed/ 14개 초기 데이터 파일 분리
- database/_deprecated/로 구 init.sql, auth_init.sql 이동
- database/README.md 신규 작성 (DB 아키텍처, 설치 절차)
- docs/ 6개 가이드 문서 wing_auth→auth 스키마 구조로 수정
- README.md, CLAUDE.md wing 단일 DB 구조 반영

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 15:16:38 +09:00

19 KiB

새 메뉴 탭 추가 가이드

새로운 메뉴 탭을 추가하는 전체 절차를 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)
  • TopBarenabled && hasPermission 조건으로 탭을 필터링하고 order 순 정렬
  • App.tsxrenderView가 탭 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/seed/05_auth_settings.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. 뷰 컴포넌트 (보일러플레이트)

서브탭이 없는 간단한 탭:

// 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 패턴):

// 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 서비스 (보일러플레이트)

// 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)

// 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 추가

// 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 추가

// 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곳을 수정한다:

// 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과 빈 문자열을 설정한다:

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

Step 3: featureIds.ts에 FEATURE_ID 등록

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

// 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. 라우터 (보일러플레이트)

// 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. 서비스 (보일러플레이트)

// 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, wing 스키마) 또는 authPool (wing DB re-export, auth 스키마)
에러 처리 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에 라우트 등록

// 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에 메뉴 항목 추가

// 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. seed/05_auth_settings.sql에 menu.config 초기 JSON 추가

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

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

-- 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단계: 프론트엔드 파일 생성

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단계: 프론트엔드 기존 파일 수정

--- 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 등록

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

4단계: 백엔드 파일 생성

  • backend/src/monitoring/monitoringRouter.ts 생성
  • backend/src/monitoring/monitoringService.ts 생성

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

--- 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/seed/05_auth_settings.sql 의 menu.config JSON에 항목 추가

6단계: 검증

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/seed/05_auth_settings.sql menu.config 초기 JSON 업데이트
  • SQL 실행 검증

배포 후

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