# 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/tabs/{탭명}/

# 공통 디렉토리에서 해당 탭 관련 데이터 확인 (반드시!)
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/tabs/{탭명}/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/tabs/{탭명}/

# 공통 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/tabs/{탭명}/

# 커밋 (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/tabs/{탭명}/

# 반드시 공통 디렉토리도 검색
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/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` | DB 설계 규칙, Service/Router 구현 패턴, 권한 모델 상세 |
| `COMMON-GUIDE.md` | 인증, 감사로그, 메뉴, API 통신, 상태관리 |
| `MENU-TAB-GUIDE.md` | 새 메뉴 탭 추가 절차 (5단계) |
| `DEVELOPMENT-GUIDE.md` | 개발 워크플로우 전체 흐름 (Plan -> Branch -> MR -> Deploy) |
