# WING-OPS 공통 로직 개발 가이드

개별 탭 개발자가 공통 영역(`frontend/src/common/`)과 백엔드 공통 모듈을 빠르게 이해하고
연동할 수 있도록 정리한 문서이다.
공통 기능을 추가/변경할 때 반드시 이 문서를 최신화할 것.

> **최종 갱신**: 2026-03-11 (KHOA API 교체 + Vite CORS 프록시 추가)

---

## 목차

1. [인증 시스템](#1-인증-시스템)
2. [RBAC 2차원 권한](#2-rbac-2차원-권한)
3. [API 통신 패턴](#3-api-통신-패턴)
4. [상태 관리](#4-상태-관리)
5. [메뉴 시스템](#5-메뉴-시스템)
6. [지도 (MapLibre GL + deck.gl)](#6-지도-maplibre-gl--deckgl)
7. [스타일링](#7-스타일링)
8. [감사 로그](#8-감사-로그)
9. [보안](#9-보안)

---

## 1. 인증 시스템

### 인증 흐름 개요

JWT 기반 세션 인증을 사용한다. 토큰은 HttpOnly 쿠키(`WING_SESSION`)로 관리되며,
프론트엔드 JavaScript에서는 토큰에 직접 접근할 수 없다.

```
[브라우저]                          [백엔드 (Express)]
    |                                    |
    |-- POST /auth/login (계정/비밀번호) -->|
    |                                    |-- JWT 생성
    |                                    |-- Set-Cookie: WING_SESSION=<token>; HttpOnly
    |<--- 200 { user }                   |
    |                                    |
    |-- GET /auth/me (쿠키 자동 포함)  -->|
    |                                    |-- 쿠키에서 JWT 검증
    |<--- 200 { user, permissions }      |
    |                                    |
    |-- POST /auth/logout              -->|
    |                                    |-- Set-Cookie: WING_SESSION=; expires=과거
    |<--- 200                            |
```

### authStore (Zustand) - 프론트엔드 인증 상태

인증 상태는 Zustand 스토어 하나로 관리한다.

```typescript
// frontend/src/common/store/authStore.ts
import { useAuthStore } from '@common/store/authStore';

// 상태 조회
const { user, isAuthenticated, isLoading, error } = useAuthStore();

// 사용자 정보 (AuthUser 타입)
interface AuthUser {
  id: string;                              // UUID
  account: string;                         // 로그인 계정
  name: string;                            // 사용자명
  rank: string | null;                     // 직급
  org: { sn: number; name: string; abbr: string } | null; // 소속 기관
  roles: string[];                         // ['ADMIN', 'USER'] 등
  permissions: Record<string, string[]>;   // { 'prediction': ['READ','CREATE'], ... }
}
```

### 로그인/로그아웃 호출 예시

```typescript
import { useAuthStore } from '@common/store/authStore';

// 컴포넌트 내부
const { login, logout, error, clearError } = useAuthStore();

// 일반 로그인
const handleLogin = async () => {
  try {
    await login(account, password);
    // 성공 시 user, isAuthenticated가 자동 갱신됨
  } catch {
    // error 상태에 메시지가 설정됨
  }
};

// 로그아웃
const handleLogout = async () => {
  await logout();
  // user=null, isAuthenticated=false로 초기화
  // 로그아웃 API 실패해도 클라이언트 상태는 초기화됨
};
```

### 세션 복원 (앱 시작 시)

App.tsx에서 마운트 직후 `checkSession()`을 호출하여 기존 쿠키가 유효한지 확인한다.

```typescript
// frontend/src/App.tsx
const { isAuthenticated, isLoading, checkSession } = useAuthStore();

useEffect(() => {
  checkSession();
}, [checkSession]);

// isLoading=true 동안 스플래시 표시
// 쿠키 유효 -> isAuthenticated=true, user 설정
// 쿠키 만료/없음 -> isAuthenticated=false, 로그인 페이지 표시
```

### Google OAuth 흐름

`@react-oauth/google`의 `GoogleOAuthProvider`를 사용한다.

```typescript
import { useAuthStore } from '@common/store/authStore';

const { googleLogin, pendingMessage } = useAuthStore();

// Google 로그인 (credential은 Google에서 발급한 ID 토큰)
const handleGoogleLogin = async (credential: string) => {
  await googleLogin(credential);
  // 성공: user, isAuthenticated 갱신
  // 승인 대기: pendingMessage에 메시지 설정 ("관리자 승인 후 로그인할 수 있습니다.")
};
```

환경변수 `VITE_GOOGLE_CLIENT_ID`가 설정되어 있어야 GoogleOAuthProvider가 활성화된다.

### 백엔드 인증 미들웨어

```typescript
// backend/src/auth/authMiddleware.ts
import { requireAuth, requireRole, requirePermission } from '../auth/authMiddleware.js';

// 1. 인증만 필요 (로그인한 사용자)
router.use(requireAuth);

// 2. 역할 기반 (관리자 전용 API)
router.use(requireRole('ADMIN'));

// 3. 리소스 x 오퍼레이션 기반 (일반 비즈니스 API)
router.post('/list', requirePermission('board:notice', 'READ'), handler);
```

`requireAuth` 통과 후 `req.user`에 담기는 JWT 페이로드:

```typescript
interface JwtPayload {
  sub: string;    // 사용자 UUID (USER_ID)
  acnt: string;   // 계정명 (USER_ACNT)
  name: string;   // 사용자명 (USER_NM)
  roles: string[];// 역할 코드 목록 ['ADMIN', 'USER']
}
```

---

## 2. RBAC 2차원 권한

### 권한 모델 구조

**리소스 트리(상속)** x **오퍼레이션(RCUD, 플랫)**의 2차원 모델이다.

```
AUTH_PERM 테이블: (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN)

리소스 트리 (AUTH_PERM_TREE)       오퍼레이션 (플랫)
+-- prediction                     READ   = 조회/열람
|   +-- prediction:analysis        CREATE = 생성
|   +-- prediction:list            UPDATE = 수정
|   +-- prediction:theory          DELETE = 삭제
+-- board
|   +-- board:notice
|   +-- board:data
+-- admin
    +-- admin:users
    +-- admin:permissions
```

### FEATURE_ID 체계

`frontend/src/common/constants/featureIds.ts`에 서브탭 단위 기능 식별자를 정의한다.
이 값은 `AUTH_PERM.RSRC_CD` 및 감사 로그 `ACTION_DTL`과 동기화된다.

```typescript
// frontend/src/common/constants/featureIds.ts
export const FEATURE_IDS = {
  // prediction
  'prediction:analysis': '확산 분석',
  'prediction:list': '시뮬레이션 목록',
  'prediction:theory': '확산 이론',
  'prediction:boom-theory': '오일펜스 배치 이론',

  // hns
  'hns:analysis': 'HNS 분석',
  'hns:list': 'HNS 시뮬레이션 목록',
  'hns:scenario': 'HNS 시나리오',
  // ...

  // admin
  'admin:users': '사용자 관리',
  'admin:permissions': '권한 매트릭스',
  'admin:menus': '메뉴 관리',
  'admin:settings': '시스템 설정',
} as const;

export type FeatureId = keyof typeof FEATURE_IDS;
```

형식: `'{메인탭}:{서브탭}'` (콜론으로 구분)

### permResolver 작동 방식

`backend/src/roles/permResolver.ts`의 핵심 규칙:

| 규칙 | 설명 |
|------|------|
| 1 | 부모 리소스의 READ가 N이면 자식의 **모든 오퍼레이션** 강제 N |
| 2 | 해당 (RSRC_CD, OPER_CD) 명시적 레코드 있으면 그 값 사용 |
| 3 | 명시적 레코드 없으면 부모의 **같은 OPER_CD** 상속 |
| 4 | 최상위까지 없으면 기본 N (거부) |

```
예시: board (READ:Y, CREATE:Y, UPDATE:Y, DELETE:N)
+-- board:notice
    +-- READ:    상속 Y (부모 READ Y)
    +-- CREATE:  상속 Y (부모 CREATE Y)
    +-- UPDATE:  명시적 N (override)
    +-- DELETE:  상속 N (부모 DELETE N)
```

키 구분자:
- 리소스 내부 경로: `:` (board:notice)
- 리소스-오퍼레이션 결합 (내부용): `::` (board:notice::READ)

다중 역할은 역할별 resolve 후 OR 연산 (하나라도 Y이면 Y).

### 프론트엔드에서 권한 체크

```typescript
import { useAuthStore } from '@common/store/authStore';

const { hasPermission } = useAuthStore();

// 기본 조회 권한 (operation 생략 시 'READ')
hasPermission('prediction');              // === hasPermission('prediction', 'READ')

// 명시적 오퍼레이션 지정
hasPermission('board:notice', 'CREATE');  // 공지사항 생성 권한
hasPermission('board:notice', 'DELETE');  // 공지사항 삭제 권한
hasPermission('admin:users', 'UPDATE');   // 사용자 수정 권한

// 조건부 렌더링 예시
{hasPermission('board:notice', 'CREATE') && (
  <button className="wing-btn wing-btn-primary">새 글 작성</button>
)}

{hasPermission('board:notice', 'DELETE') && (
  <button className="wing-btn wing-btn-danger" onClick={handleDelete}>삭제</button>
)}
```

`hasPermission` 내부 구현:

```typescript
// authStore.ts
hasPermission: (resource: string, operation?: string) => {
  const { user } = get();
  if (!user) return false;
  const ops = user.permissions[resource];
  if (!ops) return false;
  return ops.includes(operation ?? 'READ');
};
```

### 백엔드에서 API 보호

```typescript
// backend/src/board/boardRouter.ts
import { Router } from 'express';
import { requireAuth, requirePermission } from '../auth/authMiddleware.js';

const router = Router();
router.use(requireAuth);

// 조회
router.get('/',     requirePermission('board:notice', 'READ'),   listHandler);
router.get('/:sn', requirePermission('board:notice', 'READ'),   detailHandler);

// 생성/수정/삭제
router.post('/',          requirePermission('board:notice', 'CREATE'), createHandler);
router.post('/:sn/update', requirePermission('board:notice', 'UPDATE'), updateHandler);
router.post('/:sn/delete', requirePermission('board:notice', 'DELETE'), deleteHandler);

export default router;
```

`requirePermission`은 요청당 1회만 DB 조회하고 `req.resolvedPermissions`에 캐싱한다.
동일 요청 내에서 여러 `requirePermission`을 체이닝해도 DB 조회는 최초 1회만 발생한다.

---

## 3. API 통신 패턴

### Axios 인스턴스 구성

```typescript
// frontend/src/common/services/api.ts
import axios from 'axios';

export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api';

export const api = axios.create({
  baseURL: API_BASE_URL,
  headers: { 'Content-Type': 'application/json' },
  withCredentials: true,              // JWT 쿠키 자동 포함
  timeout: 30000,                     // 30초 타임아웃
  maxContentLength: 10 * 1024 * 1024, // 응답 최대 10MB
  maxBodyLength: 1 * 1024 * 1024,     // 요청 최대 1MB
});
```

주요 특징:
- `withCredentials: true`로 모든 요청에 HttpOnly 쿠키가 자동 포함됨
- 401 응답 시 인터셉터가 자동으로 authStore의 `logout()` 호출 (로그인 요청 제외)
- 에러 응답에서 민감한 정보를 제거하고 안전한 메시지만 반환

### 응답 인터셉터

```typescript
// 401 자동 로그아웃
api.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response) {
      const { status, data } = error.response;

      // 401: 인증 만료 -> 자동 로그아웃 (로그인 요청 제외)
      if (status === 401 && !error.config?.url?.includes('/auth/login')) {
        // authStore.logout() 호출
      }

      return Promise.reject({
        status,
        message: data?.error || data?.message || '요청 처리 중 오류가 발생했습니다.',
      });
    }
    return Promise.reject({ status: 0, message: '서버에 연결할 수 없습니다.' });
  }
);
```

### GET/POST only 정책

보안 취약점 점검 가이드에 따라 **GET/POST 메서드를 기본**으로 사용한다.

| URL 패턴 | HTTP Method | OPER_CD | 설명 |
|----------|-------------|---------|------|
| `/resource` | GET | READ | 목록 조회 |
| `/resource/:id` | GET | READ | 상세 조회 |
| `/resource` | POST | CREATE | 신규 생성 |
| `/resource/:id/update` | POST | UPDATE | 수정 |
| `/resource/:id/delete` | POST | DELETE | 삭제 |

PUT, DELETE, PATCH 등 기타 메서드는 사용하지 않는다.
오퍼레이션 코드(OPER_CD)는 HTTP Method가 아닌 **비즈니스 의미**로 결정한다.

> **참고**: 일부 레거시 API(authApi.ts의 관리자 API)에서 PUT/DELETE를 사용하는 코드가 남아있다.
> 신규 API는 반드시 GET/POST only 정책을 따를 것.

### 탭별 API 서비스 패턴

각 탭은 `tabs/{탭명}/services/{탭명}Api.ts`에 API 함수를 정의한다.

```typescript
// frontend/src/tabs/board/services/boardApi.ts
import { api } from '@common/services/api';

// 인터페이스 정의
export interface BoardPostItem {
  sn: number;
  categoryCd: string;
  title: string;
  authorId: string;
  authorName: string;
  viewCnt: number;
  pinnedYn: string;
  regDtm: string;
}

export interface BoardListResponse {
  items: BoardPostItem[];
  totalCount: number;
  page: number;
  size: number;
}

// API 함수
export async function fetchBoardPosts(params?: BoardListParams): Promise<BoardListResponse> {
  const response = await api.get<BoardListResponse>('/board', { params });
  return response.data;
}

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

패턴 요약:
1. `api` 인스턴스를 `@common/services/api`에서 import
2. 요청/응답 인터페이스를 같은 파일에 정의
3. 함수명: `fetch*` (조회), `create*` (생성), `update*` (수정), `delete*` (삭제)
4. 응답에서 `response.data`만 추출하여 반환

### 에러 처리 패턴

```typescript
// 컴포넌트에서의 에러 처리
const handleCreate = async () => {
  try {
    await createBoardPost({ categoryCd: 'notice', title, content });
    // 성공 처리
  } catch (err) {
    const message = (err as { message?: string })?.message || '생성에 실패했습니다.';
    // 에러 메시지 표시
  }
};
```

에러 코드별 동작:
- **401**: 인터셉터가 자동 로그아웃 (개발자 처리 불필요)
- **403**: 권한 부족 (`requirePermission` 미들웨어에서 반환)
- **400**: 입력값 오류 (보안 미들웨어 또는 비즈니스 검증)
- **500**: 서버 내부 오류 (운영 환경에서는 상세 메시지 숨김)

---

## 4. 상태 관리

### Zustand (클라이언트 상태)

전역 상태는 Zustand로 관리한다. 현재 정의된 스토어:

| 스토어 | 파일 | 용도 |
|--------|------|------|
| `useAuthStore` | `common/store/authStore.ts` | 인증 상태, 사용자 정보, 권한 |
| `useMenuStore` | `common/store/menuStore.ts` | 메뉴 설정 (표시/순서) |

#### authStore 주요 API

```typescript
import { useAuthStore } from '@common/store/authStore';

// 상태 구독 (컴포넌트 리렌더링 최적화)
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const user = useAuthStore((s) => s.user);
const hasPermission = useAuthStore((s) => s.hasPermission);

// 전체 상태 (필요시에만)
const { user, isAuthenticated, isLoading, error, pendingMessage } = useAuthStore();

// 액션
const { login, googleLogin, logout, checkSession, clearError } = useAuthStore();
```

#### menuStore 주요 API

```typescript
import { useMenuStore } from '@common/store/menuStore';

const { menuConfig, isLoaded, loadMenuConfig, setMenuConfig } = useMenuStore();

// menuConfig: MenuConfigItem[]
interface MenuConfigItem {
  id: string;      // 탭 ID (prediction, hns, ...)
  label: string;   // 표시 이름
  icon: string;    // 아이콘 (이모지)
  enabled: boolean; // 활성화 여부
  order: number;   // 정렬 순서
}
```

#### 새 스토어 작성 패턴

```typescript
// frontend/src/common/store/newStore.ts (공통) 또는
// frontend/src/tabs/{탭}/store/newStore.ts (탭 전용)
import { create } from 'zustand';

interface MyState {
  items: string[];
  isLoading: boolean;
  addItem: (item: string) => void;
  reset: () => void;
}

export const useMyStore = create<MyState>((set) => ({
  items: [],
  isLoading: false,
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
  reset: () => set({ items: [], isLoading: false }),
}));
```

### TanStack Query (서버 상태)

서버에서 조회하는 데이터는 TanStack Query로 캐싱/동기화한다.

```typescript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchBoardPosts, createBoardPost } from '@tabs/board/services/boardApi';

// 조회 (캐싱 + 자동 리페치)
const { data, isLoading, error } = useQuery({
  queryKey: ['board', 'posts', { categoryCd, page }],
  queryFn: () => fetchBoardPosts({ categoryCd, page }),
  staleTime: 1000 * 60 * 5, // 5분간 캐시 유지
});

// 생성 (뮤테이션 + 캐시 무효화)
const queryClient = useQueryClient();
const mutation = useMutation({
  mutationFn: createBoardPost,
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['board', 'posts'] });
  },
});
```

실제 사용 예시 (`useLayers` 훅):

```typescript
// frontend/src/common/hooks/useLayers.ts
import { useQuery } from '@tanstack/react-query';
import { fetchAllLayers, fetchLayerTree } from '../services/api';

export function useLayers() {
  return useQuery<Layer[], Error>({
    queryKey: ['layers'],
    queryFn: fetchAllLayers,
    staleTime: 1000 * 60 * 5, // 5분간 캐시 유지
    retry: 3,
  });
}

export function useLayerTree() {
  return useQuery<Layer[], Error>({
    queryKey: ['layers', 'tree'],
    queryFn: fetchLayerTree,
    staleTime: 1000 * 60 * 5,
    retry: 3,
  });
}
```

---

## 5. 메뉴 시스템

### menuStore 구조

DB 기반 동적 메뉴 구성이다. 관리자가 메뉴 표시 여부/순서를 설정하면 모든 사용자에게 반영된다.

```typescript
// 앱 시작 시 메뉴 설정 로드 (App.tsx)
const { loadMenuConfig } = useMenuStore();

useEffect(() => {
  if (isAuthenticated) {
    loadMenuConfig(); // GET /api/menus -> menuConfig 설정
  }
}, [isAuthenticated, loadMenuConfig]);
```

메뉴 설정 저장소:
- DB: `AUTH_SETTING` 테이블의 `menu.config` 키 (JSON 배열)
- API: `GET /api/menus` (조회), `PUT /api/menus` (관리자 수정)

### MainTab 타입

```typescript
// frontend/src/common/types/navigation.ts
export type MainTab =
  | 'prediction' | 'hns' | 'rescue' | 'reports' | 'aerial'
  | 'assets' | 'scat' | 'incidents' | 'board' | 'weather' | 'admin';
```

### 서브메뉴 시스템 (useSubMenu)

각 메인 탭은 하위 서브메뉴를 가질 수 있다. `useSubMenu` 훅이 이를 관리한다.

```typescript
// frontend/src/common/hooks/useSubMenu.ts
import { useSubMenu } from '@common/hooks/useSubMenu';

// 컴포넌트 내부 (예: HNSView)
const { activeSubTab, setActiveSubTab, subMenuConfig } = useSubMenu('hns');

// activeSubTab: 'analysis' (현재 선택된 서브탭 ID)
// setActiveSubTab: (subTab: string) => void (서브탭 전환)
// subMenuConfig: SubMenuItem[] | null (권한 기반 필터링된 서브메뉴 목록)
```

서브메뉴 설정은 `useSubMenu.ts` 내부의 `subMenuConfigs`에 정적으로 정의되어 있다:

```typescript
const subMenuConfigs: Record<MainTab, SubMenuItem[] | null> = {
  hns: [
    { id: 'analysis', label: '대기확산 분석', icon: '...' },
    { id: 'list', label: '분석 목록', icon: '...' },
    { id: 'scenario', label: '시나리오 관리', icon: '...' },
    // ...
  ],
  prediction: [
    { id: 'analysis', label: '유출유 확산분석', icon: '...' },
    // ...
  ],
  weather: null,  // 서브메뉴 없음
  // ...
};
```

특징:
- **권한 필터링**: `subMenuConfig`는 `hasPermission('{mainTab}:{subTabId}')`로 자동 필터링됨
- **감사 로그 자동 기록**: 서브탭 전환 시 `sendBeacon`으로 `SUBTAB_VIEW` 로그 기록
- **전역 상태**: 서브탭 상태는 모듈 레벨 변수로 관리되어 탭 전환 시 이전 상태 보존

### 크로스 뷰 네비게이션

어느 컴포넌트에서든 다른 메인 탭 + 서브탭으로 한번에 전환할 수 있다.

```typescript
import { navigateToTab } from '@common/hooks/useSubMenu';

// 유출유 확산분석 탭의 분석 목록으로 이동
navigateToTab('prediction', 'list');

// 게시판의 공지사항으로 이동
navigateToTab('board', 'notice');

// 보고서 생성 탭으로 이동 (카테고리 힌트 포함)
import { setReportGenCategory, navigateToTab } from '@common/hooks/useSubMenu';
setReportGenCategory(0); // 0=유출유, 1=HNS, 2=긴급구난
navigateToTab('reports', 'generate');
```

### 새 메뉴 탭 추가 시 공통 영역 연동

1. `MainTab` 타입에 새 탭 ID 추가
2. `useSubMenu.ts`의 `subMenuConfigs`에 서브메뉴 설정 추가
3. `featureIds.ts`의 `FEATURE_IDS`에 서브탭별 식별자 추가
4. `App.tsx`의 `renderView()` switch문에 뷰 컴포넌트 추가
5. DB `AUTH_PERM_TREE`에 리소스 트리 등록 (마이그레이션 SQL)

상세 절차는 `docs/MENU-TAB-GUIDE.md` 참조.

---

## 6. 지도 (MapLibre GL + deck.gl)

### 기술 스택

| 라이브러리 | 버전 | 용도 |
|-----------|------|------|
| MapLibre GL JS | 5.x | 기본 지도 렌더링 (WebGL) |
| @vis.gl/react-maplibre | 8.1 | React 바인딩 |
| deck.gl | 9.x | 고성능 데이터 시각화 레이어 |

### MapView 컴포넌트 구조

```typescript
// frontend/src/common/components/map/MapView.tsx
import { Map, Source, Layer } from '@vis.gl/react-maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
import { ScatterplotLayer, PathLayer, TextLayer } from '@deck.gl/layers';
```

기본 설정:

```typescript
// 남해안 중심 좌표 (여수 앞바다)
const DEFAULT_CENTER: [number, number] = [34.5, 127.8];
const DEFAULT_ZOOM = 10;

// CartoDB Dark Matter 베이스맵
const BASE_STYLE: StyleSpecification = {
  version: 8,
  sources: {
    'carto-dark': {
      type: 'raster',
      tiles: ['https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png'],
      tileSize: 256,
    },
  },
  layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }],
};
```

### deck.gl 레이어 추가 패턴

`MapboxOverlay`를 사용하여 MapLibre 위에 deck.gl 레이어를 오버레이한다.

```typescript
import { MapboxOverlay } from '@deck.gl/mapbox';
import { ScatterplotLayer, PathLayer } from '@deck.gl/layers';
import { useControl } from '@vis.gl/react-maplibre';

// deck.gl 오버레이 컨트롤 (MapView 내부에서 사용)
function DeckGLOverlay({ layers }: { layers: Layer[] }) {
  useControl(() => new MapboxOverlay({ layers, interleaved: true }));
  return null;
}

// 레이어 정의 예시
const scatterLayer = new ScatterplotLayer({
  id: 'spill-points',
  data: spillPoints,
  getPosition: (d) => [d.lon, d.lat],
  getRadius: (d) => d.radius,
  getFillColor: [6, 182, 212, 180],
  pickable: true,
});

const pathLayer = new PathLayer({
  id: 'boom-lines',
  data: boomLines,
  getPath: (d) => d.coordinates,
  getColor: [245, 158, 11, 200],
  getWidth: 3,
});
```

### 지도 유틸리티

```typescript
// frontend/src/common/components/map/mapUtils.ts

/** hex 색상(#rrggbb)을 deck.gl용 RGBA 배열로 변환 */
export function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
  const r = parseInt(hex.slice(1, 3), 16);
  const g = parseInt(hex.slice(3, 5), 16);
  const b = parseInt(hex.slice(5, 7), 16);
  return [r, g, b, alpha];
}
```

```typescript
// frontend/src/common/utils/coordinates.ts
import { decimalToDMS } from '@common/utils/coordinates';

// 십진수 -> 도분초 변환 (지도 좌표 표시용)
const dms = decimalToDMS(34.5, 127.8);
```

### WMS 레이어 (GeoServer)

```typescript
const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080';

// MapLibre의 Source/Layer로 WMS 타일 추가
<Source
  id="wms-layer"
  type="raster"
  tiles={[`${GEOSERVER_URL}/geoserver/wms?service=WMS&version=1.1.0&...`]}
  tileSize={256}
/>
<Layer id="wms-layer-display" type="raster" source="wms-layer" />
```

### 레이어 데이터 조회

```typescript
import { useLayers, useLayerTree, useWMSLayers } from '@common/hooks/useLayers';

// TanStack Query로 캐싱 (5분)
const { data: layers, isLoading } = useLayers();       // 전체 레이어
const { data: tree } = useLayerTree();                  // 계층 구조 트리
const { data: wmsLayers } = useWMSLayers();             // WMS 레이어만
```

---

## 7. 스타일링

### CSS 아키텍처 개요

Tailwind CSS 3의 `@layer` 지시자를 활용한 3단 계층 구조이다.

```css
/* frontend/src/index.css */
@import './common/styles/base.css';        /* @layer base */
@import './common/styles/components.css';   /* @layer components */
@import './common/styles/wing.css';        /* @layer components (wing-* 디자인 시스템) */

@tailwind base;
@tailwind components;
@tailwind utilities;
```

| 파일 | @layer | 내용 |
|------|--------|------|
| `base.css` | `base` | CSS 변수, 리셋, body 기본 스타일 |
| `components.css` | `components` | 도메인별 컴포넌트 클래스 (prd-*, combo-*, lyr-* 등) |
| `wing.css` | `components` | wing-* 디자인 시스템 (공통 UI 컴포넌트) |

### CSS 변수 시스템

`base.css`의 `:root`에 정의된 디자인 토큰:

```css
:root {
  /* 배경 (어두운 -> 밝은) */
  --bg0: #0a0e1a;    /* 최하위 배경, body */
  --bg1: #0f1524;    /* 사이드바, 모달 */
  --bg2: #121929;    /* 테이블 헤더, 세컨더리 배경 */
  --bg3: #1a2236;    /* 카드, 섹션, 버튼 배경 */
  --bgH: #1e2844;    /* 호버 배경 */

  /* 보더 */
  --bd:  #1e2a42;    /* 기본 보더 */
  --bdL: #2a3a5c;    /* 밝은 보더 (스크롤바 등) */

  /* 텍스트 */
  --t1: #edf0f7;     /* 기본 텍스트 (밝음) */
  --t2: #b0b8cc;     /* 보조 텍스트 */
  --t3: #8690a6;     /* 비활성/라벨 텍스트 */

  /* 시맨틱 색상 */
  --blue:   #3b82f6;
  --cyan:   #06b6d4;  /* 주요 액센트 */
  --red:    #ef4444;
  --orange: #f97316;
  --yellow: #eab308;
  --green:  #22c55e;
  --purple: #a855f7;

  /* 오일펜스 전용 */
  --boom:  #f59e0b;
  --boomH: #fbbf24;

  /* 폰트 */
  --fK: Noto Sans KR, sans-serif;   /* 한국어 */
  --fM: JetBrains Mono, monospace;  /* 모노스페이스 (수치 표시) */

  /* 라운드 */
  --rS: 6px;   /* small */
  --rM: 8px;   /* medium */
}
```

### cn() 유틸리티

`clsx`/`classnames` 대신 경량 유틸리티를 사용한다. falsy 값을 자동 필터링한다.

```typescript
// frontend/src/common/utils/cn.ts
export function cn(...classes: (string | false | null | undefined)[]): string {
  return classes.filter(Boolean).join(' ');
}
```

사용 예시:

```tsx
import { cn } from '@common/utils/cn';

<div className={cn(
  'wing-card',
  isActive && 'border-cyan-500',
  isDisabled && 'opacity-50',
)}>
  ...
</div>

<button className={cn(
  'wing-btn',
  variant === 'primary' && 'wing-btn-primary',
  variant === 'danger' && 'wing-btn-danger',
)}>
  {label}
</button>
```

### wing-* 디자인 시스템 클래스 목록

`wing.css`에 정의된 공통 UI 컴포넌트 클래스:

#### 레이아웃

| 클래스 | 용도 |
|--------|------|
| `.wing-panel` | 패널 컨테이너 (flex column, full height) |
| `.wing-panel-scroll` | 스크롤 가능한 패널 본문 (flex-1, scrollbar-thin) |
| `.wing-header-bar` | 패널 상단 헤더 바 (flex, border-bottom) |
| `.wing-sidebar` | 사이드바 (border-right, bg1 배경) |

#### 카드/섹션

| 클래스 | 용도 |
|--------|------|
| `.wing-card` | 카드 (rounded, p-4, border, bg3) |
| `.wing-card-sm` | 작은 카드 (p-3) |
| `.wing-section` | 섹션 블록 (mb-3 간격 포함) |
| `.wing-section-header` | 섹션 제목 (13px, bold) |
| `.wing-section-desc` | 섹션 설명 (10px, t3 색상) |

#### 타이포그래피

| 클래스 | 용도 |
|--------|------|
| `.wing-title` | 제목 (15px, bold) |
| `.wing-subtitle` | 부제목 (10px, t3 색상) |
| `.wing-label` | 라벨 (11px, semibold) |
| `.wing-value` | 값 표시 (11px, mono, semibold) |
| `.wing-meta` | 메타 정보 (9px, t3 색상) |

#### 버튼

| 클래스 | 용도 |
|--------|------|
| `.wing-btn` | 버튼 기본 (11px, semibold, cursor-pointer) |
| `.wing-btn-primary` | 주요 버튼 (cyan-blue 그라데이션) |
| `.wing-btn-secondary` | 보조 버튼 (bg3 배경, border) |
| `.wing-btn-outline` | 아웃라인 버튼 (투명 배경) |
| `.wing-btn-pdf` | PDF 버튼 (blue 계열) |
| `.wing-btn-danger` | 위험 버튼 (red 계열) |

```tsx
// 버튼 사용 예시
<button className="wing-btn wing-btn-primary">분석 시작</button>
<button className="wing-btn wing-btn-secondary">취소</button>
<button className="wing-btn wing-btn-danger">삭제</button>
```

#### 입력

| 클래스 | 용도 |
|--------|------|
| `.wing-input` | 텍스트 입력 (11px, bg0, border) |

```tsx
<input className="wing-input" placeholder="검색어 입력" />
```

#### 테이블

| 클래스 | 용도 |
|--------|------|
| `.wing-table` | 테이블 (10px, border-collapse) |
| `.wing-th` | 테이블 헤더 셀 (bg2, semibold) |
| `.wing-td` | 테이블 데이터 셀 |
| `.wing-tr-hover` | 행 호버 효과 |

```tsx
<table className="wing-table">
  <thead>
    <tr>
      <th className="wing-th">이름</th>
      <th className="wing-th">상태</th>
    </tr>
  </thead>
  <tbody>
    <tr className="wing-tr-hover">
      <td className="wing-td">테스트</td>
      <td className="wing-td">활성</td>
    </tr>
  </tbody>
</table>
```

#### 탭 바

| 클래스 | 용도 |
|--------|------|
| `.wing-tab-bar` | 탭 바 컨테이너 |
| `.wing-tab` | 개별 탭 (+ `.active` 클래스로 활성 상태) |

```tsx
<div className="wing-tab-bar">
  <div className={cn('wing-tab', activeTab === 'a' && 'active')} onClick={() => setTab('a')}>
    탭 A
  </div>
  <div className={cn('wing-tab', activeTab === 'b' && 'active')} onClick={() => setTab('b')}>
    탭 B
  </div>
</div>
```

#### 모달

| 클래스 | 용도 |
|--------|------|
| `.wing-overlay` | 모달 배경 오버레이 (fixed, blur) |
| `.wing-modal` | 모달 본체 (rounded-xl, shadow) |
| `.wing-modal-header` | 모달 헤더 (flex, border-bottom) |

```tsx
<div className="wing-overlay">
  <div className="wing-modal" style={{ width: 600 }}>
    <div className="wing-modal-header">
      <span className="wing-title">모달 제목</span>
      <button onClick={onClose}>X</button>
    </div>
    <div className="p-5">모달 내용</div>
  </div>
</div>
```

#### 배지/아이콘

| 클래스 | 용도 |
|--------|------|
| `.wing-badge` | 배지 (9px, bold, inline-flex) |
| `.wing-icon-badge` | 아이콘 배지 (40x40, rounded) |
| `.wing-icon-badge-sm` | 작은 아이콘 배지 (38x38) |

#### 유틸리티

| 클래스 | 용도 |
|--------|------|
| `.wing-divider` | 수평 구분선 |
| `.wing-kv-row` | 키-값 행 (flex, justify-between) |
| `.wing-kv-label` | 키 라벨 (10px, t3) |
| `.wing-kv-value` | 값 (11px, semibold, mono) |

```tsx
<div className="wing-kv-row">
  <span className="wing-kv-label">유출량</span>
  <span className="wing-kv-value">150 kL</span>
</div>
```

### 스타일링 작성 원칙

1. **Tailwind 유틸리티 우선**: 단순한 스타일은 Tailwind 클래스 사용
2. **wing-* 클래스**: 반복되는 UI 패턴은 wing-* 시스템 클래스 사용
3. **CSS 변수**: 색상은 반드시 CSS 변수 참조 (`var(--cyan)`, `var(--bg3)` 등)
4. **인라인 스타일 지양**: 불가피한 경우(동적 계산값)에만 사용
5. **!important 금지**

---

## 8. 감사 로그

### 자동 기록 (탭 이동)

App.tsx에서 메인 탭 전환을 감지하여 자동으로 감사 로그를 전송한다.
개별 탭 개발자는 별도 작업이 필요 없다.

```typescript
// App.tsx (자동 적용)
useEffect(() => {
  if (!isAuthenticated) return;
  const blob = new Blob(
    [JSON.stringify({ action: 'TAB_VIEW', detail: activeMainTab })],
    { type: 'text/plain' }
  );
  navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob);
}, [activeMainTab, isAuthenticated]);
```

### 서브탭 자동 기록

`useSubMenu` 훅 내부에서 서브탭 전환 시 자동으로 `SUBTAB_VIEW` 로그를 기록한다.

```typescript
// useSubMenu.ts 내부 (자동 적용)
useEffect(() => {
  if (!isAuthenticated || !activeSubTab) return;
  const resourcePath = `${mainTab}:${activeSubTab}`;
  const blob = new Blob(
    [JSON.stringify({ action: 'SUBTAB_VIEW', detail: resourcePath })],
    { type: 'text/plain' }
  );
  navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob);
}, [mainTab, activeSubTab, isAuthenticated]);
```

### useFeatureTracking 훅

특정 기능 진입 시 감사 로그를 명시적으로 기록해야 하는 경우 사용한다.

```typescript
// frontend/src/common/hooks/useFeatureTracking.ts
import { useFeatureTracking } from '@common/hooks/useFeatureTracking';

// 컴포넌트 내부
useFeatureTracking('aerial:media');  // 진입 시 1회 기록
```

### 수동 기록

특정 작업에 대해 명시적으로 감사 로그를 기록하려면:

```typescript
import { API_BASE_URL } from '@common/services/api';

// sendBeacon 사용 (비동기, 페이지 언로드 시에도 전송 보장)
const blob = new Blob(
  [JSON.stringify({ action: 'ADMIN_ACTION', detail: '사용자 승인: user123' })],
  { type: 'text/plain' }
);
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob);
```

### ACTION_CD 코드 체계

| 코드 | 설명 | 기록 주체 |
|------|------|-----------|
| `TAB_VIEW` | 메인 탭 이동 | App.tsx (자동) |
| `SUBTAB_VIEW` | 서브 탭 이동 | useSubMenu (자동) |
| `ADMIN_ACTION` | 관리자 작업 | 수동 |
| `API_CALL` | API 호출 | 향후 확장 |
| `LOGIN` | 로그인 | 향후 확장 |
| `LOGOUT` | 로그아웃 | 향후 확장 |

### 백엔드 감사 로그 서비스

```typescript
// backend/src/audit/auditService.ts
import { insertAuditLog } from './auditService.js';

await insertAuditLog({
  userId: req.user.sub,
  actionCd: 'API_CALL',
  actionDtl: '/board/create',
  httpMethod: 'POST',
  crudType: 'INSERT',
  reqUrl: req.originalUrl,
  ipAddr: req.ip,
  userAgent: req.headers['user-agent'],
});
```

### 감사 로그 테이블 (AUTH_AUDIT_LOG)

| 컬럼 | 타입 | 설명 |
|------|------|------|
| LOG_SN | SERIAL PK | 로그 순번 |
| USER_ID | UUID | 사용자 ID |
| ACTION_CD | VARCHAR(30) | 액션 코드 |
| ACTION_DTL | VARCHAR(100) | 액션 상세 (탭ID 등) |
| HTTP_METHOD | VARCHAR(10) | GET/POST |
| CRUD_TYPE | VARCHAR(10) | SELECT/INSERT/UPDATE/DELETE |
| REQ_URL | VARCHAR(500) | 요청 URL |
| REQ_DTM | TIMESTAMPTZ | 요청 시각 |
| RES_DTM | TIMESTAMPTZ | 응답 완료 시각 |
| RES_STATUS | SMALLINT | HTTP 상태 코드 |
| RES_SIZE | INTEGER | 응답 데이터 크기 (bytes) |
| IP_ADDR | VARCHAR(45) | 클라이언트 IP |
| USER_AGENT | VARCHAR(500) | 브라우저 정보 |
| EXTRA | JSONB | 추가 메타데이터 |

### 관리자 조회 API

```typescript
import { fetchAuditLogs } from '@common/services/authApi';

const result = await fetchAuditLogs({
  page: 1,
  size: 50,
  actionCd: 'TAB_VIEW',
  from: '2026-03-01',
  to: '2026-03-01',
});
// result: { items: AuditLogItem[], total: number, page: number, size: number }
```

---

## 9. 보안

### XSS 방지 (프론트엔드)

`frontend/src/common/utils/sanitize.ts`에 입력 살균 유틸리티가 정의되어 있다.

```typescript
import {
  escapeHtml,
  stripHtmlTags,
  sanitizeHtml,
  sanitizeInput,
  sanitizeUrlParam,
  safeJsonParse,
  safeGetLocalStorage,
  safeSetLocalStorage,
  safePrintHtml,
} from '@common/utils/sanitize';

// HTML 특수문자 이스케이프 (XSS 방지)
escapeHtml('<script>alert("xss")</script>');
// -> '&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;'

// HTML 태그 제거 (텍스트만 추출)
stripHtmlTags('<b>Bold</b> text');
// -> 'Bold text'

// 안전한 HTML 살균 (위험 태그/속성 제거, 허용 태그 유지)
sanitizeHtml(userHtml);
// script, iframe, onclick 등 제거

// 사용자 입력 살균 (게시판, 검색 등)
sanitizeInput(userInput, 1000);
// 위험 특수문자 제거, 길이 제한

// URL 파라미터 인코딩
sanitizeUrlParam(param);

// 안전한 localStorage 접근
const value = safeGetLocalStorage('key', defaultValue);
safeSetLocalStorage('key', value, 5120); // 최대 5MB

// 안전한 PDF 내보내기 (document.write 대체)
safePrintHtml(htmlContent, 'WING 보고서', '<style>...</style>');
```

### 입력 살균 (백엔드)

`backend/src/middleware/security.ts`에 3종 미들웨어가 정의되어 있고,
`server.ts`에서 전역으로 적용된다.

```typescript
// backend/src/server.ts
import { sanitizeBody, sanitizeQuery, removeServerInfo, BODY_SIZE_LIMIT } from './middleware/security.js';

app.use(sanitizeBody);    // 요청 본문 살균 (XSS + SQL 인젝션 패턴 차단)
app.use(sanitizeQuery);   // 쿼리 파라미터 살균
```

미들웨어 동작:
- **sanitizeBody**: 요청 본문의 모든 문자열 필드에서 XSS/SQL 인젝션 패턴 검사
- **sanitizeQuery**: URL 쿼리 파라미터에서 위험 문자 검사
- **sanitizeParams**: URL 경로 파라미터에서 위험 문자 검사 (라우터별 선택 적용)

패턴 탐지 시 400 응답 반환:

```json
{
  "error": "유효하지 않은 입력값",
  "field": "title",
  "message": "허용되지 않는 문자가 포함되어 있습니다."
}
```

추가 검증 함수:

```typescript
import {
  isValidNumber,
  isValidLatitude,
  isValidLongitude,
  isAllowedValue,
  isValidStringLength,
} from '../middleware/security.js';

// 숫자 범위 검증
isValidNumber(value, 0, 100);       // 0~100 사이
isValidLatitude(lat);               // -90~90
isValidLongitude(lon);              // -180~180

// 화이트리스트 검증
isAllowedValue(status, ['ACTIVE', 'INACTIVE', 'PENDING']);

// 문자열 길이 검증
isValidStringLength(title, 200);
```

### CORS 설정

```typescript
// backend/src/server.ts
const allowedOrigins = [
  process.env.FRONTEND_URL || 'https://wing-demo.gc-si.dev',
  // 개발 환경에서만 localhost 허용
  ...(process.env.NODE_ENV !== 'production'
    ? ['http://localhost:5173', 'http://localhost:5174', 'http://localhost:3000']
    : []),
];

app.use(cors({
  origin: allowedOrigins,
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,     // 쿠키 전송 허용
  maxAge: 86400,          // preflight 캐시 24시간
}));
```

### Helmet (HTTP 보안 헤더)

```typescript
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", "data:", "blob:"],
      connectSrc: ["'self'", 'https://*.gc-si.dev', 'https://*.data.go.kr', 'https://*.khoa.go.kr'],
      objectSrc: ["'none'"],
      frameSrc: ["'none'"],
    }
  },
  crossOriginEmbedderPolicy: false,
  crossOriginResourcePolicy: { policy: 'cross-origin' },  // sendBeacon 허용
}));
```

### Vite 개발 서버 프록시

외부 API 이미지의 CORS 문제를 해결하기 위해 `vite.config.ts`에 프록시를 설정한다:

```typescript
server: {
  proxy: {
    '/api': {
      target: 'http://localhost:3001',
      changeOrigin: true,
    },
    '/daily_ocean': {
      target: 'https://www.khoa.go.kr',
      changeOrigin: true,
    },
  },
},
```

적용되는 보안 헤더:
- `X-Content-Type-Options: nosniff` (MIME 스니핑 방지)
- `X-Frame-Options: DENY` (클릭재킹 방지)
- `X-XSS-Protection: 1` (브라우저 XSS 필터)
- `Strict-Transport-Security` (HTTPS 강제)
- `Content-Security-Policy` (CSP)

### Rate Limiting

```typescript
// 일반 요청: 15분당 IP당 200회
const generalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 200,
});

// 시뮬레이션 요청: 1분당 IP당 10회 (비용이 큰 작업)
const simulationLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 10,
});

app.use(generalLimiter);
app.use('/api/simulation', simulationLimiter, simulationRouter);
```

### JSON 본문 크기 제한

```typescript
const BODY_SIZE_LIMIT = '100kb';

app.use(express.json({ limit: BODY_SIZE_LIMIT }));
app.use(express.text({ limit: BODY_SIZE_LIMIT }));
```

---

## 파일 구조 요약

```
frontend/src/
+-- common/
|   +-- components/
|   |   +-- auth/LoginPage.tsx         로그인 페이지
|   |   +-- layout/MainLayout.tsx      메인 레이아웃
|   |   +-- layout/SubMenuBar.tsx      서브메뉴 바
|   |   +-- map/MapView.tsx            MapLibre + deck.gl 지도
|   |   +-- map/mapUtils.ts            지도 유틸리티
|   |   +-- layer/LayerTree.tsx        WMS 레이어 트리
|   |   +-- ui/ComboBox.tsx            공통 UI 컴포넌트
|   +-- constants/
|   |   +-- featureIds.ts              FEATURE_ID 레지스트리
|   +-- hooks/
|   |   +-- useSubMenu.ts             서브메뉴 + 크로스뷰 네비게이션
|   |   +-- useFeatureTracking.ts     감사 로그 기록 훅
|   |   +-- useLayers.ts              레이어 조회 훅 (TanStack Query)
|   +-- services/
|   |   +-- api.ts                     Axios 인스턴스 + 레이어 API
|   |   +-- authApi.ts                 인증/사용자/역할/설정/감사로그 API
|   |   +-- layerService.ts           레이어 서비스
|   +-- store/
|   |   +-- authStore.ts              인증 상태 (Zustand)
|   |   +-- menuStore.ts              메뉴 상태 (Zustand)
|   +-- styles/
|   |   +-- base.css                   CSS 변수, 리셋 (@layer base)
|   |   +-- components.css             도메인 컴포넌트 (@layer components)
|   |   +-- wing.css                   wing-* 디자인 시스템 (@layer components)
|   +-- types/
|   |   +-- navigation.ts             MainTab 타입
|   |   +-- backtrack.ts              역추적 타입
|   |   +-- boomLine.ts               오일펜스 타입
|   +-- utils/
|       +-- cn.ts                      className 조합 유틸리티
|       +-- sanitize.ts               XSS 방지/입력 살균
|       +-- coordinates.ts            좌표 변환 유틸리티
+-- tabs/                              탭별 패키지 (11개)
|   +-- {탭명}/
|       +-- components/                탭 뷰 컴포넌트
|       +-- services/{탭명}Api.ts      탭별 API 서비스
+-- App.tsx                            탭 라우팅 + 감사 로그 자동 기록
+-- index.css                          CSS 임포트 진입점

backend/src/
+-- auth/                              인증 (JWT, OAuth, 미들웨어)
|   +-- authMiddleware.ts             requireAuth, requireRole, requirePermission
|   +-- authRouter.ts                 /api/auth 라우터
|   +-- authService.ts                인증 비즈니스 로직
|   +-- jwtProvider.ts                JWT 발급/검증
+-- roles/                             역할/권한 관리
|   +-- permResolver.ts              2차원 권한 해석 엔진
|   +-- roleRouter.ts                /api/roles 라우터
|   +-- roleService.ts               역할 비즈니스 로직
+-- users/                             사용자 관리
+-- settings/                          시스템 설정
+-- menus/                             메뉴 설정
+-- audit/                             감사 로그
|   +-- auditService.ts              감사 로그 INSERT/SELECT
|   +-- auditRouter.ts               /api/audit 라우터
+-- board/                             게시판 (CRUD 예시 모듈)
+-- reports/                           보고서
+-- hns/                               HNS 물질 검색
+-- prediction/                        확산 예측
+-- rescue/                            구조 시나리오
+-- aerial/                            항공 방제
+-- assets/                            자산 관리
+-- incidents/                         사건/사고
+-- scat/                              Pre-SCAT 조사
+-- db/
|   +-- wingDb.ts                     wing DB Pool (운영 데이터)
|   +-- authDb.ts                     wing_auth DB Pool (인증 데이터)
+-- middleware/
|   +-- security.ts                   입력 살균, 크기 제한, 서버 정보 제거
+-- server.ts                          Express 진입점 + 보안 미들웨어 + 라우터 등록

database/
+-- auth_init.sql                      인증 DB DDL + 초기 데이터
+-- init.sql                           운영 DB DDL
+-- migration/                         마이그레이션 스크립트
    +-- 003_perm_tree.sql             리소스 트리 (AUTH_PERM_TREE)
    +-- 004_oper_cd.sql               오퍼레이션 코드 (OPER_CD) 추가
    +-- 006_board.sql                 게시판 (BOARD_POST)
    +-- 007~016                       각 탭 마이그레이션
```

---

## 백엔드 모듈 추가 절차

새 백엔드 모듈을 추가할 때:

1. `backend/src/{모듈명}/` 디렉토리 생성
2. `{모듈명}Service.ts` -- 비즈니스 로직 (DB 쿼리)
3. `{모듈명}Router.ts` -- Express 라우터 (CRUD 엔드포인트 + requirePermission)
4. `backend/src/server.ts`에 라우터 등록:
   ```typescript
   import newRouter from './{모듈명}/{모듈명}Router.js';
   app.use('/api/{경로}', newRouter);
   ```
5. DB 테이블 필요 시 `database/migration/` 에 마이그레이션 SQL 추가
6. 리소스 코드를 `AUTH_PERM_TREE`에 등록 (마이그레이션 SQL)

### DB 접근

```typescript
// wing DB (운영 데이터: 레이어, 사고, 예측, 게시판 등)
import { wingPool } from '../db/wingDb.js';
const result = await wingPool.query('SELECT * FROM LAYER WHERE LAYER_CD = $1', [id]);

// wing_auth DB (인증 데이터: 사용자, 역할, 권한, 감사로그 등)
import { authPool } from '../db/authDb.js';
const result = await authPool.query('SELECT * FROM AUTH_USER WHERE USER_ID = $1', [id]);
```

---

## 탭별 API 서비스 작성 가이드

### 파일 위치

```
frontend/src/tabs/{탭명}/services/{탭명}Api.ts
```

### 작성 패턴

```typescript
// frontend/src/tabs/{탭명}/services/{탭명}Api.ts
import { api } from '@common/services/api';

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

export interface MyItem {
  sn: number;
  title: string;
  // ...
}

export interface MyListResponse {
  items: MyItem[];
  totalCount: number;
  page: number;
  size: number;
}

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

// 목록 조회
export async function fetchMyItems(params?: {
  page?: number;
  size?: number;
  search?: string;
}): Promise<MyListResponse> {
  const response = await api.get<MyListResponse>('/my-module', { params });
  return response.data;
}

// 상세 조회
export async function fetchMyItem(sn: number): Promise<MyItem> {
  const response = await api.get<MyItem>(`/my-module/${sn}`);
  return response.data;
}

// 생성
export async function createMyItem(input: CreateMyItemInput): Promise<{ sn: number }> {
  const response = await api.post<{ sn: number }>('/my-module', input);
  return response.data;
}

// 수정 (POST only 정책)
export async function updateMyItem(sn: number, input: UpdateMyItemInput): Promise<void> {
  await api.post(`/my-module/${sn}/update`, input);
}

// 삭제 (POST only 정책)
export async function deleteMyItem(sn: number): Promise<void> {
  await api.post(`/my-module/${sn}/delete`);
}
```
