# 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=; 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; // { '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') && ( )} {hasPermission('board:notice', 'DELETE') && ( )} ``` `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 { const response = await api.get('/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((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({ queryKey: ['layers'], queryFn: fetchAllLayers, staleTime: 1000 * 60 * 5, // 5분간 캐시 유지 retry: 3, }); } export function useLayerTree() { return useQuery({ 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 = { 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 타일 추가 ``` ### 레이어 데이터 조회 ```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';
...
``` ### 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 // 버튼 사용 예시 ``` #### 입력 | 클래스 | 용도 | |--------|------| | `.wing-input` | 텍스트 입력 (11px, bg0, border) | ```tsx ``` #### 테이블 | 클래스 | 용도 | |--------|------| | `.wing-table` | 테이블 (10px, border-collapse) | | `.wing-th` | 테이블 헤더 셀 (bg2, semibold) | | `.wing-td` | 테이블 데이터 셀 | | `.wing-tr-hover` | 행 호버 효과 | ```tsx
이름 상태
테스트 활성
``` #### 탭 바 | 클래스 | 용도 | |--------|------| | `.wing-tab-bar` | 탭 바 컨테이너 | | `.wing-tab` | 개별 탭 (+ `.active` 클래스로 활성 상태) | ```tsx
setTab('a')}> 탭 A
setTab('b')}> 탭 B
``` #### 모달 | 클래스 | 용도 | |--------|------| | `.wing-overlay` | 모달 배경 오버레이 (fixed, blur) | | `.wing-modal` | 모달 본체 (rounded-xl, shadow) | | `.wing-modal-header` | 모달 헤더 (flex, border-bottom) | ```tsx
모달 제목
모달 내용
``` #### 배지/아이콘 | 클래스 | 용도 | |--------|------| | `.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
유출량 150 kL
``` ### 스타일링 작성 원칙 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>' // HTML 태그 제거 (텍스트만 추출) stripHtmlTags('Bold 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 보고서', ''); ``` ### 입력 살균 (백엔드) `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 { const response = await api.get('/my-module', { params }); return response.data; } // 상세 조회 export async function fetchMyItem(sn: number): Promise { const response = await api.get(`/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 { await api.post(`/my-module/${sn}/update`, input); } // 삭제 (POST only 정책) export async function deleteMyItem(sn: number): Promise { await api.post(`/my-module/${sn}/delete`); } ```