From 6fbb3fc249976d363bd4501f88597bbee0521e16 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 1 Mar 2026 14:03:08 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20=EC=A0=84=EC=B2=B4=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=20=EB=AC=B8=EC=84=9C=20=EC=B5=9C=EC=8B=A0=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20=EC=8B=A0=EA=B7=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 137 +- README.md | 181 +- docs/COMMON-GUIDE.md | 1760 ++++++++++--- docs/CRUD-API-GUIDE.md | 2740 ++++++++++---------- docs/DEVELOPMENT-GUIDE.md | 948 +++++-- docs/INSTALL_GUIDE.md | 736 +++++- docs/MENU-TAB-GUIDE.md | 694 ++++- docs/MOCK-TO-API-GUIDE.md | 905 +++++-- docs/README.md | 371 +-- docs/_backup_20260301/CHANGELOG.md | 42 + docs/_backup_20260301/COMMON-GUIDE.md | 502 ++++ docs/_backup_20260301/CRUD-API-GUIDE.md | 1436 ++++++++++ docs/_backup_20260301/DEVELOPMENT-GUIDE.md | 433 ++++ docs/_backup_20260301/INSTALL_GUIDE.md | 165 ++ docs/_backup_20260301/MENU-TAB-GUIDE.md | 194 ++ docs/_backup_20260301/MOCK-TO-API-GUIDE.md | 435 ++++ docs/_backup_20260301/README.md | 247 ++ docs/_backup_20260301/ROOT_CLAUDE.md | 123 + docs/_backup_20260301/ROOT_README.md | 223 ++ 19 files changed, 9548 insertions(+), 2724 deletions(-) create mode 100644 docs/_backup_20260301/CHANGELOG.md create mode 100644 docs/_backup_20260301/COMMON-GUIDE.md create mode 100644 docs/_backup_20260301/CRUD-API-GUIDE.md create mode 100644 docs/_backup_20260301/DEVELOPMENT-GUIDE.md create mode 100755 docs/_backup_20260301/INSTALL_GUIDE.md create mode 100644 docs/_backup_20260301/MENU-TAB-GUIDE.md create mode 100644 docs/_backup_20260301/MOCK-TO-API-GUIDE.md create mode 100755 docs/_backup_20260301/README.md create mode 100644 docs/_backup_20260301/ROOT_CLAUDE.md create mode 100644 docs/_backup_20260301/ROOT_README.md diff --git a/CLAUDE.md b/CLAUDE.md index 42410c7..991cbe0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,123 +1,132 @@ # WING-OPS (해양 방제 운영 지원 시스템) -## 프로젝트 개요 -해양 오염 사고 대응을 위한 방제 운영 지원 시스템. -유류/HNS 확산 예측, 역추적 분석, 구조 시나리오, 항공 방제, 자산 관리, SCAT 조사, 기상/해상 정보를 통합 제공한다. +해양 오염 사고 대응 방제 운영 지원 시스템. 유류/HNS 확산 예측, 역추적 분석, 구조 시나리오, 항공 방제, 자산 관리, SCAT 조사, 기상/해상 정보를 통합 제공한다. -- **프로젝트 타입**: react-ts (모노레포) +- **타입**: react-ts 모노레포 (frontend + backend) - **Frontend**: React 19 + Vite 7 + TypeScript 5.9 + Tailwind CSS 3 - **Backend**: Express 4 + PostgreSQL (pg) + TypeScript - **DB**: PostgreSQL 16 + PostGIS (wing 운영DB + wing_auth 인증DB) - **상태관리**: Zustand (클라이언트), TanStack Query (서버) -- **지도**: Leaflet + react-leaflet +- **지도**: MapLibre GL JS 5.x + deck.gl 9.x (Leaflet 제거됨) - **실시간**: Socket.IO +- **인증**: JWT (HttpOnly Cookie) + Google OAuth +- **CI/CD**: Gitea Actions +- **CSS**: Tailwind @layer 아키텍처 (base.css, components.css, wing.css) +- **HTTP 정책**: GET/POST만 사용 (PUT/DELETE/PATCH 금지, 한국 보안취약점 점검 가이드 준수) +- **RBAC**: AUTH_PERM OPER_CD (RCUD), permResolver 2차원 권한 엔진 ## 빌드/실행 -### Frontend ```bash -cd frontend -npm install -npm run dev # 개발 서버 (Vite, localhost:5173) -npm run build # 프로덕션 빌드 (tsc -b && vite build) -npm run lint # ESLint 검증 -npm run preview # 빌드 미리보기 +cd frontend && npm install # Frontend +npm run dev # Vite dev (localhost:5173) +npm run build # tsc -b && vite build +npm run lint # ESLint + +cd backend && npm install # Backend +npm run dev # tsx watch (localhost:3001) +npm run build && npm start # 프로덕션 +npm run db:seed # DB 초기 데이터 ``` -### Backend -```bash -cd backend -npm install -npm run dev # 개발 서버 (tsx watch, localhost:3001) -npm run build # TypeScript 컴파일 (tsc) -npm start # 프로덕션 실행 -npm run db:seed # DB 초기 데이터 -``` +테스트 프레임워크 미구성 (향후 Vitest + React Testing Library 도입 예정). -## 테스트 -테스트 프레임워크 미구성. 향후 Vitest + React Testing Library 도입 예정. - -## Lint/Format ```bash -cd frontend && npx eslint . # ESLint (flat config) -npx prettier --check . # Prettier 검증 -npx prettier --write . # Prettier 자동 수정 +cd frontend && npx eslint . # ESLint (flat config) +npx prettier --write . # Prettier 자동 수정 ``` ## 프로젝트 구조 + ``` wing/ -├── frontend/ React 19 + Vite + TypeScript + Tailwind +├── frontend/ React 19 + Vite 7 + TypeScript + Tailwind │ └── src/ │ ├── App.tsx 메인 (탭 라우팅, 감사 로그 자동 기록) +│ ├── index.css @tailwind + @import 엔트리포인트 │ ├── common/ 공통 모듈 (@common/ alias) │ │ ├── components/ auth/, layer/, layout/, map/, ui/ -│ │ ├── hooks/ useLayers, useSubMenu +│ │ ├── hooks/ useLayers, useSubMenu, useFeatureTracking │ │ ├── services/ api.ts, authApi.ts, layerService.ts │ │ ├── store/ authStore, menuStore (Zustand) +│ │ ├── styles/ base.css, components.css, wing.css +│ │ ├── constants/ featureIds.ts │ │ ├── types/ backtrack, boomLine, hns, navigation -│ │ ├── utils/ coordinates, geo, sanitize -│ │ ├── data/ layerData.ts (UI 레이어 트리) -│ │ └── mock/ vesselMockData, backtrackMockData +│ │ ├── utils/ coordinates, geo, sanitize, cn.ts +│ │ └── data/ layerData.ts (UI 레이어 트리) │ └── tabs/ 탭 단위 패키지 (@tabs/ alias) -│ ├── prediction/ 확산 예측 (OilSpillView, LeftPanel 등) -│ ├── hns/ HNS 분석 (HNSView, HNSSubstanceView 등) +│ ├── prediction/ 확산 예측 (OilSpillView, 역추적, 오일붐) +│ ├── hns/ HNS 분석 (시나리오, 물질 DB, 재계산) │ ├── rescue/ 구조 시나리오 -│ ├── aerial/ 항공 방제 -│ ├── weather/ 해양 기상 (오버레이, hooks, services) +│ ├── aerial/ 항공 방제 (위성영상, 드론) +│ ├── weather/ 해양 기상 (KHOA API, 오버레이) │ ├── incidents/ 사건/사고 관리 │ ├── board/ 게시판 │ ├── reports/ 보고서 -│ ├── assets/ 자산 관리 -│ ├── scat/ Pre-SCAT 조사 -│ └── admin/ 관리자 (사용자/권한/메뉴/설정) +│ ├── assets/ 자산 관리 (기관, 장비, 선박보험) +│ ├── scat/ Pre-SCAT 해안조사 +│ └── admin/ 관리자 (사용자/역할/권한/메뉴/설정) ├── backend/ Express + TypeScript │ └── src/ │ ├── server.ts 진입점 + 라우터 등록 │ ├── auth/ 인증 (JWT, OAuth, 미들웨어) │ ├── users/ 사용자 관리 -│ ├── roles/ 역할/권한 관리 +│ ├── roles/ 역할/권한 관리 (permResolver) │ ├── settings/ 시스템 설정 │ ├── menus/ 메뉴 설정 │ ├── audit/ 감사 로그 +│ ├── board/ 게시판 CRUD +│ ├── reports/ 보고서 CRUD +│ ├── assets/ 자산 관리 CRUD +│ ├── incidents/ 사건/사고 CRUD +│ ├── scat/ SCAT 조사 CRUD +│ ├── prediction/ 확산 예측 CRUD +│ ├── aerial/ 항공 방제 CRUD +│ ├── rescue/ 구조 시나리오 CRUD │ ├── hns/ HNS 물질 검색 API │ ├── routes/ 레이어, 시뮬레이션 -│ ├── middleware/ 보안 (입력 살균, rate-limit) +│ ├── middleware/ 보안 (입력 살균, rate-limit, Helmet CORP cross-origin) │ └── db/ DB 연결 (wingDb, authDb), seed -├── database/ SQL 스크립트 -│ ├── init.sql wing DB 초기 스키마 -│ ├── auth_init.sql wing_auth DB 초기 스키마 -│ └── migration/ 마이그레이션 (001_layer, 002_hns_substance) +├── database/ SQL 스크립트 + 마이그레이션 (001~016) ├── docs/ 개발 문서 -├── .claude/ 팀 워크플로우 (rules, skills, scripts) +├── .claude/ 팀 워크플로우 (rules, skills, scripts, agents) └── .githooks/ Git hooks (pre-commit, commit-msg) ``` ### Path Alias -- `@common/*` → `src/common/*` (공통 모듈) -- `@tabs/*` → `src/tabs/*` (탭 패키지) + +- `@common/*` -> `src/common/*` (공통 모듈) +- `@tabs/*` -> `src/tabs/*` (탭 패키지) ## 팀 컨벤션 -`.claude/rules/` 디렉토리 참조: -- `team-policy.md` — 보안/품질 정책 -- `git-workflow.md` — 브랜치/커밋/MR 규칙 -- `code-style.md` — TypeScript/React 코드 스타일 -- `naming.md` — 네이밍 규칙 -- `testing.md` — 테스트 규칙 -## 개발 문서 (`docs/`) -- `docs/README.md` — 프로젝트 개요, 초기 세팅, 워크플로우 요약, 문서 안내 -- `docs/DEVELOPMENT-GUIDE.md` — 개발 워크플로우 전체 흐름 (Plan → Branch → MR → Deploy) -- `docs/COMMON-GUIDE.md` — 공통 로직 개발 가이드 (인증, 감사로그, 메뉴, API 통신, 상태 관리) -- `docs/MENU-TAB-GUIDE.md` — 새 메뉴 탭 추가 절차 (5단계) -- `docs/INSTALL_GUIDE.md` — 설치 매뉴얼 (온라인/오프라인, DB) -- `docs/CHANGELOG.md` — 변경 이력 +`.claude/rules/` 디렉토리 참조: +- `team-policy.md` -- 보안/품질 정책 +- `git-workflow.md` -- 브랜치/커밋/MR 규칙 +- `code-style.md` -- TypeScript/React 코드 스타일 +- `naming.md` -- 네이밍 규칙 +- `testing.md` -- 테스트 규칙 +- `subagent-policy.md` -- 서브에이전트 활용 정책 + +## 개발 문서 (docs/) + +- `docs/README.md` -- 프로젝트 아키텍처 상세 +- `docs/DEVELOPMENT-GUIDE.md` -- 개발 워크플로우 전체 흐름 +- `docs/COMMON-GUIDE.md` -- 공통 로직 가이드 (인증, 감사로그, 메뉴, API 통신, 상태관리, RBAC, 지도, CSS) +- `docs/MENU-TAB-GUIDE.md` -- 새 메뉴 탭 추가 절차 (5단계) +- `docs/CRUD-API-GUIDE.md` -- CRUD API 개발 가이드 (DB -> 백엔드 -> 프론트 End-to-End) +- `docs/MOCK-TO-API-GUIDE.md` -- Mock -> API 전환 프로세스 +- `docs/INSTALL_GUIDE.md` -- 설치 매뉴얼 (온라인/오프라인, DB 초기화) +- `docs/CHANGELOG.md` -- 변경 이력 ### 문서 최신화 규칙 -- 공통 기능(인증, 감사로그, 메뉴 시스템, API 통신 등)을 추가/변경할 때 반드시 `docs/COMMON-GUIDE.md`를 최신화할 것 -- 개별 탭 개발자는 이 문서를 참조하여 공통 영역과의 연동을 구현 + +- 공통 기능을 추가/변경할 때 반드시 `docs/COMMON-GUIDE.md`를 최신화할 것 +- API 인터페이스 변경 시 `memory/api-types.md` 갱신 +- 개별 탭 개발자는 공통 가이드를 참조하여 연동 구현 ## 환경 설정 + - Node.js 20 (`.node-version`, fnm 사용) - npm registry: Nexus proxy (`.npmrc`) - Git hooks: `.githooks/` (core.hooksPath 설정됨) diff --git a/README.md b/README.md index aab89dc..c709935 100644 --- a/README.md +++ b/README.md @@ -1,167 +1,164 @@ -# WING-OPS (해양 방제 운영 지원 시스템) +# WING-OPS 해양 오염 사고 대응을 위한 방제 운영 지원 시스템. -유류/HNS 확산 예측, 역추적 분석, 구조 시나리오, 항공 방제, 자산 관리, SCAT 조사, 기상/해상 정보를 통합 제공합니다. +유류/HNS 확산 예측, 역추적 분석, 구조 시나리오, 항공 방제, 자산 관리, SCAT 조사, 기상/해상 정보를 통합 제공한다. --- -## 1. 시작하기 - -### 1-1. 저장소 복제 +## 시작하기 ```bash -git clone https://gitea.gc-si.dev/gc/wing-ops.git -cd wing-ops -``` +# 1. 저장소 복제 +git clone https://gitea.gc-si.dev/gc/wing-ops.git && cd wing-ops -### 1-2. Claude Code 초기화 - -```bash -# Claude Code 세션 열기 +# 2. Claude Code 초기화 (.claude/, .githooks/, 메모리 디렉토리 자동 구성) claude - -# 팀 워크플로우 초기화 /init-project -``` -`/init-project` 실행 시 자동으로 구성되는 항목: -- `.claude/` 디렉토리 (rules, skills, scripts, settings) -- `.githooks/` (pre-commit, commit-msg 자동 검증) -- Git hooks 경로 설정 (`core.hooksPath`) -- 메모리 디렉토리 초기화 - -### 1-3. 의존성 설치 및 실행 - -```bash -# 백엔드 (터미널 1) +# 3. 백엔드 (터미널 1) cd backend && npm install && npm run dev # localhost:3001 -# 프론트엔드 (터미널 2) +# 4. 프론트엔드 (터미널 2) cd frontend && npm install && npm run dev # localhost:5173 ``` -> 사전 요구사항: Node.js 20+ (`.node-version`, fnm 사용), PostgreSQL 16+ (운영 DB 직접 연결) -> -> 상세 설치 절차(오프라인 환경, DB 초기화 등)는 [docs/INSTALL_GUIDE.md](docs/INSTALL_GUIDE.md)를 참조하세요. +사전 요구사항: Node.js 20+ (`.node-version`, fnm 사용), PostgreSQL 16+ (원격 DB 직접 연결) + +상세 설치 절차: [docs/INSTALL_GUIDE.md](docs/INSTALL_GUIDE.md) + +### 빌드 및 검증 + +```bash +cd frontend && npm run build # tsc -b && vite build +cd frontend && npx eslint . # ESLint 검증 +cd backend && npm run build # tsc +cd backend && npm run db:seed # DB 초기 데이터 +``` --- -## 2. 개발 워크플로우 +## 개발 워크플로우 ``` -계획 → 브랜치 → 개발 → 커밋/푸시 → develop MR → main PR → 자동 배포 +계획 -> 브랜치 -> 개발 -> 커밋/푸시 -> develop MR -> main PR -> 자동 배포 ``` | 단계 | 작업 | Claude 스킬 | |------|------|-------------| -| 1. 계획 | 3개+ 파일 수정 시 Claude가 Plan Mode 진입 | (자동) | -| 2. 브랜치 | `feature/기능명` 으로 develop에서 분기 | - | -| 3. 개발 | Claude가 코드 작성 + 타입/린트 검증 | - | -| 4. 커밋/푸시 | pre-commit 자동 검증 후 푸시 | `/push` | -| 5. develop MR | feature → develop MR 생성 | `/mr` | -| 6. 릴리즈 | develop → main PR 생성 | `/release` | -| 7. 배포 | main 머지 시 Gitea Actions 자동 배포 | - | +| 계획 | 3개+ 파일 수정 시 Plan Mode 자동 진입 | (자동) | +| 브랜치 | `feature/기능명`으로 develop에서 분기 | - | +| 개발 | 코드 작성 + 타입/린트 검증 | - | +| 커밋/푸시 | pre-commit 자동 검증 후 푸시 | `/push` | +| develop MR | feature -> develop MR 생성 | `/mr` | +| 릴리즈 | develop -> main PR 생성 | `/release` | +| 배포 | main 머지 시 Gitea Actions 자동 배포 | - | -> 상세 워크플로우(브랜치 규칙, 커밋 형식, MR 절차, 배포 확인, 실전 예시)는 [docs/DEVELOPMENT-GUIDE.md](docs/DEVELOPMENT-GUIDE.md)를 참조하세요. +상세 워크플로우: [docs/DEVELOPMENT-GUIDE.md](docs/DEVELOPMENT-GUIDE.md) --- -## 3. 탭 개발 +## 탭 개발 -개별 탭(기능 화면)을 개발할 때 아래 공통 기능을 활용합니다. +11개 탭: prediction, hns, rescue, aerial, weather, incidents, board, reports, assets, scat, admin | 기능 | 프론트엔드 | 백엔드 | 상세 | |------|-----------|--------|------| | 인증/인가 | `authStore`, `api.ts` (자동 쿠키) | `requireAuth`, `requireRole` | [COMMON-GUIDE.md #1](docs/COMMON-GUIDE.md#1-인증인가) | | 감사 로그 | 탭 이동 자동 기록 (sendBeacon) | `audit/` 모듈 | [COMMON-GUIDE.md #2](docs/COMMON-GUIDE.md#2-감사-로그-audit-log) | | 메뉴 시스템 | `menuStore` | `menus/`, `settings/` | [COMMON-GUIDE.md #3](docs/COMMON-GUIDE.md#3-메뉴-시스템) | -| API 통신 | `api.ts` (Axios + 인터셉터) | Express 라우터 | [COMMON-GUIDE.md #4](docs/COMMON-GUIDE.md#4-api-통신-패턴) | +| API 통신 | `api.ts` (Axios + 인터셉터) | Express 라우터 (GET/POST only) | [COMMON-GUIDE.md #4](docs/COMMON-GUIDE.md#4-api-통신-패턴) | | 상태 관리 | Zustand, TanStack Query | - | [COMMON-GUIDE.md #5](docs/COMMON-GUIDE.md#5-상태-관리) | -> 공통 로직 전체 가이드: [docs/COMMON-GUIDE.md](docs/COMMON-GUIDE.md) -> -> 새 메뉴 탭 추가 절차 (5단계): [docs/MENU-TAB-GUIDE.md](docs/MENU-TAB-GUIDE.md) +- 새 메뉴 탭 추가 (5단계): [docs/MENU-TAB-GUIDE.md](docs/MENU-TAB-GUIDE.md) +- CRUD API 개발: [docs/CRUD-API-GUIDE.md](docs/CRUD-API-GUIDE.md) +- HTTP 정책: GET/POST만 사용 (PUT/DELETE/PATCH 금지, 보안취약점 가이드 준수) --- -## 4. 프로젝트 구조 +## 프로젝트 구조 + +Path Alias: `@common/*` -> `src/common/*`, `@tabs/*` -> `src/tabs/*` ``` wing/ -├── frontend/ React 19 + Vite + TypeScript + Tailwind +├── frontend/ React 19 + Vite 7 + TypeScript + Tailwind │ └── src/ -│ ├── App.tsx 메인 (탭 라우팅, 감사 로그) +│ ├── App.tsx 메인 (탭 라우팅, 감사 로그 자동 기록) +│ ├── index.css @tailwind + @import 엔트리포인트 │ ├── common/ 공통 모듈 (@common/ alias) │ │ ├── components/ auth/, layer/, layout/, map/, ui/ -│ │ ├── hooks/ useLayers, useSubMenu +│ │ ├── hooks/ useLayers, useSubMenu, useFeatureTracking │ │ ├── services/ api.ts, authApi.ts, layerService.ts │ │ ├── store/ authStore, menuStore (Zustand) +│ │ ├── styles/ base.css, components.css, wing.css +│ │ ├── constants/ featureIds.ts │ │ ├── types/ backtrack, boomLine, hns, navigation -│ │ └── utils/ coordinates, geo, sanitize +│ │ ├── utils/ coordinates, geo, sanitize, cn.ts +│ │ └── data/ layerData.ts (UI 레이어 트리) │ └── tabs/ 탭 단위 패키지 (@tabs/ alias) -│ ├── prediction/ 확산 예측 -│ ├── hns/ HNS 분석 +│ ├── prediction/ 확산 예측 (OilSpillView, 역추적, 오일붐) +│ ├── hns/ HNS 분석 (시나리오, 물질 DB, 재계산) │ ├── rescue/ 구조 시나리오 -│ ├── aerial/ 항공 방제 -│ ├── weather/ 해양 기상 -│ ├── incidents/ 사건/사고 +│ ├── aerial/ 항공 방제 (위성영상, 드론) +│ ├── weather/ 해양 기상 (KHOA API, 오버레이) +│ ├── incidents/ 사건/사고 관리 │ ├── board/ 게시판 │ ├── reports/ 보고서 -│ ├── assets/ 자산 관리 -│ ├── scat/ Pre-SCAT -│ └── admin/ 관리자 +│ ├── assets/ 자산 관리 (기관, 장비, 선박보험) +│ ├── scat/ Pre-SCAT 해안조사 +│ └── admin/ 관리자 (사용자/역할/권한/메뉴/설정) ├── backend/ Express + TypeScript │ └── src/ │ ├── server.ts 진입점 + 라우터 등록 │ ├── auth/ 인증 (JWT, OAuth, 미들웨어) -│ ├── users/ 사용자 관리 -│ ├── roles/ 역할/권한 관리 -│ ├── settings/ 시스템 설정 -│ ├── menus/ 메뉴 설정 +│ ├── users/, roles/ 사용자, 역할/권한 (permResolver) +│ ├── settings/, menus/ 시스템 설정, 메뉴 설정 │ ├── audit/ 감사 로그 +│ ├── board/, reports/ 게시판, 보고서 CRUD +│ ├── assets/, incidents/, scat/ 자산, 사건/사고, SCAT CRUD +│ ├── prediction/, aerial/, rescue/ 예측, 항공, 구조 CRUD │ ├── hns/ HNS 물질 검색 API │ ├── routes/ 레이어, 시뮬레이션 │ ├── middleware/ 보안 (입력 살균, rate-limit) │ └── db/ DB 연결 (wingDb, authDb), seed -├── database/ SQL 스크립트 + 마이그레이션 +├── database/ SQL 스크립트 + 마이그레이션 (001~016) ├── docs/ 개발 문서 -├── .claude/ 팀 워크플로우 (rules, skills, scripts) +├── .claude/ 팀 워크플로우 (rules, skills, scripts, agents) └── .githooks/ Git hooks (pre-commit, commit-msg) ``` --- -## 5. 기술 스택 +## 기술 스택 | 영역 | 기술 | |------|------| -| Frontend | React 19, Vite 7, TypeScript 5.9, Tailwind CSS 3 | -| Backend | Express 4, TypeScript, PostgreSQL (pg) | +| Frontend | React 19, Vite 7.3, TypeScript 5.9, Tailwind CSS 3 | +| CSS 아키텍처 | Tailwind @layer (base.css, components.css, wing.css), cn() 유틸리티 | | 상태 관리 | Zustand (클라이언트), TanStack Query (서버) | -| 지도 | Leaflet + react-leaflet | -| 실시간 | Socket.IO | -| 인증 | JWT (HttpOnly Cookie), Google OAuth | +| 지도 | MapLibre GL JS 5.x + @vis.gl/react-maplibre 8.1 + deck.gl 9.x | +| 실시간 | Socket.IO Client 4.8 | +| UI | lucide-react, @dnd-kit, emoji-mart | +| Backend | Express 4, TypeScript, pg (PostgreSQL) | +| 보안 | Helmet, CORS, Rate-limit, Input sanitization | +| 인증 | JWT (HttpOnly Cookie `WING_SESSION`), bcrypt, Google OAuth | | DB | PostgreSQL 16 + PostGIS (wing 운영DB + wing_auth 인증DB) | -| CI/CD | Gitea Actions | +| CI/CD | Gitea Actions (.gitea/workflows/deploy.yml) | --- -## 6. 문서 안내 - -### 개발 가이드 +## 문서 안내 | 문서 | 설명 | 대상 | |------|------|------| -| [DEVELOPMENT-GUIDE.md](docs/DEVELOPMENT-GUIDE.md) | 개발 워크플로우 전체 흐름 (Plan → Branch → MR → Deploy) | 모든 개발자 | -| [COMMON-GUIDE.md](docs/COMMON-GUIDE.md) | 공통 로직 개발 가이드 (인증, 감사로그, 메뉴, API, 상태 관리) | 탭 개발자 | -| [MENU-TAB-GUIDE.md](docs/MENU-TAB-GUIDE.md) | 새 메뉴 탭 추가 절차 (5단계) | 탭 개발자 | - -### 운영 가이드 - -| 문서 | 설명 | 대상 | -|------|------|------| -| [INSTALL_GUIDE.md](docs/INSTALL_GUIDE.md) | 설치 매뉴얼 (온라인/오프라인, DB 초기화) | 운영/인프라 | -| [CHANGELOG.md](docs/CHANGELOG.md) | 변경 이력 | 모든 개발자 | +| [docs/README.md](docs/README.md) | 프로젝트 아키텍처 상세 | 모든 개발자 | +| [docs/DEVELOPMENT-GUIDE.md](docs/DEVELOPMENT-GUIDE.md) | 개발 워크플로우 전체 흐름 | 모든 개발자 | +| [docs/COMMON-GUIDE.md](docs/COMMON-GUIDE.md) | 공통 로직 가이드 (인증, 감사로그, 메뉴, API, 상태 관리) | 탭 개발자 | +| [docs/MENU-TAB-GUIDE.md](docs/MENU-TAB-GUIDE.md) | 새 메뉴 탭 추가 절차 (5단계) | 탭 개발자 | +| [docs/CRUD-API-GUIDE.md](docs/CRUD-API-GUIDE.md) | CRUD API 개발 가이드 | 탭 개발자 | +| [docs/MOCK-TO-API-GUIDE.md](docs/MOCK-TO-API-GUIDE.md) | Mock -> API 전환 프로세스 | 탭 개발자 | +| [docs/INSTALL_GUIDE.md](docs/INSTALL_GUIDE.md) | 설치 매뉴얼 (온라인/오프라인, DB 초기화) | 운영/인프라 | +| [docs/CHANGELOG.md](docs/CHANGELOG.md) | 변경 이력 | 모든 개발자 | ### 코드 컨벤션 (.claude/rules/) @@ -172,18 +169,21 @@ wing/ | `code-style.md` | TypeScript/React 코드 스타일 | | `naming.md` | 네이밍 규칙 | | `testing.md` | 테스트 규칙 | +| `subagent-policy.md` | 서브에이전트 활용 정책 | --- -## 7. 환경 변수 +## 환경 변수 ### 프론트엔드 (`frontend/.env`) + ``` VITE_API_URL=http://localhost:3001/api VITE_GOOGLE_CLIENT_ID=your-google-client-id ``` ### 백엔드 (`backend/.env`) + ``` PORT=3001 NODE_ENV=development @@ -198,26 +198,27 @@ GOOGLE_CLIENT_ID=your-google-client-id --- -## 8. 배포 +## 배포 | 항목 | 값 | |------|---| | 프론트엔드 | https://wing-demo.gc-si.dev | | 백엔드 API | https://wing-demo.gc-si.dev/api/ | | CI/CD | Gitea Actions (main 머지 시 자동 배포) | - -배포 파이프라인 상세는 [docs/DEVELOPMENT-GUIDE.md #7](docs/DEVELOPMENT-GUIDE.md#7-자동-배포)을 참조하세요. +| Gitea | https://gitea.gc-si.dev/gc/wing-ops | +| 보호 브랜치 | main, develop (MR 필수) | --- -## 9. Claude Code 스킬 +## Claude Code 스킬 | 스킬 | 설명 | |------|------| | `/push` | 커밋 + 푸시 (한 번에) | | `/mr` | 커밋 + 푸시 + develop MR (한 번에) | -| `/release` | develop → main 릴리즈 MR | +| `/release` | develop -> main 릴리즈 MR | | `/create-mr` | MR만 생성 (세부 옵션) | | `/fix-issue` | Gitea 이슈 분석 + 수정 브랜치 생성 | | `/sync-team-workflow` | 팀 워크플로우 동기화 | | `/changelog` | CHANGELOG.md 갱신 | +| `/init-project` | 프로젝트 초기 설정 | diff --git a/docs/COMMON-GUIDE.md b/docs/COMMON-GUIDE.md index b43ec70..cc84ae5 100644 --- a/docs/COMMON-GUIDE.md +++ b/docs/COMMON-GUIDE.md @@ -1,466 +1,1350 @@ # WING-OPS 공통 로직 개발 가이드 -개별 탭 개발자가 공통 영역 구현을 참조하여 연동할 수 있도록 정리한 문서입니다. -공통 기능을 추가/변경할 때 반드시 이 문서를 최신화하세요. +개별 탭 개발자가 공통 영역(`frontend/src/common/`)과 백엔드 공통 모듈을 빠르게 이해하고 +연동할 수 있도록 정리한 문서이다. +공통 기능을 추가/변경할 때 반드시 이 문서를 최신화할 것. + +> **최종 갱신**: 2026-03-01 (CSS 리팩토링 + MapLibre GL + deck.gl 전환 반영) --- -## 1. 인증/인가 +## 목차 -### 개요 -JWT 기반 세션 인증. HttpOnly 쿠키(`WING_SESSION`)로 토큰을 관리하며, 프론트엔드에서는 Zustand `authStore`로 상태를 관리합니다. +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-보안) -### 권한 모델: 리소스 × 오퍼레이션 (RBAC) +--- -**2차원 권한 모델**: 리소스 트리(상속) × 오퍼레이션(RCUD, 플랫) +## 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 ++-- prediction READ = 조회/열람 +| +-- prediction:analysis CREATE = 생성 +| +-- prediction:list UPDATE = 수정 +| +-- prediction:theory DELETE = 삭제 ++-- board +| +-- board:notice +| +-- board:data ++-- admin + +-- admin:users + +-- admin:permissions ``` -#### 오퍼레이션 코드 +### FEATURE_ID 체계 -| OPER_CD | 설명 | 비고 | -|---------|------|------| -| `READ` | 조회/열람 | 목록, 상세 조회 | -| `CREATE` | 생성 | 새 데이터 등록 | -| `UPDATE` | 수정 | 기존 데이터 변경 | -| `DELETE` | 삭제 | 데이터 삭제 | -| `MANAGE` | 관리 | 관리자 설정 (확장용) | -| `EXPORT` | 내보내기 | 다운로드/출력 (확장용) | +`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': '오일펜스 배치 이론', -1. 부모 리소스의 **READ**가 N → 자식의 **모든 오퍼레이션** 강제 N (접근 자체 차단) -2. 해당 `(RSRC_CD, OPER_CD)` 명시적 레코드 있으면 → 그 값 사용 -3. 명시적 레코드 없으면 → 부모의 **같은 OPER_CD** 상속 -4. 최상위까지 없으면 → 기본 N (거부) + // 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 + +-- 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 { requireAuth, requireRole, requirePermission } from '../auth/authMiddleware.js' +import { useAuthStore } from '@common/store/authStore'; -// 인증만 필요한 라우트 -router.use(requireAuth) +const { hasPermission } = useAuthStore(); -// 역할 기반 (관리 API용) -router.use(requireRole('ADMIN')) +// 기본 조회 권한 (operation 생략 시 'READ') +hasPermission('prediction'); // === hasPermission('prediction', 'READ') -// 리소스×오퍼레이션 기반 (일반 비즈니스 API용) -router.post('/notice/list', requirePermission('board:notice', 'READ'), handler) -router.post('/notice/create', requirePermission('board:notice', 'CREATE'), handler) -router.post('/notice/update', requirePermission('board:notice', 'UPDATE'), handler) -router.post('/notice/delete', requirePermission('board:notice', 'DELETE'), handler) +// 명시적 오퍼레이션 지정 +hasPermission('board:notice', 'CREATE'); // 공지사항 생성 권한 +hasPermission('board:notice', 'DELETE'); // 공지사항 삭제 권한 +hasPermission('admin:users', 'UPDATE'); // 사용자 수정 권한 + +// 조건부 렌더링 예시 +{hasPermission('board:notice', 'CREATE') && ( + +)} + +{hasPermission('board:notice', 'DELETE') && ( + +)} ``` -`requirePermission`은 요청당 1회만 DB 조회하고 `req.resolvedPermissions`에 캐싱합니다. +`hasPermission` 내부 구현: -#### JWT 페이로드 (req.user) -`requireAuth` 통과 후 `req.user`에 담기는 정보: ```typescript -interface JwtPayload { - sub: string // 사용자 UUID (USER_ID) - acnt: string // 계정명 (USER_ACNT) - name: string // 사용자명 (USER_NM) - roles: string[] // 역할 코드 목록 (ADMIN, MANAGER, USER, VIEWER) +// authStore.ts +hasPermission: (resource: string, operation?: string) => { + const { user } = get(); + if (!user) return false; + const ops = user.permissions[resource]; + if (!ops) return false; + return ops.includes(operation ?? 'READ'); +}; +``` + +### 백엔드에서 API 보호 + +```typescript +// backend/src/board/boardRouter.ts +import { Router } from 'express'; +import { requireAuth, requirePermission } from '../auth/authMiddleware.js'; + +const router = Router(); +router.use(requireAuth); + +// 조회 +router.get('/', requirePermission('board:notice', 'READ'), listHandler); +router.get('/:sn', requirePermission('board:notice', 'READ'), detailHandler); + +// 생성/수정/삭제 +router.post('/', requirePermission('board:notice', 'CREATE'), createHandler); +router.post('/:sn/update', requirePermission('board:notice', 'UPDATE'), updateHandler); +router.post('/:sn/delete', requirePermission('board:notice', 'DELETE'), deleteHandler); + +export default router; +``` + +`requirePermission`은 요청당 1회만 DB 조회하고 `req.resolvedPermissions`에 캐싱한다. +동일 요청 내에서 여러 `requirePermission`을 체이닝해도 DB 조회는 최초 1회만 발생한다. + +--- + +## 3. API 통신 패턴 + +### Axios 인스턴스 구성 + +```typescript +// frontend/src/common/services/api.ts +import axios from 'axios'; + +export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'; + +export const api = axios.create({ + baseURL: API_BASE_URL, + headers: { 'Content-Type': 'application/json' }, + withCredentials: true, // JWT 쿠키 자동 포함 + timeout: 30000, // 30초 타임아웃 + maxContentLength: 10 * 1024 * 1024, // 응답 최대 10MB + maxBodyLength: 1 * 1024 * 1024, // 요청 최대 1MB +}); +``` + +주요 특징: +- `withCredentials: true`로 모든 요청에 HttpOnly 쿠키가 자동 포함됨 +- 401 응답 시 인터셉터가 자동으로 authStore의 `logout()` 호출 (로그인 요청 제외) +- 에러 응답에서 민감한 정보를 제거하고 안전한 메시지만 반환 + +### 응답 인터셉터 + +```typescript +// 401 자동 로그아웃 +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response) { + const { status, data } = error.response; + + // 401: 인증 만료 -> 자동 로그아웃 (로그인 요청 제외) + if (status === 401 && !error.config?.url?.includes('/auth/login')) { + // authStore.logout() 호출 + } + + return Promise.reject({ + status, + message: data?.error || data?.message || '요청 처리 중 오류가 발생했습니다.', + }); + } + return Promise.reject({ status: 0, message: '서버에 연결할 수 없습니다.' }); + } +); +``` + +### GET/POST only 정책 + +보안 취약점 점검 가이드에 따라 **GET/POST 메서드를 기본**으로 사용한다. + +| URL 패턴 | HTTP Method | OPER_CD | 설명 | +|----------|-------------|---------|------| +| `/resource` | GET | READ | 목록 조회 | +| `/resource/:id` | GET | READ | 상세 조회 | +| `/resource` | POST | CREATE | 신규 생성 | +| `/resource/:id/update` | POST | UPDATE | 수정 | +| `/resource/:id/delete` | POST | DELETE | 삭제 | + +PUT, DELETE, PATCH 등 기타 메서드는 사용하지 않는다. +오퍼레이션 코드(OPER_CD)는 HTTP Method가 아닌 **비즈니스 의미**로 결정한다. + +> **참고**: 일부 레거시 API(authApi.ts의 관리자 API)에서 PUT/DELETE를 사용하는 코드가 남아있다. +> 신규 API는 반드시 GET/POST only 정책을 따를 것. + +### 탭별 API 서비스 패턴 + +각 탭은 `tabs/{탭명}/services/{탭명}Api.ts`에 API 함수를 정의한다. + +```typescript +// frontend/src/tabs/board/services/boardApi.ts +import { api } from '@common/services/api'; + +// 인터페이스 정의 +export interface BoardPostItem { + sn: number; + categoryCd: string; + title: string; + authorId: string; + authorName: string; + viewCnt: number; + pinnedYn: string; + regDtm: string; +} + +export interface BoardListResponse { + items: BoardPostItem[]; + totalCount: number; + page: number; + size: number; +} + +// API 함수 +export async function fetchBoardPosts(params?: BoardListParams): Promise { + 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; } ``` -#### 라우터 패턴 (CRUD 구조) +패턴 요약: +1. `api` 인스턴스를 `@common/services/api`에서 import +2. 요청/응답 인터페이스를 같은 파일에 정의 +3. 함수명: `fetch*` (조회), `create*` (생성), `update*` (수정), `delete*` (삭제) +4. 응답에서 `response.data`만 추출하여 반환 + +### 에러 처리 패턴 + ```typescript -// backend/src/[모듈]/[모듈]Router.ts -import { Router } from 'express' -import { requireAuth, requirePermission } from '../auth/authMiddleware.js' - -const router = Router() -router.use(requireAuth) - -// 리소스별 CRUD 엔드포인트 -router.post('/list', requirePermission('module:sub', 'READ'), listHandler) -router.post('/detail', requirePermission('module:sub', 'READ'), detailHandler) -router.post('/create', requirePermission('module:sub', 'CREATE'), createHandler) -router.post('/update', requirePermission('module:sub', 'UPDATE'), updateHandler) -router.post('/delete', requirePermission('module:sub', 'DELETE'), deleteHandler) - -export default router +// 컴포넌트에서의 에러 처리 +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 -#### authStore (Zustand) ```typescript -import { useAuthStore } from '@common/store/authStore' +import { useAuthStore } from '@common/store/authStore'; -const { user, isAuthenticated, hasPermission, logout } = useAuthStore() +// 상태 구독 (컴포넌트 리렌더링 최적화) +const isAuthenticated = useAuthStore((s) => s.isAuthenticated); +const user = useAuthStore((s) => s.user); +const hasPermission = useAuthStore((s) => s.hasPermission); -// 사용자 정보 -user?.id // UUID -user?.name // 이름 -user?.roles // ['ADMIN', 'USER'] -user?.permissions // { 'prediction': ['READ','CREATE','UPDATE','DELETE'], ... } +// 전체 상태 (필요시에만) +const { user, isAuthenticated, isLoading, error, pendingMessage } = useAuthStore(); -// 권한 확인 (리소스 × 오퍼레이션) -hasPermission('prediction') // READ 확인 (기본값) -hasPermission('prediction', 'READ') // 명시적 READ 확인 -hasPermission('board:notice', 'CREATE') // 공지사항 생성 권한 -hasPermission('board:notice', 'DELETE') // 공지사항 삭제 권한 - -// 하위 호환: operation 생략 시 'READ' 기본값 -hasPermission('admin') // === hasPermission('admin', 'READ') +// 액션 +const { login, googleLogin, logout, checkSession, clearError } = useAuthStore(); ``` -#### API 클라이언트 +#### menuStore 주요 API + ```typescript -import { api } from '@common/services/api' +import { useMenuStore } from '@common/store/menuStore'; -// withCredentials: true 설정으로 JWT 쿠키 자동 포함 -const response = await api.post('/your-endpoint/list', params) -const response = await api.post('/your-endpoint/create', data) +const { menuConfig, isLoaded, loadMenuConfig, setMenuConfig } = useMenuStore(); -// 401 응답 시 자동 로그아웃 처리 (인터셉터) -// 403 응답 시 권한 부족 (requirePermission 미들웨어) +// menuConfig: MenuConfigItem[] +interface MenuConfigItem { + id: string; // 탭 ID (prediction, hns, ...) + label: string; // 표시 이름 + icon: string; // 아이콘 (이모지) + enabled: boolean; // 활성화 여부 + order: number; // 정렬 순서 +} +``` + +#### 새 스토어 작성 패턴 + +```typescript +// frontend/src/common/store/newStore.ts (공통) 또는 +// frontend/src/tabs/{탭}/store/newStore.ts (탭 전용) +import { create } from 'zustand'; + +interface MyState { + items: string[]; + isLoading: boolean; + addItem: (item: string) => void; + reset: () => void; +} + +export const useMyStore = create((set) => ({ + items: [], + isLoading: false, + addItem: (item) => set((state) => ({ items: [...state.items, item] })), + reset: () => set({ items: [], isLoading: false }), +})); +``` + +### TanStack Query (서버 상태) + +서버에서 조회하는 데이터는 TanStack Query로 캐싱/동기화한다. + +```typescript +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { fetchBoardPosts, createBoardPost } from '@tabs/board/services/boardApi'; + +// 조회 (캐싱 + 자동 리페치) +const { data, isLoading, error } = useQuery({ + queryKey: ['board', 'posts', { categoryCd, page }], + queryFn: () => fetchBoardPosts({ categoryCd, page }), + staleTime: 1000 * 60 * 5, // 5분간 캐시 유지 +}); + +// 생성 (뮤테이션 + 캐시 무효화) +const queryClient = useQueryClient(); +const mutation = useMutation({ + mutationFn: createBoardPost, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['board', 'posts'] }); + }, +}); +``` + +실제 사용 예시 (`useLayers` 훅): + +```typescript +// frontend/src/common/hooks/useLayers.ts +import { useQuery } from '@tanstack/react-query'; +import { fetchAllLayers, fetchLayerTree } from '../services/api'; + +export function useLayers() { + return useQuery({ + 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, + }); +} ``` --- -## 2. 감사 로그 (Audit Log) +## 5. 메뉴 시스템 -### 개요 -사용자 행동을 추적하는 감사 로그 시스템. 현재 탭 이동 로그를 자동 기록하며, 향후 API 호출 로깅으로 확장 가능합니다. +### menuStore 구조 -### 자동 기록 (탭 이동) -`App.tsx`의 `useEffect`에서 `activeMainTab` 변경을 감지하여 `navigator.sendBeacon`으로 자동 전송합니다. 개별 탭 개발자는 별도 작업이 필요 없습니다. +DB 기반 동적 메뉴 구성이다. 관리자가 메뉴 표시 여부/순서를 설정하면 모든 사용자에게 반영된다. ```typescript -// frontend/src/App.tsx (자동 적용, 수정 불필요) -import { API_BASE_URL } from '@common/services/api' +// 앱 시작 시 메뉴 설정 로드 (App.tsx) +const { loadMenuConfig } = useMenuStore(); useEffect(() => { - if (!isAuthenticated) return + 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]) + ); + 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' +import { API_BASE_URL } from '@common/services/api'; +// sendBeacon 사용 (비동기, 페이지 언로드 시에도 전송 보장) const blob = new Blob( - [JSON.stringify({ action: 'ADMIN_ACTION', detail: '사용자 승인' })], + [JSON.stringify({ action: 'ADMIN_ACTION', detail: '사용자 승인: user123' })], { type: 'text/plain' } -) -navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob) +); +navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob); ``` -### 감사 로그 테이블 구조 (AUTH_AUDIT_LOG) - -| 컬럼 | 타입 | 용도 | 현재 사용 | -|------|------|------|-----------| -| LOG_SN | SERIAL PK | 로그 순번 | O | -| USER_ID | UUID | 사용자 ID | O | -| ACTION_CD | VARCHAR(30) | 액션 코드 | O (TAB_VIEW) | -| ACTION_DTL | VARCHAR(100) | 액션 상세 (탭ID 등) | O | -| HTTP_METHOD | VARCHAR(10) | GET/POST/PUT/DELETE | - (향후) | -| CRUD_TYPE | VARCHAR(10) | SELECT/INSERT/UPDATE/DELETE | - (향후) | -| REQ_URL | VARCHAR(500) | 요청 URL | - (향후) | -| REQ_DTM | TIMESTAMPTZ | 요청 시각 | O | -| RES_DTM | TIMESTAMPTZ | 응답 완료 시각 | - (향후) | -| RES_STATUS | SMALLINT | HTTP 상태 코드 | - (향후) | -| RES_SIZE | INTEGER | 응답 데이터 크기(bytes) | - (향후) | -| IP_ADDR | VARCHAR(45) | 클라이언트 IP | O | -| USER_AGENT | VARCHAR(500) | 브라우저 정보 | O | -| EXTRA | JSONB | 추가 메타데이터 | - (향후) | - ### ACTION_CD 코드 체계 -| 코드 | 설명 | -|------|------| -| TAB_VIEW | 상단 탭 이동 | -| API_CALL | API 호출 (향후) | -| LOGIN | 로그인 (향후) | -| LOGOUT | 로그아웃 (향후) | -| ADMIN_ACTION | 관리자 작업 (향후) | + +| 코드 | 설명 | 기록 주체 | +|------|------|-----------| +| `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 -// frontend/src/services/authApi.ts -import { fetchAuditLogs } from '../services/authApi' +import { fetchAuditLogs } from '@common/services/authApi'; const result = await fetchAuditLogs({ page: 1, size: 50, actionCd: 'TAB_VIEW', - from: '2026-02-28', - to: '2026-02-28', -}) + from: '2026-03-01', + to: '2026-03-01', +}); // result: { items: AuditLogItem[], total: number, page: number, size: number } ``` --- -## 3. 메뉴 시스템 +## 9. 보안 -### 개요 -DB 기반 동적 메뉴 구성. 관리자가 메뉴 표시 여부/순서를 설정하면 모든 사용자에게 반영됩니다. -새 메뉴 탭 추가 시 `docs/MENU-TAB-GUIDE.md`를 참조하세요. +### XSS 방지 (프론트엔드) + +`frontend/src/common/utils/sanitize.ts`에 입력 살균 유틸리티가 정의되어 있다. -### 메뉴 상태 (menuStore) ```typescript -// frontend/src/store/menuStore.ts -import { useMenuStore } from '../store/menuStore' +import { + escapeHtml, + stripHtmlTags, + sanitizeHtml, + sanitizeInput, + sanitizeUrlParam, + safeJsonParse, + safeGetLocalStorage, + safeSetLocalStorage, + safePrintHtml, +} from '@common/utils/sanitize'; -const { menus, loadMenuConfig } = useMenuStore() +// HTML 특수문자 이스케이프 (XSS 방지) +escapeHtml(''); +// -> '<script>alert("xss")</script>' -// menus: MenuConfigItem[] — 활성화되고 정렬된 메뉴 목록 -// menus[0].id → 'prediction' -// menus[0].label → '유출유 확산예측' -// menus[0].enabled → true +// 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 보고서', ''); ``` -### 메뉴 설정 저장소 -- DB: `AUTH_SETTING` 테이블의 `menu.config` 키 (JSON 배열) -- 백엔드: `backend/src/settings/settingsService.ts`의 `DEFAULT_MENU_CONFIG` -- API: `GET/PUT /api/menus` +### 입력 살균 (백엔드) ---- +`backend/src/middleware/security.ts`에 3종 미들웨어가 정의되어 있고, +`server.ts`에서 전역으로 적용된다. -## 4. API 통신 패턴 - -### Axios 인스턴스 설정 ```typescript -// frontend/src/services/api.ts -export const api = axios.create({ - baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3001/api', - withCredentials: true, // JWT 쿠키 자동 포함 - timeout: 30000, -}) +// backend/src/server.ts +import { sanitizeBody, sanitizeQuery, removeServerInfo, BODY_SIZE_LIMIT } from './middleware/security.js'; + +app.use(sanitizeBody); // 요청 본문 살균 (XSS + SQL 인젝션 패턴 차단) +app.use(sanitizeQuery); // 쿼리 파라미터 살균 ``` -### 새 API 서비스 작성 패턴 -```typescript -// frontend/src/services/newService.ts -import { api } from './api' +미들웨어 동작: +- **sanitizeBody**: 요청 본문의 모든 문자열 필드에서 XSS/SQL 인젝션 패턴 검사 +- **sanitizeQuery**: URL 쿼리 파라미터에서 위험 문자 검사 +- **sanitizeParams**: URL 경로 파라미터에서 위험 문자 검사 (라우터별 선택 적용) -export interface MyData { - id: string - name: string -} +패턴 탐지 시 400 응답 반환: -export async function fetchMyData(): Promise { - const response = await api.get('/my-endpoint') - return response.data -} - -export async function createMyData(data: Omit): Promise { - const response = await api.post('/my-endpoint', data) - return response.data +```json +{ + "error": "유효하지 않은 입력값", + "field": "title", + "message": "허용되지 않는 문자가 포함되어 있습니다." } ``` -### 에러 처리 -- 401 응답: `api.ts` 인터셉터가 자동으로 로그아웃 처리 -- 비즈니스 에러: `response.data.error` 메시지로 사용자에게 안내 -- 백엔드에서 `AuthError` 사용 시 적절한 HTTP 상태 코드와 메시지 반환 +추가 검증 함수: ---- - -## 5. 상태 관리 - -### Zustand (클라이언트 상태) ```typescript -// frontend/src/store/newStore.ts -import { create } from 'zustand' +import { + isValidNumber, + isValidLatitude, + isValidLongitude, + isAllowedValue, + isValidStringLength, +} from '../middleware/security.js'; -interface MyState { - items: string[] - addItem: (item: string) => void -} +// 숫자 범위 검증 +isValidNumber(value, 0, 100); // 0~100 사이 +isValidLatitude(lat); // -90~90 +isValidLongitude(lon); // -180~180 -export const useMyStore = create((set) => ({ - items: [], - addItem: (item) => set((state) => ({ items: [...state.items, item] })), -})) +// 화이트리스트 검증 +isAllowedValue(status, ['ACTIVE', 'INACTIVE', 'PENDING']); + +// 문자열 길이 검증 +isValidStringLength(title, 200); ``` -### TanStack Query (서버 상태) — 권장 +### CORS 설정 + ```typescript -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { fetchMyData, createMyData } from '../services/newService' +// 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'] + : []), +]; -// 조회 -const { data, isLoading } = useQuery({ - queryKey: ['myData'], - queryFn: fetchMyData, -}) - -// 생성/수정 -const queryClient = useQueryClient() -const mutation = useMutation({ - mutationFn: createMyData, - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['myData'] }), -}) +app.use(cors({ + origin: allowedOrigins, + methods: ['GET', 'POST', 'PUT', 'DELETE'], + allowedHeaders: ['Content-Type', 'Authorization'], + credentials: true, // 쿠키 전송 허용 + maxAge: 86400, // preflight 캐시 24시간 +})); ``` ---- - -## 6. 백엔드 API CRUD 규칙 - -> 상세 가이드 + 게시판 실전 튜토리얼: **[CRUD-API-GUIDE.md](./CRUD-API-GUIDE.md)** 참조 - -### HTTP Method 정책 (보안 가이드 준수) -- 보안 취약점 점검 가이드에 따라 **POST 메서드를 기본**으로 사용한다. -- GET은 단순 조회 중 민감하지 않은 경우에만 허용 (필요 시 POST로 전환). -- PUT, DELETE, PATCH 등 기타 메서드는 사용하지 않는다. - -### 오퍼레이션 기반 권한 미들웨어 -OPER_CD는 HTTP Method가 아닌 **비즈니스 의미**로 결정한다. -`requirePermission` 미들웨어에 명시적으로 오퍼레이션을 지정한다. - -| URL 패턴 | OPER_CD | 미들웨어 | -|----------|---------|----------| -| `/resource/list` | READ | `requirePermission(resource, 'READ')` | -| `/resource/detail` | READ | `requirePermission(resource, 'READ')` | -| `/resource/create` | CREATE | `requirePermission(resource, 'CREATE')` | -| `/resource/update` | UPDATE | `requirePermission(resource, 'UPDATE')` | -| `/resource/delete` | DELETE | `requirePermission(resource, 'DELETE')` | - -### 라우터 작성 예시 +### Helmet (HTTP 보안 헤더) ```typescript -// backend/src/board/noticeRouter.ts -import { Router } from 'express' -import { requireAuth, requirePermission } from '../auth/authMiddleware.js' - -const router = Router() -router.use(requireAuth) - -// 조회 -router.post('/list', requirePermission('board:notice', 'READ'), listHandler) -router.post('/detail', requirePermission('board:notice', 'READ'), detailHandler) - -// 생성/수정/삭제 -router.post('/create', requirePermission('board:notice', 'CREATE'), createHandler) -router.post('/update', requirePermission('board:notice', 'UPDATE'), updateHandler) -router.post('/delete', requirePermission('board:notice', 'DELETE'), deleteHandler) - -export default router +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 허용 +})); ``` -### 관리 API (예외) -사용자/역할/설정 등 관리 API는 `requireRole('ADMIN')` 유지: +적용되는 보안 헤더: +- `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 -router.use(requireAuth) -router.use(requireRole('ADMIN')) +// 일반 요청: 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); ``` ---- - -## 7. 백엔드 모듈 추가 절차 - -새 백엔드 모듈을 추가할 때: - -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/auth_init.sql`에 DDL 추가 -6. 리소스 코드를 `AUTH_PERM_TREE`에 등록 (마이그레이션 SQL) - -### DB 접근 -```typescript -// PostgreSQL — wing DB (운영 데이터: 레이어, 사고, 예측 등) -import { wingPool } from '../db/wingDb.js' -const result = await wingPool.query('SELECT * FROM LAYER WHERE LAYER_CD = $1', [id]) - -// PostgreSQL — wing_auth DB (인증 데이터: 사용자, 역할, 권한 등) -import { authPool } from '../db/authDb.js' -const result = await authPool.query('SELECT * FROM AUTH_USER WHERE USER_ID = $1', [id]) -``` - ---- - -## 8. Mock → API 전환 가이드 - -각 탭의 mock 데이터를 DB/API로 전환하는 프로세스는 **[MOCK-TO-API-GUIDE.md](./MOCK-TO-API-GUIDE.md)** 참조. - -### 전환 완료 탭 - -| 탭 | MR | API 경로 | 비고 | -|----|-----|----------|------| -| Board (게시판) | MR#29 | `/api/board` | PUT/DELETE 사용 (레거시, POST 전환 예정) | -| Reports (보고서) | MR#31 | `/api/reports` | GET/POST only 적용 | - -### Reports API 엔드포인트 - -| Method | Path | 설명 | 권한 | -|--------|------|------|------| -| GET | `/api/reports/templates` | 템플릿 목록 + 섹션 정의 | requireAuth | -| GET | `/api/reports/categories` | 분석 카테고리 목록 + 섹션 | requireAuth | -| GET | `/api/reports` | 보고서 목록 (필터: jrsdCd, tmplCd, sttsCd, search) | reports READ | -| GET | `/api/reports/:sn` | 보고서 상세 (섹션 데이터 포함) | reports READ | -| POST | `/api/reports` | 보고서 생성 | reports CREATE | -| POST | `/api/reports/:sn/update` | 보고서 수정 | reports UPDATE | -| POST | `/api/reports/:sn/delete` | 보고서 삭제 (논리) | reports DELETE | -| POST | `/api/reports/:sn/sections/:sectCd` | 개별 섹션 수정 | reports UPDATE | - -### 프론트엔드 API 서비스 +### JSON 본문 크기 제한 ```typescript -// frontend/src/tabs/reports/services/reportsApi.ts -import { api } from '@common/services/api' +const BODY_SIZE_LIMIT = '100kb'; -// 조회 (GET) -const templates = await fetchTemplates() // GET /reports/templates (캐싱) -const categories = await fetchCategories() // GET /reports/categories (캐싱) -const list = await fetchReports({ tmplCd, sttsCd }) // GET /reports -const detail = await fetchReport(sn) // GET /reports/:sn - -// 생성/수정/삭제 (POST) -await createReportApi({ tmplSn, title, sections }) // POST /reports -await updateReportApi(sn, { title, sections }) // POST /reports/:sn/update -await deleteReportApi(sn) // POST /reports/:sn/delete - -// 고수준 함수 (OilSpillReportData ↔ API 변환 포함) -await saveReport(reportData) // create 또는 update 자동 분기 -const reports = await loadReportsFromApi() // 전체 목록 + 변환 -const detail = await loadReportDetail(sn) // 상세 + 섹션 복원 +app.use(express.json({ limit: BODY_SIZE_LIMIT })); +app.use(express.text({ limit: BODY_SIZE_LIMIT })); ``` --- @@ -469,34 +1353,184 @@ const detail = await loadReportDetail(sn) // 상세 + 섹션 복원 ``` frontend/src/ -├── common/ -│ ├── services/api.ts Axios 인스턴스 + API_BASE_URL + 인터셉터 -│ ├── services/authApi.ts 인증/사용자/역할/설정/메뉴/감사로그 API -│ ├── store/authStore.ts 인증 상태 + hasPermission (Zustand) -│ ├── store/menuStore.ts 메뉴 상태 (Zustand) -│ └── hooks/ useSubMenu, useFeatureTracking 등 -├── tabs/ 탭별 패키지 (11개) -└── App.tsx 탭 라우팅 + 감사 로그 자동 기록 ++-- 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, 미들웨어, requirePermission) -├── users/ 사용자 관리 -├── roles/ 역할/권한 관리 (permResolver, roleService) -├── board/ 게시판 CRUD (boardService, boardRouter) -├── reports/ 보고서 CRUD (reportsService, reportsRouter) -├── settings/ 시스템 설정 -├── menus/ 메뉴 설정 -├── audit/ 감사 로그 -├── db/ DB 연결 (authDb, wingDb) -├── middleware/ 보안 미들웨어 -└── server.ts Express 진입점 + 라우터 등록 ++-- 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_reports.sql 보고서 (REPORT_TMPL, REPORT, REPORT_SECT_DATA 등 7개) ++-- auth_init.sql 인증 DB DDL + 초기 데이터 ++-- init.sql 운영 DB DDL ++-- migration/ 마이그레이션 스크립트 + +-- 003_perm_tree.sql 리소스 트리 (AUTH_PERM_TREE) + +-- 004_oper_cd.sql 오퍼레이션 코드 (OPER_CD) 추가 + +-- 006_board.sql 게시판 (BOARD_POST) + +-- 007~016 각 탭 마이그레이션 +``` + +--- + +## 백엔드 모듈 추가 절차 + +새 백엔드 모듈을 추가할 때: + +1. `backend/src/{모듈명}/` 디렉토리 생성 +2. `{모듈명}Service.ts` -- 비즈니스 로직 (DB 쿼리) +3. `{모듈명}Router.ts` -- Express 라우터 (CRUD 엔드포인트 + requirePermission) +4. `backend/src/server.ts`에 라우터 등록: + ```typescript + import newRouter from './{모듈명}/{모듈명}Router.js'; + app.use('/api/{경로}', newRouter); + ``` +5. DB 테이블 필요 시 `database/migration/` 에 마이그레이션 SQL 추가 +6. 리소스 코드를 `AUTH_PERM_TREE`에 등록 (마이그레이션 SQL) + +### DB 접근 + +```typescript +// wing DB (운영 데이터: 레이어, 사고, 예측, 게시판 등) +import { wingPool } from '../db/wingDb.js'; +const result = await wingPool.query('SELECT * FROM LAYER WHERE LAYER_CD = $1', [id]); + +// wing_auth DB (인증 데이터: 사용자, 역할, 권한, 감사로그 등) +import { authPool } from '../db/authDb.js'; +const result = await authPool.query('SELECT * FROM AUTH_USER WHERE USER_ID = $1', [id]); +``` + +--- + +## 탭별 API 서비스 작성 가이드 + +### 파일 위치 + +``` +frontend/src/tabs/{탭명}/services/{탭명}Api.ts +``` + +### 작성 패턴 + +```typescript +// frontend/src/tabs/{탭명}/services/{탭명}Api.ts +import { api } from '@common/services/api'; + +// ============================================================ +// 인터페이스 +// ============================================================ + +export interface MyItem { + sn: number; + title: string; + // ... +} + +export interface MyListResponse { + items: MyItem[]; + totalCount: number; + page: number; + size: number; +} + +// ============================================================ +// API 함수 +// ============================================================ + +// 목록 조회 +export async function fetchMyItems(params?: { + page?: number; + size?: number; + search?: string; +}): Promise { + 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`); +} ``` diff --git a/docs/CRUD-API-GUIDE.md b/docs/CRUD-API-GUIDE.md index 8d91db0..7212063 100644 --- a/docs/CRUD-API-GUIDE.md +++ b/docs/CRUD-API-GUIDE.md @@ -1,1436 +1,1546 @@ -# RBAC 기반 CRUD API 개발 가이드 +# CRUD API 개발 가이드 -새 CRUD API를 추가할 때 따라야 할 표준 가이드. -Phase 5 RBAC 체계(리소스 x 오퍼레이션 2차원 모델)를 기반으로 한다. - -**DB 구조**: wing DB 단일 DB, 스키마 분리 -- `wing` 스키마: 운영 데이터 (BOARD_POST, LAYER 등) -- `auth` 스키마: 인증/인가 데이터 (AUTH_USER, AUTH_ROLE, AUTH_PERM 등) -- `public` 스키마: PostGIS 시스템 테이블만 유지 (사용 금지) +새로운 도메인의 CRUD API 엔드포인트를 개발하는 전체 절차를 설명한다. +DB 설계부터 백엔드 구현, 프론트엔드 연동까지 End-to-End 패턴을 제공한다. --- -## Part 1: 범용 가이드 +## 목차 -### 1. 개요 - -이 문서는 WING-OPS의 **모든 탭 개발자**가 새 CRUD API를 만들 때 참조하는 표준이다. - -- 백엔드: Express Router + Service 2-Layer -- 권한: `requirePermission(resource, operation)` 미들웨어 -- DB: PostgreSQL (`wingPool` 단일 Pool, `search_path = wing, auth, public`) -- 프론트: Axios + `hasPermission()` 조건부 렌더링 - -각 섹션에 복사해서 바로 사용할 수 있는 실제 코드 스니펫을 포함한다. +1. [아키텍처 개요](#아키텍처-개요) +2. [백엔드 API 개발 패턴](#백엔드-api-개발-패턴) +3. [DB 마이그레이션 작성법](#db-마이그레이션-작성법) +4. [프론트엔드 API 서비스 작성법](#프론트엔드-api-서비스-작성법) +5. [전체 예시: 장비 관리 API](#전체-예시-장비-관리-api) --- -### 2. 아키텍처 +## 아키텍처 개요 -#### 3-Layer 구조 +### 3-Layer 구조 ``` -클라이언트 (React) - ↓ Axios (withCredentials: true, JWT 쿠키 자동 포함) -Router (Express) ← requireAuth → requirePermission - ↓ -Service ← 비즈니스 로직, DB 쿼리 - ↓ -DB (pg Pool) ← wingPool (search_path = wing, auth) +[Frontend] [Backend] [Database] +tabs/{탭}/services/{tab}Api.ts src/{domain}/{domain}Router.ts PostgreSQL 16 + Axios (withCredentials: true) requireAuth -> requirePermission + --HTTP--> src/{domain}/{domain}Service.ts wingPool / authPool + wingPool.query(SQL, params) --SQL--> ``` -#### 디렉토리 구조 +### 레이어별 책임 + +| 레이어 | 파일 | 책임 | +|--------|------|------| +| **Router** | `{domain}Router.ts` | HTTP 요청 파싱, 파라미터 검증, 미들웨어 적용, 에러 응답 | +| **Service** | `{domain}Service.ts` | 비즈니스 로직, DB 쿼리, 도메인 검증, 소유자 검증 | +| **Frontend API** | `{tabName}Api.ts` | Axios 호출, 인터페이스 정의, 응답 변환 | + +### DB Pool 선택 기준 + +```ts +import { wingPool } from '../db/wingDb.js'; // 업무 데이터 (BOARD_POST, LAYER 등) +import { authPool } from '../db/authDb.js'; // 인증 데이터 (AUTH_USER, AUTH_ROLE 등) +``` + +> **참고**: `authPool`은 `wingPool`의 re-export이다 (동일 서버, search_path = wing, auth, public). +> 신규 코드는 `wingPool`을 사용한다. 다만 의미적으로 인증 데이터를 다룰 때 `authPool`을 쓰는 것도 허용한다. + +### HTTP 메서드 정책 + +**GET/POST only** (보안취약점 가이드 준수, PUT/DELETE 미사용 권장) + +| 작업 | HTTP 메서드 | URL 패턴 | 예시 | +|------|------------|----------|------| +| 목록 조회 | `GET` | `/api/{domain}` | `GET /api/equipment` | +| 상세 조회 | `GET` | `/api/{domain}/:sn` | `GET /api/equipment/42` | +| 등록 | `POST` | `/api/{domain}` | `POST /api/equipment` | +| 수정 | `POST` | `/api/{domain}/:sn/update` | `POST /api/equipment/42/update` | +| 삭제 | `POST` | `/api/{domain}/:sn/delete` | `POST /api/equipment/42/delete` | + +> **참고**: 기존 board 등 일부 레거시 API는 PUT/DELETE를 사용한다. 신규 개발 시 GET/POST only 정책을 따른다. + +--- + +## 백엔드 API 개발 패턴 + +### Router + Service 2레이어 구조 ``` backend/src/{domain}/ -├── {domain}Router.ts ← Express 라우터 (엔드포인트 + 미들웨어) -└── {domain}Service.ts ← 비즈니스 로직 (쿼리, 인터페이스) + {domain}Router.ts Express 라우터 (요청 파싱, 응답 포맷) + {domain}Service.ts 비즈니스 로직 + DB 쿼리 ``` -#### DB Pool +### 인증 미들웨어 적용 패턴 -```typescript -// backend/src/db/wingDb.ts -import { wingPool } from '../db/wingDb.js' +3단계 미들웨어를 조합하여 사용한다: -// wingPool은 연결 시 search_path = wing, auth, public 자동 설정 -// → 스키마 접두사 없이 wing.BOARD_POST, auth.AUTH_USER 모두 접근 가능 +```ts +import { requireAuth, requireRole, requirePermission } from '../auth/authMiddleware.js'; + +// 1. 인증만 (로그인 여부) +router.get('/public-data', requireAuth, handler); + +// 2. 인증 + 역할 기반 (ADMIN 등 특정 역할만) +router.post('/admin-action', requireAuth, requireRole('ADMIN'), handler); + +// 3. 인증 + 리소스 권한 (RBAC, 가장 일반적) +router.get('/', requireAuth, requirePermission('equipment', 'READ'), handler); +router.post('/', requireAuth, requirePermission('equipment', 'CREATE'), handler); ``` -> **주의**: `authPool`은 하위 호환용 re-export이다. 신규 코드는 반드시 `wingPool`을 직접 import할 것. +**requirePermission 파라미터**: +- `resource`: FEATURE_ID 형태 (예: `'equipment'`, `'board:notice'`) +- `operation`: `'READ'` | `'CREATE'` | `'UPDATE'` | `'DELETE'` -```typescript -// backend/src/db/authDb.ts (하위 호환 — 신규 코드에서 사용 금지) -import { wingPool } from './wingDb.js' -export const authPool = wingPool // 같은 Pool +요청당 1회만 DB를 조회하고 `req.resolvedPermissions`에 캐싱한다. 한 요청에서 여러 번 호출해도 성능 문제 없다. + +### req.user 구조 (JWT 페이로드) + +`requireAuth` 통과 후 `req.user`에 담기는 정보: + +```ts +interface JwtPayload { + sub: string; // 사용자 UUID (USER_ID) + acnt: string; // 계정명 (USER_ACNT) + name: string; // 사용자명 (USER_NM) + roles: string[]; // 역할 코드 목록 ['ADMIN', 'MANAGER', 'USER', 'VIEWER'] +} + +// 사용 예시 +const userId = req.user!.sub; +const userName = req.user!.name; +const isAdmin = req.user!.roles.includes('ADMIN'); ``` ---- +### 에러 처리 표준 -### 3. 권한 모델 빠른 요약 +```ts +import { AuthError } from '../auth/authService.js'; + +// AuthError: 비즈니스 에러에 HTTP 상태 코드를 포함하는 커스텀 에러 +throw new AuthError('데이터를 찾을 수 없습니다.', 404); +throw new AuthError('제목은 필수입니다.', 400); +throw new AuthError('본인의 데이터만 수정할 수 있습니다.', 403); +throw new AuthError('이미 존재하는 데이터입니다.', 409); + +// Router에서의 에러 처리 패턴 (모든 핸들러에 동일 적용) +try { + // 비즈니스 로직 호출 +} catch (err) { + // 1. AuthError -> 비즈니스 에러 (클라이언트에 메시지 전달) + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }); + return; + } + // 2. 그 외 -> 서버 에러 (내부 정보 노출 방지) + console.error('[domain] 작업 오류:', err); + res.status(500).json({ error: '처리 중 오류가 발생했습니다.' }); +} +``` + +### Router 보일러플레이트 + +```ts +// backend/src/{domain}/{domain}Router.ts + +import { Router } from 'express'; +import { requireAuth, requirePermission } from '../auth/authMiddleware.js'; +import { AuthError } from '../auth/authService.js'; +import { + listItems, getItem, createItem, updateItem, deleteItem, +} from './{domain}Service.js'; + +const router = Router(); + +// GET /api/{domain} -- 목록 조회 +router.get('/', requireAuth, requirePermission('{domain}', 'READ'), async (req, res) => { + try { + const { search, page, size } = req.query; + const result = await listItems({ + search: search as string | undefined, + page: page ? parseInt(page as string, 10) : undefined, + size: size ? parseInt(size as string, 10) : undefined, + }); + res.json(result); + } catch (err) { + console.error('[{domain}] 목록 조회 오류:', err); + res.status(500).json({ error: '목록 조회 중 오류가 발생했습니다.' }); + } +}); + +// GET /api/{domain}/:sn -- 상세 조회 +router.get('/:sn', requireAuth, requirePermission('{domain}', 'READ'), async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10); + if (isNaN(sn)) { + res.status(400).json({ error: '유효하지 않은 번호입니다.' }); + return; + } + const item = await getItem(sn); + res.json(item); + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }); + return; + } + console.error('[{domain}] 상세 조회 오류:', err); + res.status(500).json({ error: '조회 중 오류가 발생했습니다.' }); + } +}); + +// POST /api/{domain} -- 등록 +router.post('/', requireAuth, requirePermission('{domain}', 'CREATE'), async (req, res) => { + try { + const { title, content } = req.body; + if (!title) { + res.status(400).json({ error: '제목은 필수입니다.' }); + return; + } + const result = await createItem({ + title, + content, + authorId: req.user!.sub, + }); + res.status(201).json(result); + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }); + return; + } + console.error('[{domain}] 등록 오류:', err); + res.status(500).json({ error: '등록 중 오류가 발생했습니다.' }); + } +}); + +// POST /api/{domain}/:sn/update -- 수정 +router.post('/:sn/update', requireAuth, requirePermission('{domain}', 'UPDATE'), async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10); + if (isNaN(sn)) { + res.status(400).json({ error: '유효하지 않은 번호입니다.' }); + return; + } + const { title, content } = req.body; + await updateItem(sn, { title, content }, req.user!.sub); + res.json({ success: true }); + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }); + return; + } + console.error('[{domain}] 수정 오류:', err); + res.status(500).json({ error: '수정 중 오류가 발생했습니다.' }); + } +}); + +// POST /api/{domain}/:sn/delete -- 삭제 (논리 삭제) +router.post('/:sn/delete', requireAuth, requirePermission('{domain}', 'DELETE'), async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10); + if (isNaN(sn)) { + res.status(400).json({ error: '유효하지 않은 번호입니다.' }); + return; + } + await deleteItem(sn, req.user!.sub); + res.json({ success: true }); + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }); + return; + } + console.error('[{domain}] 삭제 오류:', err); + res.status(500).json({ error: '삭제 중 오류가 발생했습니다.' }); + } +}); + +export default router; +``` + +### Service 보일러플레이트 + +```ts +// backend/src/{domain}/{domain}Service.ts + +import { wingPool } from '../db/wingDb.js'; +import { AuthError } from '../auth/authService.js'; + +// ============================================================ +// 인터페이스 +// ============================================================ + +interface ItemRow { + sn: number; + title: string; + content: string | null; + authorId: string; + regDtm: string; + mdfcnDtm: string | null; +} + +interface ListInput { + search?: string; + page?: number; + size?: number; +} + +interface ListResult { + items: ItemRow[]; + totalCount: number; + page: number; + size: number; +} + +interface CreateInput { + title: string; + content?: string; + authorId: string; +} + +interface UpdateInput { + title?: string; + content?: string; +} + +// ============================================================ +// 페이징 목록 조회 +// ============================================================ + +export async function listItems(input: ListInput): Promise { + // 1. 페이징 파라미터 정규화 + const page = input.page && input.page > 0 ? input.page : 1; + const size = input.size && input.size > 0 ? Math.min(input.size, 100) : 20; + const offset = (page - 1) * size; + + // 2. 동적 WHERE 절 구성 + let whereClause = "WHERE USE_YN = 'Y'"; + const params: (string | number)[] = []; + let paramIdx = 1; + + if (input.search) { + whereClause += ` AND TITLE ILIKE $${paramIdx}`; + params.push(`%${input.search}%`); + paramIdx++; + } + + // 3. COUNT 쿼리 + const countResult = await wingPool.query( + `SELECT COUNT(*) as cnt FROM {TABLE_NAME} ${whereClause}`, + params, + ); + const totalCount = parseInt(countResult.rows[0].cnt, 10); + + // 4. 목록 쿼리 + const listParams = [...params, size, offset]; + const listResult = await wingPool.query( + `SELECT SN, TITLE, CONTENT, AUTHOR_ID, REG_DTM, MDFCN_DTM + FROM {TABLE_NAME} + ${whereClause} + ORDER BY REG_DTM DESC + LIMIT $${paramIdx++} OFFSET $${paramIdx}`, + listParams, + ); + + // 5. snake_case -> camelCase 매핑 + const items: ItemRow[] = listResult.rows.map((r: Record) => ({ + sn: r.sn as number, + title: r.title as string, + content: r.content as string | null, + authorId: r.author_id as string, + regDtm: r.reg_dtm as string, + mdfcnDtm: r.mdfcn_dtm as string | null, + })); + + return { items, totalCount, page, size }; +} + +// ============================================================ +// 상세 조회 +// ============================================================ + +export async function getItem(sn: number): Promise { + const result = await wingPool.query( + `SELECT SN, TITLE, CONTENT, AUTHOR_ID, REG_DTM, MDFCN_DTM + FROM {TABLE_NAME} + WHERE SN = $1 AND USE_YN = 'Y'`, + [sn], + ); + + if (result.rows.length === 0) { + throw new AuthError('데이터를 찾을 수 없습니다.', 404); + } + + const r = result.rows[0]; + return { + sn: r.sn, + title: r.title, + content: r.content, + authorId: r.author_id, + regDtm: r.reg_dtm, + mdfcnDtm: r.mdfcn_dtm, + }; +} + +// ============================================================ +// 등록 +// ============================================================ + +export async function createItem(input: CreateInput): Promise<{ sn: number }> { + if (!input.title || input.title.trim().length === 0) { + throw new AuthError('제목은 필수입니다.', 400); + } + + const result = await wingPool.query( + `INSERT INTO {TABLE_NAME} (TITLE, CONTENT, AUTHOR_ID) + VALUES ($1, $2, $3) + RETURNING SN`, + [input.title.trim(), input.content || null, input.authorId], + ); + + return { sn: result.rows[0].sn }; +} + +// ============================================================ +// 동적 UPDATE (부분 수정) +// ============================================================ + +export async function updateItem( + sn: number, + input: UpdateInput, + requesterId: string, +): Promise { + // 존재 확인 + 소유자 검증 + const existing = await wingPool.query( + `SELECT AUTHOR_ID as author_id FROM {TABLE_NAME} WHERE SN = $1 AND USE_YN = 'Y'`, + [sn], + ); + + if (existing.rows.length === 0) { + throw new AuthError('데이터를 찾을 수 없습니다.', 404); + } + + if (existing.rows[0].author_id !== requesterId) { + throw new AuthError('본인의 데이터만 수정할 수 있습니다.', 403); + } + + // 동적 SET 절 구성 + const sets: string[] = []; + const params: (string | number | null)[] = []; + let idx = 1; + + if (input.title !== undefined) { + sets.push(`TITLE = $${idx++}`); + params.push(input.title.trim()); + } + if (input.content !== undefined) { + sets.push(`CONTENT = $${idx++}`); + params.push(input.content); + } + + if (sets.length === 0) { + throw new AuthError('수정할 항목이 없습니다.', 400); + } + + sets.push('MDFCN_DTM = NOW()'); + params.push(sn); + + await wingPool.query( + `UPDATE {TABLE_NAME} SET ${sets.join(', ')} WHERE SN = $${idx}`, + params, + ); +} + +// ============================================================ +// 논리 삭제 +// ============================================================ + +export async function deleteItem(sn: number, requesterId: string): Promise { + const existing = await wingPool.query( + `SELECT AUTHOR_ID as author_id FROM {TABLE_NAME} WHERE SN = $1 AND USE_YN = 'Y'`, + [sn], + ); + + if (existing.rows.length === 0) { + throw new AuthError('데이터를 찾을 수 없습니다.', 404); + } + + if (existing.rows[0].author_id !== requesterId) { + throw new AuthError('본인의 데이터만 삭제할 수 있습니다.', 403); + } + + await wingPool.query( + `UPDATE {TABLE_NAME} SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE SN = $1`, + [sn], + ); +} +``` + +### 트랜잭션 패턴 + +여러 테이블을 동시에 변경해야 할 때: + +```ts +export async function createWithAttachments( + input: CreateInput, + attachments: AttachmentInput[], +): Promise<{ sn: number }> { + const client = await wingPool.connect(); + + try { + await client.query('BEGIN'); + + const postResult = await client.query( + `INSERT INTO {TABLE_NAME} (TITLE, CONTENT, AUTHOR_ID) + VALUES ($1, $2, $3) + RETURNING SN`, + [input.title, input.content, input.authorId], + ); + const sn = postResult.rows[0].sn; + + for (const att of attachments) { + await client.query( + `INSERT INTO {TABLE_ATTACH} (PARENT_SN, FILE_NM, FILE_PATH, FILE_SIZE) + VALUES ($1, $2, $3, $4)`, + [sn, att.fileName, att.filePath, att.fileSize], + ); + } + + await client.query('COMMIT'); + return { sn }; + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } +} +``` + +### server.ts 등록 + +```ts +// backend/src/server.ts + +// 1. import 추가 (반드시 .js 확장자) +import equipmentRouter from './equipment/equipmentRouter.js'; + +// 2. 업무 API 라우트 등록 (기존 라우트 아래에) +app.use('/api/equipment', equipmentRouter); +``` + +### 권한 모델 요약 #### 2차원 모델: 리소스 트리 x 오퍼레이션 ``` AUTH_PERM 테이블: (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) -리소스 트리 (AUTH_PERM_TREE) 오퍼레이션 (플랫) -├── board READ = 조회/열람 -│ ├── board:notice CREATE = 생성 -│ ├── board:data UPDATE = 수정 -│ └── board:qna DELETE = 삭제 -├── prediction -│ ├── prediction:analysis -│ └── prediction:list -└── admin - ├── admin:users - └── admin:permissions -``` - -#### 리소스 코드 - -`AUTH_PERM_TREE` 테이블에 등록된 코드. 콜론(`:`)으로 계층 구분. - -| 형식 | 예시 | 설명 | -|------|------|------| -| `{탭}` | `board` | 메인 탭 (level 0) | -| `{탭}:{서브}` | `board:notice` | 서브 리소스 (level 1) | - -#### 오퍼레이션 - -| OPER_CD | 설명 | 용도 | -|---------|------|------| -| `READ` | 조회/열람 | 목록, 상세 조회 | -| `CREATE` | 생성 | 새 데이터 등록 | -| `UPDATE` | 수정 | 기존 데이터 변경 | -| `DELETE` | 삭제 | 데이터 삭제 | - -#### 백엔드: requirePermission - -```typescript -import { requireAuth, requirePermission } from '../auth/authMiddleware.js' - -// requirePermission(리소스코드, 오퍼레이션코드) -// 오퍼레이션 생략 시 기본값 'READ' -router.post('/list', requirePermission('board:notice', 'READ'), handler) -router.post('/create', requirePermission('board:notice', 'CREATE'), handler) -``` - -`requirePermission`은 **요청당 1회**만 DB를 조회하고 `req.resolvedPermissions`에 캐싱한다. 한 요청에서 여러 번 호출해도 성능 문제 없다. - -#### 프론트엔드: hasPermission - -```typescript -import { useAuthStore } from '@common/store/authStore' - -const { hasPermission } = useAuthStore() - -hasPermission('board:notice') // READ 확인 (기본값) -hasPermission('board:notice', 'CREATE') // 생성 권한 확인 -hasPermission('board:notice', 'UPDATE') // 수정 권한 확인 -hasPermission('board:notice', 'DELETE') // 삭제 권한 확인 +리소스 트리 (AUTH_PERM_TREE) 오퍼레이션 + board READ = 조회/열람 + board:notice CREATE = 생성 + board:data UPDATE = 수정 + board:qna DELETE = 삭제 ``` #### 상속 규칙 ``` -규칙 1: 부모 READ=N → 자식의 모든 오퍼레이션 강제 N -규칙 2: 명시적 레코드 있으면 → 그 값 사용 -규칙 3: 명시적 레코드 없으면 → 부모의 같은 오퍼레이션 상속 -규칙 4: 최상위까지 없으면 → 기본 N (거부) +규칙 1: 부모 READ=N -> 자식의 모든 오퍼레이션 강제 N +규칙 2: 명시적 레코드 있으면 -> 그 값 사용 +규칙 3: 명시적 레코드 없으면 -> 부모의 같은 오퍼레이션 상속 +규칙 4: 최상위까지 없으면 -> 기본 N (거부) +``` + +#### 카테고리별 동적 리소스 결정 (board 패턴) + +카테고리에 따라 다른 리소스에 대해 권한을 검사하는 패턴: + +```ts +const CATEGORY_RESOURCE: Record = { + NOTICE: 'board:notice', + DATA: 'board:data', + QNA: 'board:qna', + MANUAL: 'board:manual', +}; + +// 작성 시: body의 categoryCd로 리소스 결정 +router.post('/', requireAuth, async (req, res, next) => { + const resource = CATEGORY_RESOURCE[req.body.categoryCd] || 'board'; + requirePermission(resource, 'CREATE')(req, res, next); +}, createHandler); ``` --- -### 4. DB 설계 규칙 +## DB 마이그레이션 작성법 -#### 스키마 선택 +### 파일 네이밍 -| 데이터 성격 | 스키마 | 예시 | -|-------------|--------|------| -| 운영 데이터 | `wing` | BOARD_POST, LAYER, HNS_SUBSTANCE | -| 인증/인가 | `auth` | AUTH_USER, AUTH_ROLE, AUTH_PERM | +``` +database/migration/NNN_{domain}.sql +``` -> `search_path = wing, auth, public` 설정으로 스키마 접두사 없이 접근 가능. -> 단, 다른 스키마 테이블을 FK로 참조할 때는 `auth.AUTH_USER(USER_ID)` 처럼 명시한다. +- `NNN`: 3자리 순번 (001, 002, ..., 017) +- `{domain}`: 도메인명 (board, assets, equipment 등) +- 현재 마지막 순번을 확인하여 다음 번호를 사용한다 -#### 네이밍 규칙 +### 표준 테이블 구조 + +```sql +-- ============================================================ +-- 마이그레이션 NNN: {도메인 한글명} ({TABLE_NAME}) +-- wing 스키마에 생성, auth.AUTH_USER FK 참조 +-- ============================================================ + +CREATE TABLE IF NOT EXISTS {TABLE_NAME} ( + -- PK (SERIAL 자동 증가) + {PREFIX}_SN SERIAL PRIMARY KEY, + + -- 비즈니스 컬럼 + TITLE VARCHAR(200) NOT NULL, + CONTENT TEXT, + STATUS_CD VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + + -- 외래키 (작성자) + AUTHOR_ID UUID NOT NULL, + + -- 공통 감사 컬럼 (모든 테이블 필수) + USE_YN CHAR(1) NOT NULL DEFAULT 'Y', + REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), + MDFCN_DTM TIMESTAMPTZ, + + -- 제약조건 + CONSTRAINT FK_{PREFIX}_AUTHOR FOREIGN KEY (AUTHOR_ID) + REFERENCES auth.AUTH_USER(USER_ID), + CONSTRAINT CK_{PREFIX}_USE CHECK (USE_YN IN ('Y','N')) +); + +-- 테이블/컬럼 설명 +COMMENT ON TABLE {TABLE_NAME} IS '{도메인 한글명}'; +COMMENT ON COLUMN {TABLE_NAME}.USE_YN IS '사용여부 (N=논리삭제)'; + +-- 인덱스 +CREATE INDEX IF NOT EXISTS IDX_{PREFIX}_REG_DTM ON {TABLE_NAME}(REG_DTM DESC); +CREATE INDEX IF NOT EXISTS IDX_{PREFIX}_AUTHOR ON {TABLE_NAME}(AUTHOR_ID); +``` + +### 네이밍 규칙 | 항목 | 규칙 | 예시 | |------|------|------| | 테이블명 | UPPER_SNAKE_CASE | `BOARD_POST`, `HNS_SUBSTANCE` | | 컬럼명 | UPPER_SNAKE_CASE | `POST_SN`, `CATEGORY_CD`, `REG_DTM` | | PK | `{접두어}_SN` (SERIAL) 또는 `{접두어}_ID` (UUID) | `POST_SN`, `USER_ID` | -| FK 컬럼 | 참조 테이블의 PK 컬럼명 그대로 사용 | `AUTHOR_ID` (→ AUTH_USER.USER_ID) | -| 코드성 컬럼 | `{의미}_CD` | `CATEGORY_CD`, `OPER_CD` | -| 여부 컬럼 | `{의미}_YN` (CHAR(1), 'Y'/'N') | `USE_YN`, `PINNED_YN` | -| 일시 컬럼 | `{의미}_DTM` (TIMESTAMPTZ) | `REG_DTM`, `MDFCN_DTM` | +| FK 컬럼 | 참조 테이블의 PK 이름 그대로 | `AUTHOR_ID` (-> AUTH_USER.USER_ID) | +| 코드성 | `{의미}_CD` | `CATEGORY_CD`, `OPER_CD` | +| 여부 | `{의미}_YN` (CHAR(1), 'Y'/'N') | `USE_YN`, `PINNED_YN` | +| 일시 | `{의미}_DTM` (TIMESTAMPTZ) | `REG_DTM`, `MDFCN_DTM` | -#### 공통 컬럼 패턴 +### 공통 감사 컬럼 (모든 테이블 필수) -모든 운영 테이블에 포함하는 표준 컬럼: +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `USE_YN` | `CHAR(1) DEFAULT 'Y'` | 논리 삭제 플래그 (Y=활성, N=삭제) | +| `REG_DTM` | `TIMESTAMPTZ DEFAULT NOW()` | 등록 일시 | +| `MDFCN_DTM` | `TIMESTAMPTZ` | 수정 일시 (NULL=미수정) | + +### 코드형 컬럼 (CHECK 제약) ```sql -USE_YN CHAR(1) NOT NULL DEFAULT 'Y', -- 논리삭제 (Y=활성, N=삭제) -REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시 -MDFCN_DTM TIMESTAMPTZ, -- 수정일시 +CATEGORY_CD VARCHAR(20) NOT NULL, +CONSTRAINT CK_{PREFIX}_CATEGORY + CHECK (CATEGORY_CD IN ('NOTICE', 'DATA', 'QNA', 'MANUAL')) ``` -#### DDL 작성 예시 +### PostGIS GEOMETRY 컬럼 + +공간 데이터가 필요한 경우: ```sql --- database/migration/NNN_description.sql +-- PostGIS 확장 (이미 활성화되어 있으나 안전하게) +CREATE EXTENSION IF NOT EXISTS postgis; -CREATE TABLE IF NOT EXISTS BOARD_POST ( - POST_SN SERIAL PRIMARY KEY, - CATEGORY_CD VARCHAR(20) NOT NULL, - TITLE VARCHAR(200) NOT NULL, - CONTENT TEXT, - AUTHOR_ID UUID NOT NULL, - VIEW_CNT INTEGER NOT NULL DEFAULT 0, - PINNED_YN CHAR(1) NOT NULL DEFAULT 'N', - USE_YN CHAR(1) NOT NULL DEFAULT 'Y', - REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), - MDFCN_DTM TIMESTAMPTZ, +-- 좌표 컬럼 (WGS84, SRID=4326) +GEOM GEOMETRY(POINT, 4326), - -- FK: 다른 스키마 참조 시 스키마 명시 - CONSTRAINT FK_BOARD_AUTHOR FOREIGN KEY (AUTHOR_ID) - REFERENCES auth.AUTH_USER(USER_ID), +-- 공간 인덱스 +CREATE INDEX IF NOT EXISTS IDX_{PREFIX}_GEOM ON {TABLE_NAME} USING GIST(GEOM); - -- CHECK: 코드성 컬럼에 허용값 명시 - CONSTRAINT CK_BOARD_CATEGORY - CHECK (CATEGORY_CD IN ('NOTICE','DATA','QNA','MANUAL')), - CONSTRAINT CK_BOARD_PINNED CHECK (PINNED_YN IN ('Y','N')), - CONSTRAINT CK_BOARD_USE CHECK (USE_YN IN ('Y','N')) +-- INSERT 예시 +INSERT INTO {TABLE_NAME} (TITLE, GEOM) +VALUES ('부산항', ST_SetSRID(ST_MakePoint(129.0756, 35.1796), 4326)); + +-- SELECT 예시 (좌표 추출) +SELECT TITLE, ST_X(GEOM) as lon, ST_Y(GEOM) as lat +FROM {TABLE_NAME}; + +-- 반경 검색 (10km 이내) +SELECT * FROM {TABLE_NAME} +WHERE ST_DWithin( + GEOM::geography, + ST_SetSRID(ST_MakePoint(129.0, 35.0), 4326)::geography, + 10000 ); - --- COMMENT: 테이블/컬럼 설명 -COMMENT ON TABLE BOARD_POST IS '게시판 게시글'; -COMMENT ON COLUMN BOARD_POST.CATEGORY_CD IS '카테고리: NOTICE=공지, DATA=자료실, QNA=Q&A, MANUAL=해경매뉴얼'; - --- INDEX: 검색/필터 대상, FK 컬럼 -CREATE INDEX IF NOT EXISTS IDX_BOARD_CATEGORY ON BOARD_POST(CATEGORY_CD); -CREATE INDEX IF NOT EXISTS IDX_BOARD_AUTHOR ON BOARD_POST(AUTHOR_ID); -CREATE INDEX IF NOT EXISTS IDX_BOARD_REG_DTM ON BOARD_POST(REG_DTM DESC); ``` -#### 마이그레이션 파일 규칙 +### 시드 데이터 패턴 -- 경로: `database/migration/NNN_description.sql` -- 번호: 기존 파일 다음 번호 (001, 003, 004, 005, 006, ...) -- 모든 DDL에 `IF NOT EXISTS` / `IF EXISTS` 사용 (재실행 안전) -- 파일 끝에 검증 SELECT 포함 - ---- - -### 5. Service 레이어 패턴 - -#### 인터페이스 정의 - -Service 파일 상단에 반환 타입과 입력 타입을 정의한다. - -```typescript -// backend/src/{domain}/{domain}Service.ts - -import { wingPool } from '../db/wingDb.js' -import { AuthError } from '../auth/authService.js' - -// 목록/상세 조회 반환 타입 -interface PostItem { - postSn: number - categoryCd: string - title: string - content: string | null - authorId: string - authorName: string - viewCnt: number - pinnedYn: string - useYn: string - regDtm: string - mdfcnDtm: string | null -} - -// 생성 입력 타입 -interface CreatePostInput { - categoryCd: string - title: string - content?: string - authorId: string - pinnedYn?: string -} - -// 수정 입력 타입 (모든 필드 optional — 부분 업데이트) -interface UpdatePostInput { - title?: string - content?: string - categoryCd?: string - pinnedYn?: string -} - -// 페이징 응답 타입 -interface PagedResult { - items: T[] - totalCount: number - page: number - size: number -} -``` - -#### wingPool 사용 - -```typescript -import { wingPool } from '../db/wingDb.js' - -// 단순 조회 -const result = await wingPool.query( - 'SELECT * FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = $2', - [postSn, 'Y'] -) - -// Parameterized Query — 반드시 $1, $2, ... 사용 (SQL Injection 방지) -// 문자열 결합으로 쿼리를 만들지 않는다 -``` - -#### 동적 WHERE 빌드 패턴 (필터, 검색) - -```typescript -export async function listPosts( - categoryCd?: string, - search?: string, - page: number = 1, - size: number = 20, -): Promise> { - // 동적 WHERE 조건 - const conditions: string[] = ["p.USE_YN = 'Y'"] - const params: (string | number)[] = [] - let paramIdx = 1 - - if (categoryCd) { - conditions.push(`p.CATEGORY_CD = $${paramIdx++}`) - params.push(categoryCd) - } - - if (search) { - conditions.push(`(p.TITLE ILIKE $${paramIdx} OR p.CONTENT ILIKE $${paramIdx})`) - params.push(`%${search}%`) - paramIdx++ - } - - const whereClause = conditions.join(' AND ') - - // totalCount 조회 - const countResult = await wingPool.query( - `SELECT COUNT(*) as cnt FROM BOARD_POST p WHERE ${whereClause}`, - params - ) - const totalCount = parseInt(countResult.rows[0].cnt, 10) - - // 페이징 데이터 조회 - const offset = (page - 1) * size - const dataParams = [...params, size, offset] - - const dataResult = await wingPool.query( - `SELECT p.POST_SN as post_sn, p.CATEGORY_CD as category_cd, - p.TITLE as title, p.CONTENT as content, - p.AUTHOR_ID as author_id, u.USER_NM as author_name, - p.VIEW_CNT as view_cnt, p.PINNED_YN as pinned_yn, - p.USE_YN as use_yn, p.REG_DTM as reg_dtm, p.MDFCN_DTM as mdfcn_dtm - FROM BOARD_POST p - LEFT JOIN AUTH_USER u ON p.AUTHOR_ID = u.USER_ID - WHERE ${whereClause} - ORDER BY p.PINNED_YN DESC, p.REG_DTM DESC - LIMIT $${paramIdx++} OFFSET $${paramIdx++}`, - dataParams - ) - - const items: PostItem[] = dataResult.rows.map((row) => ({ - postSn: row.post_sn, - categoryCd: row.category_cd, - title: row.title, - content: row.content, - authorId: row.author_id, - authorName: row.author_name, - viewCnt: row.view_cnt, - pinnedYn: row.pinned_yn, - useYn: row.use_yn, - regDtm: row.reg_dtm, - mdfcnDtm: row.mdfcn_dtm, - })) - - return { items, totalCount, page, size } -} -``` - -#### 상세 조회 - -```typescript -export async function getPost(postSn: number): Promise { - const result = await wingPool.query( - `SELECT p.POST_SN as post_sn, p.CATEGORY_CD as category_cd, - p.TITLE as title, p.CONTENT as content, - p.AUTHOR_ID as author_id, u.USER_NM as author_name, - p.VIEW_CNT as view_cnt, p.PINNED_YN as pinned_yn, - p.USE_YN as use_yn, p.REG_DTM as reg_dtm, p.MDFCN_DTM as mdfcn_dtm - FROM BOARD_POST p - LEFT JOIN AUTH_USER u ON p.AUTHOR_ID = u.USER_ID - WHERE p.POST_SN = $1 AND p.USE_YN = 'Y'`, - [postSn] - ) - - if (result.rows.length === 0) { - throw new AuthError('게시글을 찾을 수 없습니다.', 404) - } - - const row = result.rows[0] - return { - postSn: row.post_sn, - categoryCd: row.category_cd, - title: row.title, - content: row.content, - authorId: row.author_id, - authorName: row.author_name, - viewCnt: row.view_cnt, - pinnedYn: row.pinned_yn, - useYn: row.use_yn, - regDtm: row.reg_dtm, - mdfcnDtm: row.mdfcn_dtm, - } -} -``` - -#### 생성 - -```typescript -export async function createPost(input: CreatePostInput): Promise<{ postSn: number }> { - const result = await wingPool.query( - `INSERT INTO BOARD_POST (CATEGORY_CD, TITLE, CONTENT, AUTHOR_ID, PINNED_YN) - VALUES ($1, $2, $3, $4, $5) - RETURNING POST_SN as post_sn`, - [input.categoryCd, input.title, input.content || null, input.authorId, input.pinnedYn || 'N'] - ) - - return { postSn: result.rows[0].post_sn } -} -``` - -#### 동적 SET 빌드 패턴 (부분 업데이트) - -```typescript -export async function updatePost( - postSn: number, - input: UpdatePostInput, - requesterId: string, -): Promise { - // 소유자 검증 - const existing = await wingPool.query( - "SELECT AUTHOR_ID FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'", - [postSn] - ) - if (existing.rows.length === 0) { - throw new AuthError('게시글을 찾을 수 없습니다.', 404) - } - if (existing.rows[0].author_id !== requesterId) { - throw new AuthError('본인의 게시글만 수정할 수 있습니다.', 403) - } - - // 동적 SET 빌드 - const sets: string[] = [] - const params: (string | number | null)[] = [] - let idx = 1 - - if (input.title !== undefined) { - sets.push(`TITLE = $${idx++}`) - params.push(input.title) - } - if (input.content !== undefined) { - sets.push(`CONTENT = $${idx++}`) - params.push(input.content) - } - if (input.categoryCd !== undefined) { - sets.push(`CATEGORY_CD = $${idx++}`) - params.push(input.categoryCd) - } - if (input.pinnedYn !== undefined) { - sets.push(`PINNED_YN = $${idx++}`) - params.push(input.pinnedYn) - } - - if (sets.length === 0) { - throw new AuthError('수정할 항목이 없습니다.', 400) - } - - // MDFCN_DTM 자동 갱신 - sets.push('MDFCN_DTM = NOW()') - params.push(postSn) - - await wingPool.query( - `UPDATE BOARD_POST SET ${sets.join(', ')} WHERE POST_SN = $${idx}`, - params - ) -} -``` - -#### 삭제 (논리삭제) - -```typescript -export async function deletePost(postSn: number, requesterId: string): Promise { - // 소유자 검증 - const existing = await wingPool.query( - "SELECT AUTHOR_ID FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'", - [postSn] - ) - if (existing.rows.length === 0) { - throw new AuthError('게시글을 찾을 수 없습니다.', 404) - } - if (existing.rows[0].author_id !== requesterId) { - throw new AuthError('본인의 게시글만 삭제할 수 있습니다.', 403) - } - - // 논리삭제: USE_YN = 'N' - await wingPool.query( - "UPDATE BOARD_POST SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE POST_SN = $1", - [postSn] - ) -} -``` - -#### 트랜잭션 패턴 - -여러 테이블을 동시에 변경해야 할 때: - -```typescript -export async function createPostWithAttachments( - input: CreatePostInput, - attachments: AttachmentInput[], -): Promise<{ postSn: number }> { - const client = await wingPool.connect() - - try { - await client.query('BEGIN') - - // 게시글 생성 - const postResult = await client.query( - `INSERT INTO BOARD_POST (CATEGORY_CD, TITLE, CONTENT, AUTHOR_ID) - VALUES ($1, $2, $3, $4) - RETURNING POST_SN as post_sn`, - [input.categoryCd, input.title, input.content, input.authorId] - ) - const postSn = postResult.rows[0].post_sn - - // 첨부파일 생성 - for (const att of attachments) { - await client.query( - `INSERT INTO BOARD_ATTACH (POST_SN, FILE_NM, FILE_PATH, FILE_SIZE) - VALUES ($1, $2, $3, $4)`, - [postSn, att.fileName, att.filePath, att.fileSize] - ) - } - - await client.query('COMMIT') - return { postSn } - } catch (err) { - await client.query('ROLLBACK') - throw err - } finally { - client.release() - } -} -``` - -#### 에러 처리 - -```typescript -import { AuthError } from '../auth/authService.js' - -// AuthError: status 코드와 메시지를 포함하는 커스텀 에러 -// Router에서 instanceof 체크로 적절한 HTTP 응답을 반환 - -throw new AuthError('게시글을 찾을 수 없습니다.', 404) -throw new AuthError('권한이 없습니다.', 403) -throw new AuthError('필수 항목이 누락되었습니다.', 400) -throw new AuthError('이미 존재하는 데이터입니다.', 409) -``` - -`AuthError` 클래스 정의 (`backend/src/auth/authService.ts`): - -```typescript -export class AuthError extends Error { - status: number - constructor(message: string, status: number) { - super(message) - this.status = status - this.name = 'AuthError' - } -} -``` - ---- - -### 6. Router 레이어 패턴 - -#### 미들웨어 체인 - -``` -requireAuth → requirePermission(resource, operation) → 핸들러 -``` - -- `requireAuth`: JWT 쿠키 검증, `req.user`에 페이로드 세팅 -- `requirePermission`: 리소스 x 오퍼레이션 권한 확인 - -#### CRUD 엔드포인트 표준 - -보안 취약점 점검 가이드에 따라 **POST 메서드를 기본**으로 사용한다. -OPER_CD는 HTTP Method가 아닌 **비즈니스 의미**로 결정한다. - -| URL 패턴 | OPER_CD | 미들웨어 | -|----------|---------|----------| -| `POST /api/{domain}/list` | READ | `requirePermission(resource, 'READ')` | -| `POST /api/{domain}/detail` | READ | `requirePermission(resource, 'READ')` | -| `POST /api/{domain}/create` | CREATE | `requirePermission(resource, 'CREATE')` | -| `POST /api/{domain}/update` | UPDATE | `requirePermission(resource, 'UPDATE')` | -| `POST /api/{domain}/delete` | DELETE | `requirePermission(resource, 'DELETE')` | - -#### 전체 Router 예시 - -```typescript -// backend/src/board/boardRouter.ts - -import { Router } from 'express' -import { requireAuth, requirePermission } from '../auth/authMiddleware.js' -import { AuthError } from '../auth/authService.js' -import { - listPosts, - getPost, - createPost, - updatePost, - deletePost, -} from './boardService.js' - -const router = Router() - -// 모든 엔드포인트에 인증 필수 -router.use(requireAuth) - -// 목록 조회 -router.post('/list', requirePermission('board:notice', 'READ'), async (req, res) => { - try { - const { categoryCd, search, page, size } = req.body - const result = await listPosts(categoryCd, search, page, size) - res.json(result) - } catch (err) { - if (err instanceof AuthError) { - res.status(err.status).json({ error: err.message }) - return - } - console.error('[board] 목록 조회 오류:', err) - res.status(500).json({ error: '게시글 목록 조회 중 오류가 발생했습니다.' }) - } -}) - -// 상세 조회 -router.post('/detail', requirePermission('board:notice', 'READ'), async (req, res) => { - try { - const { postSn } = req.body - if (!postSn) { - res.status(400).json({ error: '게시글 번호는 필수입니다.' }) - return - } - const post = await getPost(postSn) - res.json(post) - } catch (err) { - if (err instanceof AuthError) { - res.status(err.status).json({ error: err.message }) - return - } - console.error('[board] 상세 조회 오류:', err) - res.status(500).json({ error: '게시글 조회 중 오류가 발생했습니다.' }) - } -}) - -// 생성 -router.post('/create', requirePermission('board:notice', 'CREATE'), async (req, res) => { - try { - const { categoryCd, title, content, pinnedYn } = req.body - - // 필수 필드 검증 - if (!categoryCd || !title) { - res.status(400).json({ error: '카테고리와 제목은 필수입니다.' }) - return - } - - // req.user!.sub = 현재 로그인 사용자 UUID - const result = await createPost({ - categoryCd, - title, - content, - authorId: req.user!.sub, - pinnedYn, - }) - res.status(201).json(result) - } catch (err) { - if (err instanceof AuthError) { - res.status(err.status).json({ error: err.message }) - return - } - console.error('[board] 생성 오류:', err) - res.status(500).json({ error: '게시글 생성 중 오류가 발생했습니다.' }) - } -}) - -// 수정 -router.post('/update', requirePermission('board:notice', 'UPDATE'), async (req, res) => { - try { - const { postSn, title, content, categoryCd, pinnedYn } = req.body - - if (!postSn) { - res.status(400).json({ error: '게시글 번호는 필수입니다.' }) - return - } - - await updatePost(postSn, { title, content, categoryCd, pinnedYn }, req.user!.sub) - res.json({ success: true }) - } catch (err) { - if (err instanceof AuthError) { - res.status(err.status).json({ error: err.message }) - return - } - console.error('[board] 수정 오류:', err) - res.status(500).json({ error: '게시글 수정 중 오류가 발생했습니다.' }) - } -}) - -// 삭제 -router.post('/delete', requirePermission('board:notice', 'DELETE'), async (req, res) => { - try { - const { postSn } = req.body - - if (!postSn) { - res.status(400).json({ error: '게시글 번호는 필수입니다.' }) - return - } - - await deletePost(postSn, req.user!.sub) - res.json({ success: true }) - } catch (err) { - if (err instanceof AuthError) { - res.status(err.status).json({ error: err.message }) - return - } - console.error('[board] 삭제 오류:', err) - res.status(500).json({ error: '게시글 삭제 중 오류가 발생했습니다.' }) - } -}) - -export default router -``` - -#### 입력 검증 패턴 - -핸들러 내부에서 필수 필드를 직접 체크한다. - -```typescript -// 필수 필드 검증 -if (!categoryCd || !title) { - res.status(400).json({ error: '카테고리와 제목은 필수입니다.' }) - return -} - -// 배열 타입 검증 -if (!Array.isArray(roleSns)) { - res.status(400).json({ error: '역할 목록이 필요합니다.' }) - return -} - -// 길이 검증 -if (!password || password.length < 4) { - res.status(400).json({ error: '비밀번호는 4자 이상이어야 합니다.' }) - return -} -``` - -#### 에러 응답 패턴 - -모든 핸들러에서 동일한 에러 처리 구조를 사용한다. - -```typescript -try { - // 비즈니스 로직 -} catch (err) { - // 1. AuthError → 해당 status + message - if (err instanceof AuthError) { - res.status(err.status).json({ error: err.message }) - return - } - // 2. 예상치 못한 에러 → 500 + 일반 메시지 (내부 정보 노출 방지) - console.error('[domain] 작업 오류:', err) - res.status(500).json({ error: '처리 중 오류가 발생했습니다.' }) -} -``` - -#### server.ts 등록 - -```typescript -// backend/src/server.ts - -import boardRouter from './board/boardRouter.js' - -// API 라우트 — 업무 -app.use('/api/board', boardRouter) -``` - -#### req.user 구조 (JWT 페이로드) - -`requireAuth` 통과 후 `req.user`에 담기는 정보: - -```typescript -interface JwtPayload { - sub: string // 사용자 UUID (USER_ID) - acnt: string // 계정명 (USER_ACNT) - name: string // 사용자명 (USER_NM) - roles: string[] // 역할 코드 목록 ['ADMIN', 'MANAGER', 'USER', 'VIEWER'] -} - -// 사용 예시 -const userId = req.user!.sub // 현재 사용자 UUID -const userName = req.user!.name // 현재 사용자 이름 -const isAdmin = req.user!.roles.includes('ADMIN') -``` - ---- - -### 7. 프론트엔드 연동 패턴 - -#### API 서비스 파일 - -탭별로 `services/` 디렉토리에 API 함수를 분리한다. - -```typescript -// frontend/src/tabs/board/services/boardApi.ts - -import { api } from '@common/services/api' - -// 타입 정의 -export interface PostItem { - postSn: number - categoryCd: string - title: string - content: string | null - authorId: string - authorName: string - viewCnt: number - pinnedYn: string - useYn: string - regDtm: string - mdfcnDtm: string | null -} - -export interface PostListResult { - items: PostItem[] - totalCount: number - page: number - size: number -} - -// 목록 조회 -export async function fetchPosts(params: { - categoryCd?: string - search?: string - page?: number - size?: number -}): Promise { - const response = await api.post('/board/list', params) - return response.data -} - -// 상세 조회 -export async function fetchPost(postSn: number): Promise { - const response = await api.post('/board/detail', { postSn }) - return response.data -} - -// 생성 -export async function createPostApi(data: { - categoryCd: string - title: string - content?: string - pinnedYn?: string -}): Promise<{ postSn: number }> { - const response = await api.post<{ postSn: number }>('/board/create', data) - return response.data -} - -// 수정 -export async function updatePostApi( - postSn: number, - data: { title?: string; content?: string; categoryCd?: string; pinnedYn?: string }, -): Promise { - await api.post('/board/update', { postSn, ...data }) -} - -// 삭제 -export async function deletePostApi(postSn: number): Promise { - await api.post('/board/delete', { postSn }) -} -``` - -#### Axios 인스턴스 - -```typescript -// frontend/src/common/services/api.ts (이미 설정됨, 수정 불필요) - -import axios from 'axios' - -export const api = axios.create({ - baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3001/api', - withCredentials: true, // JWT 쿠키 자동 포함 - timeout: 30000, // 30초 타임아웃 -}) - -// 401 응답 시 자동 로그아웃 (인터셉터) -// 403 응답 시 권한 부족 (requirePermission 미들웨어) -``` - -#### 권한 기반 UI 분기 - -```tsx -// frontend/src/tabs/board/components/PostList.tsx - -import { useAuthStore } from '@common/store/authStore' - -const PostList = () => { - const { hasPermission } = useAuthStore() - - return ( -
-

게시판

- - {/* CREATE 권한이 있을 때만 글쓰기 버튼 표시 */} - {hasPermission('board:notice', 'CREATE') && ( - - )} - - {/* 목록 렌더링 */} - {posts.map((post) => ( -
- {post.title} - - {/* UPDATE 권한 + 본인 글일 때만 수정 버튼 */} - {hasPermission('board:notice', 'UPDATE') && post.authorId === user?.id && ( - - )} - - {/* DELETE 권한 + 본인 글일 때만 삭제 버튼 */} - {hasPermission('board:notice', 'DELETE') && post.authorId === user?.id && ( - - )} -
- ))} - - {/* 페이징 */} - -
- ) -} -``` - -#### TanStack Query 연동 (권장) - -```typescript -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { fetchPosts, createPostApi, deletePostApi } from '../services/boardApi' - -// 목록 조회 -const { data, isLoading } = useQuery({ - queryKey: ['posts', categoryCd, search, page], - queryFn: () => fetchPosts({ categoryCd, search, page, size: 20 }), -}) - -// 생성 -const queryClient = useQueryClient() -const createMutation = useMutation({ - mutationFn: createPostApi, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['posts'] }) - }, -}) - -// 삭제 -const deleteMutation = useMutation({ - mutationFn: deletePostApi, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['posts'] }) - }, -}) -``` - ---- - -### 8. 권한 상속 실전 시나리오 - -`AUTH_PERM_TREE`와 `AUTH_PERM`의 상속 규칙이 실제로 어떻게 동작하는지 4가지 시나리오로 설명한다. - -#### 시나리오 1: 부모 허용 → 자식 상속 - -``` -AUTH_PERM: - ADMIN 역할 — board READ=Y, CREATE=Y, UPDATE=Y, DELETE=Y - -결과: - board:notice READ → 명시적 레코드 없음 → 부모(board) READ=Y 상속 → Y - board:notice CREATE → 명시적 레코드 없음 → 부모(board) CREATE=Y 상속 → Y - board:data READ → 명시적 레코드 없음 → 부모(board) READ=Y 상속 → Y - -→ 부모에게 권한을 주면 모든 자식이 자동으로 같은 권한을 상속한다. -``` - -#### 시나리오 2: 명시적 거부 (Override) - -``` -AUTH_PERM: - MANAGER 역할 — board READ=Y, CREATE=Y - board:notice CREATE=N (명시적) - -결과: - board:notice READ → 부모 상속 Y - board:notice CREATE → 명시적 N → N (공지 작성 불가) - board:data CREATE → 부모 상속 Y (자료실은 작성 가능) - -→ 자식에 명시적 레코드가 있으면 부모 상속보다 우선한다. -``` - -#### 시나리오 3: 부모 접근 차단 → 자식 전체 차단 - -``` -AUTH_PERM: - VIEWER 역할 — board READ=N - -결과: - board:notice READ → 부모 READ=N → 강제 N (규칙 1) - board:notice CREATE → 부모 READ=N → 강제 N (규칙 1) - board:data READ → 부모 READ=N → 강제 N (규칙 1) - -→ 부모의 READ가 N이면 자식의 모든 오퍼레이션이 강제 차단된다. - 자식에 명시적 Y가 있어도 무시된다. -``` - -#### 시나리오 4: 서브리소스 개별 허용 - -``` -AUTH_PERM: - USER 역할 — board READ=Y, CREATE=N - board:qna CREATE=Y (명시적) - -결과: - board:notice CREATE → 부모 상속 N (공지 작성 불가) - board:data CREATE → 부모 상속 N (자료실 작성 불가) - board:qna CREATE → 명시적 Y → Y (Q&A는 작성 가능) - -→ 부모에서 CUD를 기본 차단하고, 특정 서브리소스만 허용하는 패턴. -``` - -#### 내부 키 형식 - -permResolver에서 리소스와 오퍼레이션을 결합할 때 더블콜론(`::`)을 사용한다. - -``` -리소스 내부 경로: board:notice (싱글콜론) -리소스-오퍼레이션 결합: board:notice::READ (더블콜론, 내부 전용) -``` - -```typescript -// backend/src/roles/permResolver.ts -export function makePermKey(rsrcCode: string, operCd: string): string { - return `${rsrcCode}::${operCd}` -} -``` - ---- - -### 9. 새 CRUD API 추가 체크리스트 - -새 도메인의 CRUD API를 추가할 때 아래 순서대로 진행한다. - -#### 백엔드 - -- [ ] `database/migration/NNN_{domain}.sql` 작성 (DDL + 초기 데이터) - - 테이블 생성 (IF NOT EXISTS) - - FK, CHECK 제약, 인덱스 - - COMMENT - - 검증 SELECT -- [ ] DB 마이그레이션 실행 (`psql`로 직접 실행) -- [ ] `backend/src/{domain}/{domain}Service.ts` 작성 - - 인터페이스 정의 (Item, CreateInput, UpdateInput) - - CRUD 함수 (list, get, create, update, delete) - - wingPool import, AuthError import - - 동적 WHERE/SET 빌드, 소유자 검증 -- [ ] `backend/src/{domain}/{domain}Router.ts` 작성 - - requireAuth + requirePermission 미들웨어 - - POST /list, /detail, /create, /update, /delete - - 입력 검증, AuthError 분기, 500 에러 처리 -- [ ] `backend/src/server.ts`에 라우터 등록 - ```typescript - import boardRouter from './board/boardRouter.js' - app.use('/api/board', boardRouter) - ``` -- [ ] 빌드 확인: `cd backend && npm run build` - -#### 권한 등록 (필요 시) - -- [ ] `AUTH_PERM_TREE`에 리소스 등록 (마이그레이션 SQL) - ```sql - INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) - VALUES ('board:notice', 'board', '공지사항', 1, 2) - ON CONFLICT (RSRC_CD) DO NOTHING; - ``` -- [ ] `AUTH_PERM`에 역할별 권한 초기값 추가 (마이그레이션 SQL) - ```sql - -- ADMIN: 모든 오퍼레이션 허용 - INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) - SELECT r.ROLE_SN, 'board:notice', op.cd, 'Y' - FROM AUTH_ROLE r, (VALUES ('READ'),('CREATE'),('UPDATE'),('DELETE')) AS op(cd) - WHERE r.ROLE_CD = 'ADMIN' - ON CONFLICT DO NOTHING; - - -- VIEWER: READ만 허용 - INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) - SELECT r.ROLE_SN, 'board:notice', 'READ', 'Y' - FROM AUTH_ROLE r - WHERE r.ROLE_CD = 'VIEWER' - ON CONFLICT DO NOTHING; - ``` - -#### 프론트엔드 - -- [ ] `frontend/src/tabs/{domain}/services/{domain}Api.ts` 작성 - - 타입 정의 (interface) - - CRUD API 함수 (api.post 사용) -- [ ] 프론트 컴포넌트에서 mock 데이터 → API 호출로 전환 -- [ ] `hasPermission()` 조건부 렌더링 적용 - - CREATE 권한 → 글쓰기 버튼 - - UPDATE 권한 → 수정 버튼 - - DELETE 권한 → 삭제 버튼 -- [ ] 빌드 확인: `cd frontend && npx tsc --noEmit` - ---- - -## Part 2: 게시판 실전 튜토리얼 - -게시판(Board) CRUD API를 처음부터 끝까지 구현한 실전 예제. -Part 1의 규칙을 실제로 어떻게 적용하는지 단계별로 설명한다. - ---- - -### Step 1: DB 테이블 설계 - -**파일**: `database/migration/006_board.sql` +마이그레이션 파일 하단에 초기 데이터를 포함할 수 있다: ```sql -CREATE TABLE IF NOT EXISTS BOARD_POST ( - POST_SN SERIAL PRIMARY KEY, - CATEGORY_CD VARCHAR(20) NOT NULL, - TITLE VARCHAR(200) NOT NULL, - CONTENT TEXT, - AUTHOR_ID UUID NOT NULL, - VIEW_CNT INTEGER NOT NULL DEFAULT 0, - PINNED_YN CHAR(1) NOT NULL DEFAULT 'N', - USE_YN CHAR(1) NOT NULL DEFAULT 'Y', - REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), - MDFCN_DTM TIMESTAMPTZ, +-- 시드 데이터 (admin 사용자 ID 동적 조회) +DO $$ +DECLARE + v_admin_id UUID; +BEGIN + SELECT USER_ID INTO v_admin_id + FROM auth.AUTH_USER + WHERE USER_ACNT = 'admin' + LIMIT 1; - CONSTRAINT FK_BOARD_AUTHOR FOREIGN KEY (AUTHOR_ID) - REFERENCES auth.AUTH_USER(USER_ID), - CONSTRAINT CK_BOARD_CATEGORY - CHECK (CATEGORY_CD IN ('NOTICE','DATA','QNA','MANUAL')), - CONSTRAINT CK_BOARD_PINNED CHECK (PINNED_YN IN ('Y','N')), - CONSTRAINT CK_BOARD_USE CHECK (USE_YN IN ('Y','N')) -); + IF v_admin_id IS NOT NULL THEN + INSERT INTO {TABLE_NAME} (TITLE, CONTENT, AUTHOR_ID, REG_DTM) VALUES + ('샘플 데이터 1', '내용', v_admin_id, '2025-03-01'::timestamptz), + ('샘플 데이터 2', '내용', v_admin_id, '2025-03-01'::timestamptz) + ON CONFLICT DO NOTHING; + END IF; +END $$; -CREATE INDEX IF NOT EXISTS IDX_BOARD_CATEGORY ON BOARD_POST(CATEGORY_CD); -CREATE INDEX IF NOT EXISTS IDX_BOARD_AUTHOR ON BOARD_POST(AUTHOR_ID); -CREATE INDEX IF NOT EXISTS IDX_BOARD_REG_DTM ON BOARD_POST(REG_DTM DESC); +-- 검증 +SELECT SN, TITLE, REG_DTM FROM {TABLE_NAME} ORDER BY SN; ``` -**설계 포인트**: -- `wing` 스키마에 생성 (search_path 덕분에 쿼리에서 스키마 접두사 불필요) -- `AUTHOR_ID`는 `auth.AUTH_USER(USER_ID)`를 cross-schema FK 참조 -- `USE_YN`으로 논리 삭제 (물리 삭제 대신 `'N'`으로 변경) -- `CATEGORY_CD` CHECK 제약으로 유효값 강제 +### 권한 리소스 등록 (필요 시) -#### 카테고리 ↔ 리소스 매핑 +```sql +-- AUTH_PERM_TREE에 리소스 등록 +INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) +VALUES + ('equipment', NULL, '장비 관리', 0, 11), + ('equipment:boom', 'equipment', '오일붐', 1, 1), + ('equipment:skimmer', 'equipment', '유회수기', 1, 2) +ON CONFLICT (RSRC_CD) DO NOTHING; -| CATEGORY_CD | AUTH_PERM_TREE 리소스 | 정책 | -|---|---|---| -| `NOTICE` | `board:notice` | ADMIN/MANAGER만 CUD | -| `DATA` | `board:data` | MANAGER 이상 CUD | -| `QNA` | `board:qna` | 인증 사용자 CUD (본인 글만 UD) | -| `MANUAL` | `board:manual` | ADMIN만 CUD | - ---- - -### Step 2: Service 구현 - -**파일**: `backend/src/board/boardService.ts` - -#### 인터페이스 정의 - -```typescript -interface PostListItem { - sn: number - categoryCd: string - title: string - authorId: string - authorName: string - viewCnt: number - pinnedYn: string - regDtm: string -} - -interface ListPostsInput { - categoryCd?: string - search?: string - page?: number - size?: number -} - -interface ListPostsResult { - items: PostListItem[] - totalCount: number - page: number - size: number -} -``` - -#### 목록 조회 (페이징 + 필터 + 검색) - -```typescript -export async function listPosts(input: ListPostsInput): Promise { - const page = input.page && input.page > 0 ? input.page : 1 - const size = input.size && input.size > 0 ? Math.min(input.size, 100) : 20 - const offset = (page - 1) * size - - let whereClause = `WHERE bp.USE_YN = 'Y'` - const params: (string | number)[] = [] - let paramIdx = 1 - - if (input.categoryCd) { - whereClause += ` AND bp.CATEGORY_CD = $${paramIdx++}` - params.push(input.categoryCd) - } - - if (input.search) { - whereClause += ` AND (bp.TITLE ILIKE $${paramIdx} OR u.USER_NM ILIKE $${paramIdx})` - params.push(`%${input.search}%`) - paramIdx++ - } - - // 전체 건수 - const countResult = await wingPool.query( - `SELECT COUNT(*) as cnt FROM BOARD_POST bp - JOIN AUTH_USER u ON bp.AUTHOR_ID = u.USER_ID ${whereClause}`, - params - ) - const totalCount = parseInt(countResult.rows[0].cnt, 10) - - // 목록 (상단고정 우선 → 등록일 내림차순) - const listParams = [...params, size, offset] - const listResult = await wingPool.query( - `SELECT bp.POST_SN as sn, bp.CATEGORY_CD as category_cd, bp.TITLE as title, - bp.AUTHOR_ID as author_id, u.USER_NM as author_name, - bp.VIEW_CNT as view_cnt, bp.PINNED_YN as pinned_yn, bp.REG_DTM as reg_dtm - FROM BOARD_POST bp - JOIN AUTH_USER u ON bp.AUTHOR_ID = u.USER_ID - ${whereClause} - ORDER BY bp.PINNED_YN DESC, bp.REG_DTM DESC - LIMIT $${paramIdx++} OFFSET $${paramIdx}`, - listParams - ) - // ... 결과 매핑 후 return -} -``` - -**핵심**: `JOIN AUTH_USER`로 cross-schema JOIN 수행 (작성자명 표시). 이것이 DB 통합의 핵심 이점. - -#### 소유자 검증 패턴 (수정/삭제) - -```typescript -export async function updatePost( - postSn: number, - input: UpdatePostInput, - requesterId: string // ← req.user.sub (JWT에서 추출) -): Promise { - const existing = await wingPool.query( - `SELECT AUTHOR_ID as author_id FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'`, - [postSn] - ) - - if (existing.rows.length === 0) { - throw new AuthError('게시글을 찾을 수 없습니다.', 404) - } - - // 본인 글만 수정 가능 - if (existing.rows[0].author_id !== requesterId) { - throw new AuthError('본인의 게시글만 수정할 수 있습니다.', 403) - } - - // ... 동적 SET 빌드 + UPDATE -} -``` - -#### 논리 삭제 - -```typescript -export async function deletePost(postSn: number, requesterId: string): Promise { - // 소유자 검증 (위와 동일) - await wingPool.query( - `UPDATE BOARD_POST SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE POST_SN = $1`, - [postSn] - ) -} +-- AUTH_PERM에 역할별 권한 초기값 (ADMIN 전체 허용) +INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) +SELECT r.ROLE_SN, 'equipment', op.cd, 'Y' +FROM AUTH_ROLE r, (VALUES ('READ'),('CREATE'),('UPDATE'),('DELETE')) AS op(cd) +WHERE r.ROLE_CD = 'ADMIN' +ON CONFLICT DO NOTHING; ``` --- -### Step 3: Router 구현 +## 프론트엔드 API 서비스 작성법 -**파일**: `backend/src/board/boardRouter.ts` +### 파일 위치 -#### 카테고리별 동적 리소스 결정 - -```typescript -const CATEGORY_RESOURCE: Record = { - NOTICE: 'board:notice', - DATA: 'board:data', - QNA: 'board:qna', - MANUAL: 'board:manual', -} +``` +frontend/src/tabs/{탭명}/services/{tabName}Api.ts ``` -#### 엔드포인트별 requirePermission 적용 +### 기본 구조 -```typescript -// 목록/상세: 부모 리소스 'board' READ -router.get('/', requireAuth, requirePermission('board', 'READ'), listHandler) -router.get('/:sn', requireAuth, requirePermission('board', 'READ'), getHandler) +```ts +// frontend/src/tabs/{탭명}/services/{tabName}Api.ts -// 작성: 카테고리별 서브리소스 CREATE (핵심!) -router.post('/', requireAuth, async (req, res, next) => { - const resource = CATEGORY_RESOURCE[req.body.categoryCd] || 'board' - requirePermission(resource, 'CREATE')(req, res, next) -}, createHandler) - -// 수정/삭제: 부모 리소스 권한 + 서비스에서 소유자 검증 -router.put('/:sn', requireAuth, requirePermission('board', 'UPDATE'), updateHandler) -router.delete('/:sn', requireAuth, requirePermission('board', 'DELETE'), deleteHandler) -``` - -**카테고리별 작성 권한의 원리**: -- POST `/api/board` 요청 시 body에 `categoryCd`가 포함 -- 미들웨어에서 `CATEGORY_RESOURCE[categoryCd]`로 서브리소스 결정 -- `board:notice` CREATE 권한이 없는 사용자는 공지 작성 불가 -- `board:qna` CREATE 권한이 있으면 Q&A는 작성 가능 - ---- - -### Step 4: server.ts 등록 - -```typescript -import boardRouter from './board/boardRouter.js' - -// API 라우트 — 업무 -app.use('/api/board', boardRouter) -``` - ---- - -### Step 5: 프론트엔드 연동 - -#### API 서비스 - -**파일**: `frontend/src/tabs/board/services/boardApi.ts` - -```typescript import { api } from '@common/services/api'; -export interface BoardPostItem { +// ============================================================ +// 인터페이스 +// ============================================================ + +export interface ItemListItem { sn: number; - categoryCd: string; title: string; - authorId: string; + status: string; authorName: string; - viewCnt: number; - pinnedYn: string; regDtm: string; } -export interface BoardListResponse { - items: BoardPostItem[]; +export interface ItemDetail extends ItemListItem { + content: string | null; + mdfcnDtm: string | null; +} + +export interface ItemListResponse { + items: ItemListItem[]; totalCount: number; page: number; size: number; } -export async function fetchBoardPosts(params?: BoardListParams): Promise { - const response = await api.get('/board', { params }); +export interface ItemListParams { + search?: string; + status?: string; + page?: number; + size?: number; +} + +export interface CreateItemInput { + title: string; + content?: string; + status?: string; +} + +export interface UpdateItemInput { + title?: string; + content?: string; + status?: string; +} + +// ============================================================ +// API 함수 +// ============================================================ + +/** 목록 조회 */ +export async function fetchItems(params?: ItemListParams): Promise { + const response = await api.get('/equipment', { params }); return response.data; } -export async function createBoardPost(input: CreateBoardPostInput): Promise<{ sn: number }> { - const response = await api.post<{ sn: number }>('/board', input); +/** 상세 조회 */ +export async function fetchItem(sn: number): Promise { + const response = await api.get(`/equipment/${sn}`); return response.data; } + +/** 등록 */ +export async function createItem(input: CreateItemInput): Promise<{ sn: number }> { + const response = await api.post<{ sn: number }>('/equipment', input); + return response.data; +} + +/** 수정 (POST /api/equipment/:sn/update) */ +export async function updateItem(sn: number, input: UpdateItemInput): Promise { + await api.post(`/equipment/${sn}/update`, input); +} + +/** 삭제 (POST /api/equipment/:sn/delete) */ +export async function deleteItem(sn: number): Promise { + await api.post(`/equipment/${sn}/delete`); +} ``` -#### 권한 기반 UI 분기 +### api 인스턴스 특징 -**파일**: `frontend/src/tabs/board/components/BoardView.tsx` +| 설정 | 값 | +|------|-----| +| baseURL | `VITE_API_URL` 환경변수 또는 `http://localhost:3001/api` | +| withCredentials | `true` (JWT 쿠키 자동 포함) | +| timeout | 30,000ms | +| Content-Type | `application/json` | +| 401 인터셉터 | 세션 만료 시 자동 로그아웃 | + +### 컴포넌트에서의 에러 핸들링 + +```tsx +// 목록 조회 +const loadData = useCallback(async () => { + setIsLoading(true); + try { + const result = await fetchItems({ search, page, size: PAGE_SIZE }); + setItems(result.items); + setTotalCount(result.totalCount); + } catch (err) { + console.error('[equipment] 목록 조회 실패:', err); + } finally { + setIsLoading(false); + } +}, [search, page]); + +// 삭제 (사용자 확인) +const handleDelete = async (sn: number) => { + if (!window.confirm('정말로 삭제하시겠습니까?')) return; + try { + await deleteItem(sn); + alert('삭제되었습니다.'); + loadData(); + } catch (err) { + alert((err as { message?: string })?.message || '삭제에 실패했습니다.'); + } +}; +``` + +### 권한 기반 UI 분기 ```tsx import { useAuthStore } from '@common/store/authStore'; const hasPermission = useAuthStore((s) => s.hasPermission); -// 서브탭 기준 글쓰기 권한 리소스 결정 -const getWriteResource = () => { - if (activeSubTab === 'all') return 'board'; - return `board:${activeSubTab}`; -}; - -// 글쓰기 버튼 조건부 렌더링 -{hasPermission(getWriteResource(), 'CREATE') && ( - +// CREATE 권한이 있을 때만 등록 버튼 표시 +{hasPermission('equipment', 'CREATE') && ( + )} + +// UPDATE 권한 + 본인 글일 때만 수정 버튼 +{hasPermission('equipment', 'UPDATE') && item.authorId === currentUserId && ( + +)} +``` + +### TanStack Query 연동 (선택) + +TanStack Query를 사용하면 캐싱, 자동 재조회, 로딩/에러 상태를 선언적으로 관리할 수 있다. + +```ts +// hooks/useEquipment.ts + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + fetchItems, fetchItem, createItem, deleteItem, + type ItemListParams, +} from '../services/equipmentApi'; + +// 목록 조회 +export function useEquipmentList(params: ItemListParams) { + return useQuery({ + queryKey: ['equipment', 'list', params], + queryFn: () => fetchItems(params), + staleTime: 30_000, + }); +} + +// 상세 조회 +export function useEquipmentDetail(sn: number) { + return useQuery({ + queryKey: ['equipment', 'detail', sn], + queryFn: () => fetchItem(sn), + enabled: sn > 0, + }); +} + +// 등록 Mutation +export function useCreateEquipment() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createItem, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['equipment', 'list'] }); + }, + }); +} + +// 삭제 Mutation +export function useDeleteEquipment() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: deleteItem, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['equipment', 'list'] }); + }, + }); +} +``` + +컴포넌트에서의 사용: + +```tsx +function EquipmentListView() { + const [params, setParams] = useState({ page: 1, size: 20 }); + const { data, isLoading, error } = useEquipmentList(params); + const deleteMutation = useDeleteEquipment(); + + if (isLoading) return
로딩 중...
; + if (error) return
에러 발생
; + + return ( +
+ {data?.items.map(item => ( +
+ {item.title} + +
+ ))} +
+ ); +} ``` --- -### Step 6: 권한 시나리오 테스트 +## 전체 예시: 장비 관리 API -| 시나리오 | 역할 | 요청 | 결과 | -|---|---|---|---| -| ADMIN이 공지 작성 | ADMIN | POST `/api/board` `{categoryCd:"NOTICE"}` | 201 Created | -| USER가 공지 작성 | USER | POST `/api/board` `{categoryCd:"NOTICE"}` | 403 (board:notice CREATE 없음) | -| USER가 Q&A 작성 | USER | POST `/api/board` `{categoryCd:"QNA"}` | 201 (board:qna CREATE 있음) | -| VIEWER가 Q&A 작성 | VIEWER | POST `/api/board` `{categoryCd:"QNA"}` | 403 (board:qna CREATE 없음) | -| USER가 본인 글 수정 | USER | PUT `/api/board/11` (본인 글) | 200 | -| USER가 타인 글 수정 | USER | PUT `/api/board/1` (타인 글) | 403 (소유자 검증 실패) | -| ADMIN이 목록 조회 | ADMIN | GET `/api/board` | 200 (board READ 있음) | +"방제 장비를 등록/조회/수정/삭제하는 API"를 처음부터 끝까지 구현하는 과정이다. + +### 요구사항 + +- 장비 목록 조회 (유형 필터, 검색, 페이징) +- 장비 상세 조회 +- 장비 등록 (관리자) +- 장비 수정 (등록자 본인) +- 장비 삭제 (등록자 본인, 논리 삭제) +- 장비 종류: `BOOM`(오일붐), `SKIMMER`(유회수기), `DISPERSANT`(유처리제), `VESSEL`(선박) +- 장비 위치 좌표 (PostGIS) + +### 1단계: DB 마이그레이션 + +```sql +-- database/migration/017_equipment.sql + +-- ============================================================ +-- 마이그레이션 017: 방제 장비 (EQUIPMENT) +-- ============================================================ + +CREATE TABLE IF NOT EXISTS EQUIPMENT ( + EQUIP_SN SERIAL PRIMARY KEY, + EQUIP_TP VARCHAR(20) NOT NULL, + EQUIP_NM VARCHAR(100) NOT NULL, + EQUIP_DC TEXT, + SPEC VARCHAR(200), + QUANTITY INTEGER NOT NULL DEFAULT 0, + LOCATION_NM VARCHAR(100), + GEOM GEOMETRY(POINT, 4326), + AUTHOR_ID UUID NOT NULL, + USE_YN CHAR(1) NOT NULL DEFAULT 'Y', + REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), + MDFCN_DTM TIMESTAMPTZ, + + CONSTRAINT FK_EQUIP_AUTHOR FOREIGN KEY (AUTHOR_ID) + REFERENCES auth.AUTH_USER(USER_ID), + CONSTRAINT CK_EQUIP_TP + CHECK (EQUIP_TP IN ('BOOM','SKIMMER','DISPERSANT','VESSEL')), + CONSTRAINT CK_EQUIP_USE CHECK (USE_YN IN ('Y','N')) +); + +COMMENT ON TABLE EQUIPMENT IS '방제 장비'; +COMMENT ON COLUMN EQUIPMENT.EQUIP_TP IS '장비유형: BOOM/SKIMMER/DISPERSANT/VESSEL'; +COMMENT ON COLUMN EQUIPMENT.GEOM IS '장비 위치 좌표 (WGS84)'; +COMMENT ON COLUMN EQUIPMENT.USE_YN IS '사용여부 (N=논리삭제)'; + +CREATE INDEX IF NOT EXISTS IDX_EQUIP_TP ON EQUIPMENT(EQUIP_TP); +CREATE INDEX IF NOT EXISTS IDX_EQUIP_REG_DTM ON EQUIPMENT(REG_DTM DESC); +CREATE INDEX IF NOT EXISTS IDX_EQUIP_GEOM ON EQUIPMENT USING GIST(GEOM); + +-- 시드 데이터 +DO $$ +DECLARE + v_admin_id UUID; +BEGIN + SELECT USER_ID INTO v_admin_id + FROM auth.AUTH_USER WHERE USER_ACNT = 'admin' LIMIT 1; + + IF v_admin_id IS NOT NULL THEN + INSERT INTO EQUIPMENT (EQUIP_TP, EQUIP_NM, EQUIP_DC, SPEC, QUANTITY, LOCATION_NM, GEOM, AUTHOR_ID) VALUES + ('BOOM', '오일붐 500m', '항구 배치용 오일붐', '500m, 내파성', 10, '부산항', + ST_SetSRID(ST_MakePoint(129.0756, 35.1796), 4326), v_admin_id), + ('SKIMMER', '유회수기 A형', '소형 유회수기', '처리량 50m3/h', 5, '여수항', + ST_SetSRID(ST_MakePoint(127.6622, 34.7604), 4326), v_admin_id), + ('DISPERSANT', '유처리제 1종', '해상용 유처리제', '100L 드럼', 200, '인천항', + ST_SetSRID(ST_MakePoint(126.6052, 37.4563), 4326), v_admin_id) + ON CONFLICT DO NOTHING; + END IF; +END $$; + +SELECT EQUIP_SN, EQUIP_TP, EQUIP_NM, QUANTITY FROM EQUIPMENT ORDER BY EQUIP_SN; +``` + +### 2단계: 백엔드 서비스 + +```ts +// backend/src/equipment/equipmentService.ts + +import { wingPool } from '../db/wingDb.js'; +import { AuthError } from '../auth/authService.js'; + +interface EquipmentItem { + equipSn: number; + equipTp: string; + equipNm: string; + equipDc: string | null; + spec: string | null; + quantity: number; + locationNm: string | null; + lon: number | null; + lat: number | null; + authorId: string; + regDtm: string; +} + +interface ListInput { + equipTp?: string; + search?: string; + page?: number; + size?: number; +} + +interface ListResult { + items: EquipmentItem[]; + totalCount: number; + page: number; + size: number; +} + +interface CreateInput { + equipTp: string; + equipNm: string; + equipDc?: string; + spec?: string; + quantity?: number; + locationNm?: string; + lon?: number; + lat?: number; + authorId: string; +} + +interface UpdateInput { + equipNm?: string; + equipDc?: string; + spec?: string; + quantity?: number; + locationNm?: string; + lon?: number; + lat?: number; +} + +const VALID_TYPES = ['BOOM', 'SKIMMER', 'DISPERSANT', 'VESSEL']; + +function rowToItem(r: Record): EquipmentItem { + return { + equipSn: r.equip_sn as number, + equipTp: r.equip_tp as string, + equipNm: r.equip_nm as string, + equipDc: r.equip_dc as string | null, + spec: r.spec as string | null, + quantity: r.quantity as number, + locationNm: r.location_nm as string | null, + lon: r.lon as number | null, + lat: r.lat as number | null, + authorId: r.author_id as string, + regDtm: r.reg_dtm as string, + }; +} + +export async function listEquipment(input: ListInput): Promise { + const page = input.page && input.page > 0 ? input.page : 1; + const size = input.size && input.size > 0 ? Math.min(input.size, 100) : 20; + const offset = (page - 1) * size; + + let whereClause = "WHERE USE_YN = 'Y'"; + const params: (string | number)[] = []; + let paramIdx = 1; + + if (input.equipTp) { + whereClause += ` AND EQUIP_TP = $${paramIdx++}`; + params.push(input.equipTp); + } + if (input.search) { + whereClause += ` AND (EQUIP_NM ILIKE $${paramIdx} OR LOCATION_NM ILIKE $${paramIdx})`; + params.push(`%${input.search}%`); + paramIdx++; + } + + const countResult = await wingPool.query( + `SELECT COUNT(*) as cnt FROM EQUIPMENT ${whereClause}`, + params, + ); + const totalCount = parseInt(countResult.rows[0].cnt, 10); + + const listParams = [...params, size, offset]; + const listResult = await wingPool.query( + `SELECT EQUIP_SN, EQUIP_TP, EQUIP_NM, EQUIP_DC, SPEC, QUANTITY, + LOCATION_NM, ST_X(GEOM) as lon, ST_Y(GEOM) as lat, + AUTHOR_ID, REG_DTM + FROM EQUIPMENT + ${whereClause} + ORDER BY REG_DTM DESC + LIMIT $${paramIdx++} OFFSET $${paramIdx}`, + listParams, + ); + + return { + items: listResult.rows.map((r: Record) => rowToItem(r)), + totalCount, + page, + size, + }; +} + +export async function getEquipment(equipSn: number): Promise { + const result = await wingPool.query( + `SELECT EQUIP_SN, EQUIP_TP, EQUIP_NM, EQUIP_DC, SPEC, QUANTITY, + LOCATION_NM, ST_X(GEOM) as lon, ST_Y(GEOM) as lat, + AUTHOR_ID, REG_DTM + FROM EQUIPMENT + WHERE EQUIP_SN = $1 AND USE_YN = 'Y'`, + [equipSn], + ); + + if (result.rows.length === 0) { + throw new AuthError('장비를 찾을 수 없습니다.', 404); + } + + return rowToItem(result.rows[0]); +} + +export async function createEquipment(input: CreateInput): Promise<{ equipSn: number }> { + if (!VALID_TYPES.includes(input.equipTp)) { + throw new AuthError('유효하지 않은 장비 유형입니다.', 400); + } + if (!input.equipNm || input.equipNm.trim().length === 0) { + throw new AuthError('장비명은 필수입니다.', 400); + } + + const hasCoord = input.lon !== undefined && input.lat !== undefined; + const geomExpr = hasCoord ? `ST_SetSRID(ST_MakePoint($7, $8), 4326)` : 'NULL'; + const params: (string | number | null)[] = [ + input.equipTp, + input.equipNm.trim(), + input.equipDc || null, + input.spec || null, + input.quantity ?? 0, + input.locationNm || null, + ]; + + if (hasCoord) { + params.push(input.lon!, input.lat!); + } + + params.push(input.authorId); + const authorIdx = params.length; + + const result = await wingPool.query( + `INSERT INTO EQUIPMENT (EQUIP_TP, EQUIP_NM, EQUIP_DC, SPEC, QUANTITY, LOCATION_NM, GEOM, AUTHOR_ID) + VALUES ($1, $2, $3, $4, $5, $6, ${geomExpr}, $${authorIdx}) + RETURNING EQUIP_SN`, + params, + ); + + return { equipSn: result.rows[0].equip_sn }; +} + +export async function updateEquipment( + equipSn: number, + input: UpdateInput, + requesterId: string, +): Promise { + const existing = await wingPool.query( + `SELECT AUTHOR_ID as author_id FROM EQUIPMENT WHERE EQUIP_SN = $1 AND USE_YN = 'Y'`, + [equipSn], + ); + + if (existing.rows.length === 0) { + throw new AuthError('장비를 찾을 수 없습니다.', 404); + } + if (existing.rows[0].author_id !== requesterId) { + throw new AuthError('본인이 등록한 장비만 수정할 수 있습니다.', 403); + } + + const sets: string[] = []; + const params: (string | number | null)[] = []; + let idx = 1; + + if (input.equipNm !== undefined) { sets.push(`EQUIP_NM = $${idx++}`); params.push(input.equipNm.trim()); } + if (input.equipDc !== undefined) { sets.push(`EQUIP_DC = $${idx++}`); params.push(input.equipDc); } + if (input.spec !== undefined) { sets.push(`SPEC = $${idx++}`); params.push(input.spec); } + if (input.quantity !== undefined) { sets.push(`QUANTITY = $${idx++}`); params.push(input.quantity); } + if (input.locationNm !== undefined) { sets.push(`LOCATION_NM = $${idx++}`); params.push(input.locationNm); } + if (input.lon !== undefined && input.lat !== undefined) { + sets.push(`GEOM = ST_SetSRID(ST_MakePoint($${idx}, $${idx + 1}), 4326)`); + params.push(input.lon, input.lat); + idx += 2; + } + + if (sets.length === 0) { + throw new AuthError('수정할 항목이 없습니다.', 400); + } + + sets.push('MDFCN_DTM = NOW()'); + params.push(equipSn); + + await wingPool.query( + `UPDATE EQUIPMENT SET ${sets.join(', ')} WHERE EQUIP_SN = $${idx}`, + params, + ); +} + +export async function deleteEquipment(equipSn: number, requesterId: string): Promise { + const existing = await wingPool.query( + `SELECT AUTHOR_ID as author_id FROM EQUIPMENT WHERE EQUIP_SN = $1 AND USE_YN = 'Y'`, + [equipSn], + ); + + if (existing.rows.length === 0) { + throw new AuthError('장비를 찾을 수 없습니다.', 404); + } + if (existing.rows[0].author_id !== requesterId) { + throw new AuthError('본인이 등록한 장비만 삭제할 수 있습니다.', 403); + } + + await wingPool.query( + `UPDATE EQUIPMENT SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE EQUIP_SN = $1`, + [equipSn], + ); +} +``` + +### 3단계: 백엔드 라우터 + +```ts +// backend/src/equipment/equipmentRouter.ts + +import { Router } from 'express'; +import { requireAuth, requirePermission } from '../auth/authMiddleware.js'; +import { AuthError } from '../auth/authService.js'; +import { + listEquipment, getEquipment, createEquipment, + updateEquipment, deleteEquipment, +} from './equipmentService.js'; + +const router = Router(); + +// GET /api/equipment -- 목록 +router.get('/', requireAuth, requirePermission('equipment', 'READ'), async (req, res) => { + try { + const { equipTp, search, page, size } = req.query; + const result = await listEquipment({ + equipTp: equipTp as string | undefined, + search: search as string | undefined, + page: page ? parseInt(page as string, 10) : undefined, + size: size ? parseInt(size as string, 10) : undefined, + }); + res.json(result); + } catch (err) { + console.error('[equipment] 목록 조회 오류:', err); + res.status(500).json({ error: '장비 목록 조회 중 오류가 발생했습니다.' }); + } +}); + +// GET /api/equipment/:sn -- 상세 +router.get('/:sn', requireAuth, requirePermission('equipment', 'READ'), async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10); + if (isNaN(sn)) { res.status(400).json({ error: '유효하지 않은 장비 번호입니다.' }); return; } + const item = await getEquipment(sn); + res.json(item); + } catch (err) { + if (err instanceof AuthError) { res.status(err.status).json({ error: err.message }); return; } + console.error('[equipment] 상세 조회 오류:', err); + res.status(500).json({ error: '장비 조회 중 오류가 발생했습니다.' }); + } +}); + +// POST /api/equipment -- 등록 +router.post('/', requireAuth, requirePermission('equipment', 'CREATE'), async (req, res) => { + try { + const { equipTp, equipNm, equipDc, spec, quantity, locationNm, lon, lat } = req.body; + if (!equipTp || !equipNm) { + res.status(400).json({ error: '장비 유형과 장비명은 필수입니다.' }); + return; + } + const result = await createEquipment({ + equipTp, equipNm, equipDc, spec, quantity, locationNm, lon, lat, + authorId: req.user!.sub, + }); + res.status(201).json(result); + } catch (err) { + if (err instanceof AuthError) { res.status(err.status).json({ error: err.message }); return; } + console.error('[equipment] 등록 오류:', err); + res.status(500).json({ error: '장비 등록 중 오류가 발생했습니다.' }); + } +}); + +// POST /api/equipment/:sn/update -- 수정 +router.post('/:sn/update', requireAuth, requirePermission('equipment', 'UPDATE'), async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10); + if (isNaN(sn)) { res.status(400).json({ error: '유효하지 않은 장비 번호입니다.' }); return; } + const { equipNm, equipDc, spec, quantity, locationNm, lon, lat } = req.body; + await updateEquipment(sn, { equipNm, equipDc, spec, quantity, locationNm, lon, lat }, req.user!.sub); + res.json({ success: true }); + } catch (err) { + if (err instanceof AuthError) { res.status(err.status).json({ error: err.message }); return; } + console.error('[equipment] 수정 오류:', err); + res.status(500).json({ error: '장비 수정 중 오류가 발생했습니다.' }); + } +}); + +// POST /api/equipment/:sn/delete -- 삭제 +router.post('/:sn/delete', requireAuth, requirePermission('equipment', 'DELETE'), async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10); + if (isNaN(sn)) { res.status(400).json({ error: '유효하지 않은 장비 번호입니다.' }); return; } + await deleteEquipment(sn, req.user!.sub); + res.json({ success: true }); + } catch (err) { + if (err instanceof AuthError) { res.status(err.status).json({ error: err.message }); return; } + console.error('[equipment] 삭제 오류:', err); + res.status(500).json({ error: '장비 삭제 중 오류가 발생했습니다.' }); + } +}); + +export default router; +``` + +### 4단계: 프론트엔드 API 서비스 + +```ts +// frontend/src/tabs/assets/services/equipmentApi.ts + +import { api } from '@common/services/api'; + +export interface EquipmentItem { + equipSn: number; + equipTp: string; + equipNm: string; + equipDc: string | null; + spec: string | null; + quantity: number; + locationNm: string | null; + lon: number | null; + lat: number | null; + authorId: string; + regDtm: string; +} + +export interface EquipmentListResponse { + items: EquipmentItem[]; + totalCount: number; + page: number; + size: number; +} + +export interface EquipmentListParams { + equipTp?: string; + search?: string; + page?: number; + size?: number; +} + +export interface CreateEquipmentInput { + equipTp: string; + equipNm: string; + equipDc?: string; + spec?: string; + quantity?: number; + locationNm?: string; + lon?: number; + lat?: number; +} + +export interface UpdateEquipmentInput { + equipNm?: string; + equipDc?: string; + spec?: string; + quantity?: number; + locationNm?: string; + lon?: number; + lat?: number; +} + +export async function fetchEquipmentList( + params?: EquipmentListParams, +): Promise { + const response = await api.get('/equipment', { params }); + return response.data; +} + +export async function fetchEquipment(equipSn: number): Promise { + const response = await api.get(`/equipment/${equipSn}`); + return response.data; +} + +export async function createEquipment( + input: CreateEquipmentInput, +): Promise<{ equipSn: number }> { + const response = await api.post<{ equipSn: number }>('/equipment', input); + return response.data; +} + +export async function updateEquipment( + equipSn: number, + input: UpdateEquipmentInput, +): Promise { + await api.post(`/equipment/${equipSn}/update`, input); +} + +export async function deleteEquipment(equipSn: number): Promise { + await api.post(`/equipment/${equipSn}/delete`); +} +``` + +### 5단계: server.ts 등록 + +```ts +// backend/src/server.ts + +import equipmentRouter from './equipment/equipmentRouter.js'; + +app.use('/api/equipment', equipmentRouter); +``` + +### 6단계: 검증 + +```bash +# 백엔드 컴파일 +cd backend && npx tsc --noEmit + +# 프론트엔드 컴파일 +cd frontend && npx tsc --noEmit + +# DB 마이그레이션 +psql -h 211.208.115.83 -U wing -d wing -f database/migration/017_equipment.sql + +# API 테스트 (curl) +curl -b cookies.txt http://localhost:3001/api/equipment + +curl -b cookies.txt -X POST http://localhost:3001/api/equipment \ + -H "Content-Type: application/json" \ + -d '{"equipTp":"BOOM","equipNm":"테스트 오일붐"}' +``` --- -### 관련 파일 전체 목록 +## 부록: 자주 쓰는 SQL 패턴 -| 위치 | 파일 | 설명 | -|---|---|---| -| DB | `database/migration/006_board.sql` | DDL + 초기 데이터 | -| 백엔드 | `backend/src/board/boardService.ts` | CRUD 비즈니스 로직 | -| 백엔드 | `backend/src/board/boardRouter.ts` | 라우터 + requirePermission | -| 백엔드 | `backend/src/server.ts` | boardRouter 등록 | -| 프론트 | `frontend/src/tabs/board/services/boardApi.ts` | API 서비스 | -| 프론트 | `frontend/src/tabs/board/components/BoardView.tsx` | 목록/상세/작성 통합 뷰 (API 연동) | -| 프론트 | `frontend/src/tabs/board/components/BoardWriteForm.tsx` | 게시글 작성/수정 폼 (API 호출) | -| 프론트 | `frontend/src/tabs/board/components/BoardDetailView.tsx` | 게시글 상세 보기 (API 호출) | +### ILIKE 검색 (대소문자 무시) + +```sql +WHERE TITLE ILIKE $1 -- params: ['%검색어%'] +``` + +### 다중 컬럼 검색 + +```sql +WHERE (TITLE ILIKE $1 OR CONTENT ILIKE $1 OR AUTHOR_NM ILIKE $1) +``` + +### 정렬 + 상단고정 + +```sql +ORDER BY PINNED_YN DESC, REG_DTM DESC +``` + +### RETURNING (INSERT 후 PK 반환) + +```sql +INSERT INTO TABLE_NAME (...) VALUES (...) +RETURNING SN +``` + +### UPDATE + RETURNING (조회수 증가 + 상세 동시) + +```sql +UPDATE TABLE_NAME SET VIEW_CNT = VIEW_CNT + 1 +WHERE SN = $1 AND USE_YN = 'Y' +RETURNING SN, TITLE, CONTENT, REG_DTM +``` + +### PostGIS 거리 검색 + +```sql +WHERE ST_DWithin( + GEOM::geography, + ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, + 10000 -- 미터 단위 (10km) +) +``` + +### 페이징 표준 + +```sql +-- page=1, size=20 -> LIMIT 20 OFFSET 0 +-- page=2, size=20 -> LIMIT 20 OFFSET 20 +LIMIT $N OFFSET $M +``` diff --git a/docs/DEVELOPMENT-GUIDE.md b/docs/DEVELOPMENT-GUIDE.md index 68cc157..7029ed8 100644 --- a/docs/DEVELOPMENT-GUIDE.md +++ b/docs/DEVELOPMENT-GUIDE.md @@ -1,107 +1,434 @@ -# WING 개발 워크플로우 가이드 +# WING-OPS 개발 워크플로우 가이드 ## 목차 + 1. [전체 흐름 요약](#1-전체-흐름-요약) -2. [계획 수립 (Plan)](#2-계획-수립-plan) -3. [브랜치 생성 및 개발](#3-브랜치-생성-및-개발) -4. [커밋 & 푸시](#4-커밋--푸시) -5. [MR 생성 (feature → develop)](#5-mr-생성-feature--develop) -6. [릴리즈 PR (develop → main)](#6-릴리즈-pr-develop--main) -7. [자동 배포](#7-자동-배포) -8. [프로젝트 문서 최신화](#8-프로젝트-문서-최신화) -9. [실전 예시: 기능 추가 A to Z](#9-실전-예시-기능-추가-a-to-z) +2. [개발 환경 설정](#2-개발-환경-설정) +3. [계획 수립 (Plan)](#3-계획-수립-plan) +4. [브랜치 생성](#4-브랜치-생성) +5. [개발](#5-개발) +6. [검증](#6-검증) +7. [커밋 & 푸시](#7-커밋--푸시) +8. [MR(Merge Request)](#8-mrmerge-request) +9. [릴리즈 & 배포](#9-릴리즈--배포) +10. [디버깅 팁](#10-디버깅-팁) +11. [실전 예시: 기능 추가 A to Z](#11-실전-예시-기능-추가-a-to-z) --- ## 1. 전체 흐름 요약 ``` -계획 수립 → 브랜치 생성 → 개발 → 커밋/푸시 → develop MR → main PR → 자동 배포 +Plan ─── Branch ─── Implement ─── Test ─── MR ─── Deploy ``` ``` -[Plan Mode] Claude가 코드베이스 분석 후 구현 계획 작성 - ↓ -[Branch] feature/기능명 브랜치 생성 (develop 기반) - ↓ -[Develop] 코드 작성 + TypeScript/ESLint 검증 - ↓ -[Commit & Push] Conventional Commits 형식 + pre-commit 자동 검증 - ↓ -[MR → develop] 코드 리뷰 + 머지 - ↓ -[PR → main] 릴리즈 MR + 머지 - ↓ -[Auto Deploy] Gitea Actions → 빌드 → 서버 배포 +[1. Plan] 작업 범위 정의, 수정 파일 식별 + | +[2. Branch] develop 기반 feature 브랜치 생성 + | +[3. Implement] 코드 작성 (Frontend / Backend / DB) + | +[4. Test] tsc --noEmit + ESLint + 수동 테스트 + | +[5. Commit & Push] Conventional Commits 형식, pre-commit 자동 검증 + | +[6. MR -> develop] 코드 리뷰 + 승인 + Squash Merge + | +[7. MR -> main] 릴리즈 MR + 머지 + | +[8. Auto Deploy] Gitea Actions -> 빌드 -> 서버 배포 +``` + +### 브랜치 흐름 + +``` +main (보호, 배포용) + | + +-- develop (보호, 개발 통합) + | + +-- feature/ISSUE-42-login (기능 개발) + +-- bugfix/ISSUE-15-token-fix (버그 수정) + +-- hotfix/critical-patch (긴급 수정, main에서 분기) ``` --- -## 2. 계획 수립 (Plan) +## 2. 개발 환경 설정 -3개 이상 파일 수정이 예상되거나 아키텍처에 영향을 주는 작업은 **Plan Mode**로 시작합니다. +### 2-1. Node.js 설치 (fnm) -### Claude에게 요청하는 방법 +프로젝트는 Node.js 20을 사용한다. `.node-version` 파일로 버전이 고정되어 있다. -``` -"사용자 프로필 페이지를 추가해줘" -→ Claude가 자동으로 Plan Mode 진입 → 코드베이스 분석 → 구현 계획 제시 -→ 사용자 승인 후 구현 시작 +```bash +# fnm 설치 (이미 설치된 경우 생략) +curl -fsSL https://fnm.vercel.app/install | bash + +# Node.js 20 설치 및 활성화 +fnm install 20 +fnm use 20 + +# 버전 확인 +node -v # v20.x.x +npm -v # v10.x.x ``` -### 계획에 포함되는 내용 -- 수정/생성할 파일 목록 -- 변경 범위 및 영향도 -- 기술적 선택지와 권장안 -- 구현 순서 +### 2-2. 프로젝트 클론 및 의존성 설치 -### Plan Mode가 불필요한 경우 -- 단순 버그 수정 (1~2개 파일) -- 텍스트/스타일 수정 -- 설정 변경 +```bash +# 클론 +git clone https://gitea.gc-si.dev/gc/wing-ops.git wing +cd wing + +# Git Hooks 경로 설정 (최초 1회) +git config core.hooksPath .githooks + +# npm 레지스트리는 .npmrc에 설정됨 (Nexus 프록시) +# 별도 설정 불필요 + +# 의존성 설치 +cd frontend && npm install +cd ../backend && npm install +``` + +### 2-3. 환경변수 설정 + +**Backend** (`backend/.env`): + +```bash +# 서버 +PORT=3001 +NODE_ENV=development + +# wing DB (운영 데이터) +WING_DB_HOST=211.208.115.83 +WING_DB_PORT=5432 +WING_DB_USER=wing +WING_DB_PASS=<비밀번호> +WING_DB_NAME=wing + +# wing_auth DB (인증/권한) +AUTH_DB_HOST=211.208.115.83 +AUTH_DB_PORT=5432 +AUTH_DB_USER=wing_auth +AUTH_DB_PASS=<비밀번호> +AUTH_DB_NAME=wing_auth + +# JWT +JWT_SECRET=<시크릿> +JWT_EXPIRES_IN=24h + +# CORS +FRONTEND_URL=http://localhost:5173 + +# Google OAuth +GOOGLE_CLIENT_ID=<클라이언트ID> +``` + +**Frontend** (`frontend/.env`): + +```bash +# 백엔드 API URL +VITE_API_URL=http://localhost:3001/api + +# Google OAuth +VITE_GOOGLE_CLIENT_ID=<클라이언트ID> + +# 공공 API 키 (해양기상 등) +VITE_DATA_GO_KR_API_KEY=<키> +VITE_WEATHER_API_KEY=<키> +``` + +> `.env` 파일은 `.gitignore`에 포함되어 있으므로 커밋되지 않는다. +> 실제 값은 팀 내부 공유 채널에서 확인한다. + +### 2-4. 개발 서버 실행 + +터미널 2개를 열어 각각 실행한다: + +```bash +# 터미널 1: 백엔드 (localhost:3001) +cd backend +npm run dev + +# 터미널 2: 프론트엔드 (localhost:5173) +cd frontend +npm run dev +``` + +### 2-5. Path Alias + +Frontend에서 두 가지 경로 별칭을 사용한다: + +| Alias | 실제 경로 | 용도 | +|-------|----------|------| +| `@common/*` | `src/common/*` | 공통 모듈 (컴포넌트, 훅, 서비스, 스토어) | +| `@tabs/*` | `src/tabs/*` | 탭별 패키지 (11개 탭) | + +```tsx +import { useAuth } from '@common/hooks/useAuth'; +import OilSpillView from '@tabs/prediction/components/OilSpillView'; +``` --- -## 3. 브랜치 생성 및 개발 +## 3. 계획 수립 (Plan) -### 브랜치 네이밍 규칙 +### 계획이 필요한 경우 -| 유형 | 형식 | 예시 | -|------|------|------| -| 기능 | `feature/설명` | `feature/user-profile` | -| 이슈 | `feature/ISSUE-번호-설명` | `feature/ISSUE-42-login-fix` | -| 버그 | `bugfix/ISSUE-번호-설명` | `bugfix/ISSUE-15-token-expired` | -| 긴급 | `hotfix/설명` | `hotfix/security-patch` | +- 3개 이상 파일 수정이 예상되는 작업 +- 아키텍처에 영향을 주는 변경 +- 새로운 탭/모듈 추가 +- DB 스키마 변경 + +### 계획에 포함할 내용 + +1. **수정/생성할 파일 목록** +2. **변경 범위 및 영향도** (다른 모듈에 미치는 영향) +3. **DB 마이그레이션 필요 여부** (필요 시 migration SQL 작성) +4. **구현 순서** (의존성 고려) + +### 계획이 불필요한 경우 + +- 단일 파일 수정 (버그 수정, 텍스트 변경) +- 스타일/포맷팅 변경 +- 설정 파일 변경 + +--- + +## 4. 브랜치 생성 + +### 네이밍 규칙 + +| 유형 | 형식 | 예시 | 분기 기준 | +|------|------|------|----------| +| 기능 | `feature/ISSUE-번호-설명` | `feature/ISSUE-42-user-login` | develop | +| 기능 (이슈 없음) | `feature/설명` | `feature/add-swagger-docs` | develop | +| 버그 | `bugfix/ISSUE-번호-설명` | `bugfix/ISSUE-15-token-expired` | develop | +| 긴급 | `hotfix/설명` | `hotfix/security-patch` | main | ### 브랜치 생성 ```bash -# develop에서 분기 +# 1. develop 최신화 git checkout develop git pull origin develop -git checkout -b feature/user-profile + +# 2. feature 브랜치 생성 +git checkout -b feature/ISSUE-42-user-login + +# 확인 +git branch # * feature/ISSUE-42-user-login ``` -### 개발 중 검증 +### 브랜치 규칙 -로컬에서 타입 체크와 린트를 수시로 확인합니다: +- main, develop 브랜치에 직접 커밋/푸시 **금지** (보호 브랜치) +- 머지는 반드시 **MR(Merge Request)**을 통해 수행 +- 머지 후 소스 브랜치 삭제 + +--- + +## 5. 개발 + +### 5-1. Frontend 개발 구조 + +``` +frontend/src/ +├── common/ 공통 모듈 (모든 탭에서 공유) +│ ├── components/ auth/, layer/, layout/, map/, ui/ +│ ├── hooks/ useAuth, useLayers, useSubMenu +│ ├── services/ api.ts (Axios 인스턴스), authApi.ts +│ ├── store/ authStore (Zustand), menuStore +│ ├── types/ backtrack, boomLine, hns, navigation +│ └── utils/ coordinates, geo, sanitize +└── tabs/ 탭 단위 패키지 (11개) + ├── prediction/ 확산 예측 + ├── hns/ HNS 분석 + ├── rescue/ 구조 시나리오 + ├── aerial/ 항공 방제 + ├── weather/ 해양 기상 + ├── incidents/ 사건/사고 관리 + ├── board/ 게시판 + ├── reports/ 보고서 + ├── assets/ 자산 관리 + ├── scat/ Pre-SCAT 조사 + └── admin/ 관리자 +``` + +**각 탭 내부 구조 패턴:** + +``` +tabs/{탭명}/ +├── components/ UI 컴포넌트 +│ ├── {Tab}View.tsx 메인 뷰 (App.tsx에서 라우팅) +│ ├── {Tab}LeftPanel.tsx +│ └── {Tab}RightPanel.tsx +├── services/ API 호출 +│ └── {탭명}Api.ts +├── hooks/ 탭 전용 훅 (선택) +└── types/ 탭 전용 타입 (선택) +``` + +### 5-2. Backend 개발 구조 + +``` +backend/src/ +├── server.ts 진입점 (미들웨어, 라우터 등록) +├── auth/ 인증 (JWT, OAuth, 미들웨어) +│ ├── authRouter.ts POST /api/auth/login, /api/auth/logout 등 +│ ├── authService.ts +│ └── authMiddleware.ts requireAuth, requireRole, requirePermission +├── users/ 사용자 관리 +├── roles/ 역할/권한 +│ └── permResolver.ts 2차원 권한 해석 엔진 +├── {도메인}/ 도메인별 모듈 +│ ├── {도메인}Router.ts +│ └── {도메인}Service.ts +├── middleware/ 보안 (입력 살균, rate-limit) +└── db/ + ├── wingDb.ts wing DB Pool + ├── authDb.ts wing_auth DB Pool + └── seed.ts 시드 데이터 +``` + +**라우터 작성 패턴:** + +```typescript +import { Router } from 'express'; +import { requireAuth } from '../auth/authMiddleware.js'; +import * as service from './exampleService.js'; + +const router = Router(); + +// GET: 목록 조회 +router.get('/', requireAuth, async (req, res) => { + try { + const result = await service.getList(); + res.json(result); + } catch (err) { + res.status(500).json({ error: '조회 실패' }); + } +}); + +// POST: 생성 (GET/POST only 정책) +router.post('/', requireAuth, async (req, res) => { + try { + const result = await service.create(req.body); + res.json(result); + } catch (err) { + res.status(500).json({ error: '생성 실패' }); + } +}); + +export default router; +``` + +> **HTTP 정책**: GET/POST만 사용한다 (보안취약점 가이드 준수, PUT/DELETE 미사용). + +### 5-3. DB 마이그레이션 + +스키마 변경이 필요한 경우 `database/migration/` 디렉토리에 SQL 파일을 추가한다. + +``` +database/migration/ +├── 001_layer_table.sql +├── 002_hns_substance.sql +├── ... +└── 016_rescue.sql +``` + +**네이밍**: `{3자리 번호}_{설명}.sql` (예: `017_weather_alert.sql`) + +```sql +-- 017_weather_alert.sql +-- 기상 경보 테이블 추가 + +CREATE TABLE WTHR_ALRT ( + ALRT_SN SERIAL NOT NULL, + ALRT_TP_CD VARCHAR(20) NOT NULL, + ALRT_CN TEXT, + REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT PK_WTHR_ALRT PRIMARY KEY (ALRT_SN) +); + +COMMENT ON TABLE WTHR_ALRT IS '기상경보'; +COMMENT ON COLUMN WTHR_ALRT.ALRT_SN IS '경보순번'; +COMMENT ON COLUMN WTHR_ALRT.ALRT_TP_CD IS '경보유형코드'; +COMMENT ON COLUMN WTHR_ALRT.ALRT_CN IS '경보내용'; +COMMENT ON COLUMN WTHR_ALRT.REG_DTM IS '등록일시'; +``` + +> DB 표준화: 공공데이터베이스 표준화 관리 매뉴얼(2021.06) 기준을 따른다. +> 컬럼명은 영문약어명 조합 (30자 이내, 언더스코어 구분). + +--- + +## 6. 검증 + +### 6-1. TypeScript 타입 체크 ```bash # Frontend -cd frontend && npx tsc --noEmit && npx eslint src/ +cd frontend && npx tsc --noEmit # Backend cd backend && npx tsc --noEmit ``` +### 6-2. ESLint + +```bash +# Frontend (flat config) +cd frontend && npx eslint src/ + +# 자동 수정 +cd frontend && npx eslint src/ --fix +``` + +### 6-3. Prettier (선택) + +```bash +cd frontend && npx prettier --check src/ +cd frontend && npx prettier --write src/ +``` + +### 6-4. 빌드 검증 + +배포 전 빌드가 성공하는지 확인한다: + +```bash +# Frontend +cd frontend && npm run build # tsc -b && vite build -> dist/ + +# Backend +cd backend && npm run build # tsc -> dist/ +``` + +### 6-5. 수동 테스트 + +테스트 프레임워크가 미구성이므로 수동으로 검증한다: + +1. 개발 서버 실행 (`npm run dev`) +2. 브라우저에서 기능 동작 확인 +3. API 호출은 브라우저 DevTools Network 탭 또는 curl로 확인 + +```bash +# API 헬스 체크 +curl http://localhost:3001/health + +# 인증이 필요한 API 테스트 (쿠키 기반) +curl -b "WING_SESSION=" http://localhost:3001/api/users +``` + --- -## 4. 커밋 & 푸시 +## 7. 커밋 & 푸시 -### Conventional Commits 형식 +### 7-1. Conventional Commits 형식 ``` -type(scope): 한국어 설명 +type(scope): subject ``` | type | 용도 | 예시 | @@ -111,323 +438,420 @@ type(scope): 한국어 설명 | `refactor` | 리팩토링 | `refactor(api): 중복 호출 제거` | | `docs` | 문서 | `docs: API 엔드포인트 문서 추가` | | `chore` | 설정/빌드 | `chore: 의존성 버전 업데이트` | -| `ci` | CI/CD | `ci: 백엔드 빌드 스텝 추가` | | `style` | 포맷팅 | `style: ESLint 경고 수정` | +| `test` | 테스트 | `test(auth): 로그인 단위 테스트 추가` | +| `ci` | CI/CD | `ci: 백엔드 빌드 스텝 추가` | +| `perf` | 성능 개선 | `perf(map): 레이어 렌더링 최적화` | -### pre-commit 자동 검증 +**규칙:** -커밋 시 `.githooks/pre-commit`이 자동 실행됩니다: -1. Frontend TypeScript 타입 체크 -2. Frontend ESLint 검증 -3. Backend TypeScript 타입 체크 +- `type` 필수, `scope` 선택 (한/영 모두 가능) +- `subject` 72자 이내, 마침표 없이 끝냄 +- 한국어, 영어 모두 허용 -**하나라도 실패하면 커밋이 차단됩니다.** +### 7-2. pre-commit Hook -### 푸시 +커밋 시 `.githooks/pre-commit`이 자동 실행되어 다음 항목을 검증한다: + +``` +[1] Frontend TypeScript 타입 체크 (tsc --noEmit) +[2] Frontend ESLint 검증 (eslint src/ --quiet) +[3] Backend TypeScript 타입 체크 (tsc --noEmit) +``` + +**하나라도 실패하면 커밋이 차단된다.** + +``` +pre-commit: [frontend] TypeScript 타입 체크 중... +pre-commit: [frontend] 타입 체크 성공 +pre-commit: [frontend] ESLint 검증 중... +pre-commit: [frontend] ESLint 통과 +pre-commit: [backend] TypeScript 타입 체크 중... +pre-commit: [backend] 타입 체크 성공 +``` + +실패 예시: + +``` +╔══════════════════════════════════════════════════════════╗ +║ [frontend] TypeScript 타입 에러! 커밋이 차단됩니다. ║ +╚══════════════════════════════════════════════════════════╝ + +타입/린트 에러를 수정한 후 다시 커밋해주세요. +``` + +### 7-3. commit-msg Hook + +커밋 메시지가 Conventional Commits 형식인지 검증한다: + +- 통과: `feat(auth): JWT 기반 로그인 구현` +- 차단: `로그인 기능 추가` (type 누락) +- 예외: `Merge ...`, `Revert ...` 커밋은 검증 건너뜀 + +### 7-4. 커밋 & 푸시 예시 ```bash -git push origin feature/user-profile -``` +# 변경 파일 확인 +git status -### Claude 스킬 활용 +# 스테이징 (파일 지정) +git add frontend/src/tabs/incidents/components/IncidentDetailView.tsx +git add backend/src/incidents/incidentService.ts -``` -/push # 변경사항 확인 → 커밋 → 푸시 (한 번에) -/mr # 커밋 → 푸시 → MR 생성 (한 번에) -/mr main # 커밋 → 푸시 → main으로 MR 생성 +# 커밋 (pre-commit + commit-msg 검증 자동 실행) +git commit -m "feat(incidents): 사고 상세 조회 페이지 추가" + +# 푸시 +git push -u origin feature/ISSUE-42-incident-detail ``` --- -## 5. MR 생성 (feature → develop) +## 8. MR(Merge Request) -### Gitea에서 MR 생성 +### 8-1. MR 생성 (feature -> develop) -1. https://gitea.gc-si.dev/gc/wing-ops/compare/develop...feature/user-profile -2. 제목: Conventional Commits 형식 -3. 본문: 변경 내용 요약 + 테스트 계획 +**Gitea Web UI:** -### Claude로 MR 생성 +1. https://gitea.gc-si.dev/gc/wing-ops 접속 +2. "New Pull Request" 클릭 +3. Base: `develop`, Compare: `feature/ISSUE-42-incident-detail` +4. 제목/본문 작성 후 생성 -``` -/create-mr develop # feature → develop MR 자동 생성 +**CLI (gh 또는 tea):** + +```bash +# Gitea API로 MR 생성 +curl -X POST "https://gitea.gc-si.dev/api/v1/repos/gc/wing-ops/pulls" \ + -H "Authorization: token " \ + -H "Content-Type: application/json" \ + -d '{ + "title": "feat(incidents): 사고 상세 조회 페이지 추가", + "body": "## Summary\n- 사고 상세 조회 페이지 추가\n- 사고 목록에서 클릭 시 상세 정보 표시", + "base": "develop", + "head": "feature/ISSUE-42-incident-detail" + }' ``` -### MR 본문 템플릿 +### 8-2. MR 본문 템플릿 ```markdown ## Summary -- 사용자 프로필 페이지 추가 -- 프로필 수정 API 연동 +- 변경 내용을 1~3줄로 요약 ## 변경 파일 -- frontend/src/components/views/ProfileView.tsx (신규) -- backend/src/users/userRouter.ts (수정) +- `frontend/src/tabs/incidents/components/IncidentDetailView.tsx` (신규) +- `backend/src/incidents/incidentService.ts` (수정) ## Test plan -- [ ] 프로필 페이지 접근 확인 -- [ ] 프로필 수정 후 저장 확인 +- [ ] 사고 목록에서 상세 조회 클릭 확인 +- [ ] 데이터 없을 때 빈 화면 처리 확인 +- [ ] API 에러 시 에러 메시지 표시 확인 ``` -### 머지 후 +### 8-3. MR 리뷰 & 머지 + +1. **리뷰어 지정**: 최소 1명 승인 필수 +2. **CI 통과**: 자동 검증이 설정된 경우 통과 필수 +3. **리뷰 코멘트**: 모두 해결 후 머지 +4. **머지 방식**: Squash Merge 권장 (깔끔한 히스토리) +5. **소스 브랜치**: 머지 후 삭제 + +### 8-4. 머지 후 로컬 동기화 ```bash -# 로컬 develop 동기화 git checkout develop git pull origin develop + +# 작업했던 feature 브랜치 삭제 +git branch -d feature/ISSUE-42-incident-detail ``` --- -## 6. 릴리즈 PR (develop → main) +## 9. 릴리즈 & 배포 -develop에 기능이 머지된 후, 배포를 위해 main으로 릴리즈 MR을 생성합니다. +### 9-1. 릴리즈 MR (develop -> main) -### Claude로 릴리즈 MR 생성 - -``` -/release # develop → main 릴리즈 MR 자동 생성 -``` - -### 릴리즈 MR 체크리스트 +develop에 기능이 머지된 후, 배포를 위해 main으로 릴리즈 MR을 생성한다. ```markdown -## Release v2.x.x +## Release ### 포함 기능 -1. feat(auth): Google OAuth 로그인 +1. feat(incidents): 사고 상세 조회 페이지 추가 2. fix(map): 레이어 오류 수정 ### 배포 전 확인 - [ ] 로컬 빌드 성공 (frontend + backend) -- [ ] 서버 환경변수 설정 완료 -- [ ] DB 마이그레이션 적용 (필요 시) +- [ ] DB 마이그레이션 적용 완료 (해당 시) +- [ ] 서버 환경변수 설정 완료 (해당 시) ``` -### main 머지 → 자동 배포 트리거 +### 9-2. 자동 배포 (Gitea Actions) -main에 머지되면 `.gitea/workflows/deploy.yml`이 자동 실행됩니다. - ---- - -## 7. 자동 배포 - -### CI/CD 파이프라인 (Gitea Actions) +main에 머지되면 `.gitea/workflows/deploy.yml`이 자동 실행된다: ``` main 브랜치 push - ↓ -[Frontend] npm ci → vite build → /deploy/wing-demo/ - ↓ -[Backend] npm ci → tsc → /deploy/wing-demo-backend/ - ↓ -[Server] .deploy-trigger 감지 → wing-demo-api 재시작 + | +[1. Checkout] 소스 코드 체크아웃 + | +[2. Setup Node.js] Node.js 24 설정 + | +[3. npm registry] Nexus 프록시 설정 + | +[4. Frontend] npm ci -> vite build -> /deploy/wing-demo/ + | +[5. Backend] npm ci -> tsc -> npm prune --omit=dev + | -> /deploy/wing-demo-backend/ + | +[6. Trigger] .deploy-trigger 생성 -> 서비스 재시작 ``` -### 배포 환경 +### 9-3. 배포 환경 | 항목 | 값 | |------|---| | 프론트엔드 | https://wing-demo.gc-si.dev | | 백엔드 API | https://wing-demo.gc-si.dev/api/ | -| 서버 | rocky-211 (Rocky Linux 9.6) | -| 프로세스 | systemd `wing-demo-api.service` | +| DB | PostgreSQL 16 + PostGIS (211.208.115.83:5432) | +| 서버 OS | Rocky Linux 9.6 | +| 프로세스 관리 | systemd (`wing-demo-api.service`) | -### 배포 확인 +### 9-4. 배포 확인 ```bash # 프론트엔드 응답 확인 curl -s -o /dev/null -w '%{http_code}' https://wing-demo.gc-si.dev/ -# 백엔드 API 확인 -curl -s https://wing-demo.gc-si.dev/api/auth/me +# 백엔드 헬스 체크 +curl -s https://wing-demo.gc-si.dev/api/health +# 기대: {"status":"ok"} + +# 백엔드 API 정보 +curl -s https://wing-demo.gc-si.dev/api/ +# 기대: {"name":"WING Backend API","version":"1.0.0","status":"running"} ``` -### 환경변수 관리 +### 9-5. 환경변수 관리 -| 위치 | 용도 | -|------|------| -| systemd 서비스 파일 | 서버 런타임 환경변수 (DB, JWT 등) | -| Gitea Secrets | CI/CD 빌드 시 환경변수 (API 키 등) | +| 위치 | 용도 | 예시 | +|------|------|------| +| systemd 서비스 파일 | 서버 런타임 환경변수 | DB 접속 정보, JWT_SECRET | +| Gitea Secrets | CI/CD 빌드 시 환경변수 | NEXUS_NPM_AUTH, GOOGLE_CLIENT_ID | + +**Gitea Secret 등록:** + +``` +Settings -> Actions -> Secrets -> Add Secret +``` + +--- + +## 10. 디버깅 팁 + +### 10-1. Frontend + +**Vite 개발 서버 프록시 문제:** + +- API 호출이 CORS 에러를 발생시키면 백엔드 `FRONTEND_URL` 환경변수를 확인한다. +- 개발 환경에서는 `localhost:5173`, `localhost:5174`, `localhost:3000`이 자동 허용된다. + +**타입 에러:** ```bash -# Gitea Secret 등록 (API) -curl -X PUT "https://gitea.gc-si.dev/api/v1/repos/gc/wing-ops/actions/secrets/KEY_NAME" \ - -H "Authorization: token " \ +# 전체 타입 체크 +cd frontend && npx tsc --noEmit + +# 특정 파일만 확인하고 싶으면 IDE의 TypeScript 서버 출력을 확인한다 +``` + +**상태 관리 디버깅:** + +- Zustand DevTools: 브라우저 확장에서 스토어 상태 확인 가능 +- React Query DevTools: TanStack Query의 캐시 상태 확인 + +### 10-2. Backend + +**DB 연결 실패:** + +```bash +# PostgreSQL 접속 테스트 +psql -h 211.208.115.83 -p 5432 -U wing -d wing -c "SELECT 1" + +# 방화벽 확인 +nc -zv 211.208.115.83 5432 +``` + +**API 디버깅:** + +```bash +# 헬스 체크 +curl http://localhost:3001/health + +# 인증 테스트 (로그인 후 쿠키 확인) +curl -c cookies.txt -X POST http://localhost:3001/api/auth/login \ -H "Content-Type: application/json" \ - -d '{"data":"secret-value"}' + -d '{"email":"test@test.com","password":"test1234"}' -# Gitea Secret 등록 (Web UI) -# Settings → Actions → Secrets → Add Secret +# 인증이 필요한 API +curl -b cookies.txt http://localhost:3001/api/users +``` + +**Rate Limit:** + +- 일반 API: 15분당 IP당 200회 +- 시뮬레이션 API: 1분당 IP당 10회 +- 초과 시 `429 Too Many Requests` 응답 + +### 10-3. 공통 + +**포트 충돌:** + +```bash +# 사용 중인 포트 확인 +lsof -i :5173 # Frontend +lsof -i :3001 # Backend + +# 프로세스 종료 +kill -9 +``` + +**node_modules 문제:** + +```bash +# 의존성 초기화 +rm -rf node_modules package-lock.json +npm install +``` + +**Git Hooks가 동작하지 않을 때:** + +```bash +# hooks 경로 확인 +git config core.hooksPath +# 출력이 .githooks가 아니면 재설정 +git config core.hooksPath .githooks + +# 실행 권한 확인 +chmod +x .githooks/pre-commit .githooks/commit-msg ``` --- -## 8. 프로젝트 문서 최신화 - -### 자동 관리되는 문서 - -Claude 세션 중 커밋/컴팩트 시 hook이 자동으로 갱신을 안내합니다: - -| 문서 | 위치 | 갱신 시점 | -|------|------|----------| -| MEMORY.md | `~/.claude/projects/.../memory/` | 매 세션 | -| project-snapshot.md | 위와 동일 | 구조 변경 시 | -| project-history.md | 위와 동일 | 매 커밋 | -| api-types.md | 위와 동일 | API 변경 시 | -| CHANGELOG.md | `docs/CHANGELOG.md` | 매 커밋 | - -### 수동으로 최신화해야 하는 문서 - -| 문서 | 위치 | 갱신 주기 | -|------|------|----------| -| CLAUDE.md | 프로젝트 루트 | 기술 스택 변경 시 | -| INSTALL_GUIDE.md | `docs/` | 배포 환경 변경 시 | -| auth_init.sql | `database/` | DB 스키마 변경 시 | - -### 주기적 최신화 체크리스트 - -**매 기능 개발 완료 시:** -``` -Claude에게: "memory 파일 최신화해줘" -→ project-snapshot.md 갱신 -→ api-types.md 갱신 (API 변경 시) -→ project-history.md에 변경 이력 추가 -``` - -**매주 또는 스프린트 종료 시:** -``` -Claude에게: "프로젝트 문서 전체 점검해줘" -→ CLAUDE.md 기술 스택 확인 -→ CHANGELOG.md 누락 항목 보충 -→ 의존성 버전 확인 (npm outdated) -``` - -**팀 워크플로우 업데이트 시:** -``` -/sync-team-workflow # Gitea에서 최신 팀 규칙 동기화 -``` - ---- - -## 9. 실전 예시: 기능 추가 A to Z +## 11. 실전 예시: 기능 추가 A to Z ### 시나리오: "사고 상세 조회 페이지 추가" #### Step 1. 계획 -``` -사용자: "사고 상세 조회 페이지를 추가해줘. 사고 목록에서 클릭하면 상세 정보를 보여주는 페이지야" +수정/생성 파일 식별: -Claude: (Plan Mode 진입) - → 코드베이스 분석 - → 구현 계획 제시: - 1. backend/src/incidents/incidentService.ts - 상세 조회 함수 - 2. backend/src/incidents/incidentRouter.ts - GET /api/incidents/:id - 3. frontend/src/services/incidentApi.ts - API 함수 - 4. frontend/src/components/views/IncidentDetailView.tsx - 상세 화면 - 5. frontend/src/App.tsx - 라우팅 연결 - -사용자: (계획 승인) -``` +| 파일 | 변경 | +|------|------| +| `database/migration/017_incident_detail.sql` | DB 마이그레이션 (필요 시) | +| `backend/src/incidents/incidentService.ts` | 상세 조회 함수 추가 | +| `backend/src/incidents/incidentRouter.ts` | `GET /api/incidents/:id` 라우트 | +| `frontend/src/tabs/incidents/services/incidentsApi.ts` | API 호출 함수 | +| `frontend/src/tabs/incidents/components/IncidentDetailView.tsx` | 상세 뷰 컴포넌트 | #### Step 2. 브랜치 생성 ```bash git checkout develop git pull origin develop -git checkout -b feature/incident-detail +git checkout -b feature/ISSUE-42-incident-detail ``` #### Step 3. 개발 -Claude가 계획에 따라 코드 작성 (5개 파일 수정/생성) +**Backend - Service:** -#### Step 4. 커밋 & 푸시 - -``` -사용자: "커밋하고 푸시해줘" - -Claude: - git add backend/src/incidents/... frontend/src/... - git commit -m "feat(incidents): 사고 상세 조회 페이지 추가" - # pre-commit: TypeScript ✓, ESLint ✓ - git push origin feature/incident-detail +```typescript +// backend/src/incidents/incidentService.ts +export async function getIncidentById(id: number) { + const { rows } = await wingPool.query( + 'SELECT * FROM ACDNT WHERE ACDNT_SN = $1', + [id] + ); + return rows[0] || null; +} ``` -또는 스킬 사용: +**Backend - Router:** -``` -/push feat(incidents): 사고 상세 조회 페이지 추가 +```typescript +// backend/src/incidents/incidentRouter.ts +router.get('/:id', requireAuth, async (req, res) => { + const id = Number(req.params.id); + const incident = await getIncidentById(id); + if (!incident) { + return res.status(404).json({ error: '사고를 찾을 수 없습니다.' }); + } + res.json(incident); +}); ``` -#### Step 5. develop MR +**Frontend - API:** -``` -사용자: "develop MR 만들어줘" - -Claude: - → Gitea API로 MR 생성 - → feature/incident-detail → develop - → MR #5: https://gitea.gc-si.dev/gc/wing-ops/pulls/5 +```typescript +// frontend/src/tabs/incidents/services/incidentsApi.ts +export async function fetchIncidentById(id: number) { + const { data } = await api.get(`/incidents/${id}`); + return data; +} ``` -또는: -``` -/create-mr develop +**Frontend - Component:** + +```tsx +// frontend/src/tabs/incidents/components/IncidentDetailView.tsx +const IncidentDetailView = ({ incidentId }: IncidentDetailViewProps) => { + const { data, isLoading } = useQuery({ + queryKey: ['incident', incidentId], + queryFn: () => fetchIncidentById(incidentId), + }); + + if (isLoading) return
Loading...
; + // ...렌더링 +}; ``` -#### Step 6. 코드 리뷰 & 머지 +#### Step 4. 검증 -- Gitea에서 MR 리뷰 -- 승인 후 Squash Merge - -#### Step 7. 릴리즈 PR - -``` -사용자: "main으로 릴리즈 MR 만들어줘" - -Claude: - → develop → main MR 생성 - → MR #6 (release) +```bash +cd frontend && npx tsc --noEmit && npx eslint src/ +cd ../backend && npx tsc --noEmit ``` -또는: -``` -/release +#### Step 5. 커밋 & 푸시 + +```bash +git add backend/src/incidents/ frontend/src/tabs/incidents/ +git commit -m "feat(incidents): 사고 상세 조회 페이지 추가" +# pre-commit: TypeScript OK, ESLint OK +# commit-msg: Conventional Commits OK + +git push -u origin feature/ISSUE-42-incident-detail ``` -#### Step 8. main 머지 → 자동 배포 +#### Step 6. MR 생성 & 리뷰 -- main에 머지 → Gitea Actions 실행 -- Frontend 빌드 (Vite) → /deploy/wing-demo/ -- Backend 빌드 (tsc) → /deploy/wing-demo-backend/ -- .deploy-trigger → cron이 감지 → wing-demo-api 재시작 +- Gitea에서 `feature/ISSUE-42-incident-detail -> develop` MR 생성 +- 리뷰어 승인 후 Squash Merge +- 소스 브랜치 삭제 + +#### Step 7. 릴리즈 & 배포 + +- `develop -> main` 릴리즈 MR 생성 +- 머지 -> Gitea Actions 자동 배포 - https://wing-demo.gc-si.dev 에서 확인 -#### Step 9. 문서 최신화 +#### Step 8. 로컬 동기화 +```bash +git checkout develop +git pull origin develop +git branch -d feature/ISSUE-42-incident-detail ``` -사용자: "memory 파일 최신화해줘" - -Claude: - → project-snapshot.md: incidents 모듈 추가 반영 - → api-types.md: GET /api/incidents/:id 추가 - → project-history.md: "사고 상세 조회 페이지 추가" 기록 -``` - ---- - -## 부록: 자주 쓰는 Claude 명령 - -| 명령 | 설명 | -|------|------| -| `"커밋하고 푸시해줘"` | 변경사항 커밋 + 푸시 | -| `"develop MR 만들어줘"` | feature → develop MR | -| `"memory 최신화해줘"` | 프로젝트 문서 갱신 | -| `/push` | 커밋 + 푸시 (스킬) | -| `/mr` | 커밋 + 푸시 + MR (스킬) | -| `/release` | develop → main 릴리즈 MR (스킬) | -| `/create-mr develop` | MR만 생성 (스킬) | -| `/sync-team-workflow` | 팀 워크플로우 동기화 (스킬) | -| `/changelog` | CHANGELOG.md 갱신 (스킬) | diff --git a/docs/INSTALL_GUIDE.md b/docs/INSTALL_GUIDE.md index 227bcd9..243e765 100755 --- a/docs/INSTALL_GUIDE.md +++ b/docs/INSTALL_GUIDE.md @@ -1,13 +1,63 @@ -# WING 해양환경 위기대응 통합시스템 - 설치 매뉴얼 +# WING-OPS 설치 매뉴얼 -## 1. 필수 소프트웨어 +## 목차 -| 소프트웨어 | 최소 버전 | 용도 | 다운로드 | -|-----------|----------|------|---------| -| Node.js | v20 이상 (권장 v25) | 프론트엔드/백엔드 실행 | https://nodejs.org | -| npm | v10 이상 | 패키지 관리 | Node.js에 포함 | +1. [시스템 요구사항](#1-시스템-요구사항) +2. [프로젝트 구조](#2-프로젝트-구조) +3. [온라인 설치](#3-온라인-설치) +4. [오프라인 설치](#4-오프라인-설치) +5. [DB 초기화 및 마이그레이션](#5-db-초기화-및-마이그레이션) +6. [개발 서버 실행](#6-개발-서버-실행) +7. [운영 서버 배포](#7-운영-서버-배포) +8. [CI/CD 자동 배포](#8-cicd-자동-배포) +9. [접속 정보 요약](#9-접속-정보-요약) +10. [트러블슈팅](#10-트러블슈팅) -> **오프라인 환경**: 인터넷이 안 되는 망에서는 `node_modules`가 포함된 압축 파일을 사용하세요 (아래 "오프라인 설치" 참고). +--- + +## 1. 시스템 요구사항 + +### 필수 소프트웨어 + +| 소프트웨어 | 최소 버전 | 권장 버전 | 용도 | +|-----------|----------|----------|------| +| Node.js | v20 | v20 LTS | Frontend/Backend 실행 | +| npm | v10 | v10+ | 패키지 관리 (Node.js 포함) | +| PostgreSQL | v15 | v16 | 운영 DB + 인증 DB | +| PostGIS | v3.3 | v3.4 | 공간 데이터 처리 | +| Git | v2.30 | v2.40+ | 소스 코드 관리 | + +### 하드웨어 권장 사양 + +| 항목 | 개발 환경 | 운영 환경 | +|------|----------|----------| +| CPU | 2 Core | 4 Core | +| RAM | 4 GB | 8 GB | +| Disk | 10 GB | 50 GB | +| OS | macOS / Linux / Windows | Rocky Linux 9 / CentOS 9 | + +### Node.js 설치 (fnm 권장) + +fnm(Fast Node Manager)으로 Node.js 버전을 관리한다. 프로젝트 루트의 `.node-version` 파일에 `20`이 지정되어 있다. + +**macOS / Linux:** + +```bash +# fnm 설치 +curl -fsSL https://fnm.vercel.app/install | bash + +# 셸 재시작 후 +fnm install 20 +fnm use 20 + +# 확인 +node -v # v20.x.x +npm -v # v10.x.x +``` + +**수동 설치 (fnm 없이):** + +https://nodejs.org 에서 Node.js 20 LTS를 다운로드하여 설치한다. --- @@ -15,151 +65,669 @@ ``` wing/ -├── frontend/ # React + Vite 프론트엔드 (포트 5173) +├── frontend/ React 19 + Vite 7 + TypeScript 5.9 + Tailwind CSS 3 │ ├── src/ -│ │ ├── components/ # UI 컴포넌트 -│ │ ├── data/ # 정적 데이터 -│ │ ├── hooks/ # 커스텀 훅 -│ │ ├── types/ # TypeScript 타입 정의 -│ │ ├── utils/ # 유틸리티 함수 -│ │ └── store/ # 상태관리 -│ └── package.json -├── backend/ # Express 백엔드 API (포트 3001) +│ │ ├── App.tsx 메인 (탭 라우팅, 감사 로그 자동 기록) +│ │ ├── common/ 공통 모듈 (@common/ alias) +│ │ │ ├── components/ auth/, layer/, layout/, map/, ui/ +│ │ │ ├── hooks/ useLayers, useSubMenu, useAuth +│ │ │ ├── services/ api.ts (Axios), authApi.ts, layerService.ts +│ │ │ ├── store/ authStore, menuStore (Zustand) +│ │ │ ├── types/ backtrack, boomLine, hns, navigation +│ │ │ └── utils/ coordinates, geo, sanitize +│ │ └── tabs/ 탭 단위 패키지 (11개) +│ │ ├── prediction/ 확산 예측 +│ │ ├── hns/ HNS 분석 +│ │ ├── rescue/ 구조 시나리오 +│ │ ├── aerial/ 항공 방제 +│ │ ├── weather/ 해양 기상 +│ │ ├── incidents/ 사건/사고 관리 +│ │ ├── board/ 게시판 +│ │ ├── reports/ 보고서 +│ │ ├── assets/ 자산 관리 +│ │ ├── scat/ Pre-SCAT 조사 +│ │ └── admin/ 관리자 +│ ├── package.json +│ └── vite.config.ts +├── backend/ Express 4 + TypeScript + PostgreSQL │ ├── src/ -│ │ ├── routes/ # API 라우트 -│ │ ├── middleware/ # 미들웨어 (보안 등) -│ │ ├── db/ # DB 연결 -│ │ └── server.ts # 서버 엔트리 -│ └── package.json -└── database/ # DB 초기화 SQL - ├── database_init.sql - └── auth_init.sql +│ │ ├── server.ts 진입점 (보안 미들웨어 + 라우터 등록) +│ │ ├── auth/ 인증 (JWT, Google OAuth) +│ │ ├── users/ 사용자 관리 +│ │ ├── roles/ 역할/권한 (RBAC 2차원 권한) +│ │ ├── db/ DB Pool (wingDb, authDb), seed +│ │ ├── middleware/ 보안 (입력 살균, rate-limit) +│ │ └── {도메인}/ 도메인별 모듈 (Router + Service) +│ ├── package.json +│ └── tsconfig.json +├── database/ SQL 스크립트 +│ ├── init.sql wing DB 초기 스키마 (PostGIS) +│ ├── auth_init.sql wing_auth DB 초기 스키마 +│ └── migration/ 마이그레이션 (001 ~ 016) +├── docs/ 개발 문서 +├── .githooks/ Git Hooks (pre-commit, commit-msg) +├── .gitea/workflows/ CI/CD (Gitea Actions) +├── .node-version Node.js 20 +└── .npmrc npm 레지스트리 (Nexus 프록시) ``` --- -## 3. 온라인 설치 (인터넷 가능한 환경) +## 3. 온라인 설치 -### 3-1. 의존성 설치 +인터넷이 가능한 환경에서의 설치 절차. + +### 3-1. 소스 코드 클론 ```bash -# 프론트엔드 -cd wing/frontend +git clone https://gitea.gc-si.dev/gc/wing-ops.git wing +cd wing +``` + +### 3-2. Git Hooks 설정 + +```bash +# pre-commit (TypeScript + ESLint 검증), commit-msg (Conventional Commits 검증) +git config core.hooksPath .githooks +chmod +x .githooks/pre-commit .githooks/commit-msg +``` + +### 3-3. 의존성 설치 + +npm 레지스트리는 프로젝트 루트의 `.npmrc`에 Nexus 프록시로 설정되어 있다. + +```bash +# Frontend +cd frontend npm install -# 백엔드 +# Backend cd ../backend npm install ``` -### 3-2. 데이터베이스 설정 +### 3-4. 환경변수 설정 -운영 PostgreSQL에 직접 연결합니다. `backend/.env` 파일에서 DB 연결 정보를 설정하세요. +#### Backend (`backend/.env`) ```bash -# backend/.env -AUTH_DB_HOST= +# =========================================== +# 서버 설정 +# =========================================== +PORT=3001 +NODE_ENV=development + +# =========================================== +# wing DB (운영 데이터 - PostgreSQL + PostGIS) +# =========================================== +WING_DB_HOST=211.208.115.83 +WING_DB_PORT=5432 +WING_DB_USER=wing +WING_DB_PASS=<비밀번호> +WING_DB_NAME=wing + +# =========================================== +# wing_auth DB (인증/권한 - PostgreSQL) +# =========================================== +AUTH_DB_HOST=211.208.115.83 AUTH_DB_PORT=5432 -AUTH_DB_NAME=wing_auth AUTH_DB_USER=wing_auth -AUTH_DB_PASSWORD=<비밀번호> +AUTH_DB_PASS=<비밀번호> +AUTH_DB_NAME=wing_auth + +# =========================================== +# JWT 인증 +# =========================================== +JWT_SECRET=<랜덤 시크릿 문자열> +JWT_EXPIRES_IN=24h + +# =========================================== +# CORS (프론트엔드 출처) +# =========================================== +FRONTEND_URL=http://localhost:5173 + +# =========================================== +# Google OAuth (선택) +# =========================================== +GOOGLE_CLIENT_ID= ``` -> 신규 DB 초기화가 필요한 경우 `database/auth_init.sql`을 실행하세요. - -### 3-3. 백엔드 실행 +#### Frontend (`frontend/.env`) ```bash -cd wing/backend -npm run dev +# 백엔드 API URL +VITE_API_URL=http://localhost:3001/api + +# Google OAuth (선택) +VITE_GOOGLE_CLIENT_ID= + +# 공공데이터 API 키 (해양기상, 선택) +VITE_DATA_GO_KR_API_KEY= +VITE_WEATHER_API_KEY=<기상 API 키> ``` -→ `http://localhost:3001` 에서 API 서버 시작 +> `.env` 파일은 `.gitignore`에 포함된다. 실제 값은 팀 내부에서 공유한다. -### 3-4. 프론트엔드 실행 +### 3-5. 실행 ```bash -cd wing/frontend +# 터미널 1: 백엔드 +cd backend npm run dev +# 출력: 서버가 포트 3001에서 실행 중입니다. + +# 터미널 2: 프론트엔드 +cd frontend +npm run dev +# 출력: VITE vX.X.X ready in XXXms +# -> Local: http://localhost:5173/ ``` -→ `http://localhost:5173` 에서 웹 앱 시작 +브라우저에서 http://localhost:5173 접속하여 확인한다. --- -## 4. 오프라인 설치 (폐쇄망/다른 망) +## 4. 오프라인 설치 -인터넷이 안 되는 환경에서는 `npm install`이 불가능합니다. -이 경우 **node_modules 포함 압축 파일**을 사용하세요. +인터넷이 불가능한 폐쇄망 환경에서의 설치 절차. -### 4-1. 압축 해제 +### 4-1. 온라인 환경에서 패키지 준비 + +인터넷이 되는 PC에서 다음을 실행한다: ```bash -# wing_full.tar.gz 파일을 작업 폴더에 복사한 뒤: +# 1. 프로젝트 클론 및 의존성 설치 +git clone https://gitea.gc-si.dev/gc/wing-ops.git wing +cd wing/frontend && npm install +cd ../backend && npm install + +# 2. 전체 프로젝트 압축 (node_modules 포함) +cd ../.. +tar -czf wing_full.tar.gz wing/ +``` + +### 4-2. Node.js 오프라인 설치 파일 준비 + +대상 OS에 맞는 설치 파일을 다운로드한다: + +| OS | 파일 | 다운로드 | +|----|------|---------| +| Linux (x64) | `node-v20.x.x-linux-x64.tar.xz` | https://nodejs.org/dist/v20.x.x/ | +| macOS (arm64) | `node-v20.x.x-darwin-arm64.tar.gz` | https://nodejs.org/dist/v20.x.x/ | +| macOS (x64) | `node-v20.x.x-darwin-x64.tar.gz` | https://nodejs.org/dist/v20.x.x/ | +| Windows | `node-v20.x.x-x64.msi` | https://nodejs.org/dist/v20.x.x/ | + +### 4-3. 대상 서버에 설치 + +```bash +# 1. Node.js 설치 (Linux 예시) +tar -xJf node-v20.x.x-linux-x64.tar.xz +export PATH=$PWD/node-v20.x.x-linux-x64/bin:$PATH +# .bashrc 또는 .profile에 PATH 영구 등록 + +# 2. 프로젝트 압축 해제 tar -xzf wing_full.tar.gz +cd wing + +# 3. 환경변수 설정 (3-4 참조) +vi backend/.env +vi frontend/.env + +# 4. 실행 (node_modules가 이미 포함되어 있으므로 npm install 불필요) +cd backend && npm run dev # 터미널 1 +cd frontend && npm run dev # 터미널 2 ``` -### 4-2. Node.js 설치 +### 4-4. 오프라인 환경 주의사항 -대상 PC에 Node.js가 없으면 오프라인 설치 파일(.msi 또는 .pkg)을 미리 준비하여 설치합니다. +- `npm install` 불가하므로 패키지 추가 시 온라인 환경에서 설치 후 재배포해야 한다. +- DB는 대상 환경에서 접근 가능한 PostgreSQL을 사용해야 한다. +- 공공데이터 API (기상, 해양) 키가 필요한 기능은 외부 네트워크 접근이 필요하다. -- Windows: `node-v25.x.x-x64.msi` -- macOS: `node-v25.x.x.pkg` +--- -### 4-3. DB 연결 설정 +## 5. DB 초기화 및 마이그레이션 -`backend/.env` 파일에서 연결 가능한 PostgreSQL 정보를 설정합니다. +### 5-1. DB 구성 -### 4-4. 실행 +프로젝트는 동일 PostgreSQL 서버에 2개의 데이터베이스를 사용한다: -node_modules가 이미 포함되어 있으므로 바로 실행 가능합니다. +| DB | 용도 | 확장 | +|----|------|------| +| `wing` | 운영 데이터 (사고, 예측, 자산 등) | PostGIS | +| `wing_auth` | 인증/권한 (사용자, 역할, 메뉴) | uuid-ossp, pgcrypto | + +### 5-2. 신규 DB 초기화 + +PostgreSQL에 최초 설치 시 아래 순서로 실행한다. ```bash -# 터미널 1 - 백엔드 -cd wing/backend -npm run dev +# 1. wing DB 초기화 (PostgreSQL superuser로 실행) +psql -U postgres -f database/init.sql -# 터미널 2 - 프론트엔드 -cd wing/frontend -npm run dev +# 2. wing_auth DB 초기화 +psql -U postgres -f database/auth_init.sql +``` + +`init.sql`에 포함된 내용: +- 사용자(wing) 및 데이터베이스(wing) 생성 +- PostGIS 확장 설치 +- 공통코드, 시뮬레이션, 사고, 예측 등 핵심 테이블 생성 + +`auth_init.sql`에 포함된 내용: +- 사용자(wing_auth) 및 데이터베이스(wing_auth) 생성 +- uuid-ossp, pgcrypto 확장 설치 +- 조직, 역할, 사용자, 권한, 메뉴, 설정, 감사로그 테이블 생성 + +### 5-3. 마이그레이션 적용 + +초기 스키마 이후 추가된 변경 사항은 `database/migration/` 디렉토리에 순번대로 관리된다. + +``` +database/migration/ +├── 001_layer_table.sql 레이어 테이블 +├── 002_hns_substance.sql HNS 물질 데이터 +├── 003_perm_tree.sql 권한 트리 +├── 004_oper_cd.sql 오퍼레이션 코드 +├── 005_db_consolidation.sql DB 통합 +├── 006_board.sql 게시판 +├── 007_reports.sql 보고서 +├── 008_assets.sql 자산 +├── 008_assets_seed.sql 자산 시드 데이터 +├── 009_incidents.sql 사건/사고 +├── 010_postgis_geom.sql PostGIS 지오메트리 +├── 011_scat.sql SCAT 조사 +├── 012_board_ext.sql 게시판 확장 +├── 013_hns_analysis.sql HNS 분석 +├── 014_prediction.sql 확산 예측 +├── 015_aerial.sql 항공 방제 +└── 016_rescue.sql 구조 시나리오 +``` + +**마이그레이션 실행 (수동):** + +```bash +# wing DB에 순번대로 적용 +psql -h <호스트> -p 5432 -U wing -d wing -f database/migration/001_layer_table.sql +psql -h <호스트> -p 5432 -U wing -d wing -f database/migration/002_hns_substance.sql +# ... 순번대로 계속 +``` + +> 이미 적용된 마이그레이션을 다시 실행해도 대부분 `IF NOT EXISTS` 조건이 포함되어 있어 안전하다. + +### 5-4. 시드 데이터 + +초기 데이터(관리자 계정, 공통코드 등)를 입력한다: + +```bash +cd backend +npm run db:seed ``` --- -## 5. 접속 정보 요약 +## 6. 개발 서버 실행 -| 서비스 | URL | 비고 | -|--------|-----|------| -| 프론트엔드 (WING) | http://localhost:5173 | Vite dev server | -| 백엔드 API | http://localhost:3001 | Express | -| PostgreSQL | 운영 DB 직접 연결 | `.env` 설정 참조 | +### 6-1. 명령어 요약 ---- +**Frontend:** -## 6. 주요 명령어 +| 명령어 | 설명 | +|--------|------| +| `npm run dev` | Vite 개발 서버 (localhost:5173, HMR 지원) | +| `npm run build` | 프로덕션 빌드 (`tsc -b && vite build` -> `dist/`) | +| `npm run lint` | ESLint 검증 | +| `npm run preview` | 빌드 결과 미리보기 | + +**Backend:** + +| 명령어 | 설명 | +|--------|------| +| `npm run dev` | tsx watch 개발 서버 (localhost:3001, 파일 변경 시 자동 재시작) | +| `npm run build` | TypeScript 컴파일 (`tsc` -> `dist/`) | +| `npm start` | 프로덕션 실행 (`node dist/server.js`) | +| `npm run db:seed` | DB 시드 데이터 입력 | + +### 6-2. 개발 서버 시작 ```bash -# 프론트엔드 빌드 (배포용) -cd frontend && npm run build # dist/ 폴더에 정적 파일 생성 +# 터미널 1: 백엔드 (먼저 실행) +cd backend +npm run dev +# 서버가 포트 3001에서 실행 중입니다. +# wing DB 연결 성공 (211.208.115.83:5432/wing) -# 백엔드 빌드 -cd backend && npm run build # dist/ 폴더에 JS 생성 +# 터미널 2: 프론트엔드 +cd frontend +npm run dev +# VITE v7.x.x ready in XXXms +# -> Local: http://localhost:5173/ +``` -# DB 시드 데이터 입력 -cd backend && npm run db:seed +### 6-3. 검증 명령어 +```bash # TypeScript 타입 체크 cd frontend && npx tsc --noEmit +cd backend && npx tsc --noEmit + +# ESLint +cd frontend && npx eslint src/ + +# Prettier +cd frontend && npx prettier --check src/ +cd frontend && npx prettier --write src/ # 자동 수정 ``` --- -## 7. 트러블슈팅 +## 7. 운영 서버 배포 -| 증상 | 해결 | +### 7-1. 서버 환경 준비 + +운영 서버(Rocky Linux 9 기준)에 다음을 설치한다: + +```bash +# Node.js 20 설치 (fnm 또는 직접 설치) +curl -fsSL https://fnm.vercel.app/install | bash +source ~/.bashrc +fnm install 20 +fnm use 20 + +# PostgreSQL 16 + PostGIS 설치 (dnf 또는 직접 설치) +# 이미 설치된 경우 생략 +``` + +### 7-2. 수동 배포 절차 + +#### Frontend + +```bash +cd frontend + +# 의존성 설치 +npm ci + +# 환경변수 설정 (빌드 시 Vite가 주입) +export VITE_API_URL=/api +export VITE_GOOGLE_CLIENT_ID=<클라이언트ID> +export VITE_DATA_GO_KR_API_KEY=<키> +export VITE_WEATHER_API_KEY=<키> + +# 프로덕션 빌드 +npx vite build + +# 빌드 결과를 웹 서버 디렉토리로 복사 +cp -r dist/* /deploy/wing-demo/ +``` + +#### Backend + +```bash +cd backend + +# 의존성 설치 +npm ci + +# TypeScript 컴파일 +npx tsc + +# 프로덕션 의존성만 유지 (devDependencies 제거) +npm prune --omit=dev + +# 배포 디렉토리로 복사 +mkdir -p /deploy/wing-demo-backend/dist +cp -r dist/* /deploy/wing-demo-backend/dist/ +cp -r node_modules /deploy/wing-demo-backend/ +cp package.json /deploy/wing-demo-backend/ +``` + +### 7-3. systemd 서비스 등록 + +Backend를 systemd 서비스로 등록하여 자동 시작/재시작을 설정한다. + +**서비스 파일 생성** (`/etc/systemd/system/wing-demo-api.service`): + +```ini +[Unit] +Description=WING-OPS Backend API +After=network.target postgresql.service + +[Service] +Type=simple +User=deploy +WorkingDirectory=/deploy/wing-demo-backend +ExecStart=/home/deploy/.local/share/fnm/aliases/default/bin/node dist/server.js +Restart=on-failure +RestartSec=5 + +# 환경변수 +Environment=NODE_ENV=production +Environment=PORT=3001 +Environment=WING_DB_HOST=211.208.115.83 +Environment=WING_DB_PORT=5432 +Environment=WING_DB_USER=wing +Environment=WING_DB_PASS=<비밀번호> +Environment=WING_DB_NAME=wing +Environment=AUTH_DB_HOST=211.208.115.83 +Environment=AUTH_DB_PORT=5432 +Environment=AUTH_DB_USER=wing_auth +Environment=AUTH_DB_PASS=<비밀번호> +Environment=AUTH_DB_NAME=wing_auth +Environment=JWT_SECRET=<시크릿> +Environment=JWT_EXPIRES_IN=24h +Environment=FRONTEND_URL=https://wing-demo.gc-si.dev +Environment=GOOGLE_CLIENT_ID=<클라이언트ID> + +[Install] +WantedBy=multi-user.target +``` + +**서비스 등록 및 시작:** + +```bash +sudo systemctl daemon-reload +sudo systemctl enable wing-demo-api +sudo systemctl start wing-demo-api + +# 상태 확인 +sudo systemctl status wing-demo-api + +# 로그 확인 +sudo journalctl -u wing-demo-api -f +``` + +### 7-4. 리버스 프록시 (Nginx) + +Frontend 정적 파일과 Backend API를 하나의 도메인으로 서비스한다. + +```nginx +server { + listen 443 ssl; + server_name wing-demo.gc-si.dev; + + ssl_certificate /etc/ssl/certs/wing-demo.crt; + ssl_certificate_key /etc/ssl/private/wing-demo.key; + + # Frontend 정적 파일 + root /deploy/wing-demo; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + # Backend API 프록시 + location /api/ { + proxy_pass http://127.0.0.1:3001/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # 헬스 체크 + location /health { + proxy_pass http://127.0.0.1:3001/health; + } +} + +# HTTP -> HTTPS 리다이렉트 +server { + listen 80; + server_name wing-demo.gc-si.dev; + return 301 https://$host$request_uri; +} +``` + +--- + +## 8. CI/CD 자동 배포 + +### 8-1. Gitea Actions 파이프라인 + +main 브랜치에 push(머지)되면 `.gitea/workflows/deploy.yml`이 자동 실행된다. + +``` +main 브랜치 push + | +[Checkout] 소스 코드 체크아웃 + | +[Setup Node.js] Node.js 24 설정 + | +[Configure npm] Nexus 프록시 레지스트리 설정 + | +[Frontend Build] npm ci -> vite build -> /deploy/wing-demo/ + | +[Backend Build] npm ci -> tsc -> npm prune --omit=dev + | -> /deploy/wing-demo-backend/ + | +[Deploy Trigger] .deploy-trigger 파일 생성 + -> cron/watchdog이 감지 -> 서비스 재시작 +``` + +### 8-2. Gitea Secrets 설정 + +CI/CD에서 사용하는 시크릿을 Gitea에 등록해야 한다: + +**등록 경로:** Settings -> Actions -> Secrets -> Add Secret + +| Secret 이름 | 용도 | +|-------------|------| +| `NEXUS_NPM_AUTH` | npm Nexus 프록시 인증 토큰 | +| `GOOGLE_CLIENT_ID` | Google OAuth 클라이언트 ID | +| `DATA_GO_KR_API_KEY` | 공공데이터 API 키 | +| `WEATHER_API_KEY` | 기상 API 키 | + +### 8-3. 배포 확인 + +```bash +# 프론트엔드 응답 확인 +curl -s -o /dev/null -w '%{http_code}' https://wing-demo.gc-si.dev/ +# 기대: 200 + +# 백엔드 헬스 체크 +curl -s https://wing-demo.gc-si.dev/api/health +# 기대: {"status":"ok"} + +# 백엔드 API 정보 +curl -s https://wing-demo.gc-si.dev/api/ +# 기대: {"name":"WING Backend API","version":"1.0.0","status":"running"} +``` + +--- + +## 9. 접속 정보 요약 + +### 개발 환경 + +| 서비스 | URL | 포트 | +|--------|-----|------| +| Frontend (Vite dev) | http://localhost:5173 | 5173 | +| Backend API | http://localhost:3001 | 3001 | +| Backend 헬스 체크 | http://localhost:3001/health | 3001 | +| PostgreSQL (wing) | 211.208.115.83:5432/wing | 5432 | +| PostgreSQL (wing_auth) | 211.208.115.83:5432/wing_auth | 5432 | + +### 운영 환경 + +| 서비스 | URL | +|--------|-----| +| Frontend | https://wing-demo.gc-si.dev | +| Backend API | https://wing-demo.gc-si.dev/api/ | +| 헬스 체크 | https://wing-demo.gc-si.dev/api/health | +| Gitea | https://gitea.gc-si.dev/gc/wing-ops | + +### API 엔드포인트 + +| 경로 | 용도 | |------|------| -| `npm run dev` 실행 시 포트 충돌 | `lsof -i :5173` 또는 `lsof -i :3001`로 확인 후 프로세스 종료 | -| `EACCES` 권한 오류 | `sudo chown -R $(whoami) wing/` | -| 프론트엔드에서 API 호출 실패 | 백엔드(`localhost:3001`)가 실행 중인지 확인 | -| DB 연결 실패 | `backend/.env`의 DB 연결 정보 확인, PostgreSQL 접근 가능 여부 확인 | -| `MODULE_NOT_FOUND` 오류 | `npm install` 재실행 (온라인) 또는 node_modules 포함 압축본 사용 | +| `GET /` | API 정보 | +| `GET /health` | 헬스 체크 | +| `POST /api/auth/login` | 로그인 | +| `POST /api/auth/logout` | 로그아웃 | +| `GET /api/auth/me` | 현재 사용자 | +| `GET /api/users` | 사용자 목록 | +| `GET /api/roles` | 역할 목록 | +| `GET /api/menus` | 메뉴 목록 | +| `GET /api/settings` | 시스템 설정 | +| `GET /api/audit` | 감사 로그 | +| `GET /api/board` | 게시판 | +| `GET /api/layers` | 레이어 | +| `GET /api/simulation` | 시뮬레이션 | +| `GET /api/hns` | HNS 물질 | +| `GET /api/reports` | 보고서 | +| `GET /api/assets` | 자산 | +| `GET /api/incidents` | 사건/사고 | +| `GET /api/scat` | SCAT 조사 | +| `GET /api/prediction` | 확산 예측 | +| `GET /api/aerial` | 항공 방제 | +| `GET /api/rescue` | 구조 시나리오 | + +--- + +## 10. 트러블슈팅 + +### 의존성 설치 + +| 증상 | 원인 | 해결 | +|------|------|------| +| `npm install` 실패 (ENETUNREACH) | Nexus 프록시 접근 불가 | `.npmrc`의 registry URL 확인, VPN/네트워크 상태 확인 | +| `npm install` 실패 (EAUTH) | Nexus 인증 토큰 만료 | `.npmrc`의 `_auth` 값 갱신 | +| `npm install` 실패 (peer dependency) | 패키지 버전 충돌 | `npm install --legacy-peer-deps` 시도 | +| `MODULE_NOT_FOUND` | node_modules 누락/손상 | `rm -rf node_modules package-lock.json && npm install` | + +### 서버 실행 + +| 증상 | 원인 | 해결 | +|------|------|------| +| 포트 5173/3001 충돌 | 이미 다른 프로세스가 사용 중 | `lsof -i :5173` 또는 `lsof -i :3001`로 PID 확인 후 `kill -9 ` | +| 백엔드 시작 시 DB 연결 실패 | DB 접속 정보 오류 또는 네트워크 | `backend/.env`의 DB 호스트/포트/사용자/비밀번호 확인, `nc -zv <호스트> 5432`로 네트워크 확인 | +| 프론트엔드에서 API 호출 CORS 에러 | 백엔드 CORS 설정 불일치 | `backend/.env`의 `FRONTEND_URL`이 프론트엔드 URL과 일치하는지 확인 | +| `env: tsx: No such file or directory` | tsx 미설치 | `cd backend && npm install` 재실행 | + +### DB + +| 증상 | 원인 | 해결 | +|------|------|------| +| `relation "XXX" does not exist` | 테이블 미생성 | `database/init.sql` 또는 해당 마이그레이션 SQL 실행 | +| PostGIS 함수 에러 | PostGIS 확장 미설치 | `psql -U postgres -d wing -c "CREATE EXTENSION IF NOT EXISTS postgis"` | +| 인코딩 문제 (한글 깨짐) | DB 인코딩 설정 | DB 생성 시 `ENCODING='UTF8' LC_COLLATE='ko_KR.UTF-8'` 지정 | +| seed 실패 | 이미 데이터 존재 또는 FK 제약 | 에러 메시지 확인 후 해당 테이블 데이터 정리 | + +### Git Hooks + +| 증상 | 원인 | 해결 | +|------|------|------| +| pre-commit이 실행되지 않음 | hooksPath 미설정 | `git config core.hooksPath .githooks` | +| pre-commit permission denied | 실행 권한 없음 | `chmod +x .githooks/pre-commit .githooks/commit-msg` | +| TypeScript 에러로 커밋 차단 | 타입 에러 존재 | `npx tsc --noEmit`으로 에러 확인 후 수정 | +| commit-msg 형식 오류 | Conventional Commits 미준수 | `type(scope): subject` 형식 준수 (type: feat, fix, docs 등) | + +### 빌드 + +| 증상 | 원인 | 해결 | +|------|------|------| +| `vite build` 메모리 부족 | Node.js 힙 메모리 부족 | `export NODE_OPTIONS=--max-old-space-size=4096` 후 재시도 | +| `tsc` 타입 에러 | TypeScript strict 모드 위반 | `npx tsc --noEmit`으로 에러 목록 확인 후 수정 | +| 빌드 후 라우팅 404 | SPA 서버 설정 누락 | Nginx `try_files $uri $uri/ /index.html` 설정 확인 | diff --git a/docs/MENU-TAB-GUIDE.md b/docs/MENU-TAB-GUIDE.md index e9bfb70..141a5d5 100644 --- a/docs/MENU-TAB-GUIDE.md +++ b/docs/MENU-TAB-GUIDE.md @@ -1,16 +1,23 @@ -# WING 메뉴 탭 추가 가이드 +# 새 메뉴 탭 추가 가이드 -새로운 메뉴 탭을 추가할 때 필요한 절차를 설명합니다. +새로운 메뉴 탭을 추가하는 전체 절차를 5단계로 설명한다. +board 탭을 기준 템플릿으로 사용하며, 각 단계별 실제 코드 예시를 제공한다. -## 메뉴 시스템 구조 +> **소요 시간**: 약 20~30분 (기본 CRUD 탭 기준) + +--- + +## 메뉴 시스템 아키텍처 ``` -DB: AUTH_SETTING (menu.config JSON) - ↕ GET/PUT /api/menus -Backend: settingsService.ts (DEFAULT_MENU_CONFIG, VALID_MENU_IDS) - ↕ API -Frontend: menuStore.ts → TopBar.tsx (탭 렌더링) - → App.tsx (renderView 라우팅) +[DB] AUTH_SETTING (menu.config JSON) + | + v GET /api/menus +[Backend] settingsService.ts (DEFAULT_MENU_CONFIG, VALID_MENU_IDS) + | + v API +[Frontend] menuStore.ts --> TopBar.tsx (탭 렌더링, enabled && hasPermission 필터링) + --> App.tsx (renderView 라우팅) ``` - **DB**가 메뉴 정의의 단일 소스 (id, label, icon, enabled, order) @@ -18,177 +25,648 @@ Frontend: menuStore.ts → TopBar.tsx (탭 렌더링) - **App.tsx**의 `renderView`가 탭 ID에 따라 뷰 컴포넌트를 매핑 - **admin** 탭은 메뉴 관리 대상에서 제외 (TopBar에서 별도 아이콘 버튼으로 접근) +--- + ## 수정 파일 요약 -| 순서 | 파일 | 작업 | 필수 | -|------|------|------|------| -| 1 | `frontend/src/tabs/{탭명}/components/XxxView.tsx` | 뷰 컴포넌트 생성 | O | -| 2 | `frontend/src/tabs/{탭명}/index.ts` | re-export 생성 | O | -| 3 | `frontend/src/App.tsx` | MainTab 타입 + import + renderView | O | -| 4 | `backend/src/settings/settingsService.ts` | DEFAULT_MENU_CONFIG에 항목 추가 | O | -| 5 | `database/auth_init.sql` | menu.config 초기 JSON에 추가 | O | -| 6 | 관리자 UI | 메뉴 관리에서 활성화 | O | +| 단계 | 파일 | 작업 | +|------|------|------| +| **Step 1** | `frontend/src/tabs/{탭명}/components/{TabName}View.tsx` | 뷰 컴포넌트 생성 | +| | `frontend/src/tabs/{탭명}/services/{tabName}Api.ts` | API 서비스 생성 | +| | `frontend/src/tabs/{탭명}/index.ts` | re-export | +| **Step 2** | `frontend/src/common/types/navigation.ts` | MainTab 타입 추가 | +| | `frontend/src/App.tsx` | import + renderView case 추가 | +| | `frontend/src/common/hooks/useSubMenu.ts` | 서브메뉴 설정 (서브탭이 있는 경우) | +| **Step 3** | `frontend/src/common/constants/featureIds.ts` | FEATURE_ID 등록 | +| **Step 4** | `backend/src/{도메인}/{domain}Router.ts` | 라우터 생성 | +| | `backend/src/{도메인}/{domain}Service.ts` | 서비스 생성 | +| **Step 5** | `backend/src/server.ts` | 라우트 등록 | +| | `backend/src/settings/settingsService.ts` | DEFAULT_MENU_CONFIG 추가 | +| | `database/auth_init.sql` | menu.config 초기 JSON 추가 | +| | `database/migration/NNN_{domain}.sql` | DB 마이그레이션 | -## Step 1: 뷰 컴포넌트 생성 +--- -`frontend/src/tabs/{탭명}/components/` 에 새 뷰 컴포넌트를 생성합니다. +## Step 1: 프론트엔드 탭 패키지 생성 + +### 1-1. 디렉토리 구조 + +``` +frontend/src/tabs/{탭명}/ + components/ + {TabName}View.tsx # 메인 뷰 컴포넌트 + services/ + {tabName}Api.ts # API 서비스 + index.ts # re-export +``` + +### 1-2. 뷰 컴포넌트 (보일러플레이트) + +서브탭이 **없는** 간단한 탭: ```tsx // frontend/src/tabs/monitoring/components/MonitoringView.tsx export function MonitoringView() { return ( -
-
-

실시간 모니터링

- {/* 뷰 콘텐츠 */} +
+
+
+ {/* 헤더 */} +
+
실시간 모니터링
+
+ + {/* 본문 */} +
+

준비 중입니다.

+
+
- ) + ); } ``` -`index.ts`에서 re-export합니다: +서브탭이 **있는** 탭 (board 패턴): + ```tsx -// frontend/src/tabs/monitoring/index.ts -export { MonitoringView } from './components/MonitoringView' +// frontend/src/tabs/monitoring/components/MonitoringView.tsx + +import { useSubMenu } from '@common/hooks/useSubMenu'; + +export function MonitoringView() { + const { activeSubTab } = useSubMenu('monitoring'); + + const renderContent = () => { + switch (activeSubTab) { + case 'dashboard': + return
대시보드 컨텐츠
; + case 'alerts': + return
알림 컨텐츠
; + default: + return
준비 중입니다.
; + } + }; + + return ( +
+
+ {renderContent()} +
+
+ ); +} ``` -기존 탭(`@tabs/prediction`, `@tabs/weather` 등)의 레이아웃 패턴을 참고하세요. -공통 모듈은 `@common/` alias로 import합니다. +### 1-3. API 서비스 (보일러플레이트) -## Step 2: App.tsx 탭 등록 +```ts +// frontend/src/tabs/monitoring/services/monitoringApi.ts -3가지를 수정합니다. +import { api } from '@common/services/api'; + +// ============================================================ +// 인터페이스 +// ============================================================ + +export interface MonitoringItem { + sn: number; + title: string; + status: string; + regDtm: string; +} + +export interface MonitoringListResponse { + items: MonitoringItem[]; + totalCount: number; + page: number; + size: number; +} + +export interface MonitoringListParams { + search?: string; + page?: number; + size?: number; +} + +export interface CreateMonitoringInput { + title: string; + status?: string; +} + +// ============================================================ +// API 함수 +// ============================================================ + +export async function fetchMonitoringList( + params?: MonitoringListParams, +): Promise { + const response = await api.get('/monitoring', { params }); + return response.data; +} + +export async function fetchMonitoringDetail(sn: number): Promise { + const response = await api.get(`/monitoring/${sn}`); + return response.data; +} + +export async function createMonitoring(input: CreateMonitoringInput): Promise<{ sn: number }> { + const response = await api.post<{ sn: number }>('/monitoring', input); + return response.data; +} +``` + +### 1-4. index.ts (re-export) + +```ts +// frontend/src/tabs/monitoring/index.ts + +export { MonitoringView } from './components/MonitoringView'; +``` + +> **참고**: 기존 탭의 index.ts 패턴과 동일하다. 모든 탭은 index.ts에서 메인 뷰만 export한다. + +--- + +## Step 2: navigation.ts에 MainTab 추가 + App.tsx 라우팅 ### 2-1. MainTab 타입에 ID 추가 -```tsx -// frontend/src/App.tsx (line 20) +```ts +// frontend/src/common/types/navigation.ts // Before -export type MainTab = 'prediction' | 'hns' | ... | 'admin' +export type MainTab = 'prediction' | 'hns' | 'rescue' | ... | 'admin'; -// After -export type MainTab = 'prediction' | 'hns' | ... | 'monitoring' | 'admin' +// After (새 탭 ID를 admin 앞에 추가) +export type MainTab = 'prediction' | 'hns' | 'rescue' | ... | 'monitoring' | 'admin'; ``` -### 2-2. 뷰 컴포넌트 import +### 2-2. App.tsx에 import + renderView case 추가 ```tsx -import { MonitoringView } from '@tabs/monitoring' -``` +// frontend/src/App.tsx -### 2-3. renderView switch에 case 추가 +// 1. import 추가 +import { MonitoringView } from '@tabs/monitoring'; -```tsx +// 2. renderView switch에 case 추가 const renderView = () => { switch (activeMainTab) { // ... 기존 case들 ... case 'monitoring': - return - // ... + return ; + // admin은 항상 마지막 + case 'admin': + return ; + default: + return
준비 중입니다...
; } +}; +``` + +### 2-3. 서브메뉴 설정 (서브탭이 있는 경우) + +서브탭이 있다면 `useSubMenu.ts`에 3곳을 수정한다: + +```ts +// frontend/src/common/hooks/useSubMenu.ts + +// 1. subMenuConfigs 에 서브탭 배열 추가 +const subMenuConfigs: Record = { + // ... 기존 설정 ... + monitoring: [ + { id: 'dashboard', label: '대시보드', icon: '📊' }, + { id: 'alerts', label: '알림 관리', icon: '🔔' }, + ], +}; + +// 2. subMenuState 에 기본 서브탭 추가 +const subMenuState: Record = { + // ... 기존 상태 ... + monitoring: 'dashboard', +}; +``` + +서브탭이 **없으면** `null`과 빈 문자열을 설정한다: + +```ts +monitoring: null, // subMenuConfigs +monitoring: '', // subMenuState +``` + +--- + +## Step 3: featureIds.ts에 FEATURE_ID 등록 + +FEATURE_ID는 RBAC 권한 검사와 감사 로그에 사용된다. +형식: `'{메인탭}:{서브탭}'` + +```ts +// frontend/src/common/constants/featureIds.ts + +export const FEATURE_IDS = { + // ... 기존 항목 ... + + // monitoring + 'monitoring:dashboard': '모니터링 대시보드', + 'monitoring:alerts': '알림 관리', +} as const; +``` + +> **동기화 필수**: 여기에 등록한 키는 백엔드의 `AUTH_PERM.RSRC_CD`와 일치해야 한다. +> 서브탭이 없는 탭은 `'{탭명}:main'` 형태로 하나만 등록한다. + +--- + +## Step 4: 백엔드 모듈 생성 + +### 4-1. 디렉토리 구조 + +``` +backend/src/{도메인}/ + {domain}Router.ts # Express 라우터 (요청 파싱, 응답 포맷) + {domain}Service.ts # 비즈니스 로직 + DB 쿼리 +``` + +### 4-2. 라우터 (보일러플레이트) + +```ts +// backend/src/monitoring/monitoringRouter.ts + +import { Router } from 'express'; +import { requireAuth, requirePermission } from '../auth/authMiddleware.js'; +import { AuthError } from '../auth/authService.js'; +import { listItems, getItem, createItem } from './monitoringService.js'; + +const router = Router(); + +// GET /api/monitoring -- 목록 조회 +router.get('/', requireAuth, requirePermission('monitoring', 'READ'), async (req, res) => { + try { + const { search, page, size } = req.query; + const result = await listItems({ + search: search as string | undefined, + page: page ? parseInt(page as string, 10) : undefined, + size: size ? parseInt(size as string, 10) : undefined, + }); + res.json(result); + } catch (err) { + console.error('[monitoring] 목록 조회 오류:', err); + res.status(500).json({ error: '목록 조회 중 오류가 발생했습니다.' }); + } +}); + +// GET /api/monitoring/:sn -- 상세 조회 +router.get('/:sn', requireAuth, requirePermission('monitoring', 'READ'), async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10); + if (isNaN(sn)) { + res.status(400).json({ error: '유효하지 않은 번호입니다.' }); + return; + } + const item = await getItem(sn); + res.json(item); + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }); + return; + } + console.error('[monitoring] 상세 조회 오류:', err); + res.status(500).json({ error: '조회 중 오류가 발생했습니다.' }); + } +}); + +// POST /api/monitoring -- 등록 +router.post('/', requireAuth, requirePermission('monitoring', 'CREATE'), async (req, res) => { + try { + const { title, status } = req.body; + if (!title) { + res.status(400).json({ error: '제목은 필수입니다.' }); + return; + } + const result = await createItem({ + title, + status, + authorId: req.user!.sub, + }); + res.status(201).json(result); + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }); + return; + } + console.error('[monitoring] 등록 오류:', err); + res.status(500).json({ error: '등록 중 오류가 발생했습니다.' }); + } +}); + +export default router; +``` + +### 4-3. 서비스 (보일러플레이트) + +```ts +// backend/src/monitoring/monitoringService.ts + +import { wingPool } from '../db/wingDb.js'; +import { AuthError } from '../auth/authService.js'; + +// ============================================================ +// 인터페이스 +// ============================================================ + +interface MonitoringItem { + sn: number; + title: string; + status: string; + authorId: string; + regDtm: string; +} + +interface ListItemsInput { + search?: string; + page?: number; + size?: number; +} + +interface ListItemsResult { + items: MonitoringItem[]; + totalCount: number; + page: number; + size: number; +} + +interface CreateItemInput { + title: string; + status?: string; + authorId: string; +} + +// ============================================================ +// CRUD 함수 +// ============================================================ + +export async function listItems(input: ListItemsInput): Promise { + const page = input.page && input.page > 0 ? input.page : 1; + const size = input.size && input.size > 0 ? Math.min(input.size, 100) : 20; + const offset = (page - 1) * size; + + let whereClause = "WHERE USE_YN = 'Y'"; + const params: (string | number)[] = []; + let paramIdx = 1; + + if (input.search) { + whereClause += ` AND TITLE ILIKE $${paramIdx}`; + params.push(`%${input.search}%`); + paramIdx++; + } + + // 전체 건수 + const countResult = await wingPool.query( + `SELECT COUNT(*) as cnt FROM MONITORING ${whereClause}`, + params, + ); + const totalCount = parseInt(countResult.rows[0].cnt, 10); + + // 목록 + const listParams = [...params, size, offset]; + const listResult = await wingPool.query( + `SELECT SN, TITLE, STATUS, AUTHOR_ID, REG_DTM + FROM MONITORING + ${whereClause} + ORDER BY REG_DTM DESC + LIMIT $${paramIdx++} OFFSET $${paramIdx}`, + listParams, + ); + + const items: MonitoringItem[] = listResult.rows.map((r: Record) => ({ + sn: r.sn as number, + title: r.title as string, + status: r.status as string, + authorId: r.author_id as string, + regDtm: r.reg_dtm as string, + })); + + return { items, totalCount, page, size }; +} + +export async function getItem(sn: number): Promise { + const result = await wingPool.query( + `SELECT SN, TITLE, STATUS, AUTHOR_ID, REG_DTM + FROM MONITORING + WHERE SN = $1 AND USE_YN = 'Y'`, + [sn], + ); + + if (result.rows.length === 0) { + throw new AuthError('데이터를 찾을 수 없습니다.', 404); + } + + const r = result.rows[0]; + return { + sn: r.sn, + title: r.title, + status: r.status, + authorId: r.author_id, + regDtm: r.reg_dtm, + }; +} + +export async function createItem(input: CreateItemInput): Promise<{ sn: number }> { + if (!input.title || input.title.trim().length === 0) { + throw new AuthError('제목은 필수입니다.', 400); + } + + const result = await wingPool.query( + `INSERT INTO MONITORING (TITLE, STATUS, AUTHOR_ID) + VALUES ($1, $2, $3) + RETURNING SN`, + [input.title.trim(), input.status || 'ACTIVE', input.authorId], + ); + + return { sn: result.rows[0].sn }; } ``` -## Step 3: 백엔드 메뉴 설정 등록 +### 주요 패턴 요약 -`backend/src/settings/settingsService.ts`의 `DEFAULT_MENU_CONFIG` 배열에 항목을 추가합니다. +| 항목 | 패턴 | +|------|------| +| DB Pool | `wingPool` (wing DB) 또는 `authPool` (wing_auth DB) | +| 에러 처리 | `AuthError(message, status)` 활용 | +| 논리 삭제 | `USE_YN = 'Y'/'N'` 컬럼 사용, DELETE 대신 UPDATE | +| 페이징 | `LIMIT $N OFFSET $M`, 기본 size 20, 최대 100 | +| 인증 | `requireAuth` (JWT 검증) + `requirePermission(resource, operation)` | +| 작성자 | `req.user!.sub` (JWT payload에서 USER_ID 추출) | + +--- + +## Step 5: server.ts 라우트 등록 + DB 마이그레이션 + +### 5-1. server.ts에 라우트 등록 + +```ts +// backend/src/server.ts + +// 1. import 추가 +import monitoringRouter from './monitoring/monitoringRouter.js'; + +// 2. 업무 API 라우트 등록 (기존 라우트 아래에) +app.use('/api/monitoring', monitoringRouter); +``` + +> **참고**: import 경로에 `.js` 확장자가 필요하다 (TypeScript ESM 빌드). + +### 5-2. DEFAULT_MENU_CONFIG에 메뉴 항목 추가 + +```ts +// backend/src/settings/settingsService.ts -```typescript const DEFAULT_MENU_CONFIG: MenuConfigItem[] = [ // ... 기존 10개 메뉴 ... + { id: 'incidents', label: '통합조회', icon: '🔍', enabled: true, order: 10 }, { id: 'monitoring', label: '실시간 모니터링', icon: '📡', enabled: true, order: 11 }, -] +]; ``` -`VALID_MENU_IDS`는 `DEFAULT_MENU_CONFIG`에서 자동 파생되므로 별도 수정 불필요합니다. - -```typescript -const VALID_MENU_IDS = DEFAULT_MENU_CONFIG.map(m => m.id) // 자동 포함됨 -``` - -> **주의**: `updateMenuConfig()`은 `VALID_MENU_IDS.length` 개수 전체가 포함되어야 저장을 허용합니다. -> 기존 운영 DB에 새 메뉴가 없는 상태에서도 `getMenuConfig()`의 fallback이 DEFAULT_MENU_CONFIG을 반환하므로 정상 동작합니다. - -## Step 4: DB 초기 데이터 업데이트 - -`database/auth_init.sql`의 `menu.config` 초기 JSON에 새 항목을 추가합니다. +### 5-3. auth_init.sql에 menu.config 초기 JSON 추가 ```sql -INSERT INTO AUTH_SETTING (SETTING_KEY, SETTING_VAL, SETTING_DC, MDFCN_DTM) VALUES -('menu.config', '[ - {"id":"prediction","label":"유출유 확산예측","icon":"🛢️","enabled":true,"order":1}, - ...기존 메뉴들... - {"id":"monitoring","label":"실시간 모니터링","icon":"📡","enabled":true,"order":11} -]', '메뉴 구성 설정', NOW()) -ON CONFLICT (SETTING_KEY) DO NOTHING; +-- database/auth_init.sql 의 menu.config INSERT 문에 새 항목 추가 +-- (신규 설치 시에만 적용. 기존 운영 DB는 관리자 UI에서 관리) ``` -> **참고**: 이 SQL은 신규 설치 시에만 적용됩니다. 기존 운영 DB는 관리자 UI에서 메뉴를 관리합니다. +### 5-4. DB 마이그레이션 작성 -## Step 5: 관리자 메뉴 관리에서 활성화 +```sql +-- database/migration/017_monitoring.sql -코드 배포 후: -1. 관리자 계정으로 로그인 -2. 관리자 패널(⚙️) → 메뉴 관리 탭 -3. 새 메뉴가 목록에 표시됨 -4. 활성/비활성 토글, 순서, 라벨, 아이콘을 설정 -5. "변경사항 저장" 클릭 +-- ============================================================ +-- 마이그레이션 017: 모니터링 (MONITORING) +-- ============================================================ -> 기존 DB에 새 메뉴 ID가 없으면 `getMenuConfig()`가 DEFAULT_MENU_CONFIG fallback을 사용하여 새 메뉴가 자동으로 목록에 나타납니다. +CREATE TABLE IF NOT EXISTS MONITORING ( + SN SERIAL PRIMARY KEY, + TITLE VARCHAR(200) NOT NULL, + STATUS VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + AUTHOR_ID UUID NOT NULL, + USE_YN CHAR(1) NOT NULL DEFAULT 'Y', + REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), + MDFCN_DTM TIMESTAMPTZ, -## 실전 예시: "모니터링" 탭 추가 + CONSTRAINT FK_MONITORING_AUTHOR FOREIGN KEY (AUTHOR_ID) + REFERENCES auth.AUTH_USER(USER_ID), + CONSTRAINT CK_MONITORING_USE CHECK (USE_YN IN ('Y','N')) +); -### 1. 뷰 컴포넌트 생성 +COMMENT ON TABLE MONITORING IS '모니터링'; +COMMENT ON COLUMN MONITORING.USE_YN IS '사용여부 (N=논리삭제)'; + +CREATE INDEX IF NOT EXISTS IDX_MONITORING_REG_DTM ON MONITORING(REG_DTM DESC); +``` + +> 마이그레이션 파일 네이밍: `NNN_{도메인}.sql` (NNN은 다음 순번). +> 자세한 마이그레이션 패턴은 `CRUD-API-GUIDE.md`를 참고한다. + +--- + +## 실전 예시: "monitoring" 탭 추가 전체 흐름 + +### 1단계: 프론트엔드 파일 생성 ```bash -# frontend/src/components/views/MonitoringView.tsx 파일 생성 +mkdir -p frontend/src/tabs/monitoring/components +mkdir -p frontend/src/tabs/monitoring/services ``` -### 2. App.tsx 수정 (3곳) +- `frontend/src/tabs/monitoring/components/MonitoringView.tsx` 생성 +- `frontend/src/tabs/monitoring/services/monitoringApi.ts` 생성 +- `frontend/src/tabs/monitoring/index.ts` 생성 + +### 2단계: 프론트엔드 기존 파일 수정 ```diff -+ import { MonitoringView } from './components/views/MonitoringView' +--- frontend/src/common/types/navigation.ts ++ export type MainTab = '...' | 'monitoring' | 'admin'; -- export type MainTab = 'prediction' | 'hns' | 'rescue' | 'reports' | 'aerial' | 'assets' | 'scat' | 'incidents' | 'board' | 'weather' | 'admin' -+ export type MainTab = 'prediction' | 'hns' | 'rescue' | 'reports' | 'aerial' | 'assets' | 'scat' | 'incidents' | 'board' | 'weather' | 'monitoring' | 'admin' - - const renderView = () => { - switch (activeMainTab) { - // ... +--- frontend/src/App.tsx ++ import { MonitoringView } from '@tabs/monitoring'; + // renderView switch 내: + case 'monitoring': -+ return - case 'admin': - return - } - } ++ return ; + +--- frontend/src/common/hooks/useSubMenu.ts + // subMenuConfigs: ++ monitoring: null, + // subMenuState: ++ monitoring: '', ``` -### 3. settingsService.ts 수정 +### 3단계: FEATURE_ID 등록 ```diff - const DEFAULT_MENU_CONFIG: MenuConfigItem[] = [ - // ... 기존 메뉴들 ... - { id: 'incidents', label: '통합조회', icon: '🔍', enabled: true, order: 10 }, -+ { id: 'monitoring', label: '실시간 모니터링', icon: '📡', enabled: true, order: 11 }, - ] +--- frontend/src/common/constants/featureIds.ts ++ // monitoring ++ 'monitoring:main': '모니터링', ``` -### 4. auth_init.sql 수정 +### 4단계: 백엔드 파일 생성 -menu.config JSON에 새 항목 추가 (신규 설치용) +- `backend/src/monitoring/monitoringRouter.ts` 생성 +- `backend/src/monitoring/monitoringService.ts` 생성 -### 5. 배포 후 관리자 UI에서 활성화 +### 5단계: 백엔드 기존 파일 수정 + DB + +```diff +--- backend/src/server.ts ++ import monitoringRouter from './monitoring/monitoringRouter.js'; ++ app.use('/api/monitoring', monitoringRouter); + +--- backend/src/settings/settingsService.ts ++ { id: 'monitoring', label: '실시간 모니터링', icon: '📡', enabled: true, order: 11 }, +``` + +- `database/migration/017_monitoring.sql` 생성 +- `database/auth_init.sql` 의 menu.config JSON에 항목 추가 + +### 6단계: 검증 + +```bash +cd frontend && npx tsc --noEmit # TypeScript 컴파일 검증 +cd frontend && npx eslint . # ESLint 검증 +cd backend && npx tsc --noEmit # 백엔드 컴파일 검증 +``` + +--- ## 체크리스트 -- [ ] 뷰 컴포넌트 생성 (`frontend/src/components/views/`) -- [ ] `MainTab` 타입 업데이트 (`App.tsx`) -- [ ] import 및 renderView switch case 추가 (`App.tsx`) -- [ ] `DEFAULT_MENU_CONFIG`에 추가 (`settingsService.ts`) -- [ ] `menu.config` 초기 JSON 업데이트 (`auth_init.sql`) -- [ ] TypeScript 컴파일 통과 (`cd frontend && npx tsc --noEmit`) -- [ ] ESLint 통과 (`cd frontend && npx eslint .`) -- [ ] 관리자 메뉴 관리에서 새 메뉴 표시 확인 +### 프론트엔드 +- [ ] `frontend/src/tabs/{탭명}/components/{TabName}View.tsx` 생성 +- [ ] `frontend/src/tabs/{탭명}/services/{tabName}Api.ts` 생성 +- [ ] `frontend/src/tabs/{탭명}/index.ts` re-export 생성 +- [ ] `navigation.ts` MainTab 타입에 새 ID 추가 +- [ ] `App.tsx` import + renderView switch case 추가 +- [ ] `useSubMenu.ts` subMenuConfigs + subMenuState 추가 (서브탭 있는 경우) +- [ ] `featureIds.ts` FEATURE_ID 등록 +- [ ] `npx tsc --noEmit` 통과 +- [ ] `npx eslint .` 통과 + +### 백엔드 +- [ ] `backend/src/{도메인}/{domain}Router.ts` 생성 +- [ ] `backend/src/{도메인}/{domain}Service.ts` 생성 +- [ ] `server.ts` import + `app.use()` 등록 +- [ ] `settingsService.ts` DEFAULT_MENU_CONFIG에 항목 추가 +- [ ] `npx tsc --noEmit` 통과 + +### DB +- [ ] `database/migration/NNN_{domain}.sql` 마이그레이션 작성 +- [ ] `database/auth_init.sql` menu.config 초기 JSON 업데이트 +- [ ] SQL 실행 검증 + +### 배포 후 +- [ ] 관리자 로그인 -> 메뉴 관리에서 새 메뉴 표시 확인 +- [ ] 메뉴 활성화/비활성화 토글 동작 확인 +- [ ] 권한 미부여 사용자에게 메뉴가 보이지 않는지 확인 diff --git a/docs/MOCK-TO-API-GUIDE.md b/docs/MOCK-TO-API-GUIDE.md index 684f201..171e23a 100644 --- a/docs/MOCK-TO-API-GUIDE.md +++ b/docs/MOCK-TO-API-GUIDE.md @@ -1,155 +1,424 @@ -# Mock → API 전환 개발 지침 +# Mock-to-API 전환 가이드 -이 문서는 각 탭의 mock 데이터를 PostgreSQL DB + REST API 기반으로 전환할 때 따라야 할 표준 프로세스를 정의한다. -CRUD API 작성 규칙은 [CRUD-API-GUIDE.md](./CRUD-API-GUIDE.md) 참조. +Mock 데이터(하드코딩 배열, localStorage 등)를 PostgreSQL DB + REST API 기반으로 전환할 때 따라야 할 표준 프로세스를 정의한다. + +> DB 스키마 설계, Service/Router 구현 패턴의 상세 사항은 `CRUD-API-GUIDE.md`를 참조한다. +> 이 문서는 **전환 프로세스 전체 흐름**과 **실전 교훈**에 집중한다. --- -## 1. 전환 프로세스 (탭당 반복) +## 1. 개요 + +### 이 문서의 목적 + +각 탭이 사용하는 mock 데이터를 PostgreSQL DB + REST API로 전환하는 **표준 프로세스**(Step A~J)를 정의한다. 10개 탭의 전환 경험에서 축적된 실전 교훈과 체크리스트를 함께 제공한다. + +### CRUD-API-GUIDE.md와의 관계 + +| 문서 | 범위 | +|------|------| +| CRUD-API-GUIDE.md | DB 설계 규칙, Service/Router 구현 패턴, 권한 모델 | +| **이 문서** | 전환 프로세스 흐름(A~J), 실전 교훈, 현황 관리 | + +전환 작업 시 두 문서를 함께 참조한다. + +--- + +## 2. 전환 프로세스 (Step A ~ J) ### Step A. 브랜치 생성 -`feature/{탭명}-crud` 형식으로 develop에서 분기한다. +develop에서 feature 브랜치를 분기한다. ```bash git checkout develop -git pull +git pull origin develop git checkout -b feature/{탭명}-crud ``` -### Step B. Mock 전수 조사 (필수!) +브랜치 네이밍 예시: `feature/board-crud`, `feature/scat-crud` -탭 디렉토리 전체에서 mock/하드코딩 데이터를 빠짐없이 검색한다. +--- -**검색 키워드**: `mock`, `Mock`, `MOCK`, `sample`, `initial`, `hardcod`, `localStorage`, 인라인 배열 상수 +### Step B. Mock 전수 조사 + +해당 탭 디렉토리에서 mock 데이터를 모두 식별한다. **누락 시 전환 후 런타임 에러가 발생한다.** + +**검색 키워드 및 명령어:** ```bash -grep -rn "mock\|Mock\|MOCK\|sample\|initial\|hardcod\|localStorage" frontend/src/tabs/{탭}/ +# 탭 디렉토리 내 mock 데이터 검색 +grep -rn "mock\|Mock\|MOCK\|sample\|initial\|hardcod\|localStorage" \ + frontend/src/tabs/{탭명}/ + +# 공통 디렉토리에서 해당 탭 관련 데이터 확인 (반드시!) +grep -rn "{탭명}\|{Tab}" frontend/src/common/mock/ +grep -rn "{탭명}\|{Tab}" frontend/src/common/data/ ``` -**체크리스트 형식**으로 정리한다: +**체크리스트 작성 형식:** -``` -□ 파일명:라인 — 변수명 (N건) — 전환방법 -□ components/AssetList.tsx:12 — MOCK_ASSETS (30건) — DB 이전 -□ services/assetService.ts:5 — INITIAL_FILTER — 프론트 상수 유지 -□ hooks/useAsset.ts:88 — localStorage.getItem('draft') — DB 이전 -``` +| 파일 | Mock 종류 | 데이터 내용 | DB 이전 여부 | +|------|-----------|-------------|-------------| +| LeftPanel.tsx:25 | 하드코딩 배열 | 카테고리 목록 30건 | O | +| RightPanel.tsx:88 | localStorage | 사고 상세 임시저장 | O | +| constants.ts:5 | 상수 객체 | 상태별 뱃지 색상 | X (프론트 유지) | +| hooks/useData.ts:12 | useState 초기값 | 빈 배열 + mock 주입 | O | -board 전환 시 mock 참조 누락으로 런타임 에러가 발생한 경험이 있다. 전수 조사를 건너뛰지 말 것. +**교훈 (board 전환 사례):** +board 전환 시 `common/mock/` 디렉토리의 mock 참조를 누락하여 전환 후 런타임 에러가 발생했다. 탭 디렉토리만 검색하면 불충분하며, `common/mock/`과 `common/data/`도 반드시 확인할 것. + +--- ### Step C. 프론트 상수 vs DB 데이터 판단 -조사한 mock/하드코딩 데이터를 아래 기준으로 분류한다. +모든 mock 데이터를 DB로 이전할 필요는 없다. 아래 기준으로 판단한다. -| 분류 | 유지/이전 | 예시 | -|------|-----------|------| -| UI 전용 색상 매핑 | 프론트 상수 유지 | 상태별 뱃지 색, 심각도 색상 | +| 분류 | 판단 | 예시 | +|------|------|------| +| UI 전용 색상/아이콘 매핑 | 프론트 상수 유지 | 상태별 뱃지 색, 심각도 아이콘 | +| 고정된 코드 매핑 (ENUM) | 프론트 상수 유지 | `STATUS_TO_CODE`, `TMPL_CODE_TO_TYPE` | | 레이아웃/뷰 설정 | 프론트 상수 유지 | 기본 페이지 크기, 컬럼 너비 | | 비즈니스 목록 데이터 | DB 이전 | 자산 목록, 사고 목록, 보고서 | -| 검색/필터 대상 데이터 | DB 이전 | 카테고리, 기관명, 상태값 | -| 유형/카테고리 코드 | DB 이전 또는 CHECK 제약 | 자산유형, 오염물질유형 | +| 검색/필터 대상 데이터 | DB 이전 | 카테고리, 기관명, 물질 목록 | +| 사용자 입력/수정 대상 | DB 이전 | 보고서, 시나리오, 조사 결과 | + +**코드 매핑은 프론트에 유지한다 (reportsApi.ts 실전 예시):** + +```typescript +// 코드 <-> 한글 라벨 매핑은 프론트에서 관리 +const STATUS_TO_CODE: Record = { + '완료': 'COMPLETED', + '수행중': 'IN_PROGRESS', + '테스트': 'DRAFT', +}; + +const CODE_TO_STATUS: Record = { + COMPLETED: '완료', + IN_PROGRESS: '수행중', + DRAFT: '테스트', +}; +``` + +--- ### Step D. DB 스키마 설계 + 마이그레이션 -DDL 규칙은 [CRUD-API-GUIDE.md](./CRUD-API-GUIDE.md) (4. DB 설계 규칙) 참조. +마이그레이션 파일 번호는 **017부터** 시작한다 (001~016 사용됨). -1. 기존 테이블 활용 가능 여부 확인 (예: ACDNT, LAYER 등) -2. `database/migration/NNN_{탭명}.sql` 파일 작성 (번호는 기존 파일 다음 순번) -3. 초기 데이터 INSERT (mock 데이터를 SQL로 변환) -4. psql로 원격 DB에 직접 실행 +**파일 규칙:** -```bash -# 원격 wing DB에 마이그레이션 실행 -PGPASSWORD=Wing2026 psql -h 211.208.115.83 -U wing -d wing \ - -f database/migration/NNN_{탭명}.sql +- 파일명: `database/migration/NNN_{탭명}.sql` (예: `017_newtab.sql`) +- 테이블/인덱스 생성: `IF NOT EXISTS` 사용 +- DROP문: `IF EXISTS` 사용 +- 파일 끝에 검증 SELECT 포함 -# 실행 결과 검증 (마이그레이션 파일 끝의 SELECT 확인) +**마이그레이션 파일 템플릿 (009_incidents.sql 기준):** + +```sql +-- ============================================================ +-- 017_newtab.sql -- {탭 한글명} 탭 테이블 + 초기 데이터 +-- ============================================================ + +-- 1. 메인 테이블 +CREATE TABLE IF NOT EXISTS {TABLE_NM} ( + {COL}_SN SERIAL NOT NULL, + {COL}_NM VARCHAR(200) NOT NULL, + {COL}_STTS_CD VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + LAT NUMERIC(9,6), + LNG NUMERIC(10,6), + RGTR_ID UUID, + USE_YN CHAR(1) DEFAULT 'Y', + REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), + MDFCN_DTM TIMESTAMPTZ, + CONSTRAINT PK_{TABLE_NM} PRIMARY KEY ({COL}_SN) +); + +CREATE INDEX IF NOT EXISTS IDX_{TABLE_NM}_REG ON {TABLE_NM}(REG_DTM DESC); +CREATE INDEX IF NOT EXISTS IDX_{TABLE_NM}_STTS ON {TABLE_NM}({COL}_STTS_CD); + +-- 2. 초기 데이터 (mock 데이터 변환) +INSERT INTO {TABLE_NM} ({COL}_NM, {COL}_STTS_CD) VALUES + ('샘플 데이터 1', 'ACTIVE'), + ('샘플 데이터 2', 'ACTIVE') +ON CONFLICT DO NOTHING; + +-- 검증 +SELECT COUNT(*) AS "{TABLE_NM} rows" FROM {TABLE_NM}; ``` -마이그레이션 파일 규칙: -- 모든 DDL에 `IF NOT EXISTS` / `IF EXISTS` 사용 (재실행 안전) -- 파일 끝에 검증 SELECT 포함 +**컬럼 네이밍 규칙:** + +| 용도 | 네이밍 | 타입 | 비고 | +|------|--------|------|------| +| PK | `{약어}_SN` | SERIAL | 자동 증가 | +| 등록자 | `RGTR_ID` | UUID | AUTH_USER.USER_ID 참조 | +| 사용여부 | `USE_YN` | CHAR(1) | 'Y' / 'N' | +| 등록일시 | `REG_DTM` | TIMESTAMPTZ | DEFAULT NOW() | +| 수정일시 | `MDFCN_DTM` | TIMESTAMPTZ | UPDATE 시 갱신 | + +**psql 실행:** + +```bash +PGPASSWORD=Wing2026 psql -h 211.208.115.83 -U wing -d wing \ + -f database/migration/017_newtab.sql +``` + +--- ### Step E. 백엔드 Service + Router 구현 -Service/Router 패턴은 [CRUD-API-GUIDE.md](./CRUD-API-GUIDE.md) (5. Service 레이어 패턴, 6. Router 레이어 패턴) 참조. +2-Layer 구조 (`{domain}Service.ts` + `{domain}Router.ts`)로 구현한다. 상세 패턴은 `CRUD-API-GUIDE.md` 참조. -**HTTP 메소드 규칙** (보안취약점 가이드 준수): +**디렉토리 생성:** -| 메소드 | 용도 | -|--------|------| -| GET | 단순 조회 (민감하지 않은 경우) | -| POST | 생성/수정/삭제 및 복잡한 조회 파라미터 | - -PUT, DELETE, PATCH는 사용하지 않는다. 자세한 내용은 [2. HTTP 메소드 정책](#2-http-메소드-정책-필독) 참조. - -**URL 패턴**: - -| URL | 설명 | -|-----|------| -| `GET /api/{domain}` | 목록 (간단한 파라미터) | -| `GET /api/{domain}/:sn` | 상세 | -| `POST /api/{domain}/list` | 목록 (복잡한 필터 파라미터) | -| `POST /api/{domain}/detail` | 상세 | -| `POST /api/{domain}/create` | 생성 | -| `POST /api/{domain}/update` | 수정 | -| `POST /api/{domain}/delete` | 삭제 | -| `GET /api/{domain}/templates` | 메타데이터/코드 조회 | - -**인증 패턴**: - -```typescript -// 현재 로그인 사용자 UUID 추출 -const userId = req.user!.sub // JWT payload의 사용자 UUID - -// ❌ 절대 사용 금지 (reports 전환 시 실제 발생한 버그) -const user = (req as unknown as { user: { id: string } }).user -const userId = user.id // undefined → DB NOT NULL 제약 위반 +```bash +mkdir -p backend/src/{탭명} ``` -구현 후 `backend/src/server.ts`에 라우터를 등록한다. +**Service 패턴 (incidentsService.ts 기준):** + +```typescript +import { wingPool } from '../db/wingDb.js'; + +// ============================================================ +// 인터페이스 +// ============================================================ +interface ItemRow { + sn: number; + name: string; + sttsCd: string; + regDtm: string; +} + +// ============================================================ +// 목록 조회 +// ============================================================ +export async function listItems(filters: { + status?: string; + search?: string; +}): Promise { + const conditions: string[] = [`USE_YN = 'Y'`]; + const params: (string | number)[] = []; + let idx = 1; + + if (filters.status) { + conditions.push(`STTS_CD = $${idx++}`); + params.push(filters.status); + } + if (filters.search) { + conditions.push(`ITEM_NM ILIKE $${idx++}`); + params.push(`%${filters.search}%`); + } + + const { rows } = await wingPool.query(` + SELECT ITEM_SN AS sn, ITEM_NM AS name, STTS_CD AS "sttsCd", + TO_CHAR(REG_DTM, 'YYYY-MM-DD') AS "regDtm" + FROM ITEM + WHERE ${conditions.join(' AND ')} + ORDER BY REG_DTM DESC + `, params); + return rows; +} + +// ============================================================ +// 생성 +// ============================================================ +export async function createItem(userId: string, input: { + name: string; +}): Promise { + const { rows } = await wingPool.query<{ sn: number }>(` + INSERT INTO ITEM (ITEM_NM, RGTR_ID, REG_DTM) + VALUES ($1, $2, NOW()) + RETURNING ITEM_SN AS sn + `, [input.name, userId]); + return rows[0].sn; +} +``` + +**Router 패턴 (incidentsRouter.ts 기준):** + +```typescript +import { Router } from 'express'; +import { requireAuth } from '../auth/authMiddleware.js'; +import { listItems, createItem, updateItem, deleteItem } from './{탭명}Service.js'; + +const router = Router(); + +// GET /api/{탭명} -- 목록 +router.get('/', requireAuth, async (req, res) => { + try { + const { status, search } = req.query as { status?: string; search?: string }; + const items = await listItems({ status, search }); + res.json(items); + } catch (err) { + console.error('[{탭명}] 목록 조회 오류:', err); + res.status(500).json({ error: '목록 조회 중 오류가 발생했습니다.' }); + } +}); + +// GET /api/{탭명}/:sn -- 상세 +router.get('/:sn', requireAuth, async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10); + if (isNaN(sn)) { + res.status(400).json({ error: '유효하지 않은 번호입니다.' }); + return; + } + // ...상세 조회 로직 + } catch (err) { + console.error('[{탭명}] 상세 조회 오류:', err); + res.status(500).json({ error: '상세 조회 중 오류가 발생했습니다.' }); + } +}); + +// POST /api/{탭명}/create -- 생성 +router.post('/create', requireAuth, async (req, res) => { + try { + const sn = await createItem(req.user!.sub, req.body); + res.json({ sn }); + } catch (err) { + console.error('[{탭명}] 생성 오류:', err); + res.status(500).json({ error: '생성 중 오류가 발생했습니다.' }); + } +}); + +export default router; +``` + +**server.ts 라우터 등록:** + +```typescript +// server.ts 상단 import 추가 +import newtabRouter from './{탭명}/{탭명}Router.js'; + +// API 라우트 -- 업무 섹션에 추가 +app.use('/api/{탭명}', newtabRouter); +``` + +--- ### Step F. 프론트엔드 API 서비스 + 컴포넌트 전환 -1. `frontend/src/tabs/{탭}/services/{탭}Api.ts` 생성 -2. API 응답 타입 (`interface Api{탭명}Item` 등) 정의 -3. API ↔ 프론트 모델 변환 함수 작성 (필요 시) -4. 정적 마스터 데이터 캐싱: 모듈 변수 또는 TanStack Query `staleTime: Infinity` -5. 컴포넌트에서 mock import → API 호출로 교체 -6. `api.post()` 사용 (`api.put()`, `api.delete()` 사용 금지) +**1) API 서비스 파일 생성:** + +파일 위치: `frontend/src/tabs/{탭명}/services/{탭명}Api.ts` ```typescript -// frontend/src/tabs/{탭}/services/{탭}Api.ts -import { api } from '@common/services/api' +import { api } from '@common/services/api'; -export interface Api{탭명}Item { - sn: number - // ... +// ============================================================ +// 타입 +// ============================================================ + +export interface ItemListItem { + sn: number; + name: string; + sttsCd: string; + regDtm: string; } -export async function fetch{탭명}List(params: { - search?: string - page?: number - size?: number -}): Promise<{ items: Api{탭명}Item[]; totalCount: number }> { - const response = await api.post('/{ 탭명}/list', params) - return response.data +export interface CreateItemInput { + name: string; } -// 수정 — POST /update 사용 -export async function update{탭명}(sn: number, data: Update{탭명}Input): Promise { - await api.post('/{탭명}/update', { sn, ...data }) +export interface UpdateItemInput { + name?: string; + sttsCd?: string; } -// 삭제 — POST /delete 사용 -export async function delete{탭명}(sn: number): Promise { - await api.post('/{탭명}/delete', { sn }) +// ============================================================ +// API 함수 +// ============================================================ + +export async function fetchItems(params?: { + status?: string; + search?: string; +}): Promise { + const { data } = await api.get('/{탭명}', { params }); + return data; +} + +export async function fetchItem(sn: number): Promise { + const { data } = await api.get(`/{탭명}/${sn}`); + return data; +} + +export async function createItem(input: CreateItemInput): Promise<{ sn: number }> { + const { data } = await api.post<{ sn: number }>('/{탭명}/create', input); + return data; +} + +export async function updateItem(sn: number, input: UpdateItemInput): Promise { + await api.post('/{탭명}/update', { sn, ...input }); +} + +export async function deleteItem(sn: number): Promise { + await api.post('/{탭명}/delete', { sn }); } ``` +**2) 컴포넌트에서 mock 교체 (실전 예시):** + +```typescript +// Before: mock 데이터 직접 사용 +import { MOCK_ITEMS } from '../mock/mockData'; +const [items, setItems] = useState(MOCK_ITEMS); + +// After: API 호출로 전환 +import { fetchItems } from '../services/{탭명}Api'; +import type { ItemListItem } from '../services/{탭명}Api'; + +const [items, setItems] = useState([]); + +useEffect(() => { + fetchItems().then(setItems).catch(console.error); +}, []); +``` + +**3) API DTO <-> 프론트 모델 변환 (필요 시):** + +기존 컴포넌트의 프론트 모델과 API 응답 형식이 다를 때 변환 함수를 작성한다. + +```typescript +// assetsApi.ts 패턴 -- API 응답을 기존 프론트 모델로 변환 +function toCompat(item: OrgListItem): AssetOrgCompat { + return { + id: item.orgSn, + type: item.orgTp, + name: item.orgNm, + // ...필드 매핑 + }; +} + +export async function fetchOrganizations(): Promise { + const { data } = await api.get('/assets/orgs'); + return data.map(toCompat); +} +``` + +**4) 정적 마스터 데이터 캐싱 패턴:** + +변경 빈도가 낮은 마스터 데이터(템플릿, 카테고리 등)는 모듈 레벨 캐시를 사용한다. + +```typescript +// reportsApi.ts 실전 패턴 +let templatesCache: ApiTemplate[] | null = null; + +export async function fetchTemplates(): Promise { + if (templatesCache) return templatesCache; + const res = await api.get('/reports/templates'); + templatesCache = res.data; + return res.data; +} +``` + +--- + ### Step G. 빌드 검증 +백엔드와 프론트엔드 모두 빌드가 통과해야 한다. + ```bash # 백엔드 TypeScript 컴파일 cd backend && npm run build @@ -160,276 +429,370 @@ cd frontend && npx tsc --noEmit && npx eslint . 빌드/린트 에러가 0건이어야 다음 단계로 진행한다. +--- + ### Step H. 로컬 API 동작 테스트 +백엔드 개발 서버를 실행하고 curl로 CRUD를 순차 검증한다. + ```bash -# 백엔드 개발 서버 시작 +# 1. 백엔드 개발 서버 실행 cd backend && npm run dev -# 로그인 — 쿠키 파일 획득 +# 2. 로그인 (JWT 쿠키 획득) curl -s -c /tmp/wing.cookie -X POST http://localhost:3001/api/auth/login \ -H 'Content-Type: application/json' \ -d '{"account":"admin","password":"admin1234"}' | jq . -# 목록 조회 -curl -s -b /tmp/wing.cookie -X POST http://localhost:3001/api/{탭명}/list \ - -H 'Content-Type: application/json' \ - -d '{"page":1,"size":10}' | jq . +# 3. 목록 조회 +curl -s -b /tmp/wing.cookie http://localhost:3001/api/{탭명} | jq . -# 생성 +# 4. 생성 curl -s -b /tmp/wing.cookie -X POST http://localhost:3001/api/{탭명}/create \ -H 'Content-Type: application/json' \ - -d '{...}' | jq . + -d '{"name":"테스트 항목"}' | jq . -# 수정 +# 5. 수정 curl -s -b /tmp/wing.cookie -X POST http://localhost:3001/api/{탭명}/update \ -H 'Content-Type: application/json' \ - -d '{"sn": 1, ...}' | jq . + -d '{"sn":1,"name":"수정된 항목"}' | jq . -# 삭제 +# 6. 삭제 curl -s -b /tmp/wing.cookie -X POST http://localhost:3001/api/{탭명}/delete \ -H 'Content-Type: application/json' \ - -d '{"sn": 1}' | jq . + -d '{"sn":1}' | jq . + +# 7. 쿠키 파일 정리 +rm /tmp/wing.cookie ``` -CRUD 전체 흐름(생성 → 조회 → 수정 → 삭제 → 필터)을 확인하고 테스트 데이터를 정리한다. +CRUD 전체 흐름(생성 -> 조회 -> 수정 -> 삭제)을 확인하고 테스트 데이터를 정리한다. + +--- ### Step I. Mock 잔여 확인 +전환 완료 후 mock 데이터가 남아 있지 않은지 최종 확인한다. + ```bash -grep -rn "mock\|Mock\|MOCK\|localStorage" frontend/src/tabs/{탭}/ -# → UI 상수(색상, 레이아웃) 외 결과 0건이어야 함 +# 해당 탭 디렉토리에서 mock 잔여 검색 +grep -rn "mock\|Mock\|MOCK\|localStorage" frontend/src/tabs/{탭명}/ + +# 공통 mock/data 디렉토리에서 해당 탭 관련 검색 +grep -rn "{탭명}" frontend/src/common/mock/ +grep -rn "{탭명}" frontend/src/common/data/ ``` -잔여가 있으면 Step F로 돌아가 처리한다. +UI 상수(색상, 레이아웃)를 제외한 결과가 0건이어야 한다. 사용하지 않는 mock 파일은 삭제하고, import도 제거한다. + +--- ### Step J. 커밋 + 푸시 + MR ```bash -# 커밋 (Conventional Commits 형식, 한국어) -git add -p -git commit -m "feat({탭명}): mock 데이터 DB + REST API 전환" +# 변경 파일 확인 +git status -# 푸시 +# 파일별 스테이징 (민감 파일 제외) +git add database/migration/017_{탭명}.sql +git add backend/src/{탭명}/ +git add backend/src/server.ts +git add frontend/src/tabs/{탭명}/ + +# 커밋 (Conventional Commits, 한국어) +git commit -m "feat({탭명}): mock 데이터를 PostgreSQL + REST API로 전환" + +# 푸시 + MR 생성 git push -u origin feature/{탭명}-crud ``` -`feature/{탭명}-crud` → `develop` MR을 Gitea에서 생성한다. -`/push` 또는 `/mr` 스킬 활용 가능. +`feature/{탭명}-crud` -> `develop` MR을 Gitea에서 생성한다. --- -## 2. HTTP 메소드 정책 (필독) +## 3. HTTP 메소드 정책 -한국 보안취약점 점검 가이드에 따라 GET/POST만 사용한다. +### GET/POST만 허용 -### 허용 +한국 보안취약점 점검 가이드 준수를 위해 **PUT, DELETE, PATCH를 사용하지 않는다.** -| 메소드 | 용도 | 예시 | -|--------|------|------| -| GET | 단순 조회 (파라미터가 단순하고 민감하지 않은 경우) | `GET /api/reports`, `GET /api/reports/:sn` | -| POST | 생성/수정/삭제, 복잡한 필터 파라미터 조회 | `POST /api/reports/create`, `POST /api/reports/list` | +| 작업 | HTTP 메소드 | URL 패턴 | +|------|------------|----------| +| 목록 조회 (단순 파라미터) | GET | `/api/{domain}` | +| 상세 조회 | GET | `/api/{domain}/:sn` | +| 목록 조회 (복합 필터) | POST | `/api/{domain}/list` | +| 메타데이터/코드 조회 | GET | `/api/{domain}/templates` | +| 생성 | POST | `/api/{domain}/create` | +| 수정 | POST | `/api/{domain}/update` | +| 삭제 | POST | `/api/{domain}/delete` | -### 금지 +### PUT/DELETE 금지 이유 -| 메소드 | 이유 | -|--------|------| -| PUT | 보안취약점 가이드 위반 | -| DELETE | 보안취약점 가이드 위반 | -| PATCH | 보안취약점 가이드 위반 | +보안취약점 점검 시 PUT/DELETE 메소드가 활성화되어 있으면 취약점으로 판정된다. 모든 변경 작업은 POST로 통일하여 메소드 제한 정책을 적용한다. -### 기존 API 현황 +### POST 마이그레이션 대상 (기존 API) -`boardRouter`, `userRouter`, `roleRouter` 등은 아직 PUT/DELETE를 사용 중이다. -별도 세션에서 POST 패턴으로 마이그레이션 예정. -**신규 탭 전환 시 반드시 POST 패턴을 적용한다.** +아래 모듈은 초기 구현 시 PUT/DELETE를 사용했으며, POST로 전환 예정이다. + +| 모듈 | 현재 사용 중인 메소드 | 파일 | +|------|---------------------|------| +| board | `api.put()`, `api.delete()` | boardApi.ts, boardRouter.ts | +| users | `api.put()`, `api.delete()` | userRouter.ts | +| roles | `api.put()`, `api.delete()` | roleRouter.ts | + +**신규 전환 시 반드시 POST 기반으로 구현한다.** --- -## 3. 전환 시 주의사항 (실전 교훈) +## 4. 실전 교훈 -### 3.1 req.user 접근 패턴 +### 4-1. req.user 접근: req.user!.sub 사용 + +reports 전환 시 `req.user.id`로 접근하여 undefined 버그가 발생했다. JWT 페이로드의 사용자 식별자는 `sub` 필드이다. ```typescript -// 올바른 패턴 -const userId = req.user!.sub // JWT payload의 사용자 UUID +// Before (버그 -- reports 전환 시 실제 발생) +const user = (req as unknown as { user: { id: string } }).user; +const userId = user.id; // undefined -> DB NOT NULL 제약 위반 -// 잘못된 패턴 (런타임 에러 발생) -const user = (req as unknown as { user: { id: string } }).user -const userId = user.id // undefined → DB NOT NULL 제약 위반 +// After (정상) +const userId = req.user!.sub; // UUID (USER_ID) ``` -Reports 전환 시 실제 발생한 버그. `boardRouter.ts`의 패턴을 확인하고 `req.user!.sub`을 사용한다. - -JWT 페이로드 전체 구조: +**JWT 페이로드 구조:** ```typescript interface JwtPayload { - sub: string // 사용자 UUID (USER_ID) - acnt: string // 계정명 (USER_ACNT) - name: string // 사용자명 (USER_NM) - roles: string[] // 역할 코드 목록 + sub: string; // 사용자 UUID (USER_ID) + acnt: string; // 계정명 (USER_ACNT) + name: string; // 사용자명 (USER_NM) + roles: string[]; // 역할 코드 목록 } - -// 사용 예시 -const userId = req.user!.sub // UUID -const userName = req.user!.name // 이름 +// 사용: req.user!.sub, req.user!.name, req.user!.acnt ``` -### 3.2 AUTH_USER 테이블 컬럼명 +--- + +### 4-2. AUTH_USER 컬럼명: USER_NM (NM 아님) + +사용자 이름 컬럼은 `NM`이 아니라 `USER_NM`이다. reports 전환 시 실제 발생한 500 에러. ```sql --- 올바른 컬럼명 -SELECT u.USER_NM as author_name FROM AUTH_USER u +-- Before (500 에러) +SELECT u.NM AS author_name FROM AUTH_USER u WHERE USER_ID = $1; --- 잘못된 컬럼명 (500 에러 발생) -SELECT u.NM as author_name FROM AUTH_USER u +-- After (정상) +SELECT u.USER_NM AS author_name FROM AUTH_USER u WHERE USER_ID = $1; ``` -Reports 전환 시 실제 발생한 버그. 반드시 `USER_NM`을 사용한다. +AUTH_USER 주요 컬럼 참조: -`AUTH_USER` 주요 컬럼 참조: +| 컬럼 | 타입 | 설명 | req.user 대응 | +|------|------|------|--------------| +| USER_ID | UUID PK | 사용자 UUID | `req.user!.sub` | +| USER_ACNT | VARCHAR | 계정명 | `req.user!.acnt` | +| USER_NM | VARCHAR | 사용자명 | `req.user!.name` | +| EMAIL | VARCHAR | 이메일 | - | -| 컬럼 | 타입 | 설명 | -|------|------|------| -| `USER_ID` | UUID PK | 사용자 UUID (`req.user!.sub`과 일치) | -| `USER_ACNT` | VARCHAR | 계정명 (`req.user!.acnt`와 일치) | -| `USER_NM` | VARCHAR | 사용자명 (`req.user!.name`와 일치) | -| `EMAIL` | VARCHAR | 이메일 | +--- -### 3.3 Mock 전수 조사 누락 +### 4-3. Mock 전수 조사 누락 위험 -Board 전환 시 일부 mock 참조를 놓쳐 런타임 에러가 발생했다. -[Step B](#step-b-mock-전수-조사-필수)의 전수 조사를 건너뛰지 말 것. +탭 디렉토리만 검색하면 `common/mock/`, `common/data/`에 숨은 mock 참조를 놓친다. + +```bash +# 불충분 -- 탭 디렉토리만 검색 +grep -rn "mock" frontend/src/tabs/{탭명}/ + +# 반드시 공통 디렉토리도 검색 +grep -rn "{탭명}\|{Tab}" frontend/src/common/mock/ +grep -rn "{탭명}\|{Tab}" frontend/src/common/data/ +``` 특히 다음 위치를 반드시 확인한다: - - 컴포넌트 파일 내 인라인 배열 (`const ITEMS = [{ id: 1, ... }]`) - 커스텀 훅 초기값 (`useState([{ ... }])`) - `localStorage.getItem` / `localStorage.setItem` 호출 - 서비스 파일 내 하드코딩 반환값 -### 3.4 프론트 api.put() / api.delete() 금지 +--- + +### 4-4. api.put() / api.delete() 사용 금지 + +프론트엔드 API 서비스에서 `api.put()`, `api.delete()`를 사용하면 안 된다. ```typescript -// 올바른 POST 패턴 -await api.post(`/reports/update`, { sn, ...input }) -await api.post(`/reports/delete`, { sn }) - -// 금지 — PUT/DELETE 사용 불가 -await api.put(`/reports/${sn}`, input) -await api.delete(`/reports/${sn}`) -``` - -### 3.5 트랜잭션 사용 시점 - -- 단일 테이블 INSERT/UPDATE: 트랜잭션 불필요 -- 다중 테이블 동시 변경 (예: 헤더 + 섹션, 보고서 + 첨부파일): 반드시 트랜잭션 사용 - -```typescript -const client = await wingPool.connect() -try { - await client.query('BEGIN') - - // 헤더 INSERT - const headerResult = await client.query( - 'INSERT INTO REPORT_HDR (...) VALUES ($1, $2) RETURNING HDR_SN', - [...] - ) - const hdrSn = headerResult.rows[0].hdr_sn - - // 섹션 INSERT (헤더 FK 참조) - for (const section of sections) { - await client.query( - 'INSERT INTO REPORT_SECT (HDR_SN, ...) VALUES ($1, ...)', - [hdrSn, ...] - ) - } - - await client.query('COMMIT') - return { hdrSn } -} catch (err) { - await client.query('ROLLBACK') - throw err -} finally { - client.release() +// Before (금지) +export async function updateItem(sn: number, input: UpdateInput): Promise { + await api.put(`/{탭명}/${sn}`, input); } -``` - -### 3.6 에러 처리 일관성 - -모든 라우트 핸들러에서 동일한 에러 처리 구조를 사용한다. - -```typescript -try { - // 비즈니스 로직 -} catch (err) { - if (err instanceof AuthError) { - res.status(err.status).json({ error: err.message }) - return - } - console.error('[{탭명}] 작업 오류:', err) - res.status(500).json({ error: '처리 중 오류가 발생했습니다.' }) -} -``` - -Board의 GET 목록 라우트에서 `AuthError` 분기 누락 이슈가 있었다. -목록 조회처럼 평범해 보이는 라우트도 예외 없이 동일한 구조를 사용한다. - -### 3.7 정적 마스터 데이터 캐싱 - -코드 목록, 기관 목록 등 변경이 드문 마스터 데이터는 매 호출마다 DB 조회하지 않는다. - -```typescript -// 방법 1: 모듈 변수 캐싱 (서버 재시작 시까지 유지) -let cachedOrgList: OrgItem[] | null = null - -export async function getOrgList(): Promise { - if (cachedOrgList) return cachedOrgList - const result = await wingPool.query('SELECT * FROM ORG WHERE USE_YN = $1', ['Y']) - cachedOrgList = result.rows.map(mapOrg) - return cachedOrgList +export async function deleteItem(sn: number): Promise { + await api.delete(`/{탭명}/${sn}`); } -// 방법 2: TanStack Query staleTime 설정 (프론트엔드) -const { data: orgList } = useQuery({ - queryKey: ['orgList'], - queryFn: fetchOrgList, - staleTime: 1000 * 60 * 10, // 10분간 리패치 없음 -}) +// After (정상 -- POST 사용) +export async function updateItem(sn: number, input: UpdateInput): Promise { + await api.post('/{탭명}/update', { sn, ...input }); +} +export async function deleteItem(sn: number): Promise { + await api.post('/{탭명}/delete', { sn }); +} ``` --- -## 4. 탭별 전환 우선순위 +### 4-5. 트랜잭션 사용 시점 -| # | 탭 | 난이도 | 상태 | 비고 | -|---|---|--------|------|------| -| 1 | Reports (보고서) | ★★★ | 완료 | 7개 DB 테이블, 섹션 단위 JSONB | -| 2 | Assets (방제자산) | ★★☆ | 대기 | mock 1파일 집중, ORG 테이블 활용 | -| 3 | Incidents (사고관리) | ★★★ | 대기 | mock 5파일 분산, ACDNT 테이블 존재 | -| 4 | SCAT (해안조사) | ★★★★ | 대기 | 1,084개 세그먼트, 스키마 격차 | -| 5 | Rescue (구조시나리오) | ★★★★ | 대기 | DB 미정의, 시뮬레이션 복잡 | -| 6 | Prediction (확산예측) | ★★★★★ | 대기 | 시뮬레이션 엔진 의존, 부분 API 연동 | +단일 테이블 INSERT/UPDATE는 트랜잭션 없이 처리한다. 다중 테이블에 걸친 작업은 반드시 트랜잭션을 사용한다. -제외: Weather (KHOA API 연동 완료), HNS (API 연동 완료), Board (API 연동 완료), Aerial (스켈레톤 수준) +```typescript +// 단일 테이블 -- 트랜잭션 불필요 +export async function createItem(userId: string, input: CreateInput): Promise { + const { rows } = await wingPool.query<{ sn: number }>( + `INSERT INTO ITEM (ITEM_NM, RGTR_ID) VALUES ($1, $2) RETURNING ITEM_SN AS sn`, + [input.name, userId] + ); + return rows[0].sn; +} + +// 다중 테이블 -- 트랜잭션 필수 (reports 전환 실전 패턴) +export async function createReport(userId: string, input: CreateReportInput): Promise { + const client = await wingPool.connect(); + try { + await client.query('BEGIN'); + + const { rows } = await client.query<{ sn: number }>( + `INSERT INTO REPORT (TITLE, RGTR_ID) VALUES ($1, $2) RETURNING REPORT_SN AS sn`, + [input.title, userId] + ); + const sn = rows[0].sn; + + for (const sect of input.sections) { + await client.query( + `INSERT INTO REPORT_SECT (REPORT_SN, SECT_CD, SECT_DATA) VALUES ($1, $2, $3)`, + [sn, sect.sectCd, JSON.stringify(sect.sectData)] + ); + } + + await client.query('COMMIT'); + return sn; + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } +} +``` --- -## 5. 완료 검증 체크리스트 (탭당) +### 4-6. 에러 처리 일관성 -- [ ] 백엔드 빌드 통과 (`cd backend && npm run build`) -- [ ] 프론트 타입 체크 통과 (`cd frontend && npx tsc --noEmit`) -- [ ] 프론트 ESLint 통과 (`cd frontend && npx eslint .`) -- [ ] API CRUD 전체 테스트 (curl: 생성, 조회, 수정, 삭제, 필터) -- [ ] Mock/localStorage 잔여 0건 (UI 상수 제외) -- [ ] PUT/DELETE 사용 0건 (프론트/백엔드 모두) +Router의 catch 블록에서 인증 에러와 일반 에러를 구분한다. + +```typescript +router.post('/create', requireAuth, async (req, res) => { + try { + const sn = await createItem(req.user!.sub, req.body); + res.json({ sn }); + } catch (err) { + // AuthError 분기 (권한 관련 에러) + if (err instanceof Error && err.message.includes('권한')) { + res.status(403).json({ error: err.message }); + return; + } + console.error('[{탭명}] 생성 오류:', err); + res.status(500).json({ error: '생성 중 오류가 발생했습니다.' }); + } +}); +``` + +프론트엔드에서는 `api.ts`의 응답 인터셉터가 401 처리를 자동으로 수행하므로, 개별 API 서비스에서 401을 별도 처리할 필요는 없다. + +--- + +### 4-7. 정적 마스터 데이터 캐싱 + +변경 빈도가 낮은 마스터 데이터(카테고리, 템플릿, 코드 목록 등)는 모듈 레벨 변수로 캐싱하여 불필요한 API 호출을 줄인다. + +```typescript +// Before (매번 API 호출) +export async function fetchCategories(): Promise { + const { data } = await api.get('/{탭명}/categories'); + return data; +} + +// After (캐싱 적용 -- reportsApi.ts 실전 패턴) +let categoriesCache: Category[] | null = null; + +export async function fetchCategories(): Promise { + if (categoriesCache) return categoriesCache; + const { data } = await api.get('/{탭명}/categories'); + categoriesCache = data; + return data; +} +``` + +--- + +## 5. 전환 현황 + +### 전환 완료 탭 (10개) + +| 탭 | 마이그레이션 | 백엔드 모듈 | API 서비스 | +|---|---|---|---| +| Board (게시판) | 006_board.sql, 012_board_ext.sql | backend/src/board/ | boardApi.ts | +| Reports (보고서) | 007_reports.sql | backend/src/reports/ | reportsApi.ts | +| Assets (방제자산) | 008_assets.sql, 008_assets_seed.sql | backend/src/assets/ | assetsApi.ts | +| Incidents (사고관리) | 009_incidents.sql | backend/src/incidents/ | incidentsApi.ts | +| SCAT (해안조사) | 011_scat.sql | backend/src/scat/ | scatApi.ts | +| HNS (물질분석) | 002_hns_substance.sql, 013_hns_analysis.sql | backend/src/hns/ | hnsApi.ts | +| Prediction (확산예측) | 014_prediction.sql | backend/src/prediction/ | predictionApi.ts | +| Aerial (항공방제) | 015_aerial.sql | backend/src/aerial/ | aerialApi.ts | +| Rescue (구조시나리오) | 016_rescue.sql | backend/src/rescue/ | rescueApi.ts | +| Weather (해양기상) | - (외부 KHOA API) | - | khoaApi.ts, weatherApi.ts | + +### 이미 API화된 공통 모듈 + +| 모듈 | 백엔드 경로 | 비고 | +|------|------------|------| +| 인증 (auth) | backend/src/auth/ | JWT, OAuth | +| 사용자 (users) | backend/src/users/ | CRUD | +| 역할/권한 (roles) | backend/src/roles/ | permResolver 2차원 권한 | +| 메뉴 (menus) | backend/src/menus/ | 메뉴 설정 | +| 감사로그 (audit) | backend/src/audit/ | 자동 기록 | +| 설정 (settings) | backend/src/settings/ | 시스템 설정 | + +### 비고 + +- **Admin 탭**은 공통 모듈(users, roles, menus, settings)로 직접 구현되어 있으며, 별도 전환 대상이 아니다. +- **마이그레이션 번호**: 001~016 사용됨. 새 마이그레이션은 **017부터** 시작한다. +- 새로운 탭을 추가할 때는 이 프로세스(Step A~J)를 그대로 적용한다. + +--- + +## 6. 완료 검증 체크리스트 + +전환 작업 완료 후 커밋 전에 아래 항목을 모두 확인한다. + +- [ ] 백엔드 빌드 성공: `cd backend && npm run build` +- [ ] 프론트 타입 체크 통과: `cd frontend && npx tsc --noEmit` +- [ ] ESLint 통과: `cd frontend && npx eslint .` +- [ ] CRUD 테스트: curl로 생성/조회/수정/삭제 정상 동작 확인 +- [ ] Mock 잔여 0건: `grep -rn "mock\|Mock" frontend/src/tabs/{탭명}/` (UI 상수 제외) +- [ ] PUT/DELETE 사용 0건: `grep -rn "api\.put\|api\.delete" frontend/src/tabs/{탭명}/` +- [ ] 라우터 등록 확인: `server.ts`에 `app.use('/api/{탭명}', ...)` 추가됨 +- [ ] 마이그레이션 실행 확인: psql로 테이블 생성 및 검증 SELECT 통과 - [ ] 커밋 + 푸시 + MR 생성 --- -## 관련 문서 +## 7. 관련 문서 -- [CRUD-API-GUIDE.md](./CRUD-API-GUIDE.md) — CRUD API 표준 (DB 설계, Service/Router 패턴, 권한 모델) -- [COMMON-GUIDE.md](./COMMON-GUIDE.md) — 공통 로직 (인증, 감사 로그, 메뉴, API 통신) -- [MENU-TAB-GUIDE.md](./MENU-TAB-GUIDE.md) — 새 메뉴 탭 추가 절차 +| 문서 | 내용 | +|------|------| +| `CRUD-API-GUIDE.md` | DB 설계 규칙, Service/Router 구현 패턴, 권한 모델 상세 | +| `COMMON-GUIDE.md` | 인증, 감사로그, 메뉴, API 통신, 상태관리 | +| `MENU-TAB-GUIDE.md` | 새 메뉴 탭 추가 절차 (5단계) | +| `DEVELOPMENT-GUIDE.md` | 개발 워크플로우 전체 흐름 (Plan -> Branch -> MR -> Deploy) | diff --git a/docs/README.md b/docs/README.md index 53f349e..dea0ae3 100755 --- a/docs/README.md +++ b/docs/README.md @@ -1,31 +1,7 @@ -# WING-OPS (해양 방제 운영 지원 시스템) +# WING-OPS 해양 오염 사고 대응을 위한 방제 운영 지원 시스템. -유류/HNS 확산 예측, 역추적 분석, 구조 시나리오, 항공 방제, 자산 관리, SCAT 조사, 기상/해상 정보를 통합 제공합니다. - ---- - -## 빠른 시작 - -```bash -# 1. 저장소 복제 -git clone https://gitea.gc-si.dev/gc/wing-ops.git -cd wing-ops - -# 2. Claude Code 세션 열기 -claude - -# 3. 팀 워크플로우 초기화 -/init-project -``` - -`/init-project` 실행 시 자동으로 구성되는 항목: -- `.claude/` 디렉토리 (rules, skills, scripts, settings) -- `.githooks/` (pre-commit, commit-msg 자동 검증) -- Git hooks 경로 설정 (`core.hooksPath`) -- 메모리 디렉토리 초기화 - -> 상세 설치 절차(Docker, DB, 오프라인 환경 등)는 [INSTALL_GUIDE.md](INSTALL_GUIDE.md)를 참조하세요. +유류/HNS 확산 예측, 역추적 분석, 구조 시나리오, 항공 방제, 자산 관리, SCAT 해안평가, 기상/해상 정보를 통합 제공한다. --- @@ -34,13 +10,43 @@ claude | 영역 | 기술 | |------|------| | Frontend | React 19, Vite 7, TypeScript 5.9, Tailwind CSS 3 | -| Backend | Express 4, TypeScript, PostgreSQL (pg) | | 상태 관리 | Zustand (클라이언트), TanStack Query (서버) | -| 지도 | Leaflet + react-leaflet | -| 실시간 | Socket.IO | -| 인증 | JWT (HttpOnly Cookie), Google OAuth | -| DB | PostgreSQL 16 + PostGIS (wing 운영DB + wing_auth 인증DB) | -| CI/CD | Gitea Actions | +| 지도 | MapLibre GL JS 5.x + deck.gl 9.x (GPU 렌더링) | +| UI | lucide-react (아이콘), @dnd-kit (드래그앤드롭), emoji-mart (이모지) | +| 실시간 | Socket.IO Client 4.8 | +| Backend | Express 4, TypeScript, PostgreSQL 16 + PostGIS | +| 인증 | JWT HttpOnly Cookie (`WING_SESSION`) + Google OAuth + RBAC 2차원 권한 | +| 보안 | Helmet, CORS, Rate-limit, 입력 살균 (sanitize) | +| CI/CD | Gitea Actions (main 머지 시 자동 배포) | + +--- + +## 아키텍처 + +``` +[Browser] + | + | React 19 + MapLibre GL JS + deck.gl + | Zustand (로컬 상태) + TanStack Query (서버 상태) + | + |--- HTTP (Axios) ---> [Express 4 API] + |--- WebSocket ------> [Socket.IO] + | + [PostgreSQL 16 + PostGIS] + wing DB (운영) + wing_auth DB (인증) +``` + +### HTTP 정책 + +- **GET / POST only** (보안취약점 가이드 준수, PUT/DELETE 미사용) +- JWT는 HttpOnly Cookie로 전송, Axios 인터셉터에서 자동 처리 +- Helmet CORP cross-origin 설정 (sendBeacon 허용) + +### 권한 체계 + +- RBAC 2차원 권한: **리소스(FEATURE_ID)** x **오퍼레이션(RCUD)** +- `permResolver` 엔진이 AUTH_PERM 테이블의 OPER_CD 기반으로 권한 해석 +- 백엔드: `requireAuth` > `requireRole` > `requirePermission` 미들웨어 체인 --- @@ -48,53 +54,140 @@ claude ``` wing/ -├── frontend/ React 19 + Vite + TypeScript + Tailwind -│ └── src/ -│ ├── App.tsx 메인 (탭 라우팅, 감사 로그) -│ ├── common/ 공통 모듈 (@common/ alias) -│ │ ├── components/ auth/, layer/, layout/, map/, ui/ -│ │ ├── hooks/ useLayers, useSubMenu -│ │ ├── services/ api.ts, authApi.ts, layerService.ts -│ │ ├── store/ authStore, menuStore (Zustand) -│ │ ├── types/ backtrack, boomLine, hns, navigation -│ │ └── utils/ coordinates, geo, sanitize -│ └── tabs/ 탭 단위 패키지 (@tabs/ alias) -│ ├── prediction/ 확산 예측 (OilSpillView, LeftPanel 등) -│ ├── hns/ HNS 분석 (HNSView, HNSSubstanceView 등) -│ ├── rescue/ 구조 시나리오 -│ ├── aerial/ 항공 방제 -│ ├── weather/ 해양 기상 -│ └── ... incidents, board, reports, assets, scat, admin -├── backend/ Express + TypeScript -│ └── src/ -│ ├── server.ts 진입점 + 라우터 등록 -│ ├── auth/ 인증 (JWT, OAuth, 미들웨어) -│ ├── users/ 사용자 관리 -│ ├── roles/ 역할/권한 관리 -│ ├── settings/ 시스템 설정 -│ ├── menus/ 메뉴 설정 -│ ├── audit/ 감사 로그 -│ ├── hns/ HNS 물질 검색 API -│ ├── routes/ 레이어, 시뮬레이션 -│ ├── middleware/ 보안 (입력 살균, rate-limit) -│ └── db/ DB 연결 (wingDb, authDb), seed -├── database/ SQL 스크립트 + 마이그레이션 -├── docs/ 개발 문서 -├── .claude/ 팀 워크플로우 (rules, skills, scripts) -└── .githooks/ Git hooks (pre-commit, commit-msg) +├── frontend/src/ +│ ├── App.tsx MainTab 라우팅, 감사 로그 자동 기록 +│ ├── index.css @tailwind + @import 엔트리 +│ ├── common/ @common/ alias +│ │ ├── components/ auth/, layer/, layout/, map/, ui/ +│ │ ├── hooks/ useFeatureTracking, useLayers, useSubMenu +│ │ ├── services/ api.ts, authApi.ts, layerService.ts +│ │ ├── store/ authStore, menuStore (Zustand) +│ │ ├── types/ backtrack, boomLine, hns, navigation +│ │ ├── utils/ cn, coordinates, geo, sanitize +│ │ ├── styles/ base.css, components.css, wing.css (@layer) +│ │ └── constants/ featureIds.ts (FEATURE_ID 상수 체계) +│ └── tabs/ @tabs/ alias (11개 탭) +│ ├── prediction/ 유류 확산 예측 +│ ├── hns/ HNS 분석 +│ ├── rescue/ 구조 시나리오 +│ ├── aerial/ 항공 방제 +│ ├── weather/ 해양 기상 +│ ├── incidents/ 사건/사고 관리 +│ ├── board/ 게시판 +│ ├── reports/ 보고서 +│ ├── assets/ 자산 관리 +│ ├── scat/ Pre-SCAT 해안평가 +│ └── admin/ 관리자 +├── backend/src/ +│ ├── server.ts Express 진입점 + 보안 미들웨어 +│ ├── auth/ JWT, OAuth, 미들웨어 +│ ├── users/, roles/ 사용자/역할 관리, permResolver +│ ├── settings/, menus/ 시스템 설정, 메뉴 +│ ├── audit/ 감사 로그 +│ ├── board/, hns/, reports/ 업무 도메인 +│ ├── assets/, incidents/ 업무 도메인 +│ ├── scat/, prediction/ 업무 도메인 +│ ├── aerial/, rescue/ 업무 도메인 +│ ├── routes/ layers, simulation +│ ├── middleware/ security (sanitize, rate-limit) +│ └── db/ wingDb, authDb, seed +├── database/ +│ ├── init.sql wing DB 스키마 +│ ├── auth_init.sql wing_auth DB 스키마 +│ └── migration/ 001~016 마이그레이션 +└── .gitea/workflows/ CI/CD 파이프라인 ``` +### Path Alias + +| Alias | 경로 | +|-------|------| +| `@common/*` | `src/common/*` | +| `@tabs/*` | `src/tabs/*` | + --- -## 개발 환경 실행 +## 탭 구성 (11개) + +| 탭 | 패키지 | 설명 | +|---|---|---| +| prediction | 확산 예측 | 유류 확산 모델 시뮬레이션 (KOSPS/POSEIDON/OpenDrift 앙상블) | +| hns | HNS 분석 | 위험유해물질 거동 분석 + 물질 DB | +| rescue | 구조 시나리오 | 해상 구조 시나리오 모의 | +| aerial | 항공 방제 | 항공 탐색, 드론, CCTV, 위성 | +| weather | 해양 기상 | 실시간 해양기상 오버레이 | +| incidents | 사건/사고 | 사건 이력 관리, 미디어 | +| board | 게시판 | 공지/자료 게시판 | +| reports | 보고서 | 오염보고서 생성기 | +| assets | 자산 관리 | 방제 장비/기관/담당자/선박보험 | +| scat | 해안평가 | Pre-SCAT 해안 구간 조사 | +| admin | 관리자 | 사용자/역할/권한/메뉴/설정 | + +--- + +## 백엔드 API 라우트 + +모든 API는 `/api/` 접두사 하위에 등록된다. + +### 인증/관리 + +| 라우트 | 설명 | +|--------|------| +| `/api/auth` | JWT 로그인/로그아웃, OAuth, 토큰 갱신 | +| `/api/users` | 사용자 CRUD | +| `/api/roles` | 역할/권한 관리, RCUD 매트릭스 | +| `/api/settings` | 시스템 설정 | +| `/api/menus` | 메뉴 설정 | +| `/api/audit` | 감사 로그 | + +### 업무 도메인 + +| 라우트 | 설명 | +|--------|------| +| `/api/board` | 게시판 CRUD + 첨부파일 | +| `/api/hns` | HNS 물질 검색/분석 | +| `/api/reports` | 보고서 템플릿/생성/조회 | +| `/api/assets` | 자산(장비/기관/선박/보험) 관리 | +| `/api/incidents` | 사건/사고 이력 관리 | +| `/api/scat` | SCAT 구역/구간/조사 | +| `/api/prediction` | 확산 분석/역추적/오일펜스 | +| `/api/aerial` | 항공 미디어/CCTV/위성 | +| `/api/rescue` | 구난 작전/시나리오 | + +### 지도/시뮬레이션 + +| 라우트 | 설명 | +|--------|------| +| `/api/layers` | 지도 레이어 데이터 | +| `/api/simulation` | 시뮬레이션 실행/결과 | + +--- + +## 데이터베이스 + +| DB | 용도 | 비고 | +|----|------|------| +| wing | 운영 데이터 | PostgreSQL 16 + PostGIS | +| wing_auth | 인증/권한 | 동일 서버, 별도 DB | + +마이그레이션 파일: `database/migration/001~016` + +--- + +## 퀵스타트 ### 사전 요구사항 -- Node.js 20+ (`.node-version`, fnm 사용) -- PostgreSQL 16+ (운영 DB에 직접 연결) + +- Node.js 20+ (`.node-version` 파일, fnm 사용) +- PostgreSQL 16+ (운영 DB 접근 가능해야 함) ### 실행 ```bash +# 저장소 복제 +git clone https://gitea.gc-si.dev/gc/wing-ops.git +cd wing-ops + # 백엔드 (터미널 1) cd backend && npm install && npm run dev # localhost:3001 @@ -113,101 +206,19 @@ cd backend && npx tsc --noEmit cd frontend && npx eslint . # 프로덕션 빌드 -cd frontend && npm run build # dist/ 생성 -cd backend && npm run build # dist/ 생성 +cd frontend && npm run build # tsc -b && vite build → dist/ +cd backend && npm run build # tsc → dist/ ``` ---- +### 환경 변수 -## 개발 워크플로우 - -``` -계획 → 브랜치 → 개발 → 커밋/푸시 → develop MR → main PR → 자동 배포 -``` - -### Claude Code 기반 개발 절차 - -| 단계 | 작업 | Claude 스킬 | -|------|------|-------------| -| 1. 계획 | 3개+ 파일 수정 시 Claude가 Plan Mode 진입 | (자동) | -| 2. 브랜치 | `feature/기능명` 으로 develop에서 분기 | - | -| 3. 개발 | Claude가 코드 작성 + 타입/린트 검증 | - | -| 4. 커밋/푸시 | pre-commit 자동 검증 후 푸시 | `/push` | -| 5. develop MR | feature → develop MR 생성 | `/mr` | -| 6. 릴리즈 | develop → main PR 생성 | `/release` | -| 7. 배포 | main 머지 시 Gitea Actions 자동 배포 | - | - -> 상세 워크플로우는 [DEVELOPMENT-GUIDE.md](DEVELOPMENT-GUIDE.md)를 참조하세요. - ---- - -## 문서 안내 - -### 개발 가이드 - -| 문서 | 설명 | 대상 | -|------|------|------| -| [DEVELOPMENT-GUIDE.md](DEVELOPMENT-GUIDE.md) | 개발 워크플로우 전체 흐름 (Plan → Branch → MR → Deploy) | 모든 개발자 | -| [COMMON-GUIDE.md](COMMON-GUIDE.md) | 공통 로직 개발 가이드 (인증, 감사로그, 메뉴, API 통신, 상태 관리) | 탭 개발자 | -| [MENU-TAB-GUIDE.md](MENU-TAB-GUIDE.md) | 새 메뉴 탭 추가 절차 (5단계) | 탭 개발자 | - -### 운영 가이드 - -| 문서 | 설명 | 대상 | -|------|------|------| -| [INSTALL_GUIDE.md](INSTALL_GUIDE.md) | 설치 매뉴얼 (온라인/오프라인, DB 초기화) | 운영/인프라 | -| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | 모든 개발자 | - -### 코드 컨벤션 (.claude/rules/) - -| 규칙 | 설명 | -|------|------| -| `team-policy.md` | 보안/품질 정책 (필수 준수) | -| `git-workflow.md` | 브랜치/커밋/MR 규칙 | -| `code-style.md` | TypeScript/React 코드 스타일 | -| `naming.md` | 네이밍 규칙 | -| `testing.md` | 테스트 규칙 | - ---- - -## 공통 기능 요약 - -개별 탭 개발 시 아래 공통 기능을 활용합니다. -상세 사용법은 [COMMON-GUIDE.md](COMMON-GUIDE.md)를 참조하세요. - -| 기능 | 프론트엔드 | 백엔드 | 상세 | -|------|-----------|--------|------| -| 인증/인가 | `authStore`, `api.ts` (자동 쿠키) | `requireAuth`, `requireRole` | [COMMON-GUIDE.md #1](COMMON-GUIDE.md#1-인증인가) | -| 감사 로그 | 탭 이동 자동 기록 (sendBeacon) | `audit/` 모듈 | [COMMON-GUIDE.md #2](COMMON-GUIDE.md#2-감사-로그-audit-log) | -| 메뉴 시스템 | `menuStore` | `menus/`, `settings/` | [COMMON-GUIDE.md #3](COMMON-GUIDE.md#3-메뉴-시스템) | -| API 통신 | `api.ts` (Axios + 인터셉터) | Express 라우터 | [COMMON-GUIDE.md #4](COMMON-GUIDE.md#4-api-통신-패턴) | -| 상태 관리 | Zustand, TanStack Query | - | [COMMON-GUIDE.md #5](COMMON-GUIDE.md#5-상태-관리) | - ---- - -## Claude Code 스킬 - -| 스킬 | 설명 | -|------|------| -| `/push` | 커밋 + 푸시 (한 번에) | -| `/mr` | 커밋 + 푸시 + develop MR (한 번에) | -| `/release` | develop → main 릴리즈 MR | -| `/create-mr` | MR만 생성 (세부 옵션) | -| `/fix-issue` | Gitea 이슈 분석 + 수정 브랜치 생성 | -| `/sync-team-workflow` | 팀 워크플로우 동기화 | -| `/changelog` | CHANGELOG.md 갱신 | - ---- - -## 환경 변수 - -### 프론트엔드 (.env) +프론트엔드 (`frontend/.env`): ``` VITE_API_URL=http://localhost:3001/api VITE_GOOGLE_CLIENT_ID=your-google-client-id ``` -### 백엔드 (.env) +백엔드 (`backend/.env`): ``` PORT=3001 NODE_ENV=development @@ -216,7 +227,7 @@ AUTH_DB_HOST=localhost AUTH_DB_PORT=5432 AUTH_DB_NAME=wing_auth AUTH_DB_USER=wing_auth -AUTH_DB_PASSWORD=WingAuth!2026 +AUTH_DB_PASSWORD=your-password GOOGLE_CLIENT_ID=your-google-client-id ``` @@ -230,18 +241,44 @@ GOOGLE_CLIENT_ID=your-google-client-id | 백엔드 API | https://wing-demo.gc-si.dev/api/ | | CI/CD | Gitea Actions (main 머지 시 자동 배포) | -배포 파이프라인 상세는 [DEVELOPMENT-GUIDE.md #7](DEVELOPMENT-GUIDE.md#7-자동-배포)을 참조하세요. +--- + +## 개발 워크플로우 + +``` +브랜치 분기 → 개발 → 커밋/푸시 → develop MR → main PR → 자동 배포 +``` + +- `main`: 배포 가능한 안정 브랜치 (보호됨) +- `develop`: 개발 통합 브랜치 (보호됨) +- `feature/`, `bugfix/`, `hotfix/`: 작업 브랜치 +- 직접 push 금지, MR을 통해서만 머지 --- -## 문서 최신화 규칙 +## 문서 안내 -공통 기능(인증, 감사로그, 메뉴 시스템, API 통신 등)을 추가/변경할 때: -1. 해당 기능 코드 구현 -2. `docs/COMMON-GUIDE.md` 최신화 (필수) -3. 필요 시 `CLAUDE.md` 프로젝트 구조 갱신 +### 개발 가이드 -매 기능 개발 완료 시: -``` -Claude에게: "memory 파일 최신화해줘" -``` +| 문서 | 설명 | +|------|------| +| [DEVELOPMENT-GUIDE.md](DEVELOPMENT-GUIDE.md) | 개발 워크플로우 전체 흐름 | +| [COMMON-GUIDE.md](COMMON-GUIDE.md) | 공통 로직 (인증, 감사로그, 메뉴, API, 상태관리) | +| [MENU-TAB-GUIDE.md](MENU-TAB-GUIDE.md) | 새 메뉴 탭 추가 절차 | + +### 운영 가이드 + +| 문서 | 설명 | +|------|------| +| [INSTALL_GUIDE.md](INSTALL_GUIDE.md) | 설치 매뉴얼 (온라인/오프라인, DB 초기화) | +| [CHANGELOG.md](CHANGELOG.md) | 전체 변경 이력 | + +### 코드 컨벤션 (`.claude/rules/`) + +| 규칙 | 설명 | +|------|------| +| `team-policy.md` | 보안/품질 정책 | +| `git-workflow.md` | 브랜치/커밋/MR 규칙 | +| `code-style.md` | TypeScript/React 코드 스타일 | +| `naming.md` | 네이밍 규칙 | +| `testing.md` | 테스트 규칙 | diff --git a/docs/_backup_20260301/CHANGELOG.md b/docs/_backup_20260301/CHANGELOG.md new file mode 100644 index 0000000..0f92471 --- /dev/null +++ b/docs/_backup_20260301/CHANGELOG.md @@ -0,0 +1,42 @@ +# 변경 이력 + +## [Unreleased] + +### 2026-03-01 + +## [2026-03-01] Phase 4 완료 — 나머지 6개 탭 Mock → API 전환 + +### Added +- SCAT: 구역/구간 조회 3 API + PostGIS (011_scat.sql) +- Board: 매뉴얼 CRUD + 첨부파일 API (012_board_ext.sql) +- HNS: 분석 CRUD 5 API (013_hns_analysis.sql) +- Prediction: 분석/역추적/오일펜스 7 API (014_prediction.sql) +- Aerial: 미디어/CCTV/위성 6 API + PostGIS (015_aerial.sql) +- Rescue: 구난 작전/시나리오 3 API + JSONB (016_rescue.sql) + +### Fixed +- Prediction 분석 상세 500 에러 (ACDNT_WEATHER 컬럼명 불일치) +- 시뮬레이션 API CORS 에러 (localhost 하드코딩 → api 인스턴스) + +### Changed +- 하드코딩 URL 환경변수 전환 (GeoServer, CORS, CSP 등) +- backtrackMockData.ts 삭제 + +### 2026-02-28 +- feat(reports): 보고서 탭 localStorage → DB/API 전환 (MR#31) + - DB 7개 테이블 (REPORT_TMPL, REPORT_TMPL_SECT, REPORT_ANALYSIS_CTGR, REPORT_CTGR_SECT, REPORT, REPORT_SECT_DATA 등) + - 백엔드 CRUD API (GET/POST only 패턴) + - 프론트 4개 컴포넌트 API 연동 (localStorage 제거) +- refactor(backend): SQLite → PostgreSQL 마이그레이션 + wing DB 연결 (MR#22) +- feat: Phase 5 View 분할 + RBAC 2차원 권한 + 게시판 CRUD API 연동 (MR#29) + - 대형 View 서브탭 분할 + FEATURE_ID 체계 도입 + - RBAC 오퍼레이션 기반 2차원 권한 시스템 (permResolver, AUTH_PERM OPER_CD) + - 게시판 CRUD API (boardService/Router) + 프론트 연동 +- refactor(frontend): 공통 모듈 common/ 분리 + 탭 단위 패키지 구조 전환 (MR#21) +- docs: MOCK-TO-API-GUIDE.md 작성 (Mock→API 전환 개발 지침) +- docs: CRUD-API-GUIDE.md 작성 (RBAC 기반 CRUD API 표준 가이드) +- chore: 팀 워크플로우 v1.4.0 동기화 (서브에이전트 3종 + 정책) +- policy: HTTP 메소드 제한 결정 (GET/POST only — 보안취약점 가이드 준수) + +### 2026-02-27 +- chore: 팀 워크플로우 v1.3.0 초기화 diff --git a/docs/_backup_20260301/COMMON-GUIDE.md b/docs/_backup_20260301/COMMON-GUIDE.md new file mode 100644 index 0000000..b43ec70 --- /dev/null +++ b/docs/_backup_20260301/COMMON-GUIDE.md @@ -0,0 +1,502 @@ +# WING-OPS 공통 로직 개발 가이드 + +개별 탭 개발자가 공통 영역 구현을 참조하여 연동할 수 있도록 정리한 문서입니다. +공통 기능을 추가/변경할 때 반드시 이 문서를 최신화하세요. + +--- + +## 1. 인증/인가 + +### 개요 +JWT 기반 세션 인증. HttpOnly 쿠키(`WING_SESSION`)로 토큰을 관리하며, 프론트엔드에서는 Zustand `authStore`로 상태를 관리합니다. + +### 권한 모델: 리소스 × 오퍼레이션 (RBAC) + +**2차원 권한 모델**: 리소스 트리(상속) × 오퍼레이션(RCUD, 플랫) + +``` +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 +``` + +#### 오퍼레이션 코드 + +| OPER_CD | 설명 | 비고 | +|---------|------|------| +| `READ` | 조회/열람 | 목록, 상세 조회 | +| `CREATE` | 생성 | 새 데이터 등록 | +| `UPDATE` | 수정 | 기존 데이터 변경 | +| `DELETE` | 삭제 | 데이터 삭제 | +| `MANAGE` | 관리 | 관리자 설정 (확장용) | +| `EXPORT` | 내보내기 | 다운로드/출력 (확장용) | + +#### 상속 규칙 + +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) + +### 백엔드 + +#### 미들웨어 + +```typescript +import { requireAuth, requireRole, requirePermission } from '../auth/authMiddleware.js' + +// 인증만 필요한 라우트 +router.use(requireAuth) + +// 역할 기반 (관리 API용) +router.use(requireRole('ADMIN')) + +// 리소스×오퍼레이션 기반 (일반 비즈니스 API용) +router.post('/notice/list', requirePermission('board:notice', 'READ'), handler) +router.post('/notice/create', requirePermission('board:notice', 'CREATE'), handler) +router.post('/notice/update', requirePermission('board:notice', 'UPDATE'), handler) +router.post('/notice/delete', requirePermission('board:notice', 'DELETE'), handler) +``` + +`requirePermission`은 요청당 1회만 DB 조회하고 `req.resolvedPermissions`에 캐싱합니다. + +#### JWT 페이로드 (req.user) +`requireAuth` 통과 후 `req.user`에 담기는 정보: +```typescript +interface JwtPayload { + sub: string // 사용자 UUID (USER_ID) + acnt: string // 계정명 (USER_ACNT) + name: string // 사용자명 (USER_NM) + roles: string[] // 역할 코드 목록 (ADMIN, MANAGER, USER, VIEWER) +} +``` + +#### 라우터 패턴 (CRUD 구조) +```typescript +// backend/src/[모듈]/[모듈]Router.ts +import { Router } from 'express' +import { requireAuth, requirePermission } from '../auth/authMiddleware.js' + +const router = Router() +router.use(requireAuth) + +// 리소스별 CRUD 엔드포인트 +router.post('/list', requirePermission('module:sub', 'READ'), listHandler) +router.post('/detail', requirePermission('module:sub', 'READ'), detailHandler) +router.post('/create', requirePermission('module:sub', 'CREATE'), createHandler) +router.post('/update', requirePermission('module:sub', 'UPDATE'), updateHandler) +router.post('/delete', requirePermission('module:sub', 'DELETE'), deleteHandler) + +export default router +``` + +### 프론트엔드 + +#### authStore (Zustand) +```typescript +import { useAuthStore } from '@common/store/authStore' + +const { user, isAuthenticated, hasPermission, logout } = useAuthStore() + +// 사용자 정보 +user?.id // UUID +user?.name // 이름 +user?.roles // ['ADMIN', 'USER'] +user?.permissions // { 'prediction': ['READ','CREATE','UPDATE','DELETE'], ... } + +// 권한 확인 (리소스 × 오퍼레이션) +hasPermission('prediction') // READ 확인 (기본값) +hasPermission('prediction', 'READ') // 명시적 READ 확인 +hasPermission('board:notice', 'CREATE') // 공지사항 생성 권한 +hasPermission('board:notice', 'DELETE') // 공지사항 삭제 권한 + +// 하위 호환: operation 생략 시 'READ' 기본값 +hasPermission('admin') // === hasPermission('admin', 'READ') +``` + +#### API 클라이언트 +```typescript +import { api } from '@common/services/api' + +// withCredentials: true 설정으로 JWT 쿠키 자동 포함 +const response = await api.post('/your-endpoint/list', params) +const response = await api.post('/your-endpoint/create', data) + +// 401 응답 시 자동 로그아웃 처리 (인터셉터) +// 403 응답 시 권한 부족 (requirePermission 미들웨어) +``` + +--- + +## 2. 감사 로그 (Audit Log) + +### 개요 +사용자 행동을 추적하는 감사 로그 시스템. 현재 탭 이동 로그를 자동 기록하며, 향후 API 호출 로깅으로 확장 가능합니다. + +### 자동 기록 (탭 이동) +`App.tsx`의 `useEffect`에서 `activeMainTab` 변경을 감지하여 `navigator.sendBeacon`으로 자동 전송합니다. 개별 탭 개발자는 별도 작업이 필요 없습니다. + +```typescript +// frontend/src/App.tsx (자동 적용, 수정 불필요) +import { API_BASE_URL } from '@common/services/api' + +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]) +``` + +### 수동 기록 (향후 확장) +특정 작업에 대해 명시적으로 감사 로그를 기록하려면: + +```typescript +import { API_BASE_URL } from '@common/services/api' + +const blob = new Blob( + [JSON.stringify({ action: 'ADMIN_ACTION', detail: '사용자 승인' })], + { type: 'text/plain' } +) +navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob) +``` + +### 감사 로그 테이블 구조 (AUTH_AUDIT_LOG) + +| 컬럼 | 타입 | 용도 | 현재 사용 | +|------|------|------|-----------| +| LOG_SN | SERIAL PK | 로그 순번 | O | +| USER_ID | UUID | 사용자 ID | O | +| ACTION_CD | VARCHAR(30) | 액션 코드 | O (TAB_VIEW) | +| ACTION_DTL | VARCHAR(100) | 액션 상세 (탭ID 등) | O | +| HTTP_METHOD | VARCHAR(10) | GET/POST/PUT/DELETE | - (향후) | +| CRUD_TYPE | VARCHAR(10) | SELECT/INSERT/UPDATE/DELETE | - (향후) | +| REQ_URL | VARCHAR(500) | 요청 URL | - (향후) | +| REQ_DTM | TIMESTAMPTZ | 요청 시각 | O | +| RES_DTM | TIMESTAMPTZ | 응답 완료 시각 | - (향후) | +| RES_STATUS | SMALLINT | HTTP 상태 코드 | - (향후) | +| RES_SIZE | INTEGER | 응답 데이터 크기(bytes) | - (향후) | +| IP_ADDR | VARCHAR(45) | 클라이언트 IP | O | +| USER_AGENT | VARCHAR(500) | 브라우저 정보 | O | +| EXTRA | JSONB | 추가 메타데이터 | - (향후) | + +### ACTION_CD 코드 체계 +| 코드 | 설명 | +|------|------| +| TAB_VIEW | 상단 탭 이동 | +| API_CALL | API 호출 (향후) | +| LOGIN | 로그인 (향후) | +| LOGOUT | 로그아웃 (향후) | +| ADMIN_ACTION | 관리자 작업 (향후) | + +### 관리자 조회 API +```typescript +// frontend/src/services/authApi.ts +import { fetchAuditLogs } from '../services/authApi' + +const result = await fetchAuditLogs({ + page: 1, + size: 50, + actionCd: 'TAB_VIEW', + from: '2026-02-28', + to: '2026-02-28', +}) +// result: { items: AuditLogItem[], total: number, page: number, size: number } +``` + +--- + +## 3. 메뉴 시스템 + +### 개요 +DB 기반 동적 메뉴 구성. 관리자가 메뉴 표시 여부/순서를 설정하면 모든 사용자에게 반영됩니다. +새 메뉴 탭 추가 시 `docs/MENU-TAB-GUIDE.md`를 참조하세요. + +### 메뉴 상태 (menuStore) +```typescript +// frontend/src/store/menuStore.ts +import { useMenuStore } from '../store/menuStore' + +const { menus, loadMenuConfig } = useMenuStore() + +// menus: MenuConfigItem[] — 활성화되고 정렬된 메뉴 목록 +// menus[0].id → 'prediction' +// menus[0].label → '유출유 확산예측' +// menus[0].enabled → true +``` + +### 메뉴 설정 저장소 +- DB: `AUTH_SETTING` 테이블의 `menu.config` 키 (JSON 배열) +- 백엔드: `backend/src/settings/settingsService.ts`의 `DEFAULT_MENU_CONFIG` +- API: `GET/PUT /api/menus` + +--- + +## 4. API 통신 패턴 + +### Axios 인스턴스 설정 +```typescript +// frontend/src/services/api.ts +export const api = axios.create({ + baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3001/api', + withCredentials: true, // JWT 쿠키 자동 포함 + timeout: 30000, +}) +``` + +### 새 API 서비스 작성 패턴 +```typescript +// frontend/src/services/newService.ts +import { api } from './api' + +export interface MyData { + id: string + name: string +} + +export async function fetchMyData(): Promise { + const response = await api.get('/my-endpoint') + return response.data +} + +export async function createMyData(data: Omit): Promise { + const response = await api.post('/my-endpoint', data) + return response.data +} +``` + +### 에러 처리 +- 401 응답: `api.ts` 인터셉터가 자동으로 로그아웃 처리 +- 비즈니스 에러: `response.data.error` 메시지로 사용자에게 안내 +- 백엔드에서 `AuthError` 사용 시 적절한 HTTP 상태 코드와 메시지 반환 + +--- + +## 5. 상태 관리 + +### Zustand (클라이언트 상태) +```typescript +// frontend/src/store/newStore.ts +import { create } from 'zustand' + +interface MyState { + items: string[] + addItem: (item: string) => void +} + +export const useMyStore = create((set) => ({ + items: [], + addItem: (item) => set((state) => ({ items: [...state.items, item] })), +})) +``` + +### TanStack Query (서버 상태) — 권장 +```typescript +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { fetchMyData, createMyData } from '../services/newService' + +// 조회 +const { data, isLoading } = useQuery({ + queryKey: ['myData'], + queryFn: fetchMyData, +}) + +// 생성/수정 +const queryClient = useQueryClient() +const mutation = useMutation({ + mutationFn: createMyData, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['myData'] }), +}) +``` + +--- + +## 6. 백엔드 API CRUD 규칙 + +> 상세 가이드 + 게시판 실전 튜토리얼: **[CRUD-API-GUIDE.md](./CRUD-API-GUIDE.md)** 참조 + +### HTTP Method 정책 (보안 가이드 준수) +- 보안 취약점 점검 가이드에 따라 **POST 메서드를 기본**으로 사용한다. +- GET은 단순 조회 중 민감하지 않은 경우에만 허용 (필요 시 POST로 전환). +- PUT, DELETE, PATCH 등 기타 메서드는 사용하지 않는다. + +### 오퍼레이션 기반 권한 미들웨어 +OPER_CD는 HTTP Method가 아닌 **비즈니스 의미**로 결정한다. +`requirePermission` 미들웨어에 명시적으로 오퍼레이션을 지정한다. + +| URL 패턴 | OPER_CD | 미들웨어 | +|----------|---------|----------| +| `/resource/list` | READ | `requirePermission(resource, 'READ')` | +| `/resource/detail` | READ | `requirePermission(resource, 'READ')` | +| `/resource/create` | CREATE | `requirePermission(resource, 'CREATE')` | +| `/resource/update` | UPDATE | `requirePermission(resource, 'UPDATE')` | +| `/resource/delete` | DELETE | `requirePermission(resource, 'DELETE')` | + +### 라우터 작성 예시 + +```typescript +// backend/src/board/noticeRouter.ts +import { Router } from 'express' +import { requireAuth, requirePermission } from '../auth/authMiddleware.js' + +const router = Router() +router.use(requireAuth) + +// 조회 +router.post('/list', requirePermission('board:notice', 'READ'), listHandler) +router.post('/detail', requirePermission('board:notice', 'READ'), detailHandler) + +// 생성/수정/삭제 +router.post('/create', requirePermission('board:notice', 'CREATE'), createHandler) +router.post('/update', requirePermission('board:notice', 'UPDATE'), updateHandler) +router.post('/delete', requirePermission('board:notice', 'DELETE'), deleteHandler) + +export default router +``` + +### 관리 API (예외) +사용자/역할/설정 등 관리 API는 `requireRole('ADMIN')` 유지: +```typescript +router.use(requireAuth) +router.use(requireRole('ADMIN')) +``` + +--- + +## 7. 백엔드 모듈 추가 절차 + +새 백엔드 모듈을 추가할 때: + +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/auth_init.sql`에 DDL 추가 +6. 리소스 코드를 `AUTH_PERM_TREE`에 등록 (마이그레이션 SQL) + +### DB 접근 +```typescript +// PostgreSQL — wing DB (운영 데이터: 레이어, 사고, 예측 등) +import { wingPool } from '../db/wingDb.js' +const result = await wingPool.query('SELECT * FROM LAYER WHERE LAYER_CD = $1', [id]) + +// PostgreSQL — wing_auth DB (인증 데이터: 사용자, 역할, 권한 등) +import { authPool } from '../db/authDb.js' +const result = await authPool.query('SELECT * FROM AUTH_USER WHERE USER_ID = $1', [id]) +``` + +--- + +## 8. Mock → API 전환 가이드 + +각 탭의 mock 데이터를 DB/API로 전환하는 프로세스는 **[MOCK-TO-API-GUIDE.md](./MOCK-TO-API-GUIDE.md)** 참조. + +### 전환 완료 탭 + +| 탭 | MR | API 경로 | 비고 | +|----|-----|----------|------| +| Board (게시판) | MR#29 | `/api/board` | PUT/DELETE 사용 (레거시, POST 전환 예정) | +| Reports (보고서) | MR#31 | `/api/reports` | GET/POST only 적용 | + +### Reports API 엔드포인트 + +| Method | Path | 설명 | 권한 | +|--------|------|------|------| +| GET | `/api/reports/templates` | 템플릿 목록 + 섹션 정의 | requireAuth | +| GET | `/api/reports/categories` | 분석 카테고리 목록 + 섹션 | requireAuth | +| GET | `/api/reports` | 보고서 목록 (필터: jrsdCd, tmplCd, sttsCd, search) | reports READ | +| GET | `/api/reports/:sn` | 보고서 상세 (섹션 데이터 포함) | reports READ | +| POST | `/api/reports` | 보고서 생성 | reports CREATE | +| POST | `/api/reports/:sn/update` | 보고서 수정 | reports UPDATE | +| POST | `/api/reports/:sn/delete` | 보고서 삭제 (논리) | reports DELETE | +| POST | `/api/reports/:sn/sections/:sectCd` | 개별 섹션 수정 | reports UPDATE | + +### 프론트엔드 API 서비스 + +```typescript +// frontend/src/tabs/reports/services/reportsApi.ts +import { api } from '@common/services/api' + +// 조회 (GET) +const templates = await fetchTemplates() // GET /reports/templates (캐싱) +const categories = await fetchCategories() // GET /reports/categories (캐싱) +const list = await fetchReports({ tmplCd, sttsCd }) // GET /reports +const detail = await fetchReport(sn) // GET /reports/:sn + +// 생성/수정/삭제 (POST) +await createReportApi({ tmplSn, title, sections }) // POST /reports +await updateReportApi(sn, { title, sections }) // POST /reports/:sn/update +await deleteReportApi(sn) // POST /reports/:sn/delete + +// 고수준 함수 (OilSpillReportData ↔ API 변환 포함) +await saveReport(reportData) // create 또는 update 자동 분기 +const reports = await loadReportsFromApi() // 전체 목록 + 변환 +const detail = await loadReportDetail(sn) // 상세 + 섹션 복원 +``` + +--- + +## 파일 구조 요약 + +``` +frontend/src/ +├── common/ +│ ├── services/api.ts Axios 인스턴스 + API_BASE_URL + 인터셉터 +│ ├── services/authApi.ts 인증/사용자/역할/설정/메뉴/감사로그 API +│ ├── store/authStore.ts 인증 상태 + hasPermission (Zustand) +│ ├── store/menuStore.ts 메뉴 상태 (Zustand) +│ └── hooks/ useSubMenu, useFeatureTracking 등 +├── tabs/ 탭별 패키지 (11개) +└── App.tsx 탭 라우팅 + 감사 로그 자동 기록 + +backend/src/ +├── auth/ 인증 (JWT, OAuth, 미들웨어, requirePermission) +├── users/ 사용자 관리 +├── roles/ 역할/권한 관리 (permResolver, roleService) +├── board/ 게시판 CRUD (boardService, boardRouter) +├── reports/ 보고서 CRUD (reportsService, reportsRouter) +├── settings/ 시스템 설정 +├── menus/ 메뉴 설정 +├── audit/ 감사 로그 +├── db/ DB 연결 (authDb, wingDb) +├── middleware/ 보안 미들웨어 +└── 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_reports.sql 보고서 (REPORT_TMPL, REPORT, REPORT_SECT_DATA 등 7개) +``` diff --git a/docs/_backup_20260301/CRUD-API-GUIDE.md b/docs/_backup_20260301/CRUD-API-GUIDE.md new file mode 100644 index 0000000..8d91db0 --- /dev/null +++ b/docs/_backup_20260301/CRUD-API-GUIDE.md @@ -0,0 +1,1436 @@ +# RBAC 기반 CRUD API 개발 가이드 + +새 CRUD API를 추가할 때 따라야 할 표준 가이드. +Phase 5 RBAC 체계(리소스 x 오퍼레이션 2차원 모델)를 기반으로 한다. + +**DB 구조**: wing DB 단일 DB, 스키마 분리 +- `wing` 스키마: 운영 데이터 (BOARD_POST, LAYER 등) +- `auth` 스키마: 인증/인가 데이터 (AUTH_USER, AUTH_ROLE, AUTH_PERM 등) +- `public` 스키마: PostGIS 시스템 테이블만 유지 (사용 금지) + +--- + +## Part 1: 범용 가이드 + +### 1. 개요 + +이 문서는 WING-OPS의 **모든 탭 개발자**가 새 CRUD API를 만들 때 참조하는 표준이다. + +- 백엔드: Express Router + Service 2-Layer +- 권한: `requirePermission(resource, operation)` 미들웨어 +- DB: PostgreSQL (`wingPool` 단일 Pool, `search_path = wing, auth, public`) +- 프론트: Axios + `hasPermission()` 조건부 렌더링 + +각 섹션에 복사해서 바로 사용할 수 있는 실제 코드 스니펫을 포함한다. + +--- + +### 2. 아키텍처 + +#### 3-Layer 구조 + +``` +클라이언트 (React) + ↓ Axios (withCredentials: true, JWT 쿠키 자동 포함) +Router (Express) ← requireAuth → requirePermission + ↓ +Service ← 비즈니스 로직, DB 쿼리 + ↓ +DB (pg Pool) ← wingPool (search_path = wing, auth) +``` + +#### 디렉토리 구조 + +``` +backend/src/{domain}/ +├── {domain}Router.ts ← Express 라우터 (엔드포인트 + 미들웨어) +└── {domain}Service.ts ← 비즈니스 로직 (쿼리, 인터페이스) +``` + +#### DB Pool + +```typescript +// backend/src/db/wingDb.ts +import { wingPool } from '../db/wingDb.js' + +// wingPool은 연결 시 search_path = wing, auth, public 자동 설정 +// → 스키마 접두사 없이 wing.BOARD_POST, auth.AUTH_USER 모두 접근 가능 +``` + +> **주의**: `authPool`은 하위 호환용 re-export이다. 신규 코드는 반드시 `wingPool`을 직접 import할 것. + +```typescript +// backend/src/db/authDb.ts (하위 호환 — 신규 코드에서 사용 금지) +import { wingPool } from './wingDb.js' +export const authPool = wingPool // 같은 Pool +``` + +--- + +### 3. 권한 모델 빠른 요약 + +#### 2차원 모델: 리소스 트리 x 오퍼레이션 + +``` +AUTH_PERM 테이블: (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) + +리소스 트리 (AUTH_PERM_TREE) 오퍼레이션 (플랫) +├── board READ = 조회/열람 +│ ├── board:notice CREATE = 생성 +│ ├── board:data UPDATE = 수정 +│ └── board:qna DELETE = 삭제 +├── prediction +│ ├── prediction:analysis +│ └── prediction:list +└── admin + ├── admin:users + └── admin:permissions +``` + +#### 리소스 코드 + +`AUTH_PERM_TREE` 테이블에 등록된 코드. 콜론(`:`)으로 계층 구분. + +| 형식 | 예시 | 설명 | +|------|------|------| +| `{탭}` | `board` | 메인 탭 (level 0) | +| `{탭}:{서브}` | `board:notice` | 서브 리소스 (level 1) | + +#### 오퍼레이션 + +| OPER_CD | 설명 | 용도 | +|---------|------|------| +| `READ` | 조회/열람 | 목록, 상세 조회 | +| `CREATE` | 생성 | 새 데이터 등록 | +| `UPDATE` | 수정 | 기존 데이터 변경 | +| `DELETE` | 삭제 | 데이터 삭제 | + +#### 백엔드: requirePermission + +```typescript +import { requireAuth, requirePermission } from '../auth/authMiddleware.js' + +// requirePermission(리소스코드, 오퍼레이션코드) +// 오퍼레이션 생략 시 기본값 'READ' +router.post('/list', requirePermission('board:notice', 'READ'), handler) +router.post('/create', requirePermission('board:notice', 'CREATE'), handler) +``` + +`requirePermission`은 **요청당 1회**만 DB를 조회하고 `req.resolvedPermissions`에 캐싱한다. 한 요청에서 여러 번 호출해도 성능 문제 없다. + +#### 프론트엔드: hasPermission + +```typescript +import { useAuthStore } from '@common/store/authStore' + +const { hasPermission } = useAuthStore() + +hasPermission('board:notice') // READ 확인 (기본값) +hasPermission('board:notice', 'CREATE') // 생성 권한 확인 +hasPermission('board:notice', 'UPDATE') // 수정 권한 확인 +hasPermission('board:notice', 'DELETE') // 삭제 권한 확인 +``` + +#### 상속 규칙 + +``` +규칙 1: 부모 READ=N → 자식의 모든 오퍼레이션 강제 N +규칙 2: 명시적 레코드 있으면 → 그 값 사용 +규칙 3: 명시적 레코드 없으면 → 부모의 같은 오퍼레이션 상속 +규칙 4: 최상위까지 없으면 → 기본 N (거부) +``` + +--- + +### 4. DB 설계 규칙 + +#### 스키마 선택 + +| 데이터 성격 | 스키마 | 예시 | +|-------------|--------|------| +| 운영 데이터 | `wing` | BOARD_POST, LAYER, HNS_SUBSTANCE | +| 인증/인가 | `auth` | AUTH_USER, AUTH_ROLE, AUTH_PERM | + +> `search_path = wing, auth, public` 설정으로 스키마 접두사 없이 접근 가능. +> 단, 다른 스키마 테이블을 FK로 참조할 때는 `auth.AUTH_USER(USER_ID)` 처럼 명시한다. + +#### 네이밍 규칙 + +| 항목 | 규칙 | 예시 | +|------|------|------| +| 테이블명 | UPPER_SNAKE_CASE | `BOARD_POST`, `HNS_SUBSTANCE` | +| 컬럼명 | UPPER_SNAKE_CASE | `POST_SN`, `CATEGORY_CD`, `REG_DTM` | +| PK | `{접두어}_SN` (SERIAL) 또는 `{접두어}_ID` (UUID) | `POST_SN`, `USER_ID` | +| FK 컬럼 | 참조 테이블의 PK 컬럼명 그대로 사용 | `AUTHOR_ID` (→ AUTH_USER.USER_ID) | +| 코드성 컬럼 | `{의미}_CD` | `CATEGORY_CD`, `OPER_CD` | +| 여부 컬럼 | `{의미}_YN` (CHAR(1), 'Y'/'N') | `USE_YN`, `PINNED_YN` | +| 일시 컬럼 | `{의미}_DTM` (TIMESTAMPTZ) | `REG_DTM`, `MDFCN_DTM` | + +#### 공통 컬럼 패턴 + +모든 운영 테이블에 포함하는 표준 컬럼: + +```sql +USE_YN CHAR(1) NOT NULL DEFAULT 'Y', -- 논리삭제 (Y=활성, N=삭제) +REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시 +MDFCN_DTM TIMESTAMPTZ, -- 수정일시 +``` + +#### DDL 작성 예시 + +```sql +-- database/migration/NNN_description.sql + +CREATE TABLE IF NOT EXISTS BOARD_POST ( + POST_SN SERIAL PRIMARY KEY, + CATEGORY_CD VARCHAR(20) NOT NULL, + TITLE VARCHAR(200) NOT NULL, + CONTENT TEXT, + AUTHOR_ID UUID NOT NULL, + VIEW_CNT INTEGER NOT NULL DEFAULT 0, + PINNED_YN CHAR(1) NOT NULL DEFAULT 'N', + USE_YN CHAR(1) NOT NULL DEFAULT 'Y', + REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), + MDFCN_DTM TIMESTAMPTZ, + + -- FK: 다른 스키마 참조 시 스키마 명시 + CONSTRAINT FK_BOARD_AUTHOR FOREIGN KEY (AUTHOR_ID) + REFERENCES auth.AUTH_USER(USER_ID), + + -- CHECK: 코드성 컬럼에 허용값 명시 + CONSTRAINT CK_BOARD_CATEGORY + CHECK (CATEGORY_CD IN ('NOTICE','DATA','QNA','MANUAL')), + CONSTRAINT CK_BOARD_PINNED CHECK (PINNED_YN IN ('Y','N')), + CONSTRAINT CK_BOARD_USE CHECK (USE_YN IN ('Y','N')) +); + +-- COMMENT: 테이블/컬럼 설명 +COMMENT ON TABLE BOARD_POST IS '게시판 게시글'; +COMMENT ON COLUMN BOARD_POST.CATEGORY_CD IS '카테고리: NOTICE=공지, DATA=자료실, QNA=Q&A, MANUAL=해경매뉴얼'; + +-- INDEX: 검색/필터 대상, FK 컬럼 +CREATE INDEX IF NOT EXISTS IDX_BOARD_CATEGORY ON BOARD_POST(CATEGORY_CD); +CREATE INDEX IF NOT EXISTS IDX_BOARD_AUTHOR ON BOARD_POST(AUTHOR_ID); +CREATE INDEX IF NOT EXISTS IDX_BOARD_REG_DTM ON BOARD_POST(REG_DTM DESC); +``` + +#### 마이그레이션 파일 규칙 + +- 경로: `database/migration/NNN_description.sql` +- 번호: 기존 파일 다음 번호 (001, 003, 004, 005, 006, ...) +- 모든 DDL에 `IF NOT EXISTS` / `IF EXISTS` 사용 (재실행 안전) +- 파일 끝에 검증 SELECT 포함 + +--- + +### 5. Service 레이어 패턴 + +#### 인터페이스 정의 + +Service 파일 상단에 반환 타입과 입력 타입을 정의한다. + +```typescript +// backend/src/{domain}/{domain}Service.ts + +import { wingPool } from '../db/wingDb.js' +import { AuthError } from '../auth/authService.js' + +// 목록/상세 조회 반환 타입 +interface PostItem { + postSn: number + categoryCd: string + title: string + content: string | null + authorId: string + authorName: string + viewCnt: number + pinnedYn: string + useYn: string + regDtm: string + mdfcnDtm: string | null +} + +// 생성 입력 타입 +interface CreatePostInput { + categoryCd: string + title: string + content?: string + authorId: string + pinnedYn?: string +} + +// 수정 입력 타입 (모든 필드 optional — 부분 업데이트) +interface UpdatePostInput { + title?: string + content?: string + categoryCd?: string + pinnedYn?: string +} + +// 페이징 응답 타입 +interface PagedResult { + items: T[] + totalCount: number + page: number + size: number +} +``` + +#### wingPool 사용 + +```typescript +import { wingPool } from '../db/wingDb.js' + +// 단순 조회 +const result = await wingPool.query( + 'SELECT * FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = $2', + [postSn, 'Y'] +) + +// Parameterized Query — 반드시 $1, $2, ... 사용 (SQL Injection 방지) +// 문자열 결합으로 쿼리를 만들지 않는다 +``` + +#### 동적 WHERE 빌드 패턴 (필터, 검색) + +```typescript +export async function listPosts( + categoryCd?: string, + search?: string, + page: number = 1, + size: number = 20, +): Promise> { + // 동적 WHERE 조건 + const conditions: string[] = ["p.USE_YN = 'Y'"] + const params: (string | number)[] = [] + let paramIdx = 1 + + if (categoryCd) { + conditions.push(`p.CATEGORY_CD = $${paramIdx++}`) + params.push(categoryCd) + } + + if (search) { + conditions.push(`(p.TITLE ILIKE $${paramIdx} OR p.CONTENT ILIKE $${paramIdx})`) + params.push(`%${search}%`) + paramIdx++ + } + + const whereClause = conditions.join(' AND ') + + // totalCount 조회 + const countResult = await wingPool.query( + `SELECT COUNT(*) as cnt FROM BOARD_POST p WHERE ${whereClause}`, + params + ) + const totalCount = parseInt(countResult.rows[0].cnt, 10) + + // 페이징 데이터 조회 + const offset = (page - 1) * size + const dataParams = [...params, size, offset] + + const dataResult = await wingPool.query( + `SELECT p.POST_SN as post_sn, p.CATEGORY_CD as category_cd, + p.TITLE as title, p.CONTENT as content, + p.AUTHOR_ID as author_id, u.USER_NM as author_name, + p.VIEW_CNT as view_cnt, p.PINNED_YN as pinned_yn, + p.USE_YN as use_yn, p.REG_DTM as reg_dtm, p.MDFCN_DTM as mdfcn_dtm + FROM BOARD_POST p + LEFT JOIN AUTH_USER u ON p.AUTHOR_ID = u.USER_ID + WHERE ${whereClause} + ORDER BY p.PINNED_YN DESC, p.REG_DTM DESC + LIMIT $${paramIdx++} OFFSET $${paramIdx++}`, + dataParams + ) + + const items: PostItem[] = dataResult.rows.map((row) => ({ + postSn: row.post_sn, + categoryCd: row.category_cd, + title: row.title, + content: row.content, + authorId: row.author_id, + authorName: row.author_name, + viewCnt: row.view_cnt, + pinnedYn: row.pinned_yn, + useYn: row.use_yn, + regDtm: row.reg_dtm, + mdfcnDtm: row.mdfcn_dtm, + })) + + return { items, totalCount, page, size } +} +``` + +#### 상세 조회 + +```typescript +export async function getPost(postSn: number): Promise { + const result = await wingPool.query( + `SELECT p.POST_SN as post_sn, p.CATEGORY_CD as category_cd, + p.TITLE as title, p.CONTENT as content, + p.AUTHOR_ID as author_id, u.USER_NM as author_name, + p.VIEW_CNT as view_cnt, p.PINNED_YN as pinned_yn, + p.USE_YN as use_yn, p.REG_DTM as reg_dtm, p.MDFCN_DTM as mdfcn_dtm + FROM BOARD_POST p + LEFT JOIN AUTH_USER u ON p.AUTHOR_ID = u.USER_ID + WHERE p.POST_SN = $1 AND p.USE_YN = 'Y'`, + [postSn] + ) + + if (result.rows.length === 0) { + throw new AuthError('게시글을 찾을 수 없습니다.', 404) + } + + const row = result.rows[0] + return { + postSn: row.post_sn, + categoryCd: row.category_cd, + title: row.title, + content: row.content, + authorId: row.author_id, + authorName: row.author_name, + viewCnt: row.view_cnt, + pinnedYn: row.pinned_yn, + useYn: row.use_yn, + regDtm: row.reg_dtm, + mdfcnDtm: row.mdfcn_dtm, + } +} +``` + +#### 생성 + +```typescript +export async function createPost(input: CreatePostInput): Promise<{ postSn: number }> { + const result = await wingPool.query( + `INSERT INTO BOARD_POST (CATEGORY_CD, TITLE, CONTENT, AUTHOR_ID, PINNED_YN) + VALUES ($1, $2, $3, $4, $5) + RETURNING POST_SN as post_sn`, + [input.categoryCd, input.title, input.content || null, input.authorId, input.pinnedYn || 'N'] + ) + + return { postSn: result.rows[0].post_sn } +} +``` + +#### 동적 SET 빌드 패턴 (부분 업데이트) + +```typescript +export async function updatePost( + postSn: number, + input: UpdatePostInput, + requesterId: string, +): Promise { + // 소유자 검증 + const existing = await wingPool.query( + "SELECT AUTHOR_ID FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'", + [postSn] + ) + if (existing.rows.length === 0) { + throw new AuthError('게시글을 찾을 수 없습니다.', 404) + } + if (existing.rows[0].author_id !== requesterId) { + throw new AuthError('본인의 게시글만 수정할 수 있습니다.', 403) + } + + // 동적 SET 빌드 + const sets: string[] = [] + const params: (string | number | null)[] = [] + let idx = 1 + + if (input.title !== undefined) { + sets.push(`TITLE = $${idx++}`) + params.push(input.title) + } + if (input.content !== undefined) { + sets.push(`CONTENT = $${idx++}`) + params.push(input.content) + } + if (input.categoryCd !== undefined) { + sets.push(`CATEGORY_CD = $${idx++}`) + params.push(input.categoryCd) + } + if (input.pinnedYn !== undefined) { + sets.push(`PINNED_YN = $${idx++}`) + params.push(input.pinnedYn) + } + + if (sets.length === 0) { + throw new AuthError('수정할 항목이 없습니다.', 400) + } + + // MDFCN_DTM 자동 갱신 + sets.push('MDFCN_DTM = NOW()') + params.push(postSn) + + await wingPool.query( + `UPDATE BOARD_POST SET ${sets.join(', ')} WHERE POST_SN = $${idx}`, + params + ) +} +``` + +#### 삭제 (논리삭제) + +```typescript +export async function deletePost(postSn: number, requesterId: string): Promise { + // 소유자 검증 + const existing = await wingPool.query( + "SELECT AUTHOR_ID FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'", + [postSn] + ) + if (existing.rows.length === 0) { + throw new AuthError('게시글을 찾을 수 없습니다.', 404) + } + if (existing.rows[0].author_id !== requesterId) { + throw new AuthError('본인의 게시글만 삭제할 수 있습니다.', 403) + } + + // 논리삭제: USE_YN = 'N' + await wingPool.query( + "UPDATE BOARD_POST SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE POST_SN = $1", + [postSn] + ) +} +``` + +#### 트랜잭션 패턴 + +여러 테이블을 동시에 변경해야 할 때: + +```typescript +export async function createPostWithAttachments( + input: CreatePostInput, + attachments: AttachmentInput[], +): Promise<{ postSn: number }> { + const client = await wingPool.connect() + + try { + await client.query('BEGIN') + + // 게시글 생성 + const postResult = await client.query( + `INSERT INTO BOARD_POST (CATEGORY_CD, TITLE, CONTENT, AUTHOR_ID) + VALUES ($1, $2, $3, $4) + RETURNING POST_SN as post_sn`, + [input.categoryCd, input.title, input.content, input.authorId] + ) + const postSn = postResult.rows[0].post_sn + + // 첨부파일 생성 + for (const att of attachments) { + await client.query( + `INSERT INTO BOARD_ATTACH (POST_SN, FILE_NM, FILE_PATH, FILE_SIZE) + VALUES ($1, $2, $3, $4)`, + [postSn, att.fileName, att.filePath, att.fileSize] + ) + } + + await client.query('COMMIT') + return { postSn } + } catch (err) { + await client.query('ROLLBACK') + throw err + } finally { + client.release() + } +} +``` + +#### 에러 처리 + +```typescript +import { AuthError } from '../auth/authService.js' + +// AuthError: status 코드와 메시지를 포함하는 커스텀 에러 +// Router에서 instanceof 체크로 적절한 HTTP 응답을 반환 + +throw new AuthError('게시글을 찾을 수 없습니다.', 404) +throw new AuthError('권한이 없습니다.', 403) +throw new AuthError('필수 항목이 누락되었습니다.', 400) +throw new AuthError('이미 존재하는 데이터입니다.', 409) +``` + +`AuthError` 클래스 정의 (`backend/src/auth/authService.ts`): + +```typescript +export class AuthError extends Error { + status: number + constructor(message: string, status: number) { + super(message) + this.status = status + this.name = 'AuthError' + } +} +``` + +--- + +### 6. Router 레이어 패턴 + +#### 미들웨어 체인 + +``` +requireAuth → requirePermission(resource, operation) → 핸들러 +``` + +- `requireAuth`: JWT 쿠키 검증, `req.user`에 페이로드 세팅 +- `requirePermission`: 리소스 x 오퍼레이션 권한 확인 + +#### CRUD 엔드포인트 표준 + +보안 취약점 점검 가이드에 따라 **POST 메서드를 기본**으로 사용한다. +OPER_CD는 HTTP Method가 아닌 **비즈니스 의미**로 결정한다. + +| URL 패턴 | OPER_CD | 미들웨어 | +|----------|---------|----------| +| `POST /api/{domain}/list` | READ | `requirePermission(resource, 'READ')` | +| `POST /api/{domain}/detail` | READ | `requirePermission(resource, 'READ')` | +| `POST /api/{domain}/create` | CREATE | `requirePermission(resource, 'CREATE')` | +| `POST /api/{domain}/update` | UPDATE | `requirePermission(resource, 'UPDATE')` | +| `POST /api/{domain}/delete` | DELETE | `requirePermission(resource, 'DELETE')` | + +#### 전체 Router 예시 + +```typescript +// backend/src/board/boardRouter.ts + +import { Router } from 'express' +import { requireAuth, requirePermission } from '../auth/authMiddleware.js' +import { AuthError } from '../auth/authService.js' +import { + listPosts, + getPost, + createPost, + updatePost, + deletePost, +} from './boardService.js' + +const router = Router() + +// 모든 엔드포인트에 인증 필수 +router.use(requireAuth) + +// 목록 조회 +router.post('/list', requirePermission('board:notice', 'READ'), async (req, res) => { + try { + const { categoryCd, search, page, size } = req.body + const result = await listPosts(categoryCd, search, page, size) + res.json(result) + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + console.error('[board] 목록 조회 오류:', err) + res.status(500).json({ error: '게시글 목록 조회 중 오류가 발생했습니다.' }) + } +}) + +// 상세 조회 +router.post('/detail', requirePermission('board:notice', 'READ'), async (req, res) => { + try { + const { postSn } = req.body + if (!postSn) { + res.status(400).json({ error: '게시글 번호는 필수입니다.' }) + return + } + const post = await getPost(postSn) + res.json(post) + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + console.error('[board] 상세 조회 오류:', err) + res.status(500).json({ error: '게시글 조회 중 오류가 발생했습니다.' }) + } +}) + +// 생성 +router.post('/create', requirePermission('board:notice', 'CREATE'), async (req, res) => { + try { + const { categoryCd, title, content, pinnedYn } = req.body + + // 필수 필드 검증 + if (!categoryCd || !title) { + res.status(400).json({ error: '카테고리와 제목은 필수입니다.' }) + return + } + + // req.user!.sub = 현재 로그인 사용자 UUID + const result = await createPost({ + categoryCd, + title, + content, + authorId: req.user!.sub, + pinnedYn, + }) + res.status(201).json(result) + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + console.error('[board] 생성 오류:', err) + res.status(500).json({ error: '게시글 생성 중 오류가 발생했습니다.' }) + } +}) + +// 수정 +router.post('/update', requirePermission('board:notice', 'UPDATE'), async (req, res) => { + try { + const { postSn, title, content, categoryCd, pinnedYn } = req.body + + if (!postSn) { + res.status(400).json({ error: '게시글 번호는 필수입니다.' }) + return + } + + await updatePost(postSn, { title, content, categoryCd, pinnedYn }, req.user!.sub) + res.json({ success: true }) + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + console.error('[board] 수정 오류:', err) + res.status(500).json({ error: '게시글 수정 중 오류가 발생했습니다.' }) + } +}) + +// 삭제 +router.post('/delete', requirePermission('board:notice', 'DELETE'), async (req, res) => { + try { + const { postSn } = req.body + + if (!postSn) { + res.status(400).json({ error: '게시글 번호는 필수입니다.' }) + return + } + + await deletePost(postSn, req.user!.sub) + res.json({ success: true }) + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + console.error('[board] 삭제 오류:', err) + res.status(500).json({ error: '게시글 삭제 중 오류가 발생했습니다.' }) + } +}) + +export default router +``` + +#### 입력 검증 패턴 + +핸들러 내부에서 필수 필드를 직접 체크한다. + +```typescript +// 필수 필드 검증 +if (!categoryCd || !title) { + res.status(400).json({ error: '카테고리와 제목은 필수입니다.' }) + return +} + +// 배열 타입 검증 +if (!Array.isArray(roleSns)) { + res.status(400).json({ error: '역할 목록이 필요합니다.' }) + return +} + +// 길이 검증 +if (!password || password.length < 4) { + res.status(400).json({ error: '비밀번호는 4자 이상이어야 합니다.' }) + return +} +``` + +#### 에러 응답 패턴 + +모든 핸들러에서 동일한 에러 처리 구조를 사용한다. + +```typescript +try { + // 비즈니스 로직 +} catch (err) { + // 1. AuthError → 해당 status + message + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + // 2. 예상치 못한 에러 → 500 + 일반 메시지 (내부 정보 노출 방지) + console.error('[domain] 작업 오류:', err) + res.status(500).json({ error: '처리 중 오류가 발생했습니다.' }) +} +``` + +#### server.ts 등록 + +```typescript +// backend/src/server.ts + +import boardRouter from './board/boardRouter.js' + +// API 라우트 — 업무 +app.use('/api/board', boardRouter) +``` + +#### req.user 구조 (JWT 페이로드) + +`requireAuth` 통과 후 `req.user`에 담기는 정보: + +```typescript +interface JwtPayload { + sub: string // 사용자 UUID (USER_ID) + acnt: string // 계정명 (USER_ACNT) + name: string // 사용자명 (USER_NM) + roles: string[] // 역할 코드 목록 ['ADMIN', 'MANAGER', 'USER', 'VIEWER'] +} + +// 사용 예시 +const userId = req.user!.sub // 현재 사용자 UUID +const userName = req.user!.name // 현재 사용자 이름 +const isAdmin = req.user!.roles.includes('ADMIN') +``` + +--- + +### 7. 프론트엔드 연동 패턴 + +#### API 서비스 파일 + +탭별로 `services/` 디렉토리에 API 함수를 분리한다. + +```typescript +// frontend/src/tabs/board/services/boardApi.ts + +import { api } from '@common/services/api' + +// 타입 정의 +export interface PostItem { + postSn: number + categoryCd: string + title: string + content: string | null + authorId: string + authorName: string + viewCnt: number + pinnedYn: string + useYn: string + regDtm: string + mdfcnDtm: string | null +} + +export interface PostListResult { + items: PostItem[] + totalCount: number + page: number + size: number +} + +// 목록 조회 +export async function fetchPosts(params: { + categoryCd?: string + search?: string + page?: number + size?: number +}): Promise { + const response = await api.post('/board/list', params) + return response.data +} + +// 상세 조회 +export async function fetchPost(postSn: number): Promise { + const response = await api.post('/board/detail', { postSn }) + return response.data +} + +// 생성 +export async function createPostApi(data: { + categoryCd: string + title: string + content?: string + pinnedYn?: string +}): Promise<{ postSn: number }> { + const response = await api.post<{ postSn: number }>('/board/create', data) + return response.data +} + +// 수정 +export async function updatePostApi( + postSn: number, + data: { title?: string; content?: string; categoryCd?: string; pinnedYn?: string }, +): Promise { + await api.post('/board/update', { postSn, ...data }) +} + +// 삭제 +export async function deletePostApi(postSn: number): Promise { + await api.post('/board/delete', { postSn }) +} +``` + +#### Axios 인스턴스 + +```typescript +// frontend/src/common/services/api.ts (이미 설정됨, 수정 불필요) + +import axios from 'axios' + +export const api = axios.create({ + baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3001/api', + withCredentials: true, // JWT 쿠키 자동 포함 + timeout: 30000, // 30초 타임아웃 +}) + +// 401 응답 시 자동 로그아웃 (인터셉터) +// 403 응답 시 권한 부족 (requirePermission 미들웨어) +``` + +#### 권한 기반 UI 분기 + +```tsx +// frontend/src/tabs/board/components/PostList.tsx + +import { useAuthStore } from '@common/store/authStore' + +const PostList = () => { + const { hasPermission } = useAuthStore() + + return ( +
+

게시판

+ + {/* CREATE 권한이 있을 때만 글쓰기 버튼 표시 */} + {hasPermission('board:notice', 'CREATE') && ( + + )} + + {/* 목록 렌더링 */} + {posts.map((post) => ( +
+ {post.title} + + {/* UPDATE 권한 + 본인 글일 때만 수정 버튼 */} + {hasPermission('board:notice', 'UPDATE') && post.authorId === user?.id && ( + + )} + + {/* DELETE 권한 + 본인 글일 때만 삭제 버튼 */} + {hasPermission('board:notice', 'DELETE') && post.authorId === user?.id && ( + + )} +
+ ))} + + {/* 페이징 */} + +
+ ) +} +``` + +#### TanStack Query 연동 (권장) + +```typescript +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { fetchPosts, createPostApi, deletePostApi } from '../services/boardApi' + +// 목록 조회 +const { data, isLoading } = useQuery({ + queryKey: ['posts', categoryCd, search, page], + queryFn: () => fetchPosts({ categoryCd, search, page, size: 20 }), +}) + +// 생성 +const queryClient = useQueryClient() +const createMutation = useMutation({ + mutationFn: createPostApi, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['posts'] }) + }, +}) + +// 삭제 +const deleteMutation = useMutation({ + mutationFn: deletePostApi, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['posts'] }) + }, +}) +``` + +--- + +### 8. 권한 상속 실전 시나리오 + +`AUTH_PERM_TREE`와 `AUTH_PERM`의 상속 규칙이 실제로 어떻게 동작하는지 4가지 시나리오로 설명한다. + +#### 시나리오 1: 부모 허용 → 자식 상속 + +``` +AUTH_PERM: + ADMIN 역할 — board READ=Y, CREATE=Y, UPDATE=Y, DELETE=Y + +결과: + board:notice READ → 명시적 레코드 없음 → 부모(board) READ=Y 상속 → Y + board:notice CREATE → 명시적 레코드 없음 → 부모(board) CREATE=Y 상속 → Y + board:data READ → 명시적 레코드 없음 → 부모(board) READ=Y 상속 → Y + +→ 부모에게 권한을 주면 모든 자식이 자동으로 같은 권한을 상속한다. +``` + +#### 시나리오 2: 명시적 거부 (Override) + +``` +AUTH_PERM: + MANAGER 역할 — board READ=Y, CREATE=Y + board:notice CREATE=N (명시적) + +결과: + board:notice READ → 부모 상속 Y + board:notice CREATE → 명시적 N → N (공지 작성 불가) + board:data CREATE → 부모 상속 Y (자료실은 작성 가능) + +→ 자식에 명시적 레코드가 있으면 부모 상속보다 우선한다. +``` + +#### 시나리오 3: 부모 접근 차단 → 자식 전체 차단 + +``` +AUTH_PERM: + VIEWER 역할 — board READ=N + +결과: + board:notice READ → 부모 READ=N → 강제 N (규칙 1) + board:notice CREATE → 부모 READ=N → 강제 N (규칙 1) + board:data READ → 부모 READ=N → 강제 N (규칙 1) + +→ 부모의 READ가 N이면 자식의 모든 오퍼레이션이 강제 차단된다. + 자식에 명시적 Y가 있어도 무시된다. +``` + +#### 시나리오 4: 서브리소스 개별 허용 + +``` +AUTH_PERM: + USER 역할 — board READ=Y, CREATE=N + board:qna CREATE=Y (명시적) + +결과: + board:notice CREATE → 부모 상속 N (공지 작성 불가) + board:data CREATE → 부모 상속 N (자료실 작성 불가) + board:qna CREATE → 명시적 Y → Y (Q&A는 작성 가능) + +→ 부모에서 CUD를 기본 차단하고, 특정 서브리소스만 허용하는 패턴. +``` + +#### 내부 키 형식 + +permResolver에서 리소스와 오퍼레이션을 결합할 때 더블콜론(`::`)을 사용한다. + +``` +리소스 내부 경로: board:notice (싱글콜론) +리소스-오퍼레이션 결합: board:notice::READ (더블콜론, 내부 전용) +``` + +```typescript +// backend/src/roles/permResolver.ts +export function makePermKey(rsrcCode: string, operCd: string): string { + return `${rsrcCode}::${operCd}` +} +``` + +--- + +### 9. 새 CRUD API 추가 체크리스트 + +새 도메인의 CRUD API를 추가할 때 아래 순서대로 진행한다. + +#### 백엔드 + +- [ ] `database/migration/NNN_{domain}.sql` 작성 (DDL + 초기 데이터) + - 테이블 생성 (IF NOT EXISTS) + - FK, CHECK 제약, 인덱스 + - COMMENT + - 검증 SELECT +- [ ] DB 마이그레이션 실행 (`psql`로 직접 실행) +- [ ] `backend/src/{domain}/{domain}Service.ts` 작성 + - 인터페이스 정의 (Item, CreateInput, UpdateInput) + - CRUD 함수 (list, get, create, update, delete) + - wingPool import, AuthError import + - 동적 WHERE/SET 빌드, 소유자 검증 +- [ ] `backend/src/{domain}/{domain}Router.ts` 작성 + - requireAuth + requirePermission 미들웨어 + - POST /list, /detail, /create, /update, /delete + - 입력 검증, AuthError 분기, 500 에러 처리 +- [ ] `backend/src/server.ts`에 라우터 등록 + ```typescript + import boardRouter from './board/boardRouter.js' + app.use('/api/board', boardRouter) + ``` +- [ ] 빌드 확인: `cd backend && npm run build` + +#### 권한 등록 (필요 시) + +- [ ] `AUTH_PERM_TREE`에 리소스 등록 (마이그레이션 SQL) + ```sql + INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) + VALUES ('board:notice', 'board', '공지사항', 1, 2) + ON CONFLICT (RSRC_CD) DO NOTHING; + ``` +- [ ] `AUTH_PERM`에 역할별 권한 초기값 추가 (마이그레이션 SQL) + ```sql + -- ADMIN: 모든 오퍼레이션 허용 + INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) + SELECT r.ROLE_SN, 'board:notice', op.cd, 'Y' + FROM AUTH_ROLE r, (VALUES ('READ'),('CREATE'),('UPDATE'),('DELETE')) AS op(cd) + WHERE r.ROLE_CD = 'ADMIN' + ON CONFLICT DO NOTHING; + + -- VIEWER: READ만 허용 + INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) + SELECT r.ROLE_SN, 'board:notice', 'READ', 'Y' + FROM AUTH_ROLE r + WHERE r.ROLE_CD = 'VIEWER' + ON CONFLICT DO NOTHING; + ``` + +#### 프론트엔드 + +- [ ] `frontend/src/tabs/{domain}/services/{domain}Api.ts` 작성 + - 타입 정의 (interface) + - CRUD API 함수 (api.post 사용) +- [ ] 프론트 컴포넌트에서 mock 데이터 → API 호출로 전환 +- [ ] `hasPermission()` 조건부 렌더링 적용 + - CREATE 권한 → 글쓰기 버튼 + - UPDATE 권한 → 수정 버튼 + - DELETE 권한 → 삭제 버튼 +- [ ] 빌드 확인: `cd frontend && npx tsc --noEmit` + +--- + +## Part 2: 게시판 실전 튜토리얼 + +게시판(Board) CRUD API를 처음부터 끝까지 구현한 실전 예제. +Part 1의 규칙을 실제로 어떻게 적용하는지 단계별로 설명한다. + +--- + +### Step 1: DB 테이블 설계 + +**파일**: `database/migration/006_board.sql` + +```sql +CREATE TABLE IF NOT EXISTS BOARD_POST ( + POST_SN SERIAL PRIMARY KEY, + CATEGORY_CD VARCHAR(20) NOT NULL, + TITLE VARCHAR(200) NOT NULL, + CONTENT TEXT, + AUTHOR_ID UUID NOT NULL, + VIEW_CNT INTEGER NOT NULL DEFAULT 0, + PINNED_YN CHAR(1) NOT NULL DEFAULT 'N', + USE_YN CHAR(1) NOT NULL DEFAULT 'Y', + REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), + MDFCN_DTM TIMESTAMPTZ, + + CONSTRAINT FK_BOARD_AUTHOR FOREIGN KEY (AUTHOR_ID) + REFERENCES auth.AUTH_USER(USER_ID), + CONSTRAINT CK_BOARD_CATEGORY + CHECK (CATEGORY_CD IN ('NOTICE','DATA','QNA','MANUAL')), + CONSTRAINT CK_BOARD_PINNED CHECK (PINNED_YN IN ('Y','N')), + CONSTRAINT CK_BOARD_USE CHECK (USE_YN IN ('Y','N')) +); + +CREATE INDEX IF NOT EXISTS IDX_BOARD_CATEGORY ON BOARD_POST(CATEGORY_CD); +CREATE INDEX IF NOT EXISTS IDX_BOARD_AUTHOR ON BOARD_POST(AUTHOR_ID); +CREATE INDEX IF NOT EXISTS IDX_BOARD_REG_DTM ON BOARD_POST(REG_DTM DESC); +``` + +**설계 포인트**: +- `wing` 스키마에 생성 (search_path 덕분에 쿼리에서 스키마 접두사 불필요) +- `AUTHOR_ID`는 `auth.AUTH_USER(USER_ID)`를 cross-schema FK 참조 +- `USE_YN`으로 논리 삭제 (물리 삭제 대신 `'N'`으로 변경) +- `CATEGORY_CD` CHECK 제약으로 유효값 강제 + +#### 카테고리 ↔ 리소스 매핑 + +| CATEGORY_CD | AUTH_PERM_TREE 리소스 | 정책 | +|---|---|---| +| `NOTICE` | `board:notice` | ADMIN/MANAGER만 CUD | +| `DATA` | `board:data` | MANAGER 이상 CUD | +| `QNA` | `board:qna` | 인증 사용자 CUD (본인 글만 UD) | +| `MANUAL` | `board:manual` | ADMIN만 CUD | + +--- + +### Step 2: Service 구현 + +**파일**: `backend/src/board/boardService.ts` + +#### 인터페이스 정의 + +```typescript +interface PostListItem { + sn: number + categoryCd: string + title: string + authorId: string + authorName: string + viewCnt: number + pinnedYn: string + regDtm: string +} + +interface ListPostsInput { + categoryCd?: string + search?: string + page?: number + size?: number +} + +interface ListPostsResult { + items: PostListItem[] + totalCount: number + page: number + size: number +} +``` + +#### 목록 조회 (페이징 + 필터 + 검색) + +```typescript +export async function listPosts(input: ListPostsInput): Promise { + const page = input.page && input.page > 0 ? input.page : 1 + const size = input.size && input.size > 0 ? Math.min(input.size, 100) : 20 + const offset = (page - 1) * size + + let whereClause = `WHERE bp.USE_YN = 'Y'` + const params: (string | number)[] = [] + let paramIdx = 1 + + if (input.categoryCd) { + whereClause += ` AND bp.CATEGORY_CD = $${paramIdx++}` + params.push(input.categoryCd) + } + + if (input.search) { + whereClause += ` AND (bp.TITLE ILIKE $${paramIdx} OR u.USER_NM ILIKE $${paramIdx})` + params.push(`%${input.search}%`) + paramIdx++ + } + + // 전체 건수 + const countResult = await wingPool.query( + `SELECT COUNT(*) as cnt FROM BOARD_POST bp + JOIN AUTH_USER u ON bp.AUTHOR_ID = u.USER_ID ${whereClause}`, + params + ) + const totalCount = parseInt(countResult.rows[0].cnt, 10) + + // 목록 (상단고정 우선 → 등록일 내림차순) + const listParams = [...params, size, offset] + const listResult = await wingPool.query( + `SELECT bp.POST_SN as sn, bp.CATEGORY_CD as category_cd, bp.TITLE as title, + bp.AUTHOR_ID as author_id, u.USER_NM as author_name, + bp.VIEW_CNT as view_cnt, bp.PINNED_YN as pinned_yn, bp.REG_DTM as reg_dtm + FROM BOARD_POST bp + JOIN AUTH_USER u ON bp.AUTHOR_ID = u.USER_ID + ${whereClause} + ORDER BY bp.PINNED_YN DESC, bp.REG_DTM DESC + LIMIT $${paramIdx++} OFFSET $${paramIdx}`, + listParams + ) + // ... 결과 매핑 후 return +} +``` + +**핵심**: `JOIN AUTH_USER`로 cross-schema JOIN 수행 (작성자명 표시). 이것이 DB 통합의 핵심 이점. + +#### 소유자 검증 패턴 (수정/삭제) + +```typescript +export async function updatePost( + postSn: number, + input: UpdatePostInput, + requesterId: string // ← req.user.sub (JWT에서 추출) +): Promise { + const existing = await wingPool.query( + `SELECT AUTHOR_ID as author_id FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'`, + [postSn] + ) + + if (existing.rows.length === 0) { + throw new AuthError('게시글을 찾을 수 없습니다.', 404) + } + + // 본인 글만 수정 가능 + if (existing.rows[0].author_id !== requesterId) { + throw new AuthError('본인의 게시글만 수정할 수 있습니다.', 403) + } + + // ... 동적 SET 빌드 + UPDATE +} +``` + +#### 논리 삭제 + +```typescript +export async function deletePost(postSn: number, requesterId: string): Promise { + // 소유자 검증 (위와 동일) + await wingPool.query( + `UPDATE BOARD_POST SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE POST_SN = $1`, + [postSn] + ) +} +``` + +--- + +### Step 3: Router 구현 + +**파일**: `backend/src/board/boardRouter.ts` + +#### 카테고리별 동적 리소스 결정 + +```typescript +const CATEGORY_RESOURCE: Record = { + NOTICE: 'board:notice', + DATA: 'board:data', + QNA: 'board:qna', + MANUAL: 'board:manual', +} +``` + +#### 엔드포인트별 requirePermission 적용 + +```typescript +// 목록/상세: 부모 리소스 'board' READ +router.get('/', requireAuth, requirePermission('board', 'READ'), listHandler) +router.get('/:sn', requireAuth, requirePermission('board', 'READ'), getHandler) + +// 작성: 카테고리별 서브리소스 CREATE (핵심!) +router.post('/', requireAuth, async (req, res, next) => { + const resource = CATEGORY_RESOURCE[req.body.categoryCd] || 'board' + requirePermission(resource, 'CREATE')(req, res, next) +}, createHandler) + +// 수정/삭제: 부모 리소스 권한 + 서비스에서 소유자 검증 +router.put('/:sn', requireAuth, requirePermission('board', 'UPDATE'), updateHandler) +router.delete('/:sn', requireAuth, requirePermission('board', 'DELETE'), deleteHandler) +``` + +**카테고리별 작성 권한의 원리**: +- POST `/api/board` 요청 시 body에 `categoryCd`가 포함 +- 미들웨어에서 `CATEGORY_RESOURCE[categoryCd]`로 서브리소스 결정 +- `board:notice` CREATE 권한이 없는 사용자는 공지 작성 불가 +- `board:qna` CREATE 권한이 있으면 Q&A는 작성 가능 + +--- + +### Step 4: server.ts 등록 + +```typescript +import boardRouter from './board/boardRouter.js' + +// API 라우트 — 업무 +app.use('/api/board', boardRouter) +``` + +--- + +### Step 5: 프론트엔드 연동 + +#### API 서비스 + +**파일**: `frontend/src/tabs/board/services/boardApi.ts` + +```typescript +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; +} + +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; +} +``` + +#### 권한 기반 UI 분기 + +**파일**: `frontend/src/tabs/board/components/BoardView.tsx` + +```tsx +import { useAuthStore } from '@common/store/authStore'; + +const hasPermission = useAuthStore((s) => s.hasPermission); + +// 서브탭 기준 글쓰기 권한 리소스 결정 +const getWriteResource = () => { + if (activeSubTab === 'all') return 'board'; + return `board:${activeSubTab}`; +}; + +// 글쓰기 버튼 조건부 렌더링 +{hasPermission(getWriteResource(), 'CREATE') && ( + +)} +``` + +--- + +### Step 6: 권한 시나리오 테스트 + +| 시나리오 | 역할 | 요청 | 결과 | +|---|---|---|---| +| ADMIN이 공지 작성 | ADMIN | POST `/api/board` `{categoryCd:"NOTICE"}` | 201 Created | +| USER가 공지 작성 | USER | POST `/api/board` `{categoryCd:"NOTICE"}` | 403 (board:notice CREATE 없음) | +| USER가 Q&A 작성 | USER | POST `/api/board` `{categoryCd:"QNA"}` | 201 (board:qna CREATE 있음) | +| VIEWER가 Q&A 작성 | VIEWER | POST `/api/board` `{categoryCd:"QNA"}` | 403 (board:qna CREATE 없음) | +| USER가 본인 글 수정 | USER | PUT `/api/board/11` (본인 글) | 200 | +| USER가 타인 글 수정 | USER | PUT `/api/board/1` (타인 글) | 403 (소유자 검증 실패) | +| ADMIN이 목록 조회 | ADMIN | GET `/api/board` | 200 (board READ 있음) | + +--- + +### 관련 파일 전체 목록 + +| 위치 | 파일 | 설명 | +|---|---|---| +| DB | `database/migration/006_board.sql` | DDL + 초기 데이터 | +| 백엔드 | `backend/src/board/boardService.ts` | CRUD 비즈니스 로직 | +| 백엔드 | `backend/src/board/boardRouter.ts` | 라우터 + requirePermission | +| 백엔드 | `backend/src/server.ts` | boardRouter 등록 | +| 프론트 | `frontend/src/tabs/board/services/boardApi.ts` | API 서비스 | +| 프론트 | `frontend/src/tabs/board/components/BoardView.tsx` | 목록/상세/작성 통합 뷰 (API 연동) | +| 프론트 | `frontend/src/tabs/board/components/BoardWriteForm.tsx` | 게시글 작성/수정 폼 (API 호출) | +| 프론트 | `frontend/src/tabs/board/components/BoardDetailView.tsx` | 게시글 상세 보기 (API 호출) | diff --git a/docs/_backup_20260301/DEVELOPMENT-GUIDE.md b/docs/_backup_20260301/DEVELOPMENT-GUIDE.md new file mode 100644 index 0000000..68cc157 --- /dev/null +++ b/docs/_backup_20260301/DEVELOPMENT-GUIDE.md @@ -0,0 +1,433 @@ +# WING 개발 워크플로우 가이드 + +## 목차 +1. [전체 흐름 요약](#1-전체-흐름-요약) +2. [계획 수립 (Plan)](#2-계획-수립-plan) +3. [브랜치 생성 및 개발](#3-브랜치-생성-및-개발) +4. [커밋 & 푸시](#4-커밋--푸시) +5. [MR 생성 (feature → develop)](#5-mr-생성-feature--develop) +6. [릴리즈 PR (develop → main)](#6-릴리즈-pr-develop--main) +7. [자동 배포](#7-자동-배포) +8. [프로젝트 문서 최신화](#8-프로젝트-문서-최신화) +9. [실전 예시: 기능 추가 A to Z](#9-실전-예시-기능-추가-a-to-z) + +--- + +## 1. 전체 흐름 요약 + +``` +계획 수립 → 브랜치 생성 → 개발 → 커밋/푸시 → develop MR → main PR → 자동 배포 +``` + +``` +[Plan Mode] Claude가 코드베이스 분석 후 구현 계획 작성 + ↓ +[Branch] feature/기능명 브랜치 생성 (develop 기반) + ↓ +[Develop] 코드 작성 + TypeScript/ESLint 검증 + ↓ +[Commit & Push] Conventional Commits 형식 + pre-commit 자동 검증 + ↓ +[MR → develop] 코드 리뷰 + 머지 + ↓ +[PR → main] 릴리즈 MR + 머지 + ↓ +[Auto Deploy] Gitea Actions → 빌드 → 서버 배포 +``` + +--- + +## 2. 계획 수립 (Plan) + +3개 이상 파일 수정이 예상되거나 아키텍처에 영향을 주는 작업은 **Plan Mode**로 시작합니다. + +### Claude에게 요청하는 방법 + +``` +"사용자 프로필 페이지를 추가해줘" +→ Claude가 자동으로 Plan Mode 진입 → 코드베이스 분석 → 구현 계획 제시 +→ 사용자 승인 후 구현 시작 +``` + +### 계획에 포함되는 내용 +- 수정/생성할 파일 목록 +- 변경 범위 및 영향도 +- 기술적 선택지와 권장안 +- 구현 순서 + +### Plan Mode가 불필요한 경우 +- 단순 버그 수정 (1~2개 파일) +- 텍스트/스타일 수정 +- 설정 변경 + +--- + +## 3. 브랜치 생성 및 개발 + +### 브랜치 네이밍 규칙 + +| 유형 | 형식 | 예시 | +|------|------|------| +| 기능 | `feature/설명` | `feature/user-profile` | +| 이슈 | `feature/ISSUE-번호-설명` | `feature/ISSUE-42-login-fix` | +| 버그 | `bugfix/ISSUE-번호-설명` | `bugfix/ISSUE-15-token-expired` | +| 긴급 | `hotfix/설명` | `hotfix/security-patch` | + +### 브랜치 생성 + +```bash +# develop에서 분기 +git checkout develop +git pull origin develop +git checkout -b feature/user-profile +``` + +### 개발 중 검증 + +로컬에서 타입 체크와 린트를 수시로 확인합니다: + +```bash +# Frontend +cd frontend && npx tsc --noEmit && npx eslint src/ + +# Backend +cd backend && npx tsc --noEmit +``` + +--- + +## 4. 커밋 & 푸시 + +### Conventional Commits 형식 + +``` +type(scope): 한국어 설명 +``` + +| type | 용도 | 예시 | +|------|------|------| +| `feat` | 새 기능 | `feat(auth): Google OAuth 로그인 추가` | +| `fix` | 버그 수정 | `fix(map): 레이어 겹침 오류 수정` | +| `refactor` | 리팩토링 | `refactor(api): 중복 호출 제거` | +| `docs` | 문서 | `docs: API 엔드포인트 문서 추가` | +| `chore` | 설정/빌드 | `chore: 의존성 버전 업데이트` | +| `ci` | CI/CD | `ci: 백엔드 빌드 스텝 추가` | +| `style` | 포맷팅 | `style: ESLint 경고 수정` | + +### pre-commit 자동 검증 + +커밋 시 `.githooks/pre-commit`이 자동 실행됩니다: +1. Frontend TypeScript 타입 체크 +2. Frontend ESLint 검증 +3. Backend TypeScript 타입 체크 + +**하나라도 실패하면 커밋이 차단됩니다.** + +### 푸시 + +```bash +git push origin feature/user-profile +``` + +### Claude 스킬 활용 + +``` +/push # 변경사항 확인 → 커밋 → 푸시 (한 번에) +/mr # 커밋 → 푸시 → MR 생성 (한 번에) +/mr main # 커밋 → 푸시 → main으로 MR 생성 +``` + +--- + +## 5. MR 생성 (feature → develop) + +### Gitea에서 MR 생성 + +1. https://gitea.gc-si.dev/gc/wing-ops/compare/develop...feature/user-profile +2. 제목: Conventional Commits 형식 +3. 본문: 변경 내용 요약 + 테스트 계획 + +### Claude로 MR 생성 + +``` +/create-mr develop # feature → develop MR 자동 생성 +``` + +### MR 본문 템플릿 + +```markdown +## Summary +- 사용자 프로필 페이지 추가 +- 프로필 수정 API 연동 + +## 변경 파일 +- frontend/src/components/views/ProfileView.tsx (신규) +- backend/src/users/userRouter.ts (수정) + +## Test plan +- [ ] 프로필 페이지 접근 확인 +- [ ] 프로필 수정 후 저장 확인 +``` + +### 머지 후 + +```bash +# 로컬 develop 동기화 +git checkout develop +git pull origin develop +``` + +--- + +## 6. 릴리즈 PR (develop → main) + +develop에 기능이 머지된 후, 배포를 위해 main으로 릴리즈 MR을 생성합니다. + +### Claude로 릴리즈 MR 생성 + +``` +/release # develop → main 릴리즈 MR 자동 생성 +``` + +### 릴리즈 MR 체크리스트 + +```markdown +## Release v2.x.x + +### 포함 기능 +1. feat(auth): Google OAuth 로그인 +2. fix(map): 레이어 오류 수정 + +### 배포 전 확인 +- [ ] 로컬 빌드 성공 (frontend + backend) +- [ ] 서버 환경변수 설정 완료 +- [ ] DB 마이그레이션 적용 (필요 시) +``` + +### main 머지 → 자동 배포 트리거 + +main에 머지되면 `.gitea/workflows/deploy.yml`이 자동 실행됩니다. + +--- + +## 7. 자동 배포 + +### CI/CD 파이프라인 (Gitea Actions) + +``` +main 브랜치 push + ↓ +[Frontend] npm ci → vite build → /deploy/wing-demo/ + ↓ +[Backend] npm ci → tsc → /deploy/wing-demo-backend/ + ↓ +[Server] .deploy-trigger 감지 → wing-demo-api 재시작 +``` + +### 배포 환경 + +| 항목 | 값 | +|------|---| +| 프론트엔드 | https://wing-demo.gc-si.dev | +| 백엔드 API | https://wing-demo.gc-si.dev/api/ | +| 서버 | rocky-211 (Rocky Linux 9.6) | +| 프로세스 | systemd `wing-demo-api.service` | + +### 배포 확인 + +```bash +# 프론트엔드 응답 확인 +curl -s -o /dev/null -w '%{http_code}' https://wing-demo.gc-si.dev/ + +# 백엔드 API 확인 +curl -s https://wing-demo.gc-si.dev/api/auth/me +``` + +### 환경변수 관리 + +| 위치 | 용도 | +|------|------| +| systemd 서비스 파일 | 서버 런타임 환경변수 (DB, JWT 등) | +| Gitea Secrets | CI/CD 빌드 시 환경변수 (API 키 등) | + +```bash +# Gitea Secret 등록 (API) +curl -X PUT "https://gitea.gc-si.dev/api/v1/repos/gc/wing-ops/actions/secrets/KEY_NAME" \ + -H "Authorization: token " \ + -H "Content-Type: application/json" \ + -d '{"data":"secret-value"}' + +# Gitea Secret 등록 (Web UI) +# Settings → Actions → Secrets → Add Secret +``` + +--- + +## 8. 프로젝트 문서 최신화 + +### 자동 관리되는 문서 + +Claude 세션 중 커밋/컴팩트 시 hook이 자동으로 갱신을 안내합니다: + +| 문서 | 위치 | 갱신 시점 | +|------|------|----------| +| MEMORY.md | `~/.claude/projects/.../memory/` | 매 세션 | +| project-snapshot.md | 위와 동일 | 구조 변경 시 | +| project-history.md | 위와 동일 | 매 커밋 | +| api-types.md | 위와 동일 | API 변경 시 | +| CHANGELOG.md | `docs/CHANGELOG.md` | 매 커밋 | + +### 수동으로 최신화해야 하는 문서 + +| 문서 | 위치 | 갱신 주기 | +|------|------|----------| +| CLAUDE.md | 프로젝트 루트 | 기술 스택 변경 시 | +| INSTALL_GUIDE.md | `docs/` | 배포 환경 변경 시 | +| auth_init.sql | `database/` | DB 스키마 변경 시 | + +### 주기적 최신화 체크리스트 + +**매 기능 개발 완료 시:** +``` +Claude에게: "memory 파일 최신화해줘" +→ project-snapshot.md 갱신 +→ api-types.md 갱신 (API 변경 시) +→ project-history.md에 변경 이력 추가 +``` + +**매주 또는 스프린트 종료 시:** +``` +Claude에게: "프로젝트 문서 전체 점검해줘" +→ CLAUDE.md 기술 스택 확인 +→ CHANGELOG.md 누락 항목 보충 +→ 의존성 버전 확인 (npm outdated) +``` + +**팀 워크플로우 업데이트 시:** +``` +/sync-team-workflow # Gitea에서 최신 팀 규칙 동기화 +``` + +--- + +## 9. 실전 예시: 기능 추가 A to Z + +### 시나리오: "사고 상세 조회 페이지 추가" + +#### Step 1. 계획 + +``` +사용자: "사고 상세 조회 페이지를 추가해줘. 사고 목록에서 클릭하면 상세 정보를 보여주는 페이지야" + +Claude: (Plan Mode 진입) + → 코드베이스 분석 + → 구현 계획 제시: + 1. backend/src/incidents/incidentService.ts - 상세 조회 함수 + 2. backend/src/incidents/incidentRouter.ts - GET /api/incidents/:id + 3. frontend/src/services/incidentApi.ts - API 함수 + 4. frontend/src/components/views/IncidentDetailView.tsx - 상세 화면 + 5. frontend/src/App.tsx - 라우팅 연결 + +사용자: (계획 승인) +``` + +#### Step 2. 브랜치 생성 + +```bash +git checkout develop +git pull origin develop +git checkout -b feature/incident-detail +``` + +#### Step 3. 개발 + +Claude가 계획에 따라 코드 작성 (5개 파일 수정/생성) + +#### Step 4. 커밋 & 푸시 + +``` +사용자: "커밋하고 푸시해줘" + +Claude: + git add backend/src/incidents/... frontend/src/... + git commit -m "feat(incidents): 사고 상세 조회 페이지 추가" + # pre-commit: TypeScript ✓, ESLint ✓ + git push origin feature/incident-detail +``` + +또는 스킬 사용: + +``` +/push feat(incidents): 사고 상세 조회 페이지 추가 +``` + +#### Step 5. develop MR + +``` +사용자: "develop MR 만들어줘" + +Claude: + → Gitea API로 MR 생성 + → feature/incident-detail → develop + → MR #5: https://gitea.gc-si.dev/gc/wing-ops/pulls/5 +``` + +또는: +``` +/create-mr develop +``` + +#### Step 6. 코드 리뷰 & 머지 + +- Gitea에서 MR 리뷰 +- 승인 후 Squash Merge + +#### Step 7. 릴리즈 PR + +``` +사용자: "main으로 릴리즈 MR 만들어줘" + +Claude: + → develop → main MR 생성 + → MR #6 (release) +``` + +또는: +``` +/release +``` + +#### Step 8. main 머지 → 자동 배포 + +- main에 머지 → Gitea Actions 실행 +- Frontend 빌드 (Vite) → /deploy/wing-demo/ +- Backend 빌드 (tsc) → /deploy/wing-demo-backend/ +- .deploy-trigger → cron이 감지 → wing-demo-api 재시작 +- https://wing-demo.gc-si.dev 에서 확인 + +#### Step 9. 문서 최신화 + +``` +사용자: "memory 파일 최신화해줘" + +Claude: + → project-snapshot.md: incidents 모듈 추가 반영 + → api-types.md: GET /api/incidents/:id 추가 + → project-history.md: "사고 상세 조회 페이지 추가" 기록 +``` + +--- + +## 부록: 자주 쓰는 Claude 명령 + +| 명령 | 설명 | +|------|------| +| `"커밋하고 푸시해줘"` | 변경사항 커밋 + 푸시 | +| `"develop MR 만들어줘"` | feature → develop MR | +| `"memory 최신화해줘"` | 프로젝트 문서 갱신 | +| `/push` | 커밋 + 푸시 (스킬) | +| `/mr` | 커밋 + 푸시 + MR (스킬) | +| `/release` | develop → main 릴리즈 MR (스킬) | +| `/create-mr develop` | MR만 생성 (스킬) | +| `/sync-team-workflow` | 팀 워크플로우 동기화 (스킬) | +| `/changelog` | CHANGELOG.md 갱신 (스킬) | diff --git a/docs/_backup_20260301/INSTALL_GUIDE.md b/docs/_backup_20260301/INSTALL_GUIDE.md new file mode 100755 index 0000000..227bcd9 --- /dev/null +++ b/docs/_backup_20260301/INSTALL_GUIDE.md @@ -0,0 +1,165 @@ +# WING 해양환경 위기대응 통합시스템 - 설치 매뉴얼 + +## 1. 필수 소프트웨어 + +| 소프트웨어 | 최소 버전 | 용도 | 다운로드 | +|-----------|----------|------|---------| +| Node.js | v20 이상 (권장 v25) | 프론트엔드/백엔드 실행 | https://nodejs.org | +| npm | v10 이상 | 패키지 관리 | Node.js에 포함 | + +> **오프라인 환경**: 인터넷이 안 되는 망에서는 `node_modules`가 포함된 압축 파일을 사용하세요 (아래 "오프라인 설치" 참고). + +--- + +## 2. 프로젝트 구조 + +``` +wing/ +├── frontend/ # React + Vite 프론트엔드 (포트 5173) +│ ├── src/ +│ │ ├── components/ # UI 컴포넌트 +│ │ ├── data/ # 정적 데이터 +│ │ ├── hooks/ # 커스텀 훅 +│ │ ├── types/ # TypeScript 타입 정의 +│ │ ├── utils/ # 유틸리티 함수 +│ │ └── store/ # 상태관리 +│ └── package.json +├── backend/ # Express 백엔드 API (포트 3001) +│ ├── src/ +│ │ ├── routes/ # API 라우트 +│ │ ├── middleware/ # 미들웨어 (보안 등) +│ │ ├── db/ # DB 연결 +│ │ └── server.ts # 서버 엔트리 +│ └── package.json +└── database/ # DB 초기화 SQL + ├── database_init.sql + └── auth_init.sql +``` + +--- + +## 3. 온라인 설치 (인터넷 가능한 환경) + +### 3-1. 의존성 설치 + +```bash +# 프론트엔드 +cd wing/frontend +npm install + +# 백엔드 +cd ../backend +npm install +``` + +### 3-2. 데이터베이스 설정 + +운영 PostgreSQL에 직접 연결합니다. `backend/.env` 파일에서 DB 연결 정보를 설정하세요. + +```bash +# backend/.env +AUTH_DB_HOST= +AUTH_DB_PORT=5432 +AUTH_DB_NAME=wing_auth +AUTH_DB_USER=wing_auth +AUTH_DB_PASSWORD=<비밀번호> +``` + +> 신규 DB 초기화가 필요한 경우 `database/auth_init.sql`을 실행하세요. + +### 3-3. 백엔드 실행 + +```bash +cd wing/backend +npm run dev +``` + +→ `http://localhost:3001` 에서 API 서버 시작 + +### 3-4. 프론트엔드 실행 + +```bash +cd wing/frontend +npm run dev +``` + +→ `http://localhost:5173` 에서 웹 앱 시작 + +--- + +## 4. 오프라인 설치 (폐쇄망/다른 망) + +인터넷이 안 되는 환경에서는 `npm install`이 불가능합니다. +이 경우 **node_modules 포함 압축 파일**을 사용하세요. + +### 4-1. 압축 해제 + +```bash +# wing_full.tar.gz 파일을 작업 폴더에 복사한 뒤: +tar -xzf wing_full.tar.gz +``` + +### 4-2. Node.js 설치 + +대상 PC에 Node.js가 없으면 오프라인 설치 파일(.msi 또는 .pkg)을 미리 준비하여 설치합니다. + +- Windows: `node-v25.x.x-x64.msi` +- macOS: `node-v25.x.x.pkg` + +### 4-3. DB 연결 설정 + +`backend/.env` 파일에서 연결 가능한 PostgreSQL 정보를 설정합니다. + +### 4-4. 실행 + +node_modules가 이미 포함되어 있으므로 바로 실행 가능합니다. + +```bash +# 터미널 1 - 백엔드 +cd wing/backend +npm run dev + +# 터미널 2 - 프론트엔드 +cd wing/frontend +npm run dev +``` + +--- + +## 5. 접속 정보 요약 + +| 서비스 | URL | 비고 | +|--------|-----|------| +| 프론트엔드 (WING) | http://localhost:5173 | Vite dev server | +| 백엔드 API | http://localhost:3001 | Express | +| PostgreSQL | 운영 DB 직접 연결 | `.env` 설정 참조 | + +--- + +## 6. 주요 명령어 + +```bash +# 프론트엔드 빌드 (배포용) +cd frontend && npm run build # dist/ 폴더에 정적 파일 생성 + +# 백엔드 빌드 +cd backend && npm run build # dist/ 폴더에 JS 생성 + +# DB 시드 데이터 입력 +cd backend && npm run db:seed + +# TypeScript 타입 체크 +cd frontend && npx tsc --noEmit +``` + +--- + +## 7. 트러블슈팅 + +| 증상 | 해결 | +|------|------| +| `npm run dev` 실행 시 포트 충돌 | `lsof -i :5173` 또는 `lsof -i :3001`로 확인 후 프로세스 종료 | +| `EACCES` 권한 오류 | `sudo chown -R $(whoami) wing/` | +| 프론트엔드에서 API 호출 실패 | 백엔드(`localhost:3001`)가 실행 중인지 확인 | +| DB 연결 실패 | `backend/.env`의 DB 연결 정보 확인, PostgreSQL 접근 가능 여부 확인 | +| `MODULE_NOT_FOUND` 오류 | `npm install` 재실행 (온라인) 또는 node_modules 포함 압축본 사용 | diff --git a/docs/_backup_20260301/MENU-TAB-GUIDE.md b/docs/_backup_20260301/MENU-TAB-GUIDE.md new file mode 100644 index 0000000..e9bfb70 --- /dev/null +++ b/docs/_backup_20260301/MENU-TAB-GUIDE.md @@ -0,0 +1,194 @@ +# WING 메뉴 탭 추가 가이드 + +새로운 메뉴 탭을 추가할 때 필요한 절차를 설명합니다. + +## 메뉴 시스템 구조 + +``` +DB: AUTH_SETTING (menu.config JSON) + ↕ GET/PUT /api/menus +Backend: settingsService.ts (DEFAULT_MENU_CONFIG, VALID_MENU_IDS) + ↕ API +Frontend: menuStore.ts → TopBar.tsx (탭 렌더링) + → App.tsx (renderView 라우팅) +``` + +- **DB**가 메뉴 정의의 단일 소스 (id, label, icon, enabled, order) +- **TopBar**는 `enabled && hasPermission` 조건으로 탭을 필터링하고 `order` 순 정렬 +- **App.tsx**의 `renderView`가 탭 ID에 따라 뷰 컴포넌트를 매핑 +- **admin** 탭은 메뉴 관리 대상에서 제외 (TopBar에서 별도 아이콘 버튼으로 접근) + +## 수정 파일 요약 + +| 순서 | 파일 | 작업 | 필수 | +|------|------|------|------| +| 1 | `frontend/src/tabs/{탭명}/components/XxxView.tsx` | 뷰 컴포넌트 생성 | O | +| 2 | `frontend/src/tabs/{탭명}/index.ts` | re-export 생성 | O | +| 3 | `frontend/src/App.tsx` | MainTab 타입 + import + renderView | O | +| 4 | `backend/src/settings/settingsService.ts` | DEFAULT_MENU_CONFIG에 항목 추가 | O | +| 5 | `database/auth_init.sql` | menu.config 초기 JSON에 추가 | O | +| 6 | 관리자 UI | 메뉴 관리에서 활성화 | O | + +## Step 1: 뷰 컴포넌트 생성 + +`frontend/src/tabs/{탭명}/components/` 에 새 뷰 컴포넌트를 생성합니다. + +```tsx +// frontend/src/tabs/monitoring/components/MonitoringView.tsx + +export function MonitoringView() { + return ( +
+
+

실시간 모니터링

+ {/* 뷰 콘텐츠 */} +
+
+ ) +} +``` + +`index.ts`에서 re-export합니다: +```tsx +// frontend/src/tabs/monitoring/index.ts +export { MonitoringView } from './components/MonitoringView' +``` + +기존 탭(`@tabs/prediction`, `@tabs/weather` 등)의 레이아웃 패턴을 참고하세요. +공통 모듈은 `@common/` alias로 import합니다. + +## Step 2: App.tsx 탭 등록 + +3가지를 수정합니다. + +### 2-1. MainTab 타입에 ID 추가 + +```tsx +// frontend/src/App.tsx (line 20) + +// Before +export type MainTab = 'prediction' | 'hns' | ... | 'admin' + +// After +export type MainTab = 'prediction' | 'hns' | ... | 'monitoring' | 'admin' +``` + +### 2-2. 뷰 컴포넌트 import + +```tsx +import { MonitoringView } from '@tabs/monitoring' +``` + +### 2-3. renderView switch에 case 추가 + +```tsx +const renderView = () => { + switch (activeMainTab) { + // ... 기존 case들 ... + case 'monitoring': + return + // ... + } +} +``` + +## Step 3: 백엔드 메뉴 설정 등록 + +`backend/src/settings/settingsService.ts`의 `DEFAULT_MENU_CONFIG` 배열에 항목을 추가합니다. + +```typescript +const DEFAULT_MENU_CONFIG: MenuConfigItem[] = [ + // ... 기존 10개 메뉴 ... + { id: 'monitoring', label: '실시간 모니터링', icon: '📡', enabled: true, order: 11 }, +] +``` + +`VALID_MENU_IDS`는 `DEFAULT_MENU_CONFIG`에서 자동 파생되므로 별도 수정 불필요합니다. + +```typescript +const VALID_MENU_IDS = DEFAULT_MENU_CONFIG.map(m => m.id) // 자동 포함됨 +``` + +> **주의**: `updateMenuConfig()`은 `VALID_MENU_IDS.length` 개수 전체가 포함되어야 저장을 허용합니다. +> 기존 운영 DB에 새 메뉴가 없는 상태에서도 `getMenuConfig()`의 fallback이 DEFAULT_MENU_CONFIG을 반환하므로 정상 동작합니다. + +## Step 4: DB 초기 데이터 업데이트 + +`database/auth_init.sql`의 `menu.config` 초기 JSON에 새 항목을 추가합니다. + +```sql +INSERT INTO AUTH_SETTING (SETTING_KEY, SETTING_VAL, SETTING_DC, MDFCN_DTM) VALUES +('menu.config', '[ + {"id":"prediction","label":"유출유 확산예측","icon":"🛢️","enabled":true,"order":1}, + ...기존 메뉴들... + {"id":"monitoring","label":"실시간 모니터링","icon":"📡","enabled":true,"order":11} +]', '메뉴 구성 설정', NOW()) +ON CONFLICT (SETTING_KEY) DO NOTHING; +``` + +> **참고**: 이 SQL은 신규 설치 시에만 적용됩니다. 기존 운영 DB는 관리자 UI에서 메뉴를 관리합니다. + +## Step 5: 관리자 메뉴 관리에서 활성화 + +코드 배포 후: +1. 관리자 계정으로 로그인 +2. 관리자 패널(⚙️) → 메뉴 관리 탭 +3. 새 메뉴가 목록에 표시됨 +4. 활성/비활성 토글, 순서, 라벨, 아이콘을 설정 +5. "변경사항 저장" 클릭 + +> 기존 DB에 새 메뉴 ID가 없으면 `getMenuConfig()`가 DEFAULT_MENU_CONFIG fallback을 사용하여 새 메뉴가 자동으로 목록에 나타납니다. + +## 실전 예시: "모니터링" 탭 추가 + +### 1. 뷰 컴포넌트 생성 + +```bash +# frontend/src/components/views/MonitoringView.tsx 파일 생성 +``` + +### 2. App.tsx 수정 (3곳) + +```diff ++ import { MonitoringView } from './components/views/MonitoringView' + +- export type MainTab = 'prediction' | 'hns' | 'rescue' | 'reports' | 'aerial' | 'assets' | 'scat' | 'incidents' | 'board' | 'weather' | 'admin' ++ export type MainTab = 'prediction' | 'hns' | 'rescue' | 'reports' | 'aerial' | 'assets' | 'scat' | 'incidents' | 'board' | 'weather' | 'monitoring' | 'admin' + + const renderView = () => { + switch (activeMainTab) { + // ... ++ case 'monitoring': ++ return + case 'admin': + return + } + } +``` + +### 3. settingsService.ts 수정 + +```diff + const DEFAULT_MENU_CONFIG: MenuConfigItem[] = [ + // ... 기존 메뉴들 ... + { id: 'incidents', label: '통합조회', icon: '🔍', enabled: true, order: 10 }, ++ { id: 'monitoring', label: '실시간 모니터링', icon: '📡', enabled: true, order: 11 }, + ] +``` + +### 4. auth_init.sql 수정 + +menu.config JSON에 새 항목 추가 (신규 설치용) + +### 5. 배포 후 관리자 UI에서 활성화 + +## 체크리스트 + +- [ ] 뷰 컴포넌트 생성 (`frontend/src/components/views/`) +- [ ] `MainTab` 타입 업데이트 (`App.tsx`) +- [ ] import 및 renderView switch case 추가 (`App.tsx`) +- [ ] `DEFAULT_MENU_CONFIG`에 추가 (`settingsService.ts`) +- [ ] `menu.config` 초기 JSON 업데이트 (`auth_init.sql`) +- [ ] TypeScript 컴파일 통과 (`cd frontend && npx tsc --noEmit`) +- [ ] ESLint 통과 (`cd frontend && npx eslint .`) +- [ ] 관리자 메뉴 관리에서 새 메뉴 표시 확인 diff --git a/docs/_backup_20260301/MOCK-TO-API-GUIDE.md b/docs/_backup_20260301/MOCK-TO-API-GUIDE.md new file mode 100644 index 0000000..684f201 --- /dev/null +++ b/docs/_backup_20260301/MOCK-TO-API-GUIDE.md @@ -0,0 +1,435 @@ +# Mock → API 전환 개발 지침 + +이 문서는 각 탭의 mock 데이터를 PostgreSQL DB + REST API 기반으로 전환할 때 따라야 할 표준 프로세스를 정의한다. +CRUD API 작성 규칙은 [CRUD-API-GUIDE.md](./CRUD-API-GUIDE.md) 참조. + +--- + +## 1. 전환 프로세스 (탭당 반복) + +### Step A. 브랜치 생성 + +`feature/{탭명}-crud` 형식으로 develop에서 분기한다. + +```bash +git checkout develop +git pull +git checkout -b feature/{탭명}-crud +``` + +### Step B. Mock 전수 조사 (필수!) + +탭 디렉토리 전체에서 mock/하드코딩 데이터를 빠짐없이 검색한다. + +**검색 키워드**: `mock`, `Mock`, `MOCK`, `sample`, `initial`, `hardcod`, `localStorage`, 인라인 배열 상수 + +```bash +grep -rn "mock\|Mock\|MOCK\|sample\|initial\|hardcod\|localStorage" frontend/src/tabs/{탭}/ +``` + +**체크리스트 형식**으로 정리한다: + +``` +□ 파일명:라인 — 변수명 (N건) — 전환방법 +□ components/AssetList.tsx:12 — MOCK_ASSETS (30건) — DB 이전 +□ services/assetService.ts:5 — INITIAL_FILTER — 프론트 상수 유지 +□ hooks/useAsset.ts:88 — localStorage.getItem('draft') — DB 이전 +``` + +board 전환 시 mock 참조 누락으로 런타임 에러가 발생한 경험이 있다. 전수 조사를 건너뛰지 말 것. + +### Step C. 프론트 상수 vs DB 데이터 판단 + +조사한 mock/하드코딩 데이터를 아래 기준으로 분류한다. + +| 분류 | 유지/이전 | 예시 | +|------|-----------|------| +| UI 전용 색상 매핑 | 프론트 상수 유지 | 상태별 뱃지 색, 심각도 색상 | +| 레이아웃/뷰 설정 | 프론트 상수 유지 | 기본 페이지 크기, 컬럼 너비 | +| 비즈니스 목록 데이터 | DB 이전 | 자산 목록, 사고 목록, 보고서 | +| 검색/필터 대상 데이터 | DB 이전 | 카테고리, 기관명, 상태값 | +| 유형/카테고리 코드 | DB 이전 또는 CHECK 제약 | 자산유형, 오염물질유형 | + +### Step D. DB 스키마 설계 + 마이그레이션 + +DDL 규칙은 [CRUD-API-GUIDE.md](./CRUD-API-GUIDE.md) (4. DB 설계 규칙) 참조. + +1. 기존 테이블 활용 가능 여부 확인 (예: ACDNT, LAYER 등) +2. `database/migration/NNN_{탭명}.sql` 파일 작성 (번호는 기존 파일 다음 순번) +3. 초기 데이터 INSERT (mock 데이터를 SQL로 변환) +4. psql로 원격 DB에 직접 실행 + +```bash +# 원격 wing DB에 마이그레이션 실행 +PGPASSWORD=Wing2026 psql -h 211.208.115.83 -U wing -d wing \ + -f database/migration/NNN_{탭명}.sql + +# 실행 결과 검증 (마이그레이션 파일 끝의 SELECT 확인) +``` + +마이그레이션 파일 규칙: +- 모든 DDL에 `IF NOT EXISTS` / `IF EXISTS` 사용 (재실행 안전) +- 파일 끝에 검증 SELECT 포함 + +### Step E. 백엔드 Service + Router 구현 + +Service/Router 패턴은 [CRUD-API-GUIDE.md](./CRUD-API-GUIDE.md) (5. Service 레이어 패턴, 6. Router 레이어 패턴) 참조. + +**HTTP 메소드 규칙** (보안취약점 가이드 준수): + +| 메소드 | 용도 | +|--------|------| +| GET | 단순 조회 (민감하지 않은 경우) | +| POST | 생성/수정/삭제 및 복잡한 조회 파라미터 | + +PUT, DELETE, PATCH는 사용하지 않는다. 자세한 내용은 [2. HTTP 메소드 정책](#2-http-메소드-정책-필독) 참조. + +**URL 패턴**: + +| URL | 설명 | +|-----|------| +| `GET /api/{domain}` | 목록 (간단한 파라미터) | +| `GET /api/{domain}/:sn` | 상세 | +| `POST /api/{domain}/list` | 목록 (복잡한 필터 파라미터) | +| `POST /api/{domain}/detail` | 상세 | +| `POST /api/{domain}/create` | 생성 | +| `POST /api/{domain}/update` | 수정 | +| `POST /api/{domain}/delete` | 삭제 | +| `GET /api/{domain}/templates` | 메타데이터/코드 조회 | + +**인증 패턴**: + +```typescript +// 현재 로그인 사용자 UUID 추출 +const userId = req.user!.sub // JWT payload의 사용자 UUID + +// ❌ 절대 사용 금지 (reports 전환 시 실제 발생한 버그) +const user = (req as unknown as { user: { id: string } }).user +const userId = user.id // undefined → DB NOT NULL 제약 위반 +``` + +구현 후 `backend/src/server.ts`에 라우터를 등록한다. + +### Step F. 프론트엔드 API 서비스 + 컴포넌트 전환 + +1. `frontend/src/tabs/{탭}/services/{탭}Api.ts` 생성 +2. API 응답 타입 (`interface Api{탭명}Item` 등) 정의 +3. API ↔ 프론트 모델 변환 함수 작성 (필요 시) +4. 정적 마스터 데이터 캐싱: 모듈 변수 또는 TanStack Query `staleTime: Infinity` +5. 컴포넌트에서 mock import → API 호출로 교체 +6. `api.post()` 사용 (`api.put()`, `api.delete()` 사용 금지) + +```typescript +// frontend/src/tabs/{탭}/services/{탭}Api.ts +import { api } from '@common/services/api' + +export interface Api{탭명}Item { + sn: number + // ... +} + +export async function fetch{탭명}List(params: { + search?: string + page?: number + size?: number +}): Promise<{ items: Api{탭명}Item[]; totalCount: number }> { + const response = await api.post('/{ 탭명}/list', params) + return response.data +} + +// 수정 — POST /update 사용 +export async function update{탭명}(sn: number, data: Update{탭명}Input): Promise { + await api.post('/{탭명}/update', { sn, ...data }) +} + +// 삭제 — POST /delete 사용 +export async function delete{탭명}(sn: number): Promise { + await api.post('/{탭명}/delete', { sn }) +} +``` + +### Step G. 빌드 검증 + +```bash +# 백엔드 TypeScript 컴파일 +cd backend && npm run build + +# 프론트엔드 타입 체크 + ESLint +cd frontend && npx tsc --noEmit && npx eslint . +``` + +빌드/린트 에러가 0건이어야 다음 단계로 진행한다. + +### Step H. 로컬 API 동작 테스트 + +```bash +# 백엔드 개발 서버 시작 +cd backend && npm run dev + +# 로그인 — 쿠키 파일 획득 +curl -s -c /tmp/wing.cookie -X POST http://localhost:3001/api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"account":"admin","password":"admin1234"}' | jq . + +# 목록 조회 +curl -s -b /tmp/wing.cookie -X POST http://localhost:3001/api/{탭명}/list \ + -H 'Content-Type: application/json' \ + -d '{"page":1,"size":10}' | jq . + +# 생성 +curl -s -b /tmp/wing.cookie -X POST http://localhost:3001/api/{탭명}/create \ + -H 'Content-Type: application/json' \ + -d '{...}' | jq . + +# 수정 +curl -s -b /tmp/wing.cookie -X POST http://localhost:3001/api/{탭명}/update \ + -H 'Content-Type: application/json' \ + -d '{"sn": 1, ...}' | jq . + +# 삭제 +curl -s -b /tmp/wing.cookie -X POST http://localhost:3001/api/{탭명}/delete \ + -H 'Content-Type: application/json' \ + -d '{"sn": 1}' | jq . +``` + +CRUD 전체 흐름(생성 → 조회 → 수정 → 삭제 → 필터)을 확인하고 테스트 데이터를 정리한다. + +### Step I. Mock 잔여 확인 + +```bash +grep -rn "mock\|Mock\|MOCK\|localStorage" frontend/src/tabs/{탭}/ +# → UI 상수(색상, 레이아웃) 외 결과 0건이어야 함 +``` + +잔여가 있으면 Step F로 돌아가 처리한다. + +### Step J. 커밋 + 푸시 + MR + +```bash +# 커밋 (Conventional Commits 형식, 한국어) +git add -p +git commit -m "feat({탭명}): mock 데이터 DB + REST API 전환" + +# 푸시 +git push -u origin feature/{탭명}-crud +``` + +`feature/{탭명}-crud` → `develop` MR을 Gitea에서 생성한다. +`/push` 또는 `/mr` 스킬 활용 가능. + +--- + +## 2. HTTP 메소드 정책 (필독) + +한국 보안취약점 점검 가이드에 따라 GET/POST만 사용한다. + +### 허용 + +| 메소드 | 용도 | 예시 | +|--------|------|------| +| GET | 단순 조회 (파라미터가 단순하고 민감하지 않은 경우) | `GET /api/reports`, `GET /api/reports/:sn` | +| POST | 생성/수정/삭제, 복잡한 필터 파라미터 조회 | `POST /api/reports/create`, `POST /api/reports/list` | + +### 금지 + +| 메소드 | 이유 | +|--------|------| +| PUT | 보안취약점 가이드 위반 | +| DELETE | 보안취약점 가이드 위반 | +| PATCH | 보안취약점 가이드 위반 | + +### 기존 API 현황 + +`boardRouter`, `userRouter`, `roleRouter` 등은 아직 PUT/DELETE를 사용 중이다. +별도 세션에서 POST 패턴으로 마이그레이션 예정. +**신규 탭 전환 시 반드시 POST 패턴을 적용한다.** + +--- + +## 3. 전환 시 주의사항 (실전 교훈) + +### 3.1 req.user 접근 패턴 + +```typescript +// 올바른 패턴 +const userId = req.user!.sub // JWT payload의 사용자 UUID + +// 잘못된 패턴 (런타임 에러 발생) +const user = (req as unknown as { user: { id: string } }).user +const userId = user.id // undefined → DB NOT NULL 제약 위반 +``` + +Reports 전환 시 실제 발생한 버그. `boardRouter.ts`의 패턴을 확인하고 `req.user!.sub`을 사용한다. + +JWT 페이로드 전체 구조: + +```typescript +interface JwtPayload { + sub: string // 사용자 UUID (USER_ID) + acnt: string // 계정명 (USER_ACNT) + name: string // 사용자명 (USER_NM) + roles: string[] // 역할 코드 목록 +} + +// 사용 예시 +const userId = req.user!.sub // UUID +const userName = req.user!.name // 이름 +``` + +### 3.2 AUTH_USER 테이블 컬럼명 + +```sql +-- 올바른 컬럼명 +SELECT u.USER_NM as author_name FROM AUTH_USER u + +-- 잘못된 컬럼명 (500 에러 발생) +SELECT u.NM as author_name FROM AUTH_USER u +``` + +Reports 전환 시 실제 발생한 버그. 반드시 `USER_NM`을 사용한다. + +`AUTH_USER` 주요 컬럼 참조: + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `USER_ID` | UUID PK | 사용자 UUID (`req.user!.sub`과 일치) | +| `USER_ACNT` | VARCHAR | 계정명 (`req.user!.acnt`와 일치) | +| `USER_NM` | VARCHAR | 사용자명 (`req.user!.name`와 일치) | +| `EMAIL` | VARCHAR | 이메일 | + +### 3.3 Mock 전수 조사 누락 + +Board 전환 시 일부 mock 참조를 놓쳐 런타임 에러가 발생했다. +[Step B](#step-b-mock-전수-조사-필수)의 전수 조사를 건너뛰지 말 것. + +특히 다음 위치를 반드시 확인한다: + +- 컴포넌트 파일 내 인라인 배열 (`const ITEMS = [{ id: 1, ... }]`) +- 커스텀 훅 초기값 (`useState([{ ... }])`) +- `localStorage.getItem` / `localStorage.setItem` 호출 +- 서비스 파일 내 하드코딩 반환값 + +### 3.4 프론트 api.put() / api.delete() 금지 + +```typescript +// 올바른 POST 패턴 +await api.post(`/reports/update`, { sn, ...input }) +await api.post(`/reports/delete`, { sn }) + +// 금지 — PUT/DELETE 사용 불가 +await api.put(`/reports/${sn}`, input) +await api.delete(`/reports/${sn}`) +``` + +### 3.5 트랜잭션 사용 시점 + +- 단일 테이블 INSERT/UPDATE: 트랜잭션 불필요 +- 다중 테이블 동시 변경 (예: 헤더 + 섹션, 보고서 + 첨부파일): 반드시 트랜잭션 사용 + +```typescript +const client = await wingPool.connect() +try { + await client.query('BEGIN') + + // 헤더 INSERT + const headerResult = await client.query( + 'INSERT INTO REPORT_HDR (...) VALUES ($1, $2) RETURNING HDR_SN', + [...] + ) + const hdrSn = headerResult.rows[0].hdr_sn + + // 섹션 INSERT (헤더 FK 참조) + for (const section of sections) { + await client.query( + 'INSERT INTO REPORT_SECT (HDR_SN, ...) VALUES ($1, ...)', + [hdrSn, ...] + ) + } + + await client.query('COMMIT') + return { hdrSn } +} catch (err) { + await client.query('ROLLBACK') + throw err +} finally { + client.release() +} +``` + +### 3.6 에러 처리 일관성 + +모든 라우트 핸들러에서 동일한 에러 처리 구조를 사용한다. + +```typescript +try { + // 비즈니스 로직 +} catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + console.error('[{탭명}] 작업 오류:', err) + res.status(500).json({ error: '처리 중 오류가 발생했습니다.' }) +} +``` + +Board의 GET 목록 라우트에서 `AuthError` 분기 누락 이슈가 있었다. +목록 조회처럼 평범해 보이는 라우트도 예외 없이 동일한 구조를 사용한다. + +### 3.7 정적 마스터 데이터 캐싱 + +코드 목록, 기관 목록 등 변경이 드문 마스터 데이터는 매 호출마다 DB 조회하지 않는다. + +```typescript +// 방법 1: 모듈 변수 캐싱 (서버 재시작 시까지 유지) +let cachedOrgList: OrgItem[] | null = null + +export async function getOrgList(): Promise { + if (cachedOrgList) return cachedOrgList + const result = await wingPool.query('SELECT * FROM ORG WHERE USE_YN = $1', ['Y']) + cachedOrgList = result.rows.map(mapOrg) + return cachedOrgList +} + +// 방법 2: TanStack Query staleTime 설정 (프론트엔드) +const { data: orgList } = useQuery({ + queryKey: ['orgList'], + queryFn: fetchOrgList, + staleTime: 1000 * 60 * 10, // 10분간 리패치 없음 +}) +``` + +--- + +## 4. 탭별 전환 우선순위 + +| # | 탭 | 난이도 | 상태 | 비고 | +|---|---|--------|------|------| +| 1 | Reports (보고서) | ★★★ | 완료 | 7개 DB 테이블, 섹션 단위 JSONB | +| 2 | Assets (방제자산) | ★★☆ | 대기 | mock 1파일 집중, ORG 테이블 활용 | +| 3 | Incidents (사고관리) | ★★★ | 대기 | mock 5파일 분산, ACDNT 테이블 존재 | +| 4 | SCAT (해안조사) | ★★★★ | 대기 | 1,084개 세그먼트, 스키마 격차 | +| 5 | Rescue (구조시나리오) | ★★★★ | 대기 | DB 미정의, 시뮬레이션 복잡 | +| 6 | Prediction (확산예측) | ★★★★★ | 대기 | 시뮬레이션 엔진 의존, 부분 API 연동 | + +제외: Weather (KHOA API 연동 완료), HNS (API 연동 완료), Board (API 연동 완료), Aerial (스켈레톤 수준) + +--- + +## 5. 완료 검증 체크리스트 (탭당) + +- [ ] 백엔드 빌드 통과 (`cd backend && npm run build`) +- [ ] 프론트 타입 체크 통과 (`cd frontend && npx tsc --noEmit`) +- [ ] 프론트 ESLint 통과 (`cd frontend && npx eslint .`) +- [ ] API CRUD 전체 테스트 (curl: 생성, 조회, 수정, 삭제, 필터) +- [ ] Mock/localStorage 잔여 0건 (UI 상수 제외) +- [ ] PUT/DELETE 사용 0건 (프론트/백엔드 모두) +- [ ] 커밋 + 푸시 + MR 생성 + +--- + +## 관련 문서 + +- [CRUD-API-GUIDE.md](./CRUD-API-GUIDE.md) — CRUD API 표준 (DB 설계, Service/Router 패턴, 권한 모델) +- [COMMON-GUIDE.md](./COMMON-GUIDE.md) — 공통 로직 (인증, 감사 로그, 메뉴, API 통신) +- [MENU-TAB-GUIDE.md](./MENU-TAB-GUIDE.md) — 새 메뉴 탭 추가 절차 diff --git a/docs/_backup_20260301/README.md b/docs/_backup_20260301/README.md new file mode 100755 index 0000000..53f349e --- /dev/null +++ b/docs/_backup_20260301/README.md @@ -0,0 +1,247 @@ +# WING-OPS (해양 방제 운영 지원 시스템) + +해양 오염 사고 대응을 위한 방제 운영 지원 시스템. +유류/HNS 확산 예측, 역추적 분석, 구조 시나리오, 항공 방제, 자산 관리, SCAT 조사, 기상/해상 정보를 통합 제공합니다. + +--- + +## 빠른 시작 + +```bash +# 1. 저장소 복제 +git clone https://gitea.gc-si.dev/gc/wing-ops.git +cd wing-ops + +# 2. Claude Code 세션 열기 +claude + +# 3. 팀 워크플로우 초기화 +/init-project +``` + +`/init-project` 실행 시 자동으로 구성되는 항목: +- `.claude/` 디렉토리 (rules, skills, scripts, settings) +- `.githooks/` (pre-commit, commit-msg 자동 검증) +- Git hooks 경로 설정 (`core.hooksPath`) +- 메모리 디렉토리 초기화 + +> 상세 설치 절차(Docker, DB, 오프라인 환경 등)는 [INSTALL_GUIDE.md](INSTALL_GUIDE.md)를 참조하세요. + +--- + +## 기술 스택 + +| 영역 | 기술 | +|------|------| +| Frontend | React 19, Vite 7, TypeScript 5.9, Tailwind CSS 3 | +| Backend | Express 4, TypeScript, PostgreSQL (pg) | +| 상태 관리 | Zustand (클라이언트), TanStack Query (서버) | +| 지도 | Leaflet + react-leaflet | +| 실시간 | Socket.IO | +| 인증 | JWT (HttpOnly Cookie), Google OAuth | +| DB | PostgreSQL 16 + PostGIS (wing 운영DB + wing_auth 인증DB) | +| CI/CD | Gitea Actions | + +--- + +## 프로젝트 구조 + +``` +wing/ +├── frontend/ React 19 + Vite + TypeScript + Tailwind +│ └── src/ +│ ├── App.tsx 메인 (탭 라우팅, 감사 로그) +│ ├── common/ 공통 모듈 (@common/ alias) +│ │ ├── components/ auth/, layer/, layout/, map/, ui/ +│ │ ├── hooks/ useLayers, useSubMenu +│ │ ├── services/ api.ts, authApi.ts, layerService.ts +│ │ ├── store/ authStore, menuStore (Zustand) +│ │ ├── types/ backtrack, boomLine, hns, navigation +│ │ └── utils/ coordinates, geo, sanitize +│ └── tabs/ 탭 단위 패키지 (@tabs/ alias) +│ ├── prediction/ 확산 예측 (OilSpillView, LeftPanel 등) +│ ├── hns/ HNS 분석 (HNSView, HNSSubstanceView 등) +│ ├── rescue/ 구조 시나리오 +│ ├── aerial/ 항공 방제 +│ ├── weather/ 해양 기상 +│ └── ... incidents, board, reports, assets, scat, admin +├── backend/ Express + TypeScript +│ └── src/ +│ ├── server.ts 진입점 + 라우터 등록 +│ ├── auth/ 인증 (JWT, OAuth, 미들웨어) +│ ├── users/ 사용자 관리 +│ ├── roles/ 역할/권한 관리 +│ ├── settings/ 시스템 설정 +│ ├── menus/ 메뉴 설정 +│ ├── audit/ 감사 로그 +│ ├── hns/ HNS 물질 검색 API +│ ├── routes/ 레이어, 시뮬레이션 +│ ├── middleware/ 보안 (입력 살균, rate-limit) +│ └── db/ DB 연결 (wingDb, authDb), seed +├── database/ SQL 스크립트 + 마이그레이션 +├── docs/ 개발 문서 +├── .claude/ 팀 워크플로우 (rules, skills, scripts) +└── .githooks/ Git hooks (pre-commit, commit-msg) +``` + +--- + +## 개발 환경 실행 + +### 사전 요구사항 +- Node.js 20+ (`.node-version`, fnm 사용) +- PostgreSQL 16+ (운영 DB에 직접 연결) + +### 실행 + +```bash +# 백엔드 (터미널 1) +cd backend && npm install && npm run dev # localhost:3001 + +# 프론트엔드 (터미널 2) +cd frontend && npm install && npm run dev # localhost:5173 +``` + +### 빌드/검증 + +```bash +# TypeScript 타입 체크 +cd frontend && npx tsc --noEmit +cd backend && npx tsc --noEmit + +# ESLint +cd frontend && npx eslint . + +# 프로덕션 빌드 +cd frontend && npm run build # dist/ 생성 +cd backend && npm run build # dist/ 생성 +``` + +--- + +## 개발 워크플로우 + +``` +계획 → 브랜치 → 개발 → 커밋/푸시 → develop MR → main PR → 자동 배포 +``` + +### Claude Code 기반 개발 절차 + +| 단계 | 작업 | Claude 스킬 | +|------|------|-------------| +| 1. 계획 | 3개+ 파일 수정 시 Claude가 Plan Mode 진입 | (자동) | +| 2. 브랜치 | `feature/기능명` 으로 develop에서 분기 | - | +| 3. 개발 | Claude가 코드 작성 + 타입/린트 검증 | - | +| 4. 커밋/푸시 | pre-commit 자동 검증 후 푸시 | `/push` | +| 5. develop MR | feature → develop MR 생성 | `/mr` | +| 6. 릴리즈 | develop → main PR 생성 | `/release` | +| 7. 배포 | main 머지 시 Gitea Actions 자동 배포 | - | + +> 상세 워크플로우는 [DEVELOPMENT-GUIDE.md](DEVELOPMENT-GUIDE.md)를 참조하세요. + +--- + +## 문서 안내 + +### 개발 가이드 + +| 문서 | 설명 | 대상 | +|------|------|------| +| [DEVELOPMENT-GUIDE.md](DEVELOPMENT-GUIDE.md) | 개발 워크플로우 전체 흐름 (Plan → Branch → MR → Deploy) | 모든 개발자 | +| [COMMON-GUIDE.md](COMMON-GUIDE.md) | 공통 로직 개발 가이드 (인증, 감사로그, 메뉴, API 통신, 상태 관리) | 탭 개발자 | +| [MENU-TAB-GUIDE.md](MENU-TAB-GUIDE.md) | 새 메뉴 탭 추가 절차 (5단계) | 탭 개발자 | + +### 운영 가이드 + +| 문서 | 설명 | 대상 | +|------|------|------| +| [INSTALL_GUIDE.md](INSTALL_GUIDE.md) | 설치 매뉴얼 (온라인/오프라인, DB 초기화) | 운영/인프라 | +| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | 모든 개발자 | + +### 코드 컨벤션 (.claude/rules/) + +| 규칙 | 설명 | +|------|------| +| `team-policy.md` | 보안/품질 정책 (필수 준수) | +| `git-workflow.md` | 브랜치/커밋/MR 규칙 | +| `code-style.md` | TypeScript/React 코드 스타일 | +| `naming.md` | 네이밍 규칙 | +| `testing.md` | 테스트 규칙 | + +--- + +## 공통 기능 요약 + +개별 탭 개발 시 아래 공통 기능을 활용합니다. +상세 사용법은 [COMMON-GUIDE.md](COMMON-GUIDE.md)를 참조하세요. + +| 기능 | 프론트엔드 | 백엔드 | 상세 | +|------|-----------|--------|------| +| 인증/인가 | `authStore`, `api.ts` (자동 쿠키) | `requireAuth`, `requireRole` | [COMMON-GUIDE.md #1](COMMON-GUIDE.md#1-인증인가) | +| 감사 로그 | 탭 이동 자동 기록 (sendBeacon) | `audit/` 모듈 | [COMMON-GUIDE.md #2](COMMON-GUIDE.md#2-감사-로그-audit-log) | +| 메뉴 시스템 | `menuStore` | `menus/`, `settings/` | [COMMON-GUIDE.md #3](COMMON-GUIDE.md#3-메뉴-시스템) | +| API 통신 | `api.ts` (Axios + 인터셉터) | Express 라우터 | [COMMON-GUIDE.md #4](COMMON-GUIDE.md#4-api-통신-패턴) | +| 상태 관리 | Zustand, TanStack Query | - | [COMMON-GUIDE.md #5](COMMON-GUIDE.md#5-상태-관리) | + +--- + +## Claude Code 스킬 + +| 스킬 | 설명 | +|------|------| +| `/push` | 커밋 + 푸시 (한 번에) | +| `/mr` | 커밋 + 푸시 + develop MR (한 번에) | +| `/release` | develop → main 릴리즈 MR | +| `/create-mr` | MR만 생성 (세부 옵션) | +| `/fix-issue` | Gitea 이슈 분석 + 수정 브랜치 생성 | +| `/sync-team-workflow` | 팀 워크플로우 동기화 | +| `/changelog` | CHANGELOG.md 갱신 | + +--- + +## 환경 변수 + +### 프론트엔드 (.env) +``` +VITE_API_URL=http://localhost:3001/api +VITE_GOOGLE_CLIENT_ID=your-google-client-id +``` + +### 백엔드 (.env) +``` +PORT=3001 +NODE_ENV=development +JWT_SECRET=your-jwt-secret +AUTH_DB_HOST=localhost +AUTH_DB_PORT=5432 +AUTH_DB_NAME=wing_auth +AUTH_DB_USER=wing_auth +AUTH_DB_PASSWORD=WingAuth!2026 +GOOGLE_CLIENT_ID=your-google-client-id +``` + +--- + +## 배포 + +| 항목 | 값 | +|------|---| +| 프론트엔드 | https://wing-demo.gc-si.dev | +| 백엔드 API | https://wing-demo.gc-si.dev/api/ | +| CI/CD | Gitea Actions (main 머지 시 자동 배포) | + +배포 파이프라인 상세는 [DEVELOPMENT-GUIDE.md #7](DEVELOPMENT-GUIDE.md#7-자동-배포)을 참조하세요. + +--- + +## 문서 최신화 규칙 + +공통 기능(인증, 감사로그, 메뉴 시스템, API 통신 등)을 추가/변경할 때: +1. 해당 기능 코드 구현 +2. `docs/COMMON-GUIDE.md` 최신화 (필수) +3. 필요 시 `CLAUDE.md` 프로젝트 구조 갱신 + +매 기능 개발 완료 시: +``` +Claude에게: "memory 파일 최신화해줘" +``` diff --git a/docs/_backup_20260301/ROOT_CLAUDE.md b/docs/_backup_20260301/ROOT_CLAUDE.md new file mode 100644 index 0000000..42410c7 --- /dev/null +++ b/docs/_backup_20260301/ROOT_CLAUDE.md @@ -0,0 +1,123 @@ +# WING-OPS (해양 방제 운영 지원 시스템) + +## 프로젝트 개요 +해양 오염 사고 대응을 위한 방제 운영 지원 시스템. +유류/HNS 확산 예측, 역추적 분석, 구조 시나리오, 항공 방제, 자산 관리, SCAT 조사, 기상/해상 정보를 통합 제공한다. + +- **프로젝트 타입**: react-ts (모노레포) +- **Frontend**: React 19 + Vite 7 + TypeScript 5.9 + Tailwind CSS 3 +- **Backend**: Express 4 + PostgreSQL (pg) + TypeScript +- **DB**: PostgreSQL 16 + PostGIS (wing 운영DB + wing_auth 인증DB) +- **상태관리**: Zustand (클라이언트), TanStack Query (서버) +- **지도**: Leaflet + react-leaflet +- **실시간**: Socket.IO + +## 빌드/실행 + +### Frontend +```bash +cd frontend +npm install +npm run dev # 개발 서버 (Vite, localhost:5173) +npm run build # 프로덕션 빌드 (tsc -b && vite build) +npm run lint # ESLint 검증 +npm run preview # 빌드 미리보기 +``` + +### Backend +```bash +cd backend +npm install +npm run dev # 개발 서버 (tsx watch, localhost:3001) +npm run build # TypeScript 컴파일 (tsc) +npm start # 프로덕션 실행 +npm run db:seed # DB 초기 데이터 +``` + +## 테스트 +테스트 프레임워크 미구성. 향후 Vitest + React Testing Library 도입 예정. + +## Lint/Format +```bash +cd frontend && npx eslint . # ESLint (flat config) +npx prettier --check . # Prettier 검증 +npx prettier --write . # Prettier 자동 수정 +``` + +## 프로젝트 구조 +``` +wing/ +├── frontend/ React 19 + Vite + TypeScript + Tailwind +│ └── src/ +│ ├── App.tsx 메인 (탭 라우팅, 감사 로그 자동 기록) +│ ├── common/ 공통 모듈 (@common/ alias) +│ │ ├── components/ auth/, layer/, layout/, map/, ui/ +│ │ ├── hooks/ useLayers, useSubMenu +│ │ ├── services/ api.ts, authApi.ts, layerService.ts +│ │ ├── store/ authStore, menuStore (Zustand) +│ │ ├── types/ backtrack, boomLine, hns, navigation +│ │ ├── utils/ coordinates, geo, sanitize +│ │ ├── data/ layerData.ts (UI 레이어 트리) +│ │ └── mock/ vesselMockData, backtrackMockData +│ └── tabs/ 탭 단위 패키지 (@tabs/ alias) +│ ├── prediction/ 확산 예측 (OilSpillView, LeftPanel 등) +│ ├── hns/ HNS 분석 (HNSView, HNSSubstanceView 등) +│ ├── rescue/ 구조 시나리오 +│ ├── aerial/ 항공 방제 +│ ├── weather/ 해양 기상 (오버레이, hooks, services) +│ ├── incidents/ 사건/사고 관리 +│ ├── board/ 게시판 +│ ├── reports/ 보고서 +│ ├── assets/ 자산 관리 +│ ├── scat/ Pre-SCAT 조사 +│ └── admin/ 관리자 (사용자/권한/메뉴/설정) +├── backend/ Express + TypeScript +│ └── src/ +│ ├── server.ts 진입점 + 라우터 등록 +│ ├── auth/ 인증 (JWT, OAuth, 미들웨어) +│ ├── users/ 사용자 관리 +│ ├── roles/ 역할/권한 관리 +│ ├── settings/ 시스템 설정 +│ ├── menus/ 메뉴 설정 +│ ├── audit/ 감사 로그 +│ ├── hns/ HNS 물질 검색 API +│ ├── routes/ 레이어, 시뮬레이션 +│ ├── middleware/ 보안 (입력 살균, rate-limit) +│ └── db/ DB 연결 (wingDb, authDb), seed +├── database/ SQL 스크립트 +│ ├── init.sql wing DB 초기 스키마 +│ ├── auth_init.sql wing_auth DB 초기 스키마 +│ └── migration/ 마이그레이션 (001_layer, 002_hns_substance) +├── docs/ 개발 문서 +├── .claude/ 팀 워크플로우 (rules, skills, scripts) +└── .githooks/ Git hooks (pre-commit, commit-msg) +``` + +### Path Alias +- `@common/*` → `src/common/*` (공통 모듈) +- `@tabs/*` → `src/tabs/*` (탭 패키지) + +## 팀 컨벤션 +`.claude/rules/` 디렉토리 참조: +- `team-policy.md` — 보안/품질 정책 +- `git-workflow.md` — 브랜치/커밋/MR 규칙 +- `code-style.md` — TypeScript/React 코드 스타일 +- `naming.md` — 네이밍 규칙 +- `testing.md` — 테스트 규칙 + +## 개발 문서 (`docs/`) +- `docs/README.md` — 프로젝트 개요, 초기 세팅, 워크플로우 요약, 문서 안내 +- `docs/DEVELOPMENT-GUIDE.md` — 개발 워크플로우 전체 흐름 (Plan → Branch → MR → Deploy) +- `docs/COMMON-GUIDE.md` — 공통 로직 개발 가이드 (인증, 감사로그, 메뉴, API 통신, 상태 관리) +- `docs/MENU-TAB-GUIDE.md` — 새 메뉴 탭 추가 절차 (5단계) +- `docs/INSTALL_GUIDE.md` — 설치 매뉴얼 (온라인/오프라인, DB) +- `docs/CHANGELOG.md` — 변경 이력 + +### 문서 최신화 규칙 +- 공통 기능(인증, 감사로그, 메뉴 시스템, API 통신 등)을 추가/변경할 때 반드시 `docs/COMMON-GUIDE.md`를 최신화할 것 +- 개별 탭 개발자는 이 문서를 참조하여 공통 영역과의 연동을 구현 + +## 환경 설정 +- Node.js 20 (`.node-version`, fnm 사용) +- npm registry: Nexus proxy (`.npmrc`) +- Git hooks: `.githooks/` (core.hooksPath 설정됨) diff --git a/docs/_backup_20260301/ROOT_README.md b/docs/_backup_20260301/ROOT_README.md new file mode 100644 index 0000000..aab89dc --- /dev/null +++ b/docs/_backup_20260301/ROOT_README.md @@ -0,0 +1,223 @@ +# WING-OPS (해양 방제 운영 지원 시스템) + +해양 오염 사고 대응을 위한 방제 운영 지원 시스템. +유류/HNS 확산 예측, 역추적 분석, 구조 시나리오, 항공 방제, 자산 관리, SCAT 조사, 기상/해상 정보를 통합 제공합니다. + +--- + +## 1. 시작하기 + +### 1-1. 저장소 복제 + +```bash +git clone https://gitea.gc-si.dev/gc/wing-ops.git +cd wing-ops +``` + +### 1-2. Claude Code 초기화 + +```bash +# Claude Code 세션 열기 +claude + +# 팀 워크플로우 초기화 +/init-project +``` + +`/init-project` 실행 시 자동으로 구성되는 항목: +- `.claude/` 디렉토리 (rules, skills, scripts, settings) +- `.githooks/` (pre-commit, commit-msg 자동 검증) +- Git hooks 경로 설정 (`core.hooksPath`) +- 메모리 디렉토리 초기화 + +### 1-3. 의존성 설치 및 실행 + +```bash +# 백엔드 (터미널 1) +cd backend && npm install && npm run dev # localhost:3001 + +# 프론트엔드 (터미널 2) +cd frontend && npm install && npm run dev # localhost:5173 +``` + +> 사전 요구사항: Node.js 20+ (`.node-version`, fnm 사용), PostgreSQL 16+ (운영 DB 직접 연결) +> +> 상세 설치 절차(오프라인 환경, DB 초기화 등)는 [docs/INSTALL_GUIDE.md](docs/INSTALL_GUIDE.md)를 참조하세요. + +--- + +## 2. 개발 워크플로우 + +``` +계획 → 브랜치 → 개발 → 커밋/푸시 → develop MR → main PR → 자동 배포 +``` + +| 단계 | 작업 | Claude 스킬 | +|------|------|-------------| +| 1. 계획 | 3개+ 파일 수정 시 Claude가 Plan Mode 진입 | (자동) | +| 2. 브랜치 | `feature/기능명` 으로 develop에서 분기 | - | +| 3. 개발 | Claude가 코드 작성 + 타입/린트 검증 | - | +| 4. 커밋/푸시 | pre-commit 자동 검증 후 푸시 | `/push` | +| 5. develop MR | feature → develop MR 생성 | `/mr` | +| 6. 릴리즈 | develop → main PR 생성 | `/release` | +| 7. 배포 | main 머지 시 Gitea Actions 자동 배포 | - | + +> 상세 워크플로우(브랜치 규칙, 커밋 형식, MR 절차, 배포 확인, 실전 예시)는 [docs/DEVELOPMENT-GUIDE.md](docs/DEVELOPMENT-GUIDE.md)를 참조하세요. + +--- + +## 3. 탭 개발 + +개별 탭(기능 화면)을 개발할 때 아래 공통 기능을 활용합니다. + +| 기능 | 프론트엔드 | 백엔드 | 상세 | +|------|-----------|--------|------| +| 인증/인가 | `authStore`, `api.ts` (자동 쿠키) | `requireAuth`, `requireRole` | [COMMON-GUIDE.md #1](docs/COMMON-GUIDE.md#1-인증인가) | +| 감사 로그 | 탭 이동 자동 기록 (sendBeacon) | `audit/` 모듈 | [COMMON-GUIDE.md #2](docs/COMMON-GUIDE.md#2-감사-로그-audit-log) | +| 메뉴 시스템 | `menuStore` | `menus/`, `settings/` | [COMMON-GUIDE.md #3](docs/COMMON-GUIDE.md#3-메뉴-시스템) | +| API 통신 | `api.ts` (Axios + 인터셉터) | Express 라우터 | [COMMON-GUIDE.md #4](docs/COMMON-GUIDE.md#4-api-통신-패턴) | +| 상태 관리 | Zustand, TanStack Query | - | [COMMON-GUIDE.md #5](docs/COMMON-GUIDE.md#5-상태-관리) | + +> 공통 로직 전체 가이드: [docs/COMMON-GUIDE.md](docs/COMMON-GUIDE.md) +> +> 새 메뉴 탭 추가 절차 (5단계): [docs/MENU-TAB-GUIDE.md](docs/MENU-TAB-GUIDE.md) + +--- + +## 4. 프로젝트 구조 + +``` +wing/ +├── frontend/ React 19 + Vite + TypeScript + Tailwind +│ └── src/ +│ ├── App.tsx 메인 (탭 라우팅, 감사 로그) +│ ├── common/ 공통 모듈 (@common/ alias) +│ │ ├── components/ auth/, layer/, layout/, map/, ui/ +│ │ ├── hooks/ useLayers, useSubMenu +│ │ ├── services/ api.ts, authApi.ts, layerService.ts +│ │ ├── store/ authStore, menuStore (Zustand) +│ │ ├── types/ backtrack, boomLine, hns, navigation +│ │ └── utils/ coordinates, geo, sanitize +│ └── tabs/ 탭 단위 패키지 (@tabs/ alias) +│ ├── prediction/ 확산 예측 +│ ├── hns/ HNS 분석 +│ ├── rescue/ 구조 시나리오 +│ ├── aerial/ 항공 방제 +│ ├── weather/ 해양 기상 +│ ├── incidents/ 사건/사고 +│ ├── board/ 게시판 +│ ├── reports/ 보고서 +│ ├── assets/ 자산 관리 +│ ├── scat/ Pre-SCAT +│ └── admin/ 관리자 +├── backend/ Express + TypeScript +│ └── src/ +│ ├── server.ts 진입점 + 라우터 등록 +│ ├── auth/ 인증 (JWT, OAuth, 미들웨어) +│ ├── users/ 사용자 관리 +│ ├── roles/ 역할/권한 관리 +│ ├── settings/ 시스템 설정 +│ ├── menus/ 메뉴 설정 +│ ├── audit/ 감사 로그 +│ ├── hns/ HNS 물질 검색 API +│ ├── routes/ 레이어, 시뮬레이션 +│ ├── middleware/ 보안 (입력 살균, rate-limit) +│ └── db/ DB 연결 (wingDb, authDb), seed +├── database/ SQL 스크립트 + 마이그레이션 +├── docs/ 개발 문서 +├── .claude/ 팀 워크플로우 (rules, skills, scripts) +└── .githooks/ Git hooks (pre-commit, commit-msg) +``` + +--- + +## 5. 기술 스택 + +| 영역 | 기술 | +|------|------| +| Frontend | React 19, Vite 7, TypeScript 5.9, Tailwind CSS 3 | +| Backend | Express 4, TypeScript, PostgreSQL (pg) | +| 상태 관리 | Zustand (클라이언트), TanStack Query (서버) | +| 지도 | Leaflet + react-leaflet | +| 실시간 | Socket.IO | +| 인증 | JWT (HttpOnly Cookie), Google OAuth | +| DB | PostgreSQL 16 + PostGIS (wing 운영DB + wing_auth 인증DB) | +| CI/CD | Gitea Actions | + +--- + +## 6. 문서 안내 + +### 개발 가이드 + +| 문서 | 설명 | 대상 | +|------|------|------| +| [DEVELOPMENT-GUIDE.md](docs/DEVELOPMENT-GUIDE.md) | 개발 워크플로우 전체 흐름 (Plan → Branch → MR → Deploy) | 모든 개발자 | +| [COMMON-GUIDE.md](docs/COMMON-GUIDE.md) | 공통 로직 개발 가이드 (인증, 감사로그, 메뉴, API, 상태 관리) | 탭 개발자 | +| [MENU-TAB-GUIDE.md](docs/MENU-TAB-GUIDE.md) | 새 메뉴 탭 추가 절차 (5단계) | 탭 개발자 | + +### 운영 가이드 + +| 문서 | 설명 | 대상 | +|------|------|------| +| [INSTALL_GUIDE.md](docs/INSTALL_GUIDE.md) | 설치 매뉴얼 (온라인/오프라인, DB 초기화) | 운영/인프라 | +| [CHANGELOG.md](docs/CHANGELOG.md) | 변경 이력 | 모든 개발자 | + +### 코드 컨벤션 (.claude/rules/) + +| 규칙 | 설명 | +|------|------| +| `team-policy.md` | 보안/품질 정책 (필수 준수) | +| `git-workflow.md` | 브랜치/커밋/MR 규칙 | +| `code-style.md` | TypeScript/React 코드 스타일 | +| `naming.md` | 네이밍 규칙 | +| `testing.md` | 테스트 규칙 | + +--- + +## 7. 환경 변수 + +### 프론트엔드 (`frontend/.env`) +``` +VITE_API_URL=http://localhost:3001/api +VITE_GOOGLE_CLIENT_ID=your-google-client-id +``` + +### 백엔드 (`backend/.env`) +``` +PORT=3001 +NODE_ENV=development +JWT_SECRET=your-jwt-secret +AUTH_DB_HOST=localhost +AUTH_DB_PORT=5432 +AUTH_DB_NAME=wing_auth +AUTH_DB_USER=wing_auth +AUTH_DB_PASSWORD=<비밀번호> +GOOGLE_CLIENT_ID=your-google-client-id +``` + +--- + +## 8. 배포 + +| 항목 | 값 | +|------|---| +| 프론트엔드 | https://wing-demo.gc-si.dev | +| 백엔드 API | https://wing-demo.gc-si.dev/api/ | +| CI/CD | Gitea Actions (main 머지 시 자동 배포) | + +배포 파이프라인 상세는 [docs/DEVELOPMENT-GUIDE.md #7](docs/DEVELOPMENT-GUIDE.md#7-자동-배포)을 참조하세요. + +--- + +## 9. Claude Code 스킬 + +| 스킬 | 설명 | +|------|------| +| `/push` | 커밋 + 푸시 (한 번에) | +| `/mr` | 커밋 + 푸시 + develop MR (한 번에) | +| `/release` | develop → main 릴리즈 MR | +| `/create-mr` | MR만 생성 (세부 옵션) | +| `/fix-issue` | Gitea 이슈 분석 + 수정 브랜치 생성 | +| `/sync-team-workflow` | 팀 워크플로우 동기화 | +| `/changelog` | CHANGELOG.md 갱신 |