# 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] components/{탭}/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 { // 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) => ({ 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 { 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 { // 존재 확인 + 소유자 검증 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 { 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 = { 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/components/{탭명}/services/{tabName}Api.ts ``` ### 기본 구조 ```ts // frontend/src/components/{탭명}/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 { const response = await api.get('/equipment', { params }); return response.data; } /** 상세 조회 */ export async function fetchItem(sn: number): Promise { const response = await api.get(`/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 { await api.post(`/equipment/${sn}/update`, input); } /** 삭제 (POST /api/equipment/:sn/delete) */ export async function deleteItem(sn: number): Promise { 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') && ( )} // UPDATE 권한 + 본인 글일 때만 수정 버튼 {hasPermission('equipment', 'UPDATE') && item.authorId === currentUserId && ( )} ``` ### 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({ page: 1, size: 20 }); const { data, isLoading, error } = useEquipmentList(params); const deleteMutation = useDeleteEquipment(); if (isLoading) return
로딩 중...
; if (error) return
에러 발생
; return (
{data?.items.map(item => (
{item.title}
))}
); } ``` --- ## 전체 예시: 장비 관리 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): 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 { 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) => rowToItem(r)), totalCount, page, size, }; } export async function getEquipment(equipSn: number): Promise { 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 { 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 { 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/components/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 { const response = await api.get('/equipment', { params }); return response.data; } export async function fetchEquipment(equipSn: number): Promise { const response = await api.get(`/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 { await api.post(`/equipment/${equipSn}/update`, input); } export async function deleteEquipment(equipSn: number): Promise { 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 ```