1556 lines
46 KiB
Markdown
1556 lines
46 KiB
Markdown
# 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 서비스 패턴
|
|
|
|
각 탭은 `components/{탭명}/services/{탭명}Api.ts`에 API 함수를 정의한다.
|
|
|
|
```typescript
|
|
// frontend/src/components/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/components/{탭}/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 '@components/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>');
|
|
// -> '<script>alert("xss")</script>'
|
|
|
|
// 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 좌표 변환 유틸리티
|
|
+-- components/ 탭별 패키지 (11개, MPA 컴포넌트 구조)
|
|
| +-- {탭명}/
|
|
| +-- 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/components/{탭명}/services/{탭명}Api.ts
|
|
```
|
|
|
|
### 작성 패턴
|
|
|
|
```typescript
|
|
// frontend/src/components/{탭명}/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`);
|
|
}
|
|
```
|