Co-authored-by: leedano <dnlee@gcsc.co.kr> Co-committed-by: leedano <dnlee@gcsc.co.kr>
46 KiB
WING-OPS 공통 로직 개발 가이드
개별 탭 개발자가 공통 영역(frontend/src/common/)과 백엔드 공통 모듈을 빠르게 이해하고
연동할 수 있도록 정리한 문서이다.
공통 기능을 추가/변경할 때 반드시 이 문서를 최신화할 것.
최종 갱신: 2026-03-11 (KHOA API 교체 + Vite CORS 프록시 추가)
목차
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 스토어 하나로 관리한다.
// 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'], ... }
}
로그인/로그아웃 호출 예시
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()을 호출하여 기존 쿠키가 유효한지 확인한다.
// 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를 사용한다.
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가 활성화된다.
백엔드 인증 미들웨어
// 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 페이로드:
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과 동기화된다.
// 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).
프론트엔드에서 권한 체크
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 내부 구현:
// 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 보호
// 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 인스턴스 구성
// 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()호출 (로그인 요청 제외) - 에러 응답에서 민감한 정보를 제거하고 안전한 메시지만 반환
응답 인터셉터
// 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 함수를 정의한다.
// 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;
}
패턴 요약:
api인스턴스를@common/services/api에서 import- 요청/응답 인터페이스를 같은 파일에 정의
- 함수명:
fetch*(조회),create*(생성),update*(수정),delete*(삭제) - 응답에서
response.data만 추출하여 반환
에러 처리 패턴
// 컴포넌트에서의 에러 처리
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
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
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; // 정렬 순서
}
새 스토어 작성 패턴
// 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로 캐싱/동기화한다.
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 훅):
// 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 기반 동적 메뉴 구성이다. 관리자가 메뉴 표시 여부/순서를 설정하면 모든 사용자에게 반영된다.
// 앱 시작 시 메뉴 설정 로드 (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 타입
// frontend/src/common/types/navigation.ts
export type MainTab =
| 'prediction' | 'hns' | 'rescue' | 'reports' | 'aerial'
| 'assets' | 'scat' | 'incidents' | 'board' | 'weather' | 'admin';
서브메뉴 시스템 (useSubMenu)
각 메인 탭은 하위 서브메뉴를 가질 수 있다. useSubMenu 훅이 이를 관리한다.
// 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에 정적으로 정의되어 있다:
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로그 기록 - 전역 상태: 서브탭 상태는 모듈 레벨 변수로 관리되어 탭 전환 시 이전 상태 보존
크로스 뷰 네비게이션
어느 컴포넌트에서든 다른 메인 탭 + 서브탭으로 한번에 전환할 수 있다.
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');
새 메뉴 탭 추가 시 공통 영역 연동
MainTab타입에 새 탭 ID 추가useSubMenu.ts의subMenuConfigs에 서브메뉴 설정 추가featureIds.ts의FEATURE_IDS에 서브탭별 식별자 추가App.tsx의renderView()switch문에 뷰 컴포넌트 추가- 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 컴포넌트 구조
// 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';
기본 설정:
// 남해안 중심 좌표 (여수 앞바다)
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 레이어를 오버레이한다.
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,
});
지도 유틸리티
// 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];
}
// frontend/src/common/utils/coordinates.ts
import { decimalToDMS } from '@common/utils/coordinates';
// 십진수 -> 도분초 변환 (지도 좌표 표시용)
const dms = decimalToDMS(34.5, 127.8);
WMS 레이어 (GeoServer)
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" />
레이어 데이터 조회
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단 계층 구조이다.
/* 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에 정의된 디자인 토큰:
: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 값을 자동 필터링한다.
// frontend/src/common/utils/cn.ts
export function cn(...classes: (string | false | null | undefined)[]): string {
return classes.filter(Boolean).join(' ');
}
사용 예시:
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 계열) |
// 버튼 사용 예시
<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) |
<input className="wing-input" placeholder="검색어 입력" />
테이블
| 클래스 | 용도 |
|---|---|
.wing-table |
테이블 (10px, border-collapse) |
.wing-th |
테이블 헤더 셀 (bg2, semibold) |
.wing-td |
테이블 데이터 셀 |
.wing-tr-hover |
행 호버 효과 |
<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 클래스로 활성 상태) |
<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) |
<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) |
<div className="wing-kv-row">
<span className="wing-kv-label">유출량</span>
<span className="wing-kv-value">150 kL</span>
</div>
스타일링 작성 원칙
- Tailwind 유틸리티 우선: 단순한 스타일은 Tailwind 클래스 사용
- wing- 클래스*: 반복되는 UI 패턴은 wing-* 시스템 클래스 사용
- CSS 변수: 색상은 반드시 CSS 변수 참조 (
var(--cyan),var(--bg3)등) - 인라인 스타일 지양: 불가피한 경우(동적 계산값)에만 사용
- !important 금지
8. 감사 로그
자동 기록 (탭 이동)
App.tsx에서 메인 탭 전환을 감지하여 자동으로 감사 로그를 전송한다. 개별 탭 개발자는 별도 작업이 필요 없다.
// 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 로그를 기록한다.
// 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 훅
특정 기능 진입 시 감사 로그를 명시적으로 기록해야 하는 경우 사용한다.
// frontend/src/common/hooks/useFeatureTracking.ts
import { useFeatureTracking } from '@common/hooks/useFeatureTracking';
// 컴포넌트 내부
useFeatureTracking('aerial:media'); // 진입 시 1회 기록
수동 기록
특정 작업에 대해 명시적으로 감사 로그를 기록하려면:
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 |
로그아웃 | 향후 확장 |
백엔드 감사 로그 서비스
// 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
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에 입력 살균 유틸리티가 정의되어 있다.
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에서 전역으로 적용된다.
// 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 응답 반환:
{
"error": "유효하지 않은 입력값",
"field": "title",
"message": "허용되지 않는 문자가 포함되어 있습니다."
}
추가 검증 함수:
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 설정
// 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 보안 헤더)
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에 프록시를 설정한다:
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
// 일반 요청: 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 본문 크기 제한
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 각 탭 마이그레이션
백엔드 모듈 추가 절차
새 백엔드 모듈을 추가할 때:
backend/src/{모듈명}/디렉토리 생성{모듈명}Service.ts-- 비즈니스 로직 (DB 쿼리){모듈명}Router.ts-- Express 라우터 (CRUD 엔드포인트 + requirePermission)backend/src/server.ts에 라우터 등록:import newRouter from './{모듈명}/{모듈명}Router.js'; app.use('/api/{경로}', newRouter);- DB 테이블 필요 시
database/migration/에 마이그레이션 SQL 추가 - 리소스 코드를
AUTH_PERM_TREE에 등록 (마이그레이션 SQL)
DB 접근
// 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
작성 패턴
// 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`);
}