wing-ops/docs/COMMON-GUIDE.md
htlee 6fbb3fc249 docs: 전체 프로젝트 문서 최신 기준 신규 작성
Phase 6(MapLibre+deck.gl), CSS 리팩토링, RBAC, 10탭 API 전환 등
현재 시스템 상태를 정확히 반영하여 모든 문서를 처음부터 재작성.

- README.md: 기술 스택(MapLibre+deck.gl), 빌드, 구조, 스킬 갱신
- CLAUDE.md: CSS @layer, RBAC, HTTP 정책, 백엔드 모듈 반영
- docs/README.md: 아키텍처 상세 (3-Layer, 인증, 권한, CSS)
- docs/DEVELOPMENT-GUIDE.md: 워크플로우 전체 흐름 + 실전 예시
- docs/INSTALL_GUIDE.md: 온라인/오프라인 설치 매뉴얼
- docs/COMMON-GUIDE.md: 공통 로직 9개 섹션 (인증~CSS)
- docs/MENU-TAB-GUIDE.md: 새 탭 추가 5단계 + 예시
- docs/CRUD-API-GUIDE.md: End-to-End CRUD API 패턴
- docs/MOCK-TO-API-GUIDE.md: Mock→API 전환 10단계 프로세스
- docs/_backup_20260301/: 기존 문서 백업

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

46 KiB

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

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

최종 갱신: 2026-03-01 (CSS 리팩토링 + MapLibre GL + deck.gl 전환 반영)


목차

  1. 인증 시스템
  2. RBAC 2차원 권한
  3. API 통신 패턴
  4. 상태 관리
  5. 메뉴 시스템
  6. 지도 (MapLibre GL + deck.gl)
  7. 스타일링
  8. 감사 로그
  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 스토어 하나로 관리한다.

// 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/googleGoogleOAuthProvider를 사용한다.

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;
}

패턴 요약:

  1. api 인스턴스를 @common/services/api에서 import
  2. 요청/응답 인터페이스를 같은 파일에 정의
  3. 함수명: fetch* (조회), create* (생성), update* (수정), delete* (삭제)
  4. 응답에서 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,  // 서브메뉴 없음
  // ...
};

특징:

  • 권한 필터링: subMenuConfighasPermission('{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');

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

  1. MainTab 타입에 새 탭 ID 추가
  2. useSubMenu.tssubMenuConfigs에 서브메뉴 설정 추가
  3. featureIds.tsFEATURE_IDS에 서브탭별 식별자 추가
  4. App.tsxrenderView() 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 컴포넌트 구조

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

스타일링 작성 원칙

  1. Tailwind 유틸리티 우선: 단순한 스타일은 Tailwind 클래스 사용
  2. wing- 클래스*: 반복되는 UI 패턴은 wing-* 시스템 클래스 사용
  3. CSS 변수: 색상은 반드시 CSS 변수 참조 (var(--cyan), var(--bg3) 등)
  4. 인라인 스타일 지양: 불가피한 경우(동적 계산값)에만 사용
  5. !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>');
// -> '&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;'

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

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

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

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

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

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

입력 살균 (백엔드)

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

// 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 허용
}));

적용되는 보안 헤더:

  • 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                       각 탭 마이그레이션

백엔드 모듈 추가 절차

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

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