799 lines
25 KiB
Markdown
799 lines
25 KiB
Markdown
# Mock-to-API 전환 가이드
|
|
|
|
Mock 데이터(하드코딩 배열, localStorage 등)를 PostgreSQL DB + REST API 기반으로 전환할 때 따라야 할 표준 프로세스를 정의한다.
|
|
|
|
> DB 스키마 설계, Service/Router 구현 패턴의 상세 사항은 `CRUD-API-GUIDE.md`를 참조한다.
|
|
> 이 문서는 **전환 프로세스 전체 흐름**과 **실전 교훈**에 집중한다.
|
|
|
|
---
|
|
|
|
## 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. 브랜치 생성
|
|
|
|
develop에서 feature 브랜치를 분기한다.
|
|
|
|
```bash
|
|
git checkout develop
|
|
git pull origin develop
|
|
git checkout -b feature/{탭명}-crud
|
|
```
|
|
|
|
브랜치 네이밍 예시: `feature/board-crud`, `feature/scat-crud`
|
|
|
|
---
|
|
|
|
### Step B. Mock 전수 조사
|
|
|
|
해당 탭 디렉토리에서 mock 데이터를 모두 식별한다. **누락 시 전환 후 런타임 에러가 발생한다.**
|
|
|
|
**검색 키워드 및 명령어:**
|
|
|
|
```bash
|
|
# 탭 디렉토리 내 mock 데이터 검색
|
|
grep -rn "mock\|Mock\|MOCK\|sample\|initial\|hardcod\|localStorage" \
|
|
frontend/src/components/{탭명}/
|
|
|
|
# 공통 디렉토리에서 해당 탭 관련 데이터 확인 (반드시!)
|
|
grep -rn "{탭명}\|{Tab}" frontend/src/common/mock/
|
|
grep -rn "{탭명}\|{Tab}" frontend/src/common/data/
|
|
```
|
|
|
|
**체크리스트 작성 형식:**
|
|
|
|
| 파일 | Mock 종류 | 데이터 내용 | DB 이전 여부 |
|
|
|------|-----------|-------------|-------------|
|
|
| LeftPanel.tsx:25 | 하드코딩 배열 | 카테고리 목록 30건 | O |
|
|
| RightPanel.tsx:88 | localStorage | 사고 상세 임시저장 | O |
|
|
| constants.ts:5 | 상수 객체 | 상태별 뱃지 색상 | X (프론트 유지) |
|
|
| hooks/useData.ts:12 | useState 초기값 | 빈 배열 + mock 주입 | O |
|
|
|
|
**교훈 (board 전환 사례):**
|
|
board 전환 시 `common/mock/` 디렉토리의 mock 참조를 누락하여 전환 후 런타임 에러가 발생했다. 탭 디렉토리만 검색하면 불충분하며, `common/mock/`과 `common/data/`도 반드시 확인할 것.
|
|
|
|
---
|
|
|
|
### Step C. 프론트 상수 vs DB 데이터 판단
|
|
|
|
모든 mock 데이터를 DB로 이전할 필요는 없다. 아래 기준으로 판단한다.
|
|
|
|
| 분류 | 판단 | 예시 |
|
|
|------|------|------|
|
|
| UI 전용 색상/아이콘 매핑 | 프론트 상수 유지 | 상태별 뱃지 색, 심각도 아이콘 |
|
|
| 고정된 코드 매핑 (ENUM) | 프론트 상수 유지 | `STATUS_TO_CODE`, `TMPL_CODE_TO_TYPE` |
|
|
| 레이아웃/뷰 설정 | 프론트 상수 유지 | 기본 페이지 크기, 컬럼 너비 |
|
|
| 비즈니스 목록 데이터 | DB 이전 | 자산 목록, 사고 목록, 보고서 |
|
|
| 검색/필터 대상 데이터 | DB 이전 | 카테고리, 기관명, 물질 목록 |
|
|
| 사용자 입력/수정 대상 | DB 이전 | 보고서, 시나리오, 조사 결과 |
|
|
|
|
**코드 매핑은 프론트에 유지한다 (reportsApi.ts 실전 예시):**
|
|
|
|
```typescript
|
|
// 코드 <-> 한글 라벨 매핑은 프론트에서 관리
|
|
const STATUS_TO_CODE: Record<ReportStatus, string> = {
|
|
'완료': 'COMPLETED',
|
|
'수행중': 'IN_PROGRESS',
|
|
'테스트': 'DRAFT',
|
|
};
|
|
|
|
const CODE_TO_STATUS: Record<string, ReportStatus> = {
|
|
COMPLETED: '완료',
|
|
IN_PROGRESS: '수행중',
|
|
DRAFT: '테스트',
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
### Step D. DB 스키마 설계 + 마이그레이션
|
|
|
|
마이그레이션 파일 번호는 **017부터** 시작한다 (001~016 사용됨).
|
|
|
|
**파일 규칙:**
|
|
|
|
- 파일명: `database/migration/NNN_{탭명}.sql` (예: `017_newtab.sql`)
|
|
- 테이블/인덱스 생성: `IF NOT EXISTS` 사용
|
|
- DROP문: `IF EXISTS` 사용
|
|
- 파일 끝에 검증 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};
|
|
```
|
|
|
|
**컬럼 네이밍 규칙:**
|
|
|
|
| 용도 | 네이밍 | 타입 | 비고 |
|
|
|------|--------|------|------|
|
|
| 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 구현
|
|
|
|
2-Layer 구조 (`{domain}Service.ts` + `{domain}Router.ts`)로 구현한다. 상세 패턴은 `CRUD-API-GUIDE.md` 참조.
|
|
|
|
**디렉토리 생성:**
|
|
|
|
```bash
|
|
mkdir -p backend/src/{탭명}
|
|
```
|
|
|
|
**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<ItemRow[]> {
|
|
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<ItemRow>(`
|
|
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<number> {
|
|
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) API 서비스 파일 생성:**
|
|
|
|
파일 위치: `frontend/src/components/{탭명}/services/{탭명}Api.ts`
|
|
|
|
```typescript
|
|
import { api } from '@common/services/api';
|
|
|
|
// ============================================================
|
|
// 타입
|
|
// ============================================================
|
|
|
|
export interface ItemListItem {
|
|
sn: number;
|
|
name: string;
|
|
sttsCd: string;
|
|
regDtm: string;
|
|
}
|
|
|
|
export interface CreateItemInput {
|
|
name: string;
|
|
}
|
|
|
|
export interface UpdateItemInput {
|
|
name?: string;
|
|
sttsCd?: string;
|
|
}
|
|
|
|
// ============================================================
|
|
// API 함수
|
|
// ============================================================
|
|
|
|
export async function fetchItems(params?: {
|
|
status?: string;
|
|
search?: string;
|
|
}): Promise<ItemListItem[]> {
|
|
const { data } = await api.get<ItemListItem[]>('/{탭명}', { params });
|
|
return data;
|
|
}
|
|
|
|
export async function fetchItem(sn: number): Promise<ItemListItem> {
|
|
const { data } = await api.get<ItemListItem>(`/{탭명}/${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<void> {
|
|
await api.post('/{탭명}/update', { sn, ...input });
|
|
}
|
|
|
|
export async function deleteItem(sn: number): Promise<void> {
|
|
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<ItemListItem[]>([]);
|
|
|
|
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<AssetOrgCompat[]> {
|
|
const { data } = await api.get<OrgListItem[]>('/assets/orgs');
|
|
return data.map(toCompat);
|
|
}
|
|
```
|
|
|
|
**4) 정적 마스터 데이터 캐싱 패턴:**
|
|
|
|
변경 빈도가 낮은 마스터 데이터(템플릿, 카테고리 등)는 모듈 레벨 캐시를 사용한다.
|
|
|
|
```typescript
|
|
// reportsApi.ts 실전 패턴
|
|
let templatesCache: ApiTemplate[] | null = null;
|
|
|
|
export async function fetchTemplates(): Promise<ApiTemplate[]> {
|
|
if (templatesCache) return templatesCache;
|
|
const res = await api.get<ApiTemplate[]>('/reports/templates');
|
|
templatesCache = res.data;
|
|
return res.data;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Step G. 빌드 검증
|
|
|
|
백엔드와 프론트엔드 모두 빌드가 통과해야 한다.
|
|
|
|
```bash
|
|
# 백엔드 TypeScript 컴파일
|
|
cd backend && npm run build
|
|
|
|
# 프론트엔드 타입 체크 + ESLint
|
|
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 .
|
|
|
|
# 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 '{"name":"테스트 항목"}' | jq .
|
|
|
|
# 5. 수정
|
|
curl -s -b /tmp/wing.cookie -X POST http://localhost:3001/api/{탭명}/update \
|
|
-H 'Content-Type: application/json' \
|
|
-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 .
|
|
|
|
# 7. 쿠키 파일 정리
|
|
rm /tmp/wing.cookie
|
|
```
|
|
|
|
CRUD 전체 흐름(생성 -> 조회 -> 수정 -> 삭제)을 확인하고 테스트 데이터를 정리한다.
|
|
|
|
---
|
|
|
|
### Step I. Mock 잔여 확인
|
|
|
|
전환 완료 후 mock 데이터가 남아 있지 않은지 최종 확인한다.
|
|
|
|
```bash
|
|
# 해당 탭 디렉토리에서 mock 잔여 검색
|
|
grep -rn "mock\|Mock\|MOCK\|localStorage" frontend/src/components/{탭명}/
|
|
|
|
# 공통 mock/data 디렉토리에서 해당 탭 관련 검색
|
|
grep -rn "{탭명}" frontend/src/common/mock/
|
|
grep -rn "{탭명}" frontend/src/common/data/
|
|
```
|
|
|
|
UI 상수(색상, 레이아웃)를 제외한 결과가 0건이어야 한다. 사용하지 않는 mock 파일은 삭제하고, import도 제거한다.
|
|
|
|
---
|
|
|
|
### Step J. 커밋 + 푸시 + MR
|
|
|
|
```bash
|
|
# 변경 파일 확인
|
|
git status
|
|
|
|
# 파일별 스테이징 (민감 파일 제외)
|
|
git add database/migration/017_{탭명}.sql
|
|
git add backend/src/{탭명}/
|
|
git add backend/src/server.ts
|
|
git add frontend/src/components/{탭명}/
|
|
|
|
# 커밋 (Conventional Commits, 한국어)
|
|
git commit -m "feat({탭명}): mock 데이터를 PostgreSQL + REST API로 전환"
|
|
|
|
# 푸시 + MR 생성
|
|
git push -u origin feature/{탭명}-crud
|
|
```
|
|
|
|
`feature/{탭명}-crud` -> `develop` MR을 Gitea에서 생성한다.
|
|
|
|
---
|
|
|
|
## 3. HTTP 메소드 정책
|
|
|
|
### GET/POST만 허용
|
|
|
|
한국 보안취약점 점검 가이드 준수를 위해 **PUT, DELETE, PATCH를 사용하지 않는다.**
|
|
|
|
| 작업 | 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 메소드가 활성화되어 있으면 취약점으로 판정된다. 모든 변경 작업은 POST로 통일하여 메소드 제한 정책을 적용한다.
|
|
|
|
### POST 마이그레이션 대상 (기존 API)
|
|
|
|
아래 모듈은 초기 구현 시 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 기반으로 구현한다.**
|
|
|
|
---
|
|
|
|
## 4. 실전 교훈
|
|
|
|
### 4-1. req.user 접근: req.user!.sub 사용
|
|
|
|
reports 전환 시 `req.user.id`로 접근하여 undefined 버그가 발생했다. JWT 페이로드의 사용자 식별자는 `sub` 필드이다.
|
|
|
|
```typescript
|
|
// Before (버그 -- reports 전환 시 실제 발생)
|
|
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)
|
|
```
|
|
|
|
**JWT 페이로드 구조:**
|
|
|
|
```typescript
|
|
interface JwtPayload {
|
|
sub: string; // 사용자 UUID (USER_ID)
|
|
acnt: string; // 계정명 (USER_ACNT)
|
|
name: string; // 사용자명 (USER_NM)
|
|
roles: string[]; // 역할 코드 목록
|
|
}
|
|
// 사용: req.user!.sub, req.user!.name, req.user!.acnt
|
|
```
|
|
|
|
---
|
|
|
|
### 4-2. AUTH_USER 컬럼명: USER_NM (NM 아님)
|
|
|
|
사용자 이름 컬럼은 `NM`이 아니라 `USER_NM`이다. reports 전환 시 실제 발생한 500 에러.
|
|
|
|
```sql
|
|
-- Before (500 에러)
|
|
SELECT u.NM AS author_name FROM AUTH_USER u WHERE USER_ID = $1;
|
|
|
|
-- After (정상)
|
|
SELECT u.USER_NM AS author_name FROM AUTH_USER u WHERE USER_ID = $1;
|
|
```
|
|
|
|
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 | 이메일 | - |
|
|
|
|
---
|
|
|
|
### 4-3. Mock 전수 조사 누락 위험
|
|
|
|
탭 디렉토리만 검색하면 `common/mock/`, `common/data/`에 숨은 mock 참조를 놓친다.
|
|
|
|
```bash
|
|
# 불충분 -- 탭 디렉토리만 검색
|
|
grep -rn "mock" frontend/src/components/{탭명}/
|
|
|
|
# 반드시 공통 디렉토리도 검색
|
|
grep -rn "{탭명}\|{Tab}" frontend/src/common/mock/
|
|
grep -rn "{탭명}\|{Tab}" frontend/src/common/data/
|
|
```
|
|
|
|
특히 다음 위치를 반드시 확인한다:
|
|
- 컴포넌트 파일 내 인라인 배열 (`const ITEMS = [{ id: 1, ... }]`)
|
|
- 커스텀 훅 초기값 (`useState([{ ... }])`)
|
|
- `localStorage.getItem` / `localStorage.setItem` 호출
|
|
- 서비스 파일 내 하드코딩 반환값
|
|
|
|
---
|
|
|
|
### 4-4. api.put() / api.delete() 사용 금지
|
|
|
|
프론트엔드 API 서비스에서 `api.put()`, `api.delete()`를 사용하면 안 된다.
|
|
|
|
```typescript
|
|
// Before (금지)
|
|
export async function updateItem(sn: number, input: UpdateInput): Promise<void> {
|
|
await api.put(`/{탭명}/${sn}`, input);
|
|
}
|
|
export async function deleteItem(sn: number): Promise<void> {
|
|
await api.delete(`/{탭명}/${sn}`);
|
|
}
|
|
|
|
// After (정상 -- POST 사용)
|
|
export async function updateItem(sn: number, input: UpdateInput): Promise<void> {
|
|
await api.post('/{탭명}/update', { sn, ...input });
|
|
}
|
|
export async function deleteItem(sn: number): Promise<void> {
|
|
await api.post('/{탭명}/delete', { sn });
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 4-5. 트랜잭션 사용 시점
|
|
|
|
단일 테이블 INSERT/UPDATE는 트랜잭션 없이 처리한다. 다중 테이블에 걸친 작업은 반드시 트랜잭션을 사용한다.
|
|
|
|
```typescript
|
|
// 단일 테이블 -- 트랜잭션 불필요
|
|
export async function createItem(userId: string, input: CreateInput): Promise<number> {
|
|
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<number> {
|
|
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();
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 4-6. 에러 처리 일관성
|
|
|
|
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<Category[]> {
|
|
const { data } = await api.get<Category[]>('/{탭명}/categories');
|
|
return data;
|
|
}
|
|
|
|
// After (캐싱 적용 -- reportsApi.ts 실전 패턴)
|
|
let categoriesCache: Category[] | null = null;
|
|
|
|
export async function fetchCategories(): Promise<Category[]> {
|
|
if (categoriesCache) return categoriesCache;
|
|
const { data } = await api.get<Category[]>('/{탭명}/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/components/{탭명}/` (UI 상수 제외)
|
|
- [ ] PUT/DELETE 사용 0건: `grep -rn "api\.put\|api\.delete" frontend/src/components/{탭명}/`
|
|
- [ ] 라우터 등록 확인: `server.ts`에 `app.use('/api/{탭명}', ...)` 추가됨
|
|
- [ ] 마이그레이션 실행 확인: psql로 테이블 생성 및 검증 SELECT 통과
|
|
- [ ] 커밋 + 푸시 + MR 생성
|
|
|
|
---
|
|
|
|
## 7. 관련 문서
|
|
|
|
| 문서 | 내용 |
|
|
|------|------|
|
|
| `CRUD-API-GUIDE.md` | DB 설계 규칙, Service/Router 구현 패턴, 권한 모델 상세 |
|
|
| `COMMON-GUIDE.md` | 인증, 감사로그, 메뉴, API 통신, 상태관리 |
|
|
| `MENU-TAB-GUIDE.md` | 새 메뉴 탭 추가 절차 (5단계) |
|
|
| `DEVELOPMENT-GUIDE.md` | 개발 워크플로우 전체 흐름 (Plan -> Branch -> MR -> Deploy) |
|