# 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
```
