# 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 = { '완료': 'COMPLETED', '수행중': 'IN_PROGRESS', '테스트': 'DRAFT', }; const CODE_TO_STATUS: Record = { 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 { 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) 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 { 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 # 프론트엔드 타입 체크 + 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 { await api.put(`/{탭명}/${sn}`, input); } export async function deleteItem(sn: number): Promise { await api.delete(`/{탭명}/${sn}`); } // 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-5. 트랜잭션 사용 시점 단일 테이블 INSERT/UPDATE는 트랜잭션 없이 처리한다. 다중 테이블에 걸친 작업은 반드시 트랜잭션을 사용한다. ```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(); } } ``` --- ### 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 { 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` | DB 설계 규칙, Service/Router 구현 패턴, 권한 모델 상세 | | `COMMON-GUIDE.md` | 인증, 감사로그, 메뉴, API 통신, 상태관리 | | `MENU-TAB-GUIDE.md` | 새 메뉴 탭 추가 절차 (5단계) | | `DEVELOPMENT-GUIDE.md` | 개발 워크플로우 전체 흐름 (Plan -> Branch -> MR -> Deploy) |