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>
1547 lines
44 KiB
Markdown
1547 lines
44 KiB
Markdown
# CRUD API 개발 가이드
|
|
|
|
새로운 도메인의 CRUD API 엔드포인트를 개발하는 전체 절차를 설명한다.
|
|
DB 설계부터 백엔드 구현, 프론트엔드 연동까지 End-to-End 패턴을 제공한다.
|
|
|
|
---
|
|
|
|
## 목차
|
|
|
|
1. [아키텍처 개요](#아키텍처-개요)
|
|
2. [백엔드 API 개발 패턴](#백엔드-api-개발-패턴)
|
|
3. [DB 마이그레이션 작성법](#db-마이그레이션-작성법)
|
|
4. [프론트엔드 API 서비스 작성법](#프론트엔드-api-서비스-작성법)
|
|
5. [전체 예시: 장비 관리 API](#전체-예시-장비-관리-api)
|
|
|
|
---
|
|
|
|
## 아키텍처 개요
|
|
|
|
### 3-Layer 구조
|
|
|
|
```
|
|
[Frontend] [Backend] [Database]
|
|
tabs/{탭}/services/{tab}Api.ts src/{domain}/{domain}Router.ts PostgreSQL 16
|
|
Axios (withCredentials: true) requireAuth -> requirePermission
|
|
--HTTP--> src/{domain}/{domain}Service.ts wingPool / authPool
|
|
wingPool.query(SQL, params) --SQL-->
|
|
```
|
|
|
|
### 레이어별 책임
|
|
|
|
| 레이어 | 파일 | 책임 |
|
|
|--------|------|------|
|
|
| **Router** | `{domain}Router.ts` | HTTP 요청 파싱, 파라미터 검증, 미들웨어 적용, 에러 응답 |
|
|
| **Service** | `{domain}Service.ts` | 비즈니스 로직, DB 쿼리, 도메인 검증, 소유자 검증 |
|
|
| **Frontend API** | `{tabName}Api.ts` | Axios 호출, 인터페이스 정의, 응답 변환 |
|
|
|
|
### DB Pool 선택 기준
|
|
|
|
```ts
|
|
import { wingPool } from '../db/wingDb.js'; // 업무 데이터 (BOARD_POST, LAYER 등)
|
|
import { authPool } from '../db/authDb.js'; // 인증 데이터 (AUTH_USER, AUTH_ROLE 등)
|
|
```
|
|
|
|
> **참고**: `authPool`은 `wingPool`의 re-export이다 (동일 서버, search_path = wing, auth, public).
|
|
> 신규 코드는 `wingPool`을 사용한다. 다만 의미적으로 인증 데이터를 다룰 때 `authPool`을 쓰는 것도 허용한다.
|
|
|
|
### HTTP 메서드 정책
|
|
|
|
**GET/POST only** (보안취약점 가이드 준수, PUT/DELETE 미사용 권장)
|
|
|
|
| 작업 | HTTP 메서드 | URL 패턴 | 예시 |
|
|
|------|------------|----------|------|
|
|
| 목록 조회 | `GET` | `/api/{domain}` | `GET /api/equipment` |
|
|
| 상세 조회 | `GET` | `/api/{domain}/:sn` | `GET /api/equipment/42` |
|
|
| 등록 | `POST` | `/api/{domain}` | `POST /api/equipment` |
|
|
| 수정 | `POST` | `/api/{domain}/:sn/update` | `POST /api/equipment/42/update` |
|
|
| 삭제 | `POST` | `/api/{domain}/:sn/delete` | `POST /api/equipment/42/delete` |
|
|
|
|
> **참고**: 기존 board 등 일부 레거시 API는 PUT/DELETE를 사용한다. 신규 개발 시 GET/POST only 정책을 따른다.
|
|
|
|
---
|
|
|
|
## 백엔드 API 개발 패턴
|
|
|
|
### Router + Service 2레이어 구조
|
|
|
|
```
|
|
backend/src/{domain}/
|
|
{domain}Router.ts Express 라우터 (요청 파싱, 응답 포맷)
|
|
{domain}Service.ts 비즈니스 로직 + DB 쿼리
|
|
```
|
|
|
|
### 인증 미들웨어 적용 패턴
|
|
|
|
3단계 미들웨어를 조합하여 사용한다:
|
|
|
|
```ts
|
|
import { requireAuth, requireRole, requirePermission } from '../auth/authMiddleware.js';
|
|
|
|
// 1. 인증만 (로그인 여부)
|
|
router.get('/public-data', requireAuth, handler);
|
|
|
|
// 2. 인증 + 역할 기반 (ADMIN 등 특정 역할만)
|
|
router.post('/admin-action', requireAuth, requireRole('ADMIN'), handler);
|
|
|
|
// 3. 인증 + 리소스 권한 (RBAC, 가장 일반적)
|
|
router.get('/', requireAuth, requirePermission('equipment', 'READ'), handler);
|
|
router.post('/', requireAuth, requirePermission('equipment', 'CREATE'), handler);
|
|
```
|
|
|
|
**requirePermission 파라미터**:
|
|
- `resource`: FEATURE_ID 형태 (예: `'equipment'`, `'board:notice'`)
|
|
- `operation`: `'READ'` | `'CREATE'` | `'UPDATE'` | `'DELETE'`
|
|
|
|
요청당 1회만 DB를 조회하고 `req.resolvedPermissions`에 캐싱한다. 한 요청에서 여러 번 호출해도 성능 문제 없다.
|
|
|
|
### req.user 구조 (JWT 페이로드)
|
|
|
|
`requireAuth` 통과 후 `req.user`에 담기는 정보:
|
|
|
|
```ts
|
|
interface JwtPayload {
|
|
sub: string; // 사용자 UUID (USER_ID)
|
|
acnt: string; // 계정명 (USER_ACNT)
|
|
name: string; // 사용자명 (USER_NM)
|
|
roles: string[]; // 역할 코드 목록 ['ADMIN', 'MANAGER', 'USER', 'VIEWER']
|
|
}
|
|
|
|
// 사용 예시
|
|
const userId = req.user!.sub;
|
|
const userName = req.user!.name;
|
|
const isAdmin = req.user!.roles.includes('ADMIN');
|
|
```
|
|
|
|
### 에러 처리 표준
|
|
|
|
```ts
|
|
import { AuthError } from '../auth/authService.js';
|
|
|
|
// AuthError: 비즈니스 에러에 HTTP 상태 코드를 포함하는 커스텀 에러
|
|
throw new AuthError('데이터를 찾을 수 없습니다.', 404);
|
|
throw new AuthError('제목은 필수입니다.', 400);
|
|
throw new AuthError('본인의 데이터만 수정할 수 있습니다.', 403);
|
|
throw new AuthError('이미 존재하는 데이터입니다.', 409);
|
|
|
|
// Router에서의 에러 처리 패턴 (모든 핸들러에 동일 적용)
|
|
try {
|
|
// 비즈니스 로직 호출
|
|
} catch (err) {
|
|
// 1. AuthError -> 비즈니스 에러 (클라이언트에 메시지 전달)
|
|
if (err instanceof AuthError) {
|
|
res.status(err.status).json({ error: err.message });
|
|
return;
|
|
}
|
|
// 2. 그 외 -> 서버 에러 (내부 정보 노출 방지)
|
|
console.error('[domain] 작업 오류:', err);
|
|
res.status(500).json({ error: '처리 중 오류가 발생했습니다.' });
|
|
}
|
|
```
|
|
|
|
### Router 보일러플레이트
|
|
|
|
```ts
|
|
// backend/src/{domain}/{domain}Router.ts
|
|
|
|
import { Router } from 'express';
|
|
import { requireAuth, requirePermission } from '../auth/authMiddleware.js';
|
|
import { AuthError } from '../auth/authService.js';
|
|
import {
|
|
listItems, getItem, createItem, updateItem, deleteItem,
|
|
} from './{domain}Service.js';
|
|
|
|
const router = Router();
|
|
|
|
// GET /api/{domain} -- 목록 조회
|
|
router.get('/', requireAuth, requirePermission('{domain}', 'READ'), async (req, res) => {
|
|
try {
|
|
const { search, page, size } = req.query;
|
|
const result = await listItems({
|
|
search: search as string | undefined,
|
|
page: page ? parseInt(page as string, 10) : undefined,
|
|
size: size ? parseInt(size as string, 10) : undefined,
|
|
});
|
|
res.json(result);
|
|
} catch (err) {
|
|
console.error('[{domain}] 목록 조회 오류:', err);
|
|
res.status(500).json({ error: '목록 조회 중 오류가 발생했습니다.' });
|
|
}
|
|
});
|
|
|
|
// GET /api/{domain}/:sn -- 상세 조회
|
|
router.get('/:sn', requireAuth, requirePermission('{domain}', 'READ'), async (req, res) => {
|
|
try {
|
|
const sn = parseInt(req.params.sn as string, 10);
|
|
if (isNaN(sn)) {
|
|
res.status(400).json({ error: '유효하지 않은 번호입니다.' });
|
|
return;
|
|
}
|
|
const item = await getItem(sn);
|
|
res.json(item);
|
|
} catch (err) {
|
|
if (err instanceof AuthError) {
|
|
res.status(err.status).json({ error: err.message });
|
|
return;
|
|
}
|
|
console.error('[{domain}] 상세 조회 오류:', err);
|
|
res.status(500).json({ error: '조회 중 오류가 발생했습니다.' });
|
|
}
|
|
});
|
|
|
|
// POST /api/{domain} -- 등록
|
|
router.post('/', requireAuth, requirePermission('{domain}', 'CREATE'), async (req, res) => {
|
|
try {
|
|
const { title, content } = req.body;
|
|
if (!title) {
|
|
res.status(400).json({ error: '제목은 필수입니다.' });
|
|
return;
|
|
}
|
|
const result = await createItem({
|
|
title,
|
|
content,
|
|
authorId: req.user!.sub,
|
|
});
|
|
res.status(201).json(result);
|
|
} catch (err) {
|
|
if (err instanceof AuthError) {
|
|
res.status(err.status).json({ error: err.message });
|
|
return;
|
|
}
|
|
console.error('[{domain}] 등록 오류:', err);
|
|
res.status(500).json({ error: '등록 중 오류가 발생했습니다.' });
|
|
}
|
|
});
|
|
|
|
// POST /api/{domain}/:sn/update -- 수정
|
|
router.post('/:sn/update', requireAuth, requirePermission('{domain}', 'UPDATE'), async (req, res) => {
|
|
try {
|
|
const sn = parseInt(req.params.sn as string, 10);
|
|
if (isNaN(sn)) {
|
|
res.status(400).json({ error: '유효하지 않은 번호입니다.' });
|
|
return;
|
|
}
|
|
const { title, content } = req.body;
|
|
await updateItem(sn, { title, content }, req.user!.sub);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
if (err instanceof AuthError) {
|
|
res.status(err.status).json({ error: err.message });
|
|
return;
|
|
}
|
|
console.error('[{domain}] 수정 오류:', err);
|
|
res.status(500).json({ error: '수정 중 오류가 발생했습니다.' });
|
|
}
|
|
});
|
|
|
|
// POST /api/{domain}/:sn/delete -- 삭제 (논리 삭제)
|
|
router.post('/:sn/delete', requireAuth, requirePermission('{domain}', 'DELETE'), async (req, res) => {
|
|
try {
|
|
const sn = parseInt(req.params.sn as string, 10);
|
|
if (isNaN(sn)) {
|
|
res.status(400).json({ error: '유효하지 않은 번호입니다.' });
|
|
return;
|
|
}
|
|
await deleteItem(sn, req.user!.sub);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
if (err instanceof AuthError) {
|
|
res.status(err.status).json({ error: err.message });
|
|
return;
|
|
}
|
|
console.error('[{domain}] 삭제 오류:', err);
|
|
res.status(500).json({ error: '삭제 중 오류가 발생했습니다.' });
|
|
}
|
|
});
|
|
|
|
export default router;
|
|
```
|
|
|
|
### Service 보일러플레이트
|
|
|
|
```ts
|
|
// backend/src/{domain}/{domain}Service.ts
|
|
|
|
import { wingPool } from '../db/wingDb.js';
|
|
import { AuthError } from '../auth/authService.js';
|
|
|
|
// ============================================================
|
|
// 인터페이스
|
|
// ============================================================
|
|
|
|
interface ItemRow {
|
|
sn: number;
|
|
title: string;
|
|
content: string | null;
|
|
authorId: string;
|
|
regDtm: string;
|
|
mdfcnDtm: string | null;
|
|
}
|
|
|
|
interface ListInput {
|
|
search?: string;
|
|
page?: number;
|
|
size?: number;
|
|
}
|
|
|
|
interface ListResult {
|
|
items: ItemRow[];
|
|
totalCount: number;
|
|
page: number;
|
|
size: number;
|
|
}
|
|
|
|
interface CreateInput {
|
|
title: string;
|
|
content?: string;
|
|
authorId: string;
|
|
}
|
|
|
|
interface UpdateInput {
|
|
title?: string;
|
|
content?: string;
|
|
}
|
|
|
|
// ============================================================
|
|
// 페이징 목록 조회
|
|
// ============================================================
|
|
|
|
export async function listItems(input: ListInput): Promise<ListResult> {
|
|
// 1. 페이징 파라미터 정규화
|
|
const page = input.page && input.page > 0 ? input.page : 1;
|
|
const size = input.size && input.size > 0 ? Math.min(input.size, 100) : 20;
|
|
const offset = (page - 1) * size;
|
|
|
|
// 2. 동적 WHERE 절 구성
|
|
let whereClause = "WHERE USE_YN = 'Y'";
|
|
const params: (string | number)[] = [];
|
|
let paramIdx = 1;
|
|
|
|
if (input.search) {
|
|
whereClause += ` AND TITLE ILIKE $${paramIdx}`;
|
|
params.push(`%${input.search}%`);
|
|
paramIdx++;
|
|
}
|
|
|
|
// 3. COUNT 쿼리
|
|
const countResult = await wingPool.query(
|
|
`SELECT COUNT(*) as cnt FROM {TABLE_NAME} ${whereClause}`,
|
|
params,
|
|
);
|
|
const totalCount = parseInt(countResult.rows[0].cnt, 10);
|
|
|
|
// 4. 목록 쿼리
|
|
const listParams = [...params, size, offset];
|
|
const listResult = await wingPool.query(
|
|
`SELECT SN, TITLE, CONTENT, AUTHOR_ID, REG_DTM, MDFCN_DTM
|
|
FROM {TABLE_NAME}
|
|
${whereClause}
|
|
ORDER BY REG_DTM DESC
|
|
LIMIT $${paramIdx++} OFFSET $${paramIdx}`,
|
|
listParams,
|
|
);
|
|
|
|
// 5. snake_case -> camelCase 매핑
|
|
const items: ItemRow[] = listResult.rows.map((r: Record<string, unknown>) => ({
|
|
sn: r.sn as number,
|
|
title: r.title as string,
|
|
content: r.content as string | null,
|
|
authorId: r.author_id as string,
|
|
regDtm: r.reg_dtm as string,
|
|
mdfcnDtm: r.mdfcn_dtm as string | null,
|
|
}));
|
|
|
|
return { items, totalCount, page, size };
|
|
}
|
|
|
|
// ============================================================
|
|
// 상세 조회
|
|
// ============================================================
|
|
|
|
export async function getItem(sn: number): Promise<ItemRow> {
|
|
const result = await wingPool.query(
|
|
`SELECT SN, TITLE, CONTENT, AUTHOR_ID, REG_DTM, MDFCN_DTM
|
|
FROM {TABLE_NAME}
|
|
WHERE SN = $1 AND USE_YN = 'Y'`,
|
|
[sn],
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
throw new AuthError('데이터를 찾을 수 없습니다.', 404);
|
|
}
|
|
|
|
const r = result.rows[0];
|
|
return {
|
|
sn: r.sn,
|
|
title: r.title,
|
|
content: r.content,
|
|
authorId: r.author_id,
|
|
regDtm: r.reg_dtm,
|
|
mdfcnDtm: r.mdfcn_dtm,
|
|
};
|
|
}
|
|
|
|
// ============================================================
|
|
// 등록
|
|
// ============================================================
|
|
|
|
export async function createItem(input: CreateInput): Promise<{ sn: number }> {
|
|
if (!input.title || input.title.trim().length === 0) {
|
|
throw new AuthError('제목은 필수입니다.', 400);
|
|
}
|
|
|
|
const result = await wingPool.query(
|
|
`INSERT INTO {TABLE_NAME} (TITLE, CONTENT, AUTHOR_ID)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING SN`,
|
|
[input.title.trim(), input.content || null, input.authorId],
|
|
);
|
|
|
|
return { sn: result.rows[0].sn };
|
|
}
|
|
|
|
// ============================================================
|
|
// 동적 UPDATE (부분 수정)
|
|
// ============================================================
|
|
|
|
export async function updateItem(
|
|
sn: number,
|
|
input: UpdateInput,
|
|
requesterId: string,
|
|
): Promise<void> {
|
|
// 존재 확인 + 소유자 검증
|
|
const existing = await wingPool.query(
|
|
`SELECT AUTHOR_ID as author_id FROM {TABLE_NAME} WHERE SN = $1 AND USE_YN = 'Y'`,
|
|
[sn],
|
|
);
|
|
|
|
if (existing.rows.length === 0) {
|
|
throw new AuthError('데이터를 찾을 수 없습니다.', 404);
|
|
}
|
|
|
|
if (existing.rows[0].author_id !== requesterId) {
|
|
throw new AuthError('본인의 데이터만 수정할 수 있습니다.', 403);
|
|
}
|
|
|
|
// 동적 SET 절 구성
|
|
const sets: string[] = [];
|
|
const params: (string | number | null)[] = [];
|
|
let idx = 1;
|
|
|
|
if (input.title !== undefined) {
|
|
sets.push(`TITLE = $${idx++}`);
|
|
params.push(input.title.trim());
|
|
}
|
|
if (input.content !== undefined) {
|
|
sets.push(`CONTENT = $${idx++}`);
|
|
params.push(input.content);
|
|
}
|
|
|
|
if (sets.length === 0) {
|
|
throw new AuthError('수정할 항목이 없습니다.', 400);
|
|
}
|
|
|
|
sets.push('MDFCN_DTM = NOW()');
|
|
params.push(sn);
|
|
|
|
await wingPool.query(
|
|
`UPDATE {TABLE_NAME} SET ${sets.join(', ')} WHERE SN = $${idx}`,
|
|
params,
|
|
);
|
|
}
|
|
|
|
// ============================================================
|
|
// 논리 삭제
|
|
// ============================================================
|
|
|
|
export async function deleteItem(sn: number, requesterId: string): Promise<void> {
|
|
const existing = await wingPool.query(
|
|
`SELECT AUTHOR_ID as author_id FROM {TABLE_NAME} WHERE SN = $1 AND USE_YN = 'Y'`,
|
|
[sn],
|
|
);
|
|
|
|
if (existing.rows.length === 0) {
|
|
throw new AuthError('데이터를 찾을 수 없습니다.', 404);
|
|
}
|
|
|
|
if (existing.rows[0].author_id !== requesterId) {
|
|
throw new AuthError('본인의 데이터만 삭제할 수 있습니다.', 403);
|
|
}
|
|
|
|
await wingPool.query(
|
|
`UPDATE {TABLE_NAME} SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE SN = $1`,
|
|
[sn],
|
|
);
|
|
}
|
|
```
|
|
|
|
### 트랜잭션 패턴
|
|
|
|
여러 테이블을 동시에 변경해야 할 때:
|
|
|
|
```ts
|
|
export async function createWithAttachments(
|
|
input: CreateInput,
|
|
attachments: AttachmentInput[],
|
|
): Promise<{ sn: number }> {
|
|
const client = await wingPool.connect();
|
|
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
const postResult = await client.query(
|
|
`INSERT INTO {TABLE_NAME} (TITLE, CONTENT, AUTHOR_ID)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING SN`,
|
|
[input.title, input.content, input.authorId],
|
|
);
|
|
const sn = postResult.rows[0].sn;
|
|
|
|
for (const att of attachments) {
|
|
await client.query(
|
|
`INSERT INTO {TABLE_ATTACH} (PARENT_SN, FILE_NM, FILE_PATH, FILE_SIZE)
|
|
VALUES ($1, $2, $3, $4)`,
|
|
[sn, att.fileName, att.filePath, att.fileSize],
|
|
);
|
|
}
|
|
|
|
await client.query('COMMIT');
|
|
return { sn };
|
|
} catch (err) {
|
|
await client.query('ROLLBACK');
|
|
throw err;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
```
|
|
|
|
### server.ts 등록
|
|
|
|
```ts
|
|
// backend/src/server.ts
|
|
|
|
// 1. import 추가 (반드시 .js 확장자)
|
|
import equipmentRouter from './equipment/equipmentRouter.js';
|
|
|
|
// 2. 업무 API 라우트 등록 (기존 라우트 아래에)
|
|
app.use('/api/equipment', equipmentRouter);
|
|
```
|
|
|
|
### 권한 모델 요약
|
|
|
|
#### 2차원 모델: 리소스 트리 x 오퍼레이션
|
|
|
|
```
|
|
AUTH_PERM 테이블: (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN)
|
|
|
|
리소스 트리 (AUTH_PERM_TREE) 오퍼레이션
|
|
board READ = 조회/열람
|
|
board:notice CREATE = 생성
|
|
board:data UPDATE = 수정
|
|
board:qna DELETE = 삭제
|
|
```
|
|
|
|
#### 상속 규칙
|
|
|
|
```
|
|
규칙 1: 부모 READ=N -> 자식의 모든 오퍼레이션 강제 N
|
|
규칙 2: 명시적 레코드 있으면 -> 그 값 사용
|
|
규칙 3: 명시적 레코드 없으면 -> 부모의 같은 오퍼레이션 상속
|
|
규칙 4: 최상위까지 없으면 -> 기본 N (거부)
|
|
```
|
|
|
|
#### 카테고리별 동적 리소스 결정 (board 패턴)
|
|
|
|
카테고리에 따라 다른 리소스에 대해 권한을 검사하는 패턴:
|
|
|
|
```ts
|
|
const CATEGORY_RESOURCE: Record<string, string> = {
|
|
NOTICE: 'board:notice',
|
|
DATA: 'board:data',
|
|
QNA: 'board:qna',
|
|
MANUAL: 'board:manual',
|
|
};
|
|
|
|
// 작성 시: body의 categoryCd로 리소스 결정
|
|
router.post('/', requireAuth, async (req, res, next) => {
|
|
const resource = CATEGORY_RESOURCE[req.body.categoryCd] || 'board';
|
|
requirePermission(resource, 'CREATE')(req, res, next);
|
|
}, createHandler);
|
|
```
|
|
|
|
---
|
|
|
|
## DB 마이그레이션 작성법
|
|
|
|
### 파일 네이밍
|
|
|
|
```
|
|
database/migration/NNN_{domain}.sql
|
|
```
|
|
|
|
- `NNN`: 3자리 순번 (001, 002, ..., 017)
|
|
- `{domain}`: 도메인명 (board, assets, equipment 등)
|
|
- 현재 마지막 순번을 확인하여 다음 번호를 사용한다
|
|
|
|
### 표준 테이블 구조
|
|
|
|
```sql
|
|
-- ============================================================
|
|
-- 마이그레이션 NNN: {도메인 한글명} ({TABLE_NAME})
|
|
-- wing 스키마에 생성, auth.AUTH_USER FK 참조
|
|
-- ============================================================
|
|
|
|
CREATE TABLE IF NOT EXISTS {TABLE_NAME} (
|
|
-- PK (SERIAL 자동 증가)
|
|
{PREFIX}_SN SERIAL PRIMARY KEY,
|
|
|
|
-- 비즈니스 컬럼
|
|
TITLE VARCHAR(200) NOT NULL,
|
|
CONTENT TEXT,
|
|
STATUS_CD VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
|
|
|
-- 외래키 (작성자)
|
|
AUTHOR_ID UUID NOT NULL,
|
|
|
|
-- 공통 감사 컬럼 (모든 테이블 필수)
|
|
USE_YN CHAR(1) NOT NULL DEFAULT 'Y',
|
|
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
MDFCN_DTM TIMESTAMPTZ,
|
|
|
|
-- 제약조건
|
|
CONSTRAINT FK_{PREFIX}_AUTHOR FOREIGN KEY (AUTHOR_ID)
|
|
REFERENCES auth.AUTH_USER(USER_ID),
|
|
CONSTRAINT CK_{PREFIX}_USE CHECK (USE_YN IN ('Y','N'))
|
|
);
|
|
|
|
-- 테이블/컬럼 설명
|
|
COMMENT ON TABLE {TABLE_NAME} IS '{도메인 한글명}';
|
|
COMMENT ON COLUMN {TABLE_NAME}.USE_YN IS '사용여부 (N=논리삭제)';
|
|
|
|
-- 인덱스
|
|
CREATE INDEX IF NOT EXISTS IDX_{PREFIX}_REG_DTM ON {TABLE_NAME}(REG_DTM DESC);
|
|
CREATE INDEX IF NOT EXISTS IDX_{PREFIX}_AUTHOR ON {TABLE_NAME}(AUTHOR_ID);
|
|
```
|
|
|
|
### 네이밍 규칙
|
|
|
|
| 항목 | 규칙 | 예시 |
|
|
|------|------|------|
|
|
| 테이블명 | UPPER_SNAKE_CASE | `BOARD_POST`, `HNS_SUBSTANCE` |
|
|
| 컬럼명 | UPPER_SNAKE_CASE | `POST_SN`, `CATEGORY_CD`, `REG_DTM` |
|
|
| PK | `{접두어}_SN` (SERIAL) 또는 `{접두어}_ID` (UUID) | `POST_SN`, `USER_ID` |
|
|
| FK 컬럼 | 참조 테이블의 PK 이름 그대로 | `AUTHOR_ID` (-> AUTH_USER.USER_ID) |
|
|
| 코드성 | `{의미}_CD` | `CATEGORY_CD`, `OPER_CD` |
|
|
| 여부 | `{의미}_YN` (CHAR(1), 'Y'/'N') | `USE_YN`, `PINNED_YN` |
|
|
| 일시 | `{의미}_DTM` (TIMESTAMPTZ) | `REG_DTM`, `MDFCN_DTM` |
|
|
|
|
### 공통 감사 컬럼 (모든 테이블 필수)
|
|
|
|
| 컬럼 | 타입 | 설명 |
|
|
|------|------|------|
|
|
| `USE_YN` | `CHAR(1) DEFAULT 'Y'` | 논리 삭제 플래그 (Y=활성, N=삭제) |
|
|
| `REG_DTM` | `TIMESTAMPTZ DEFAULT NOW()` | 등록 일시 |
|
|
| `MDFCN_DTM` | `TIMESTAMPTZ` | 수정 일시 (NULL=미수정) |
|
|
|
|
### 코드형 컬럼 (CHECK 제약)
|
|
|
|
```sql
|
|
CATEGORY_CD VARCHAR(20) NOT NULL,
|
|
CONSTRAINT CK_{PREFIX}_CATEGORY
|
|
CHECK (CATEGORY_CD IN ('NOTICE', 'DATA', 'QNA', 'MANUAL'))
|
|
```
|
|
|
|
### PostGIS GEOMETRY 컬럼
|
|
|
|
공간 데이터가 필요한 경우:
|
|
|
|
```sql
|
|
-- PostGIS 확장 (이미 활성화되어 있으나 안전하게)
|
|
CREATE EXTENSION IF NOT EXISTS postgis;
|
|
|
|
-- 좌표 컬럼 (WGS84, SRID=4326)
|
|
GEOM GEOMETRY(POINT, 4326),
|
|
|
|
-- 공간 인덱스
|
|
CREATE INDEX IF NOT EXISTS IDX_{PREFIX}_GEOM ON {TABLE_NAME} USING GIST(GEOM);
|
|
|
|
-- INSERT 예시
|
|
INSERT INTO {TABLE_NAME} (TITLE, GEOM)
|
|
VALUES ('부산항', ST_SetSRID(ST_MakePoint(129.0756, 35.1796), 4326));
|
|
|
|
-- SELECT 예시 (좌표 추출)
|
|
SELECT TITLE, ST_X(GEOM) as lon, ST_Y(GEOM) as lat
|
|
FROM {TABLE_NAME};
|
|
|
|
-- 반경 검색 (10km 이내)
|
|
SELECT * FROM {TABLE_NAME}
|
|
WHERE ST_DWithin(
|
|
GEOM::geography,
|
|
ST_SetSRID(ST_MakePoint(129.0, 35.0), 4326)::geography,
|
|
10000
|
|
);
|
|
```
|
|
|
|
### 시드 데이터 패턴
|
|
|
|
마이그레이션 파일 하단에 초기 데이터를 포함할 수 있다:
|
|
|
|
```sql
|
|
-- 시드 데이터 (admin 사용자 ID 동적 조회)
|
|
DO $$
|
|
DECLARE
|
|
v_admin_id UUID;
|
|
BEGIN
|
|
SELECT USER_ID INTO v_admin_id
|
|
FROM auth.AUTH_USER
|
|
WHERE USER_ACNT = 'admin'
|
|
LIMIT 1;
|
|
|
|
IF v_admin_id IS NOT NULL THEN
|
|
INSERT INTO {TABLE_NAME} (TITLE, CONTENT, AUTHOR_ID, REG_DTM) VALUES
|
|
('샘플 데이터 1', '내용', v_admin_id, '2025-03-01'::timestamptz),
|
|
('샘플 데이터 2', '내용', v_admin_id, '2025-03-01'::timestamptz)
|
|
ON CONFLICT DO NOTHING;
|
|
END IF;
|
|
END $$;
|
|
|
|
-- 검증
|
|
SELECT SN, TITLE, REG_DTM FROM {TABLE_NAME} ORDER BY SN;
|
|
```
|
|
|
|
### 권한 리소스 등록 (필요 시)
|
|
|
|
```sql
|
|
-- AUTH_PERM_TREE에 리소스 등록
|
|
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD)
|
|
VALUES
|
|
('equipment', NULL, '장비 관리', 0, 11),
|
|
('equipment:boom', 'equipment', '오일붐', 1, 1),
|
|
('equipment:skimmer', 'equipment', '유회수기', 1, 2)
|
|
ON CONFLICT (RSRC_CD) DO NOTHING;
|
|
|
|
-- AUTH_PERM에 역할별 권한 초기값 (ADMIN 전체 허용)
|
|
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN)
|
|
SELECT r.ROLE_SN, 'equipment', op.cd, 'Y'
|
|
FROM AUTH_ROLE r, (VALUES ('READ'),('CREATE'),('UPDATE'),('DELETE')) AS op(cd)
|
|
WHERE r.ROLE_CD = 'ADMIN'
|
|
ON CONFLICT DO NOTHING;
|
|
```
|
|
|
|
---
|
|
|
|
## 프론트엔드 API 서비스 작성법
|
|
|
|
### 파일 위치
|
|
|
|
```
|
|
frontend/src/tabs/{탭명}/services/{tabName}Api.ts
|
|
```
|
|
|
|
### 기본 구조
|
|
|
|
```ts
|
|
// frontend/src/tabs/{탭명}/services/{tabName}Api.ts
|
|
|
|
import { api } from '@common/services/api';
|
|
|
|
// ============================================================
|
|
// 인터페이스
|
|
// ============================================================
|
|
|
|
export interface ItemListItem {
|
|
sn: number;
|
|
title: string;
|
|
status: string;
|
|
authorName: string;
|
|
regDtm: string;
|
|
}
|
|
|
|
export interface ItemDetail extends ItemListItem {
|
|
content: string | null;
|
|
mdfcnDtm: string | null;
|
|
}
|
|
|
|
export interface ItemListResponse {
|
|
items: ItemListItem[];
|
|
totalCount: number;
|
|
page: number;
|
|
size: number;
|
|
}
|
|
|
|
export interface ItemListParams {
|
|
search?: string;
|
|
status?: string;
|
|
page?: number;
|
|
size?: number;
|
|
}
|
|
|
|
export interface CreateItemInput {
|
|
title: string;
|
|
content?: string;
|
|
status?: string;
|
|
}
|
|
|
|
export interface UpdateItemInput {
|
|
title?: string;
|
|
content?: string;
|
|
status?: string;
|
|
}
|
|
|
|
// ============================================================
|
|
// API 함수
|
|
// ============================================================
|
|
|
|
/** 목록 조회 */
|
|
export async function fetchItems(params?: ItemListParams): Promise<ItemListResponse> {
|
|
const response = await api.get<ItemListResponse>('/equipment', { params });
|
|
return response.data;
|
|
}
|
|
|
|
/** 상세 조회 */
|
|
export async function fetchItem(sn: number): Promise<ItemDetail> {
|
|
const response = await api.get<ItemDetail>(`/equipment/${sn}`);
|
|
return response.data;
|
|
}
|
|
|
|
/** 등록 */
|
|
export async function createItem(input: CreateItemInput): Promise<{ sn: number }> {
|
|
const response = await api.post<{ sn: number }>('/equipment', input);
|
|
return response.data;
|
|
}
|
|
|
|
/** 수정 (POST /api/equipment/:sn/update) */
|
|
export async function updateItem(sn: number, input: UpdateItemInput): Promise<void> {
|
|
await api.post(`/equipment/${sn}/update`, input);
|
|
}
|
|
|
|
/** 삭제 (POST /api/equipment/:sn/delete) */
|
|
export async function deleteItem(sn: number): Promise<void> {
|
|
await api.post(`/equipment/${sn}/delete`);
|
|
}
|
|
```
|
|
|
|
### api 인스턴스 특징
|
|
|
|
| 설정 | 값 |
|
|
|------|-----|
|
|
| baseURL | `VITE_API_URL` 환경변수 또는 `http://localhost:3001/api` |
|
|
| withCredentials | `true` (JWT 쿠키 자동 포함) |
|
|
| timeout | 30,000ms |
|
|
| Content-Type | `application/json` |
|
|
| 401 인터셉터 | 세션 만료 시 자동 로그아웃 |
|
|
|
|
### 컴포넌트에서의 에러 핸들링
|
|
|
|
```tsx
|
|
// 목록 조회
|
|
const loadData = useCallback(async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const result = await fetchItems({ search, page, size: PAGE_SIZE });
|
|
setItems(result.items);
|
|
setTotalCount(result.totalCount);
|
|
} catch (err) {
|
|
console.error('[equipment] 목록 조회 실패:', err);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [search, page]);
|
|
|
|
// 삭제 (사용자 확인)
|
|
const handleDelete = async (sn: number) => {
|
|
if (!window.confirm('정말로 삭제하시겠습니까?')) return;
|
|
try {
|
|
await deleteItem(sn);
|
|
alert('삭제되었습니다.');
|
|
loadData();
|
|
} catch (err) {
|
|
alert((err as { message?: string })?.message || '삭제에 실패했습니다.');
|
|
}
|
|
};
|
|
```
|
|
|
|
### 권한 기반 UI 분기
|
|
|
|
```tsx
|
|
import { useAuthStore } from '@common/store/authStore';
|
|
|
|
const hasPermission = useAuthStore((s) => s.hasPermission);
|
|
|
|
// CREATE 권한이 있을 때만 등록 버튼 표시
|
|
{hasPermission('equipment', 'CREATE') && (
|
|
<button onClick={handleCreate}>등록</button>
|
|
)}
|
|
|
|
// UPDATE 권한 + 본인 글일 때만 수정 버튼
|
|
{hasPermission('equipment', 'UPDATE') && item.authorId === currentUserId && (
|
|
<button onClick={() => handleEdit(item.sn)}>수정</button>
|
|
)}
|
|
```
|
|
|
|
### TanStack Query 연동 (선택)
|
|
|
|
TanStack Query를 사용하면 캐싱, 자동 재조회, 로딩/에러 상태를 선언적으로 관리할 수 있다.
|
|
|
|
```ts
|
|
// hooks/useEquipment.ts
|
|
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import {
|
|
fetchItems, fetchItem, createItem, deleteItem,
|
|
type ItemListParams,
|
|
} from '../services/equipmentApi';
|
|
|
|
// 목록 조회
|
|
export function useEquipmentList(params: ItemListParams) {
|
|
return useQuery({
|
|
queryKey: ['equipment', 'list', params],
|
|
queryFn: () => fetchItems(params),
|
|
staleTime: 30_000,
|
|
});
|
|
}
|
|
|
|
// 상세 조회
|
|
export function useEquipmentDetail(sn: number) {
|
|
return useQuery({
|
|
queryKey: ['equipment', 'detail', sn],
|
|
queryFn: () => fetchItem(sn),
|
|
enabled: sn > 0,
|
|
});
|
|
}
|
|
|
|
// 등록 Mutation
|
|
export function useCreateEquipment() {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: createItem,
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['equipment', 'list'] });
|
|
},
|
|
});
|
|
}
|
|
|
|
// 삭제 Mutation
|
|
export function useDeleteEquipment() {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: deleteItem,
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['equipment', 'list'] });
|
|
},
|
|
});
|
|
}
|
|
```
|
|
|
|
컴포넌트에서의 사용:
|
|
|
|
```tsx
|
|
function EquipmentListView() {
|
|
const [params, setParams] = useState<ItemListParams>({ page: 1, size: 20 });
|
|
const { data, isLoading, error } = useEquipmentList(params);
|
|
const deleteMutation = useDeleteEquipment();
|
|
|
|
if (isLoading) return <div>로딩 중...</div>;
|
|
if (error) return <div>에러 발생</div>;
|
|
|
|
return (
|
|
<div>
|
|
{data?.items.map(item => (
|
|
<div key={item.sn}>
|
|
{item.title}
|
|
<button onClick={() => deleteMutation.mutate(item.sn)}>삭제</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 전체 예시: 장비 관리 API
|
|
|
|
"방제 장비를 등록/조회/수정/삭제하는 API"를 처음부터 끝까지 구현하는 과정이다.
|
|
|
|
### 요구사항
|
|
|
|
- 장비 목록 조회 (유형 필터, 검색, 페이징)
|
|
- 장비 상세 조회
|
|
- 장비 등록 (관리자)
|
|
- 장비 수정 (등록자 본인)
|
|
- 장비 삭제 (등록자 본인, 논리 삭제)
|
|
- 장비 종류: `BOOM`(오일붐), `SKIMMER`(유회수기), `DISPERSANT`(유처리제), `VESSEL`(선박)
|
|
- 장비 위치 좌표 (PostGIS)
|
|
|
|
### 1단계: DB 마이그레이션
|
|
|
|
```sql
|
|
-- database/migration/017_equipment.sql
|
|
|
|
-- ============================================================
|
|
-- 마이그레이션 017: 방제 장비 (EQUIPMENT)
|
|
-- ============================================================
|
|
|
|
CREATE TABLE IF NOT EXISTS EQUIPMENT (
|
|
EQUIP_SN SERIAL PRIMARY KEY,
|
|
EQUIP_TP VARCHAR(20) NOT NULL,
|
|
EQUIP_NM VARCHAR(100) NOT NULL,
|
|
EQUIP_DC TEXT,
|
|
SPEC VARCHAR(200),
|
|
QUANTITY INTEGER NOT NULL DEFAULT 0,
|
|
LOCATION_NM VARCHAR(100),
|
|
GEOM GEOMETRY(POINT, 4326),
|
|
AUTHOR_ID UUID NOT NULL,
|
|
USE_YN CHAR(1) NOT NULL DEFAULT 'Y',
|
|
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
MDFCN_DTM TIMESTAMPTZ,
|
|
|
|
CONSTRAINT FK_EQUIP_AUTHOR FOREIGN KEY (AUTHOR_ID)
|
|
REFERENCES auth.AUTH_USER(USER_ID),
|
|
CONSTRAINT CK_EQUIP_TP
|
|
CHECK (EQUIP_TP IN ('BOOM','SKIMMER','DISPERSANT','VESSEL')),
|
|
CONSTRAINT CK_EQUIP_USE CHECK (USE_YN IN ('Y','N'))
|
|
);
|
|
|
|
COMMENT ON TABLE EQUIPMENT IS '방제 장비';
|
|
COMMENT ON COLUMN EQUIPMENT.EQUIP_TP IS '장비유형: BOOM/SKIMMER/DISPERSANT/VESSEL';
|
|
COMMENT ON COLUMN EQUIPMENT.GEOM IS '장비 위치 좌표 (WGS84)';
|
|
COMMENT ON COLUMN EQUIPMENT.USE_YN IS '사용여부 (N=논리삭제)';
|
|
|
|
CREATE INDEX IF NOT EXISTS IDX_EQUIP_TP ON EQUIPMENT(EQUIP_TP);
|
|
CREATE INDEX IF NOT EXISTS IDX_EQUIP_REG_DTM ON EQUIPMENT(REG_DTM DESC);
|
|
CREATE INDEX IF NOT EXISTS IDX_EQUIP_GEOM ON EQUIPMENT USING GIST(GEOM);
|
|
|
|
-- 시드 데이터
|
|
DO $$
|
|
DECLARE
|
|
v_admin_id UUID;
|
|
BEGIN
|
|
SELECT USER_ID INTO v_admin_id
|
|
FROM auth.AUTH_USER WHERE USER_ACNT = 'admin' LIMIT 1;
|
|
|
|
IF v_admin_id IS NOT NULL THEN
|
|
INSERT INTO EQUIPMENT (EQUIP_TP, EQUIP_NM, EQUIP_DC, SPEC, QUANTITY, LOCATION_NM, GEOM, AUTHOR_ID) VALUES
|
|
('BOOM', '오일붐 500m', '항구 배치용 오일붐', '500m, 내파성', 10, '부산항',
|
|
ST_SetSRID(ST_MakePoint(129.0756, 35.1796), 4326), v_admin_id),
|
|
('SKIMMER', '유회수기 A형', '소형 유회수기', '처리량 50m3/h', 5, '여수항',
|
|
ST_SetSRID(ST_MakePoint(127.6622, 34.7604), 4326), v_admin_id),
|
|
('DISPERSANT', '유처리제 1종', '해상용 유처리제', '100L 드럼', 200, '인천항',
|
|
ST_SetSRID(ST_MakePoint(126.6052, 37.4563), 4326), v_admin_id)
|
|
ON CONFLICT DO NOTHING;
|
|
END IF;
|
|
END $$;
|
|
|
|
SELECT EQUIP_SN, EQUIP_TP, EQUIP_NM, QUANTITY FROM EQUIPMENT ORDER BY EQUIP_SN;
|
|
```
|
|
|
|
### 2단계: 백엔드 서비스
|
|
|
|
```ts
|
|
// backend/src/equipment/equipmentService.ts
|
|
|
|
import { wingPool } from '../db/wingDb.js';
|
|
import { AuthError } from '../auth/authService.js';
|
|
|
|
interface EquipmentItem {
|
|
equipSn: number;
|
|
equipTp: string;
|
|
equipNm: string;
|
|
equipDc: string | null;
|
|
spec: string | null;
|
|
quantity: number;
|
|
locationNm: string | null;
|
|
lon: number | null;
|
|
lat: number | null;
|
|
authorId: string;
|
|
regDtm: string;
|
|
}
|
|
|
|
interface ListInput {
|
|
equipTp?: string;
|
|
search?: string;
|
|
page?: number;
|
|
size?: number;
|
|
}
|
|
|
|
interface ListResult {
|
|
items: EquipmentItem[];
|
|
totalCount: number;
|
|
page: number;
|
|
size: number;
|
|
}
|
|
|
|
interface CreateInput {
|
|
equipTp: string;
|
|
equipNm: string;
|
|
equipDc?: string;
|
|
spec?: string;
|
|
quantity?: number;
|
|
locationNm?: string;
|
|
lon?: number;
|
|
lat?: number;
|
|
authorId: string;
|
|
}
|
|
|
|
interface UpdateInput {
|
|
equipNm?: string;
|
|
equipDc?: string;
|
|
spec?: string;
|
|
quantity?: number;
|
|
locationNm?: string;
|
|
lon?: number;
|
|
lat?: number;
|
|
}
|
|
|
|
const VALID_TYPES = ['BOOM', 'SKIMMER', 'DISPERSANT', 'VESSEL'];
|
|
|
|
function rowToItem(r: Record<string, unknown>): EquipmentItem {
|
|
return {
|
|
equipSn: r.equip_sn as number,
|
|
equipTp: r.equip_tp as string,
|
|
equipNm: r.equip_nm as string,
|
|
equipDc: r.equip_dc as string | null,
|
|
spec: r.spec as string | null,
|
|
quantity: r.quantity as number,
|
|
locationNm: r.location_nm as string | null,
|
|
lon: r.lon as number | null,
|
|
lat: r.lat as number | null,
|
|
authorId: r.author_id as string,
|
|
regDtm: r.reg_dtm as string,
|
|
};
|
|
}
|
|
|
|
export async function listEquipment(input: ListInput): Promise<ListResult> {
|
|
const page = input.page && input.page > 0 ? input.page : 1;
|
|
const size = input.size && input.size > 0 ? Math.min(input.size, 100) : 20;
|
|
const offset = (page - 1) * size;
|
|
|
|
let whereClause = "WHERE USE_YN = 'Y'";
|
|
const params: (string | number)[] = [];
|
|
let paramIdx = 1;
|
|
|
|
if (input.equipTp) {
|
|
whereClause += ` AND EQUIP_TP = $${paramIdx++}`;
|
|
params.push(input.equipTp);
|
|
}
|
|
if (input.search) {
|
|
whereClause += ` AND (EQUIP_NM ILIKE $${paramIdx} OR LOCATION_NM ILIKE $${paramIdx})`;
|
|
params.push(`%${input.search}%`);
|
|
paramIdx++;
|
|
}
|
|
|
|
const countResult = await wingPool.query(
|
|
`SELECT COUNT(*) as cnt FROM EQUIPMENT ${whereClause}`,
|
|
params,
|
|
);
|
|
const totalCount = parseInt(countResult.rows[0].cnt, 10);
|
|
|
|
const listParams = [...params, size, offset];
|
|
const listResult = await wingPool.query(
|
|
`SELECT EQUIP_SN, EQUIP_TP, EQUIP_NM, EQUIP_DC, SPEC, QUANTITY,
|
|
LOCATION_NM, ST_X(GEOM) as lon, ST_Y(GEOM) as lat,
|
|
AUTHOR_ID, REG_DTM
|
|
FROM EQUIPMENT
|
|
${whereClause}
|
|
ORDER BY REG_DTM DESC
|
|
LIMIT $${paramIdx++} OFFSET $${paramIdx}`,
|
|
listParams,
|
|
);
|
|
|
|
return {
|
|
items: listResult.rows.map((r: Record<string, unknown>) => rowToItem(r)),
|
|
totalCount,
|
|
page,
|
|
size,
|
|
};
|
|
}
|
|
|
|
export async function getEquipment(equipSn: number): Promise<EquipmentItem> {
|
|
const result = await wingPool.query(
|
|
`SELECT EQUIP_SN, EQUIP_TP, EQUIP_NM, EQUIP_DC, SPEC, QUANTITY,
|
|
LOCATION_NM, ST_X(GEOM) as lon, ST_Y(GEOM) as lat,
|
|
AUTHOR_ID, REG_DTM
|
|
FROM EQUIPMENT
|
|
WHERE EQUIP_SN = $1 AND USE_YN = 'Y'`,
|
|
[equipSn],
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
throw new AuthError('장비를 찾을 수 없습니다.', 404);
|
|
}
|
|
|
|
return rowToItem(result.rows[0]);
|
|
}
|
|
|
|
export async function createEquipment(input: CreateInput): Promise<{ equipSn: number }> {
|
|
if (!VALID_TYPES.includes(input.equipTp)) {
|
|
throw new AuthError('유효하지 않은 장비 유형입니다.', 400);
|
|
}
|
|
if (!input.equipNm || input.equipNm.trim().length === 0) {
|
|
throw new AuthError('장비명은 필수입니다.', 400);
|
|
}
|
|
|
|
const hasCoord = input.lon !== undefined && input.lat !== undefined;
|
|
const geomExpr = hasCoord ? `ST_SetSRID(ST_MakePoint($7, $8), 4326)` : 'NULL';
|
|
const params: (string | number | null)[] = [
|
|
input.equipTp,
|
|
input.equipNm.trim(),
|
|
input.equipDc || null,
|
|
input.spec || null,
|
|
input.quantity ?? 0,
|
|
input.locationNm || null,
|
|
];
|
|
|
|
if (hasCoord) {
|
|
params.push(input.lon!, input.lat!);
|
|
}
|
|
|
|
params.push(input.authorId);
|
|
const authorIdx = params.length;
|
|
|
|
const result = await wingPool.query(
|
|
`INSERT INTO EQUIPMENT (EQUIP_TP, EQUIP_NM, EQUIP_DC, SPEC, QUANTITY, LOCATION_NM, GEOM, AUTHOR_ID)
|
|
VALUES ($1, $2, $3, $4, $5, $6, ${geomExpr}, $${authorIdx})
|
|
RETURNING EQUIP_SN`,
|
|
params,
|
|
);
|
|
|
|
return { equipSn: result.rows[0].equip_sn };
|
|
}
|
|
|
|
export async function updateEquipment(
|
|
equipSn: number,
|
|
input: UpdateInput,
|
|
requesterId: string,
|
|
): Promise<void> {
|
|
const existing = await wingPool.query(
|
|
`SELECT AUTHOR_ID as author_id FROM EQUIPMENT WHERE EQUIP_SN = $1 AND USE_YN = 'Y'`,
|
|
[equipSn],
|
|
);
|
|
|
|
if (existing.rows.length === 0) {
|
|
throw new AuthError('장비를 찾을 수 없습니다.', 404);
|
|
}
|
|
if (existing.rows[0].author_id !== requesterId) {
|
|
throw new AuthError('본인이 등록한 장비만 수정할 수 있습니다.', 403);
|
|
}
|
|
|
|
const sets: string[] = [];
|
|
const params: (string | number | null)[] = [];
|
|
let idx = 1;
|
|
|
|
if (input.equipNm !== undefined) { sets.push(`EQUIP_NM = $${idx++}`); params.push(input.equipNm.trim()); }
|
|
if (input.equipDc !== undefined) { sets.push(`EQUIP_DC = $${idx++}`); params.push(input.equipDc); }
|
|
if (input.spec !== undefined) { sets.push(`SPEC = $${idx++}`); params.push(input.spec); }
|
|
if (input.quantity !== undefined) { sets.push(`QUANTITY = $${idx++}`); params.push(input.quantity); }
|
|
if (input.locationNm !== undefined) { sets.push(`LOCATION_NM = $${idx++}`); params.push(input.locationNm); }
|
|
if (input.lon !== undefined && input.lat !== undefined) {
|
|
sets.push(`GEOM = ST_SetSRID(ST_MakePoint($${idx}, $${idx + 1}), 4326)`);
|
|
params.push(input.lon, input.lat);
|
|
idx += 2;
|
|
}
|
|
|
|
if (sets.length === 0) {
|
|
throw new AuthError('수정할 항목이 없습니다.', 400);
|
|
}
|
|
|
|
sets.push('MDFCN_DTM = NOW()');
|
|
params.push(equipSn);
|
|
|
|
await wingPool.query(
|
|
`UPDATE EQUIPMENT SET ${sets.join(', ')} WHERE EQUIP_SN = $${idx}`,
|
|
params,
|
|
);
|
|
}
|
|
|
|
export async function deleteEquipment(equipSn: number, requesterId: string): Promise<void> {
|
|
const existing = await wingPool.query(
|
|
`SELECT AUTHOR_ID as author_id FROM EQUIPMENT WHERE EQUIP_SN = $1 AND USE_YN = 'Y'`,
|
|
[equipSn],
|
|
);
|
|
|
|
if (existing.rows.length === 0) {
|
|
throw new AuthError('장비를 찾을 수 없습니다.', 404);
|
|
}
|
|
if (existing.rows[0].author_id !== requesterId) {
|
|
throw new AuthError('본인이 등록한 장비만 삭제할 수 있습니다.', 403);
|
|
}
|
|
|
|
await wingPool.query(
|
|
`UPDATE EQUIPMENT SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE EQUIP_SN = $1`,
|
|
[equipSn],
|
|
);
|
|
}
|
|
```
|
|
|
|
### 3단계: 백엔드 라우터
|
|
|
|
```ts
|
|
// backend/src/equipment/equipmentRouter.ts
|
|
|
|
import { Router } from 'express';
|
|
import { requireAuth, requirePermission } from '../auth/authMiddleware.js';
|
|
import { AuthError } from '../auth/authService.js';
|
|
import {
|
|
listEquipment, getEquipment, createEquipment,
|
|
updateEquipment, deleteEquipment,
|
|
} from './equipmentService.js';
|
|
|
|
const router = Router();
|
|
|
|
// GET /api/equipment -- 목록
|
|
router.get('/', requireAuth, requirePermission('equipment', 'READ'), async (req, res) => {
|
|
try {
|
|
const { equipTp, search, page, size } = req.query;
|
|
const result = await listEquipment({
|
|
equipTp: equipTp as string | undefined,
|
|
search: search as string | undefined,
|
|
page: page ? parseInt(page as string, 10) : undefined,
|
|
size: size ? parseInt(size as string, 10) : undefined,
|
|
});
|
|
res.json(result);
|
|
} catch (err) {
|
|
console.error('[equipment] 목록 조회 오류:', err);
|
|
res.status(500).json({ error: '장비 목록 조회 중 오류가 발생했습니다.' });
|
|
}
|
|
});
|
|
|
|
// GET /api/equipment/:sn -- 상세
|
|
router.get('/:sn', requireAuth, requirePermission('equipment', 'READ'), async (req, res) => {
|
|
try {
|
|
const sn = parseInt(req.params.sn as string, 10);
|
|
if (isNaN(sn)) { res.status(400).json({ error: '유효하지 않은 장비 번호입니다.' }); return; }
|
|
const item = await getEquipment(sn);
|
|
res.json(item);
|
|
} catch (err) {
|
|
if (err instanceof AuthError) { res.status(err.status).json({ error: err.message }); return; }
|
|
console.error('[equipment] 상세 조회 오류:', err);
|
|
res.status(500).json({ error: '장비 조회 중 오류가 발생했습니다.' });
|
|
}
|
|
});
|
|
|
|
// POST /api/equipment -- 등록
|
|
router.post('/', requireAuth, requirePermission('equipment', 'CREATE'), async (req, res) => {
|
|
try {
|
|
const { equipTp, equipNm, equipDc, spec, quantity, locationNm, lon, lat } = req.body;
|
|
if (!equipTp || !equipNm) {
|
|
res.status(400).json({ error: '장비 유형과 장비명은 필수입니다.' });
|
|
return;
|
|
}
|
|
const result = await createEquipment({
|
|
equipTp, equipNm, equipDc, spec, quantity, locationNm, lon, lat,
|
|
authorId: req.user!.sub,
|
|
});
|
|
res.status(201).json(result);
|
|
} catch (err) {
|
|
if (err instanceof AuthError) { res.status(err.status).json({ error: err.message }); return; }
|
|
console.error('[equipment] 등록 오류:', err);
|
|
res.status(500).json({ error: '장비 등록 중 오류가 발생했습니다.' });
|
|
}
|
|
});
|
|
|
|
// POST /api/equipment/:sn/update -- 수정
|
|
router.post('/:sn/update', requireAuth, requirePermission('equipment', 'UPDATE'), async (req, res) => {
|
|
try {
|
|
const sn = parseInt(req.params.sn as string, 10);
|
|
if (isNaN(sn)) { res.status(400).json({ error: '유효하지 않은 장비 번호입니다.' }); return; }
|
|
const { equipNm, equipDc, spec, quantity, locationNm, lon, lat } = req.body;
|
|
await updateEquipment(sn, { equipNm, equipDc, spec, quantity, locationNm, lon, lat }, req.user!.sub);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
if (err instanceof AuthError) { res.status(err.status).json({ error: err.message }); return; }
|
|
console.error('[equipment] 수정 오류:', err);
|
|
res.status(500).json({ error: '장비 수정 중 오류가 발생했습니다.' });
|
|
}
|
|
});
|
|
|
|
// POST /api/equipment/:sn/delete -- 삭제
|
|
router.post('/:sn/delete', requireAuth, requirePermission('equipment', 'DELETE'), async (req, res) => {
|
|
try {
|
|
const sn = parseInt(req.params.sn as string, 10);
|
|
if (isNaN(sn)) { res.status(400).json({ error: '유효하지 않은 장비 번호입니다.' }); return; }
|
|
await deleteEquipment(sn, req.user!.sub);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
if (err instanceof AuthError) { res.status(err.status).json({ error: err.message }); return; }
|
|
console.error('[equipment] 삭제 오류:', err);
|
|
res.status(500).json({ error: '장비 삭제 중 오류가 발생했습니다.' });
|
|
}
|
|
});
|
|
|
|
export default router;
|
|
```
|
|
|
|
### 4단계: 프론트엔드 API 서비스
|
|
|
|
```ts
|
|
// frontend/src/tabs/assets/services/equipmentApi.ts
|
|
|
|
import { api } from '@common/services/api';
|
|
|
|
export interface EquipmentItem {
|
|
equipSn: number;
|
|
equipTp: string;
|
|
equipNm: string;
|
|
equipDc: string | null;
|
|
spec: string | null;
|
|
quantity: number;
|
|
locationNm: string | null;
|
|
lon: number | null;
|
|
lat: number | null;
|
|
authorId: string;
|
|
regDtm: string;
|
|
}
|
|
|
|
export interface EquipmentListResponse {
|
|
items: EquipmentItem[];
|
|
totalCount: number;
|
|
page: number;
|
|
size: number;
|
|
}
|
|
|
|
export interface EquipmentListParams {
|
|
equipTp?: string;
|
|
search?: string;
|
|
page?: number;
|
|
size?: number;
|
|
}
|
|
|
|
export interface CreateEquipmentInput {
|
|
equipTp: string;
|
|
equipNm: string;
|
|
equipDc?: string;
|
|
spec?: string;
|
|
quantity?: number;
|
|
locationNm?: string;
|
|
lon?: number;
|
|
lat?: number;
|
|
}
|
|
|
|
export interface UpdateEquipmentInput {
|
|
equipNm?: string;
|
|
equipDc?: string;
|
|
spec?: string;
|
|
quantity?: number;
|
|
locationNm?: string;
|
|
lon?: number;
|
|
lat?: number;
|
|
}
|
|
|
|
export async function fetchEquipmentList(
|
|
params?: EquipmentListParams,
|
|
): Promise<EquipmentListResponse> {
|
|
const response = await api.get<EquipmentListResponse>('/equipment', { params });
|
|
return response.data;
|
|
}
|
|
|
|
export async function fetchEquipment(equipSn: number): Promise<EquipmentItem> {
|
|
const response = await api.get<EquipmentItem>(`/equipment/${equipSn}`);
|
|
return response.data;
|
|
}
|
|
|
|
export async function createEquipment(
|
|
input: CreateEquipmentInput,
|
|
): Promise<{ equipSn: number }> {
|
|
const response = await api.post<{ equipSn: number }>('/equipment', input);
|
|
return response.data;
|
|
}
|
|
|
|
export async function updateEquipment(
|
|
equipSn: number,
|
|
input: UpdateEquipmentInput,
|
|
): Promise<void> {
|
|
await api.post(`/equipment/${equipSn}/update`, input);
|
|
}
|
|
|
|
export async function deleteEquipment(equipSn: number): Promise<void> {
|
|
await api.post(`/equipment/${equipSn}/delete`);
|
|
}
|
|
```
|
|
|
|
### 5단계: server.ts 등록
|
|
|
|
```ts
|
|
// backend/src/server.ts
|
|
|
|
import equipmentRouter from './equipment/equipmentRouter.js';
|
|
|
|
app.use('/api/equipment', equipmentRouter);
|
|
```
|
|
|
|
### 6단계: 검증
|
|
|
|
```bash
|
|
# 백엔드 컴파일
|
|
cd backend && npx tsc --noEmit
|
|
|
|
# 프론트엔드 컴파일
|
|
cd frontend && npx tsc --noEmit
|
|
|
|
# DB 마이그레이션
|
|
psql -h 211.208.115.83 -U wing -d wing -f database/migration/017_equipment.sql
|
|
|
|
# API 테스트 (curl)
|
|
curl -b cookies.txt http://localhost:3001/api/equipment
|
|
|
|
curl -b cookies.txt -X POST http://localhost:3001/api/equipment \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"equipTp":"BOOM","equipNm":"테스트 오일붐"}'
|
|
```
|
|
|
|
---
|
|
|
|
## 부록: 자주 쓰는 SQL 패턴
|
|
|
|
### ILIKE 검색 (대소문자 무시)
|
|
|
|
```sql
|
|
WHERE TITLE ILIKE $1 -- params: ['%검색어%']
|
|
```
|
|
|
|
### 다중 컬럼 검색
|
|
|
|
```sql
|
|
WHERE (TITLE ILIKE $1 OR CONTENT ILIKE $1 OR AUTHOR_NM ILIKE $1)
|
|
```
|
|
|
|
### 정렬 + 상단고정
|
|
|
|
```sql
|
|
ORDER BY PINNED_YN DESC, REG_DTM DESC
|
|
```
|
|
|
|
### RETURNING (INSERT 후 PK 반환)
|
|
|
|
```sql
|
|
INSERT INTO TABLE_NAME (...) VALUES (...)
|
|
RETURNING SN
|
|
```
|
|
|
|
### UPDATE + RETURNING (조회수 증가 + 상세 동시)
|
|
|
|
```sql
|
|
UPDATE TABLE_NAME SET VIEW_CNT = VIEW_CNT + 1
|
|
WHERE SN = $1 AND USE_YN = 'Y'
|
|
RETURNING SN, TITLE, CONTENT, REG_DTM
|
|
```
|
|
|
|
### PostGIS 거리 검색
|
|
|
|
```sql
|
|
WHERE ST_DWithin(
|
|
GEOM::geography,
|
|
ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography,
|
|
10000 -- 미터 단위 (10km)
|
|
)
|
|
```
|
|
|
|
### 페이징 표준
|
|
|
|
```sql
|
|
-- page=1, size=20 -> LIMIT 20 OFFSET 0
|
|
-- page=2, size=20 -> LIMIT 20 OFFSET 20
|
|
LIMIT $N OFFSET $M
|
|
```
|