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

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

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

25 KiB

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 브랜치를 분기한다.

git checkout develop
git pull origin develop
git checkout -b feature/{탭명}-crud

브랜치 네이밍 예시: feature/board-crud, feature/scat-crud


Step B. Mock 전수 조사

해당 탭 디렉토리에서 mock 데이터를 모두 식별한다. 누락 시 전환 후 런타임 에러가 발생한다.

검색 키워드 및 명령어:

# 탭 디렉토리 내 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 실전 예시):

// 코드 <-> 한글 라벨 매핑은 프론트에서 관리
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 기준):

-- ============================================================
-- 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 실행:

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 참조.

디렉토리 생성:

mkdir -p backend/src/{탭명}

Service 패턴 (incidentsService.ts 기준):

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 기준):

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 라우터 등록:

// server.ts 상단 import 추가
import newtabRouter from './{탭명}/{탭명}Router.js';

// API 라우트 -- 업무 섹션에 추가
app.use('/api/{탭명}', newtabRouter);

Step F. 프론트엔드 API 서비스 + 컴포넌트 전환

1) API 서비스 파일 생성:

파일 위치: frontend/src/tabs/{탭명}/services/{탭명}Api.ts

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 교체 (실전 예시):

// 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 응답 형식이 다를 때 변환 함수를 작성한다.

// 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) 정적 마스터 데이터 캐싱 패턴:

변경 빈도가 낮은 마스터 데이터(템플릿, 카테고리 등)는 모듈 레벨 캐시를 사용한다.

// 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. 빌드 검증

백엔드와 프론트엔드 모두 빌드가 통과해야 한다.

# 백엔드 TypeScript 컴파일
cd backend && npm run build

# 프론트엔드 타입 체크 + ESLint
cd frontend && npx tsc --noEmit && npx eslint .

빌드/린트 에러가 0건이어야 다음 단계로 진행한다.


Step H. 로컬 API 동작 테스트

백엔드 개발 서버를 실행하고 curl로 CRUD를 순차 검증한다.

# 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 데이터가 남아 있지 않은지 최종 확인한다.

# 해당 탭 디렉토리에서 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

# 변경 파일 확인
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 필드이다.

// 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 페이로드 구조:

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 에러.

-- 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 참조를 놓친다.

# 불충분 -- 탭 디렉토리만 검색
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()를 사용하면 안 된다.

// 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는 트랜잭션 없이 처리한다. 다중 테이블에 걸친 작업은 반드시 트랜잭션을 사용한다.

// 단일 테이블 -- 트랜잭션 불필요
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 블록에서 인증 에러와 일반 에러를 구분한다.

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 호출을 줄인다.

// 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.tsapp.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)