- database/schema/ 14개 DDL 파일 신규 생성 (운영 DB pg_dump 기반) - database/seed/ 14개 초기 데이터 파일 분리 - database/_deprecated/로 구 init.sql, auth_init.sql 이동 - database/README.md 신규 작성 (DB 아키텍처, 설치 절차) - docs/ 6개 가이드 문서 wing_auth→auth 스키마 구조로 수정 - README.md, CLAUDE.md wing 단일 DB 구조 반영 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
44 KiB
44 KiB
CRUD API 개발 가이드
새로운 도메인의 CRUD API 엔드포인트를 개발하는 전체 절차를 설명한다. DB 설계부터 백엔드 구현, 프론트엔드 연동까지 End-to-End 패턴을 제공한다.
목차
아키텍처 개요
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 (auth 스키마 포함)
wingPool.query(SQL, params) --SQL-->
레이어별 책임
| 레이어 | 파일 | 책임 |
|---|---|---|
| Router | {domain}Router.ts |
HTTP 요청 파싱, 파라미터 검증, 미들웨어 적용, 에러 응답 |
| Service | {domain}Service.ts |
비즈니스 로직, DB 쿼리, 도메인 검증, 소유자 검증 |
| Frontend API | {tabName}Api.ts |
Axios 호출, 인터페이스 정의, 응답 변환 |
DB Pool 선택 기준
import { wingPool } from '../db/wingDb.js'; // 업무 데이터 (wing 스키마: BOARD_POST, LAYER 등)
import { authPool } from '../db/authDb.js'; // 인증 데이터 (auth 스키마: AUTH_USER, AUTH_ROLE 등)
참고:
authPool은wingPool의 re-export이다 (wing 단일 DB, search_path = wing, auth, public). 신규 코드는wingPool을 사용한다. 다만 의미적으로 auth 스키마 데이터를 다룰 때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단계 미들웨어를 조합하여 사용한다:
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에 담기는 정보:
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');
에러 처리 표준
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 보일러플레이트
// 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 보일러플레이트
// 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],
);
}
트랜잭션 패턴
여러 테이블을 동시에 변경해야 할 때:
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 등록
// 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 패턴)
카테고리에 따라 다른 리소스에 대해 권한을 검사하는 패턴:
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 등)- 현재 마지막 순번을 확인하여 다음 번호를 사용한다
표준 테이블 구조
-- ============================================================
-- 마이그레이션 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 제약)
CATEGORY_CD VARCHAR(20) NOT NULL,
CONSTRAINT CK_{PREFIX}_CATEGORY
CHECK (CATEGORY_CD IN ('NOTICE', 'DATA', 'QNA', 'MANUAL'))
PostGIS GEOMETRY 컬럼
공간 데이터가 필요한 경우:
-- 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
);
시드 데이터 패턴
마이그레이션 파일 하단에 초기 데이터를 포함할 수 있다:
-- 시드 데이터 (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;
권한 리소스 등록 (필요 시)
-- 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
기본 구조
// 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 인터셉터 | 세션 만료 시 자동 로그아웃 |
컴포넌트에서의 에러 핸들링
// 목록 조회
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 분기
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를 사용하면 캐싱, 자동 재조회, 로딩/에러 상태를 선언적으로 관리할 수 있다.
// 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'] });
},
});
}
컴포넌트에서의 사용:
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 마이그레이션
-- 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단계: 백엔드 서비스
// 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단계: 백엔드 라우터
// 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 서비스
// 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 등록
// backend/src/server.ts
import equipmentRouter from './equipment/equipmentRouter.js';
app.use('/api/equipment', equipmentRouter);
6단계: 검증
# 백엔드 컴파일
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 검색 (대소문자 무시)
WHERE TITLE ILIKE $1 -- params: ['%검색어%']
다중 컬럼 검색
WHERE (TITLE ILIKE $1 OR CONTENT ILIKE $1 OR AUTHOR_NM ILIKE $1)
정렬 + 상단고정
ORDER BY PINNED_YN DESC, REG_DTM DESC
RETURNING (INSERT 후 PK 반환)
INSERT INTO TABLE_NAME (...) VALUES (...)
RETURNING SN
UPDATE + RETURNING (조회수 증가 + 상세 동시)
UPDATE TABLE_NAME SET VIEW_CNT = VIEW_CNT + 1
WHERE SN = $1 AND USE_YN = 'Y'
RETURNING SN, TITLE, CONTENT, REG_DTM
PostGIS 거리 검색
WHERE ST_DWithin(
GEOM::geography,
ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography,
10000 -- 미터 단위 (10km)
)
페이징 표준
-- page=1, size=20 -> LIMIT 20 OFFSET 0
-- page=2, size=20 -> LIMIT 20 OFFSET 20
LIMIT $N OFFSET $M