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/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 실전 예시):
// 코드 <-> 한글 라벨 매핑은 프론트에서 관리
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/components/{탭명}/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/components/{탭명}/
# 공통 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/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 필드이다.
// 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 |
| VARCHAR | 이메일 | - |
4-3. Mock 전수 조사 누락 위험
탭 디렉토리만 검색하면 common/mock/, common/data/에 숨은 mock 참조를 놓친다.
# 불충분 -- 탭 디렉토리만 검색
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()를 사용하면 안 된다.
// 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/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) |