wing-ops/docs/_backup_20260301/CRUD-API-GUIDE.md
htlee 6fbb3fc249 docs: 전체 프로젝트 문서 최신 기준 신규 작성
Phase 6(MapLibre+deck.gl), CSS 리팩토링, RBAC, 10탭 API 전환 등
현재 시스템 상태를 정확히 반영하여 모든 문서를 처음부터 재작성.

- README.md: 기술 스택(MapLibre+deck.gl), 빌드, 구조, 스킬 갱신
- CLAUDE.md: CSS @layer, RBAC, HTTP 정책, 백엔드 모듈 반영
- docs/README.md: 아키텍처 상세 (3-Layer, 인증, 권한, CSS)
- docs/DEVELOPMENT-GUIDE.md: 워크플로우 전체 흐름 + 실전 예시
- docs/INSTALL_GUIDE.md: 온라인/오프라인 설치 매뉴얼
- docs/COMMON-GUIDE.md: 공통 로직 9개 섹션 (인증~CSS)
- docs/MENU-TAB-GUIDE.md: 새 탭 추가 5단계 + 예시
- docs/CRUD-API-GUIDE.md: End-to-End CRUD API 패턴
- docs/MOCK-TO-API-GUIDE.md: Mock→API 전환 10단계 프로세스
- docs/_backup_20260301/: 기존 문서 백업

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:03:08 +09:00

1437 lines
41 KiB
Markdown

# RBAC 기반 CRUD API 개발 가이드
새 CRUD API를 추가할 때 따라야 할 표준 가이드.
Phase 5 RBAC 체계(리소스 x 오퍼레이션 2차원 모델)를 기반으로 한다.
**DB 구조**: wing DB 단일 DB, 스키마 분리
- `wing` 스키마: 운영 데이터 (BOARD_POST, LAYER 등)
- `auth` 스키마: 인증/인가 데이터 (AUTH_USER, AUTH_ROLE, AUTH_PERM 등)
- `public` 스키마: PostGIS 시스템 테이블만 유지 (사용 금지)
---
## Part 1: 범용 가이드
### 1. 개요
이 문서는 WING-OPS의 **모든 탭 개발자**가 새 CRUD API를 만들 때 참조하는 표준이다.
- 백엔드: Express Router + Service 2-Layer
- 권한: `requirePermission(resource, operation)` 미들웨어
- DB: PostgreSQL (`wingPool` 단일 Pool, `search_path = wing, auth, public`)
- 프론트: Axios + `hasPermission()` 조건부 렌더링
각 섹션에 복사해서 바로 사용할 수 있는 실제 코드 스니펫을 포함한다.
---
### 2. 아키텍처
#### 3-Layer 구조
```
클라이언트 (React)
↓ Axios (withCredentials: true, JWT 쿠키 자동 포함)
Router (Express) ← requireAuth → requirePermission
Service ← 비즈니스 로직, DB 쿼리
DB (pg Pool) ← wingPool (search_path = wing, auth)
```
#### 디렉토리 구조
```
backend/src/{domain}/
├── {domain}Router.ts ← Express 라우터 (엔드포인트 + 미들웨어)
└── {domain}Service.ts ← 비즈니스 로직 (쿼리, 인터페이스)
```
#### DB Pool
```typescript
// backend/src/db/wingDb.ts
import { wingPool } from '../db/wingDb.js'
// wingPool은 연결 시 search_path = wing, auth, public 자동 설정
// → 스키마 접두사 없이 wing.BOARD_POST, auth.AUTH_USER 모두 접근 가능
```
> **주의**: `authPool`은 하위 호환용 re-export이다. 신규 코드는 반드시 `wingPool`을 직접 import할 것.
```typescript
// backend/src/db/authDb.ts (하위 호환 — 신규 코드에서 사용 금지)
import { wingPool } from './wingDb.js'
export const authPool = wingPool // 같은 Pool
```
---
### 3. 권한 모델 빠른 요약
#### 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 = 삭제
├── prediction
│ ├── prediction:analysis
│ └── prediction:list
└── admin
├── admin:users
└── admin:permissions
```
#### 리소스 코드
`AUTH_PERM_TREE` 테이블에 등록된 코드. 콜론(`:`)으로 계층 구분.
| 형식 | 예시 | 설명 |
|------|------|------|
| `{탭}` | `board` | 메인 탭 (level 0) |
| `{탭}:{서브}` | `board:notice` | 서브 리소스 (level 1) |
#### 오퍼레이션
| OPER_CD | 설명 | 용도 |
|---------|------|------|
| `READ` | 조회/열람 | 목록, 상세 조회 |
| `CREATE` | 생성 | 새 데이터 등록 |
| `UPDATE` | 수정 | 기존 데이터 변경 |
| `DELETE` | 삭제 | 데이터 삭제 |
#### 백엔드: requirePermission
```typescript
import { requireAuth, requirePermission } from '../auth/authMiddleware.js'
// requirePermission(리소스코드, 오퍼레이션코드)
// 오퍼레이션 생략 시 기본값 'READ'
router.post('/list', requirePermission('board:notice', 'READ'), handler)
router.post('/create', requirePermission('board:notice', 'CREATE'), handler)
```
`requirePermission`은 **요청당 1회**만 DB를 조회하고 `req.resolvedPermissions`에 캐싱한다. 한 요청에서 여러 번 호출해도 성능 문제 없다.
#### 프론트엔드: hasPermission
```typescript
import { useAuthStore } from '@common/store/authStore'
const { hasPermission } = useAuthStore()
hasPermission('board:notice') // READ 확인 (기본값)
hasPermission('board:notice', 'CREATE') // 생성 권한 확인
hasPermission('board:notice', 'UPDATE') // 수정 권한 확인
hasPermission('board:notice', 'DELETE') // 삭제 권한 확인
```
#### 상속 규칙
```
규칙 1: 부모 READ=N → 자식의 모든 오퍼레이션 강제 N
규칙 2: 명시적 레코드 있으면 → 그 값 사용
규칙 3: 명시적 레코드 없으면 → 부모의 같은 오퍼레이션 상속
규칙 4: 최상위까지 없으면 → 기본 N (거부)
```
---
### 4. DB 설계 규칙
#### 스키마 선택
| 데이터 성격 | 스키마 | 예시 |
|-------------|--------|------|
| 운영 데이터 | `wing` | BOARD_POST, LAYER, HNS_SUBSTANCE |
| 인증/인가 | `auth` | AUTH_USER, AUTH_ROLE, AUTH_PERM |
> `search_path = wing, auth, public` 설정으로 스키마 접두사 없이 접근 가능.
> 단, 다른 스키마 테이블을 FK로 참조할 때는 `auth.AUTH_USER(USER_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` |
#### 공통 컬럼 패턴
모든 운영 테이블에 포함하는 표준 컬럼:
```sql
USE_YN CHAR(1) NOT NULL DEFAULT 'Y', -- 논리삭제 (Y=활성, N=삭제)
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
MDFCN_DTM TIMESTAMPTZ, -- 수정일시
```
#### DDL 작성 예시
```sql
-- database/migration/NNN_description.sql
CREATE TABLE IF NOT EXISTS BOARD_POST (
POST_SN SERIAL PRIMARY KEY,
CATEGORY_CD VARCHAR(20) NOT NULL,
TITLE VARCHAR(200) NOT NULL,
CONTENT TEXT,
AUTHOR_ID UUID NOT NULL,
VIEW_CNT INTEGER NOT NULL DEFAULT 0,
PINNED_YN CHAR(1) NOT NULL DEFAULT 'N',
USE_YN CHAR(1) NOT NULL DEFAULT 'Y',
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(),
MDFCN_DTM TIMESTAMPTZ,
-- FK: 다른 스키마 참조 시 스키마 명시
CONSTRAINT FK_BOARD_AUTHOR FOREIGN KEY (AUTHOR_ID)
REFERENCES auth.AUTH_USER(USER_ID),
-- CHECK: 코드성 컬럼에 허용값 명시
CONSTRAINT CK_BOARD_CATEGORY
CHECK (CATEGORY_CD IN ('NOTICE','DATA','QNA','MANUAL')),
CONSTRAINT CK_BOARD_PINNED CHECK (PINNED_YN IN ('Y','N')),
CONSTRAINT CK_BOARD_USE CHECK (USE_YN IN ('Y','N'))
);
-- COMMENT: 테이블/컬럼 설명
COMMENT ON TABLE BOARD_POST IS '게시판 게시글';
COMMENT ON COLUMN BOARD_POST.CATEGORY_CD IS '카테고리: NOTICE=공지, DATA=자료실, QNA=Q&A, MANUAL=해경매뉴얼';
-- INDEX: 검색/필터 대상, FK 컬럼
CREATE INDEX IF NOT EXISTS IDX_BOARD_CATEGORY ON BOARD_POST(CATEGORY_CD);
CREATE INDEX IF NOT EXISTS IDX_BOARD_AUTHOR ON BOARD_POST(AUTHOR_ID);
CREATE INDEX IF NOT EXISTS IDX_BOARD_REG_DTM ON BOARD_POST(REG_DTM DESC);
```
#### 마이그레이션 파일 규칙
- 경로: `database/migration/NNN_description.sql`
- 번호: 기존 파일 다음 번호 (001, 003, 004, 005, 006, ...)
- 모든 DDL에 `IF NOT EXISTS` / `IF EXISTS` 사용 (재실행 안전)
- 파일 끝에 검증 SELECT 포함
---
### 5. Service 레이어 패턴
#### 인터페이스 정의
Service 파일 상단에 반환 타입과 입력 타입을 정의한다.
```typescript
// backend/src/{domain}/{domain}Service.ts
import { wingPool } from '../db/wingDb.js'
import { AuthError } from '../auth/authService.js'
// 목록/상세 조회 반환 타입
interface PostItem {
postSn: number
categoryCd: string
title: string
content: string | null
authorId: string
authorName: string
viewCnt: number
pinnedYn: string
useYn: string
regDtm: string
mdfcnDtm: string | null
}
// 생성 입력 타입
interface CreatePostInput {
categoryCd: string
title: string
content?: string
authorId: string
pinnedYn?: string
}
// 수정 입력 타입 (모든 필드 optional — 부분 업데이트)
interface UpdatePostInput {
title?: string
content?: string
categoryCd?: string
pinnedYn?: string
}
// 페이징 응답 타입
interface PagedResult<T> {
items: T[]
totalCount: number
page: number
size: number
}
```
#### wingPool 사용
```typescript
import { wingPool } from '../db/wingDb.js'
// 단순 조회
const result = await wingPool.query(
'SELECT * FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = $2',
[postSn, 'Y']
)
// Parameterized Query — 반드시 $1, $2, ... 사용 (SQL Injection 방지)
// 문자열 결합으로 쿼리를 만들지 않는다
```
#### 동적 WHERE 빌드 패턴 (필터, 검색)
```typescript
export async function listPosts(
categoryCd?: string,
search?: string,
page: number = 1,
size: number = 20,
): Promise<PagedResult<PostItem>> {
// 동적 WHERE 조건
const conditions: string[] = ["p.USE_YN = 'Y'"]
const params: (string | number)[] = []
let paramIdx = 1
if (categoryCd) {
conditions.push(`p.CATEGORY_CD = $${paramIdx++}`)
params.push(categoryCd)
}
if (search) {
conditions.push(`(p.TITLE ILIKE $${paramIdx} OR p.CONTENT ILIKE $${paramIdx})`)
params.push(`%${search}%`)
paramIdx++
}
const whereClause = conditions.join(' AND ')
// totalCount 조회
const countResult = await wingPool.query(
`SELECT COUNT(*) as cnt FROM BOARD_POST p WHERE ${whereClause}`,
params
)
const totalCount = parseInt(countResult.rows[0].cnt, 10)
// 페이징 데이터 조회
const offset = (page - 1) * size
const dataParams = [...params, size, offset]
const dataResult = await wingPool.query(
`SELECT p.POST_SN as post_sn, p.CATEGORY_CD as category_cd,
p.TITLE as title, p.CONTENT as content,
p.AUTHOR_ID as author_id, u.USER_NM as author_name,
p.VIEW_CNT as view_cnt, p.PINNED_YN as pinned_yn,
p.USE_YN as use_yn, p.REG_DTM as reg_dtm, p.MDFCN_DTM as mdfcn_dtm
FROM BOARD_POST p
LEFT JOIN AUTH_USER u ON p.AUTHOR_ID = u.USER_ID
WHERE ${whereClause}
ORDER BY p.PINNED_YN DESC, p.REG_DTM DESC
LIMIT $${paramIdx++} OFFSET $${paramIdx++}`,
dataParams
)
const items: PostItem[] = dataResult.rows.map((row) => ({
postSn: row.post_sn,
categoryCd: row.category_cd,
title: row.title,
content: row.content,
authorId: row.author_id,
authorName: row.author_name,
viewCnt: row.view_cnt,
pinnedYn: row.pinned_yn,
useYn: row.use_yn,
regDtm: row.reg_dtm,
mdfcnDtm: row.mdfcn_dtm,
}))
return { items, totalCount, page, size }
}
```
#### 상세 조회
```typescript
export async function getPost(postSn: number): Promise<PostItem> {
const result = await wingPool.query(
`SELECT p.POST_SN as post_sn, p.CATEGORY_CD as category_cd,
p.TITLE as title, p.CONTENT as content,
p.AUTHOR_ID as author_id, u.USER_NM as author_name,
p.VIEW_CNT as view_cnt, p.PINNED_YN as pinned_yn,
p.USE_YN as use_yn, p.REG_DTM as reg_dtm, p.MDFCN_DTM as mdfcn_dtm
FROM BOARD_POST p
LEFT JOIN AUTH_USER u ON p.AUTHOR_ID = u.USER_ID
WHERE p.POST_SN = $1 AND p.USE_YN = 'Y'`,
[postSn]
)
if (result.rows.length === 0) {
throw new AuthError('게시글을 찾을 수 없습니다.', 404)
}
const row = result.rows[0]
return {
postSn: row.post_sn,
categoryCd: row.category_cd,
title: row.title,
content: row.content,
authorId: row.author_id,
authorName: row.author_name,
viewCnt: row.view_cnt,
pinnedYn: row.pinned_yn,
useYn: row.use_yn,
regDtm: row.reg_dtm,
mdfcnDtm: row.mdfcn_dtm,
}
}
```
#### 생성
```typescript
export async function createPost(input: CreatePostInput): Promise<{ postSn: number }> {
const result = await wingPool.query(
`INSERT INTO BOARD_POST (CATEGORY_CD, TITLE, CONTENT, AUTHOR_ID, PINNED_YN)
VALUES ($1, $2, $3, $4, $5)
RETURNING POST_SN as post_sn`,
[input.categoryCd, input.title, input.content || null, input.authorId, input.pinnedYn || 'N']
)
return { postSn: result.rows[0].post_sn }
}
```
#### 동적 SET 빌드 패턴 (부분 업데이트)
```typescript
export async function updatePost(
postSn: number,
input: UpdatePostInput,
requesterId: string,
): Promise<void> {
// 소유자 검증
const existing = await wingPool.query(
"SELECT AUTHOR_ID FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'",
[postSn]
)
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)
}
if (input.content !== undefined) {
sets.push(`CONTENT = $${idx++}`)
params.push(input.content)
}
if (input.categoryCd !== undefined) {
sets.push(`CATEGORY_CD = $${idx++}`)
params.push(input.categoryCd)
}
if (input.pinnedYn !== undefined) {
sets.push(`PINNED_YN = $${idx++}`)
params.push(input.pinnedYn)
}
if (sets.length === 0) {
throw new AuthError('수정할 항목이 없습니다.', 400)
}
// MDFCN_DTM 자동 갱신
sets.push('MDFCN_DTM = NOW()')
params.push(postSn)
await wingPool.query(
`UPDATE BOARD_POST SET ${sets.join(', ')} WHERE POST_SN = $${idx}`,
params
)
}
```
#### 삭제 (논리삭제)
```typescript
export async function deletePost(postSn: number, requesterId: string): Promise<void> {
// 소유자 검증
const existing = await wingPool.query(
"SELECT AUTHOR_ID FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'",
[postSn]
)
if (existing.rows.length === 0) {
throw new AuthError('게시글을 찾을 수 없습니다.', 404)
}
if (existing.rows[0].author_id !== requesterId) {
throw new AuthError('본인의 게시글만 삭제할 수 있습니다.', 403)
}
// 논리삭제: USE_YN = 'N'
await wingPool.query(
"UPDATE BOARD_POST SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE POST_SN = $1",
[postSn]
)
}
```
#### 트랜잭션 패턴
여러 테이블을 동시에 변경해야 할 때:
```typescript
export async function createPostWithAttachments(
input: CreatePostInput,
attachments: AttachmentInput[],
): Promise<{ postSn: number }> {
const client = await wingPool.connect()
try {
await client.query('BEGIN')
// 게시글 생성
const postResult = await client.query(
`INSERT INTO BOARD_POST (CATEGORY_CD, TITLE, CONTENT, AUTHOR_ID)
VALUES ($1, $2, $3, $4)
RETURNING POST_SN as post_sn`,
[input.categoryCd, input.title, input.content, input.authorId]
)
const postSn = postResult.rows[0].post_sn
// 첨부파일 생성
for (const att of attachments) {
await client.query(
`INSERT INTO BOARD_ATTACH (POST_SN, FILE_NM, FILE_PATH, FILE_SIZE)
VALUES ($1, $2, $3, $4)`,
[postSn, att.fileName, att.filePath, att.fileSize]
)
}
await client.query('COMMIT')
return { postSn }
} catch (err) {
await client.query('ROLLBACK')
throw err
} finally {
client.release()
}
}
```
#### 에러 처리
```typescript
import { AuthError } from '../auth/authService.js'
// AuthError: status 코드와 메시지를 포함하는 커스텀 에러
// Router에서 instanceof 체크로 적절한 HTTP 응답을 반환
throw new AuthError('게시글을 찾을 수 없습니다.', 404)
throw new AuthError('권한이 없습니다.', 403)
throw new AuthError('필수 항목이 누락되었습니다.', 400)
throw new AuthError('이미 존재하는 데이터입니다.', 409)
```
`AuthError` 클래스 정의 (`backend/src/auth/authService.ts`):
```typescript
export class AuthError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.status = status
this.name = 'AuthError'
}
}
```
---
### 6. Router 레이어 패턴
#### 미들웨어 체인
```
requireAuth → requirePermission(resource, operation) → 핸들러
```
- `requireAuth`: JWT 쿠키 검증, `req.user`에 페이로드 세팅
- `requirePermission`: 리소스 x 오퍼레이션 권한 확인
#### CRUD 엔드포인트 표준
보안 취약점 점검 가이드에 따라 **POST 메서드를 기본**으로 사용한다.
OPER_CD는 HTTP Method가 아닌 **비즈니스 의미**로 결정한다.
| URL 패턴 | OPER_CD | 미들웨어 |
|----------|---------|----------|
| `POST /api/{domain}/list` | READ | `requirePermission(resource, 'READ')` |
| `POST /api/{domain}/detail` | READ | `requirePermission(resource, 'READ')` |
| `POST /api/{domain}/create` | CREATE | `requirePermission(resource, 'CREATE')` |
| `POST /api/{domain}/update` | UPDATE | `requirePermission(resource, 'UPDATE')` |
| `POST /api/{domain}/delete` | DELETE | `requirePermission(resource, 'DELETE')` |
#### 전체 Router 예시
```typescript
// backend/src/board/boardRouter.ts
import { Router } from 'express'
import { requireAuth, requirePermission } from '../auth/authMiddleware.js'
import { AuthError } from '../auth/authService.js'
import {
listPosts,
getPost,
createPost,
updatePost,
deletePost,
} from './boardService.js'
const router = Router()
// 모든 엔드포인트에 인증 필수
router.use(requireAuth)
// 목록 조회
router.post('/list', requirePermission('board:notice', 'READ'), async (req, res) => {
try {
const { categoryCd, search, page, size } = req.body
const result = await listPosts(categoryCd, search, page, size)
res.json(result)
} catch (err) {
if (err instanceof AuthError) {
res.status(err.status).json({ error: err.message })
return
}
console.error('[board] 목록 조회 오류:', err)
res.status(500).json({ error: '게시글 목록 조회 중 오류가 발생했습니다.' })
}
})
// 상세 조회
router.post('/detail', requirePermission('board:notice', 'READ'), async (req, res) => {
try {
const { postSn } = req.body
if (!postSn) {
res.status(400).json({ error: '게시글 번호는 필수입니다.' })
return
}
const post = await getPost(postSn)
res.json(post)
} catch (err) {
if (err instanceof AuthError) {
res.status(err.status).json({ error: err.message })
return
}
console.error('[board] 상세 조회 오류:', err)
res.status(500).json({ error: '게시글 조회 중 오류가 발생했습니다.' })
}
})
// 생성
router.post('/create', requirePermission('board:notice', 'CREATE'), async (req, res) => {
try {
const { categoryCd, title, content, pinnedYn } = req.body
// 필수 필드 검증
if (!categoryCd || !title) {
res.status(400).json({ error: '카테고리와 제목은 필수입니다.' })
return
}
// req.user!.sub = 현재 로그인 사용자 UUID
const result = await createPost({
categoryCd,
title,
content,
authorId: req.user!.sub,
pinnedYn,
})
res.status(201).json(result)
} catch (err) {
if (err instanceof AuthError) {
res.status(err.status).json({ error: err.message })
return
}
console.error('[board] 생성 오류:', err)
res.status(500).json({ error: '게시글 생성 중 오류가 발생했습니다.' })
}
})
// 수정
router.post('/update', requirePermission('board:notice', 'UPDATE'), async (req, res) => {
try {
const { postSn, title, content, categoryCd, pinnedYn } = req.body
if (!postSn) {
res.status(400).json({ error: '게시글 번호는 필수입니다.' })
return
}
await updatePost(postSn, { title, content, categoryCd, pinnedYn }, req.user!.sub)
res.json({ success: true })
} catch (err) {
if (err instanceof AuthError) {
res.status(err.status).json({ error: err.message })
return
}
console.error('[board] 수정 오류:', err)
res.status(500).json({ error: '게시글 수정 중 오류가 발생했습니다.' })
}
})
// 삭제
router.post('/delete', requirePermission('board:notice', 'DELETE'), async (req, res) => {
try {
const { postSn } = req.body
if (!postSn) {
res.status(400).json({ error: '게시글 번호는 필수입니다.' })
return
}
await deletePost(postSn, req.user!.sub)
res.json({ success: true })
} catch (err) {
if (err instanceof AuthError) {
res.status(err.status).json({ error: err.message })
return
}
console.error('[board] 삭제 오류:', err)
res.status(500).json({ error: '게시글 삭제 중 오류가 발생했습니다.' })
}
})
export default router
```
#### 입력 검증 패턴
핸들러 내부에서 필수 필드를 직접 체크한다.
```typescript
// 필수 필드 검증
if (!categoryCd || !title) {
res.status(400).json({ error: '카테고리와 제목은 필수입니다.' })
return
}
// 배열 타입 검증
if (!Array.isArray(roleSns)) {
res.status(400).json({ error: '역할 목록이 필요합니다.' })
return
}
// 길이 검증
if (!password || password.length < 4) {
res.status(400).json({ error: '비밀번호는 4자 이상이어야 합니다.' })
return
}
```
#### 에러 응답 패턴
모든 핸들러에서 동일한 에러 처리 구조를 사용한다.
```typescript
try {
// 비즈니스 로직
} catch (err) {
// 1. AuthError → 해당 status + message
if (err instanceof AuthError) {
res.status(err.status).json({ error: err.message })
return
}
// 2. 예상치 못한 에러 → 500 + 일반 메시지 (내부 정보 노출 방지)
console.error('[domain] 작업 오류:', err)
res.status(500).json({ error: '처리 중 오류가 발생했습니다.' })
}
```
#### server.ts 등록
```typescript
// backend/src/server.ts
import boardRouter from './board/boardRouter.js'
// API 라우트 — 업무
app.use('/api/board', boardRouter)
```
#### req.user 구조 (JWT 페이로드)
`requireAuth` 통과 후 `req.user`에 담기는 정보:
```typescript
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 // 현재 사용자 UUID
const userName = req.user!.name // 현재 사용자 이름
const isAdmin = req.user!.roles.includes('ADMIN')
```
---
### 7. 프론트엔드 연동 패턴
#### API 서비스 파일
탭별로 `services/` 디렉토리에 API 함수를 분리한다.
```typescript
// frontend/src/tabs/board/services/boardApi.ts
import { api } from '@common/services/api'
// 타입 정의
export interface PostItem {
postSn: number
categoryCd: string
title: string
content: string | null
authorId: string
authorName: string
viewCnt: number
pinnedYn: string
useYn: string
regDtm: string
mdfcnDtm: string | null
}
export interface PostListResult {
items: PostItem[]
totalCount: number
page: number
size: number
}
// 목록 조회
export async function fetchPosts(params: {
categoryCd?: string
search?: string
page?: number
size?: number
}): Promise<PostListResult> {
const response = await api.post<PostListResult>('/board/list', params)
return response.data
}
// 상세 조회
export async function fetchPost(postSn: number): Promise<PostItem> {
const response = await api.post<PostItem>('/board/detail', { postSn })
return response.data
}
// 생성
export async function createPostApi(data: {
categoryCd: string
title: string
content?: string
pinnedYn?: string
}): Promise<{ postSn: number }> {
const response = await api.post<{ postSn: number }>('/board/create', data)
return response.data
}
// 수정
export async function updatePostApi(
postSn: number,
data: { title?: string; content?: string; categoryCd?: string; pinnedYn?: string },
): Promise<void> {
await api.post('/board/update', { postSn, ...data })
}
// 삭제
export async function deletePostApi(postSn: number): Promise<void> {
await api.post('/board/delete', { postSn })
}
```
#### Axios 인스턴스
```typescript
// frontend/src/common/services/api.ts (이미 설정됨, 수정 불필요)
import axios from 'axios'
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3001/api',
withCredentials: true, // JWT 쿠키 자동 포함
timeout: 30000, // 30초 타임아웃
})
// 401 응답 시 자동 로그아웃 (인터셉터)
// 403 응답 시 권한 부족 (requirePermission 미들웨어)
```
#### 권한 기반 UI 분기
```tsx
// frontend/src/tabs/board/components/PostList.tsx
import { useAuthStore } from '@common/store/authStore'
const PostList = () => {
const { hasPermission } = useAuthStore()
return (
<div>
<h2>게시판</h2>
{/* CREATE 권한이 있을 때만 글쓰기 버튼 표시 */}
{hasPermission('board:notice', 'CREATE') && (
<button onClick={handleCreate}>글쓰기</button>
)}
{/* 목록 렌더링 */}
{posts.map((post) => (
<div key={post.postSn}>
<span>{post.title}</span>
{/* UPDATE 권한 + 본인 글일 때만 수정 버튼 */}
{hasPermission('board:notice', 'UPDATE') && post.authorId === user?.id && (
<button onClick={() => handleEdit(post.postSn)}>수정</button>
)}
{/* DELETE 권한 + 본인 글일 때만 삭제 버튼 */}
{hasPermission('board:notice', 'DELETE') && post.authorId === user?.id && (
<button onClick={() => handleDelete(post.postSn)}>삭제</button>
)}
</div>
))}
{/* 페이징 */}
<Pagination
totalCount={totalCount}
page={page}
size={size}
onPageChange={handlePageChange}
/>
</div>
)
}
```
#### TanStack Query 연동 (권장)
```typescript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { fetchPosts, createPostApi, deletePostApi } from '../services/boardApi'
// 목록 조회
const { data, isLoading } = useQuery({
queryKey: ['posts', categoryCd, search, page],
queryFn: () => fetchPosts({ categoryCd, search, page, size: 20 }),
})
// 생성
const queryClient = useQueryClient()
const createMutation = useMutation({
mutationFn: createPostApi,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
})
// 삭제
const deleteMutation = useMutation({
mutationFn: deletePostApi,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
})
```
---
### 8. 권한 상속 실전 시나리오
`AUTH_PERM_TREE``AUTH_PERM`의 상속 규칙이 실제로 어떻게 동작하는지 4가지 시나리오로 설명한다.
#### 시나리오 1: 부모 허용 → 자식 상속
```
AUTH_PERM:
ADMIN 역할 — board READ=Y, CREATE=Y, UPDATE=Y, DELETE=Y
결과:
board:notice READ → 명시적 레코드 없음 → 부모(board) READ=Y 상속 → Y
board:notice CREATE → 명시적 레코드 없음 → 부모(board) CREATE=Y 상속 → Y
board:data READ → 명시적 레코드 없음 → 부모(board) READ=Y 상속 → Y
→ 부모에게 권한을 주면 모든 자식이 자동으로 같은 권한을 상속한다.
```
#### 시나리오 2: 명시적 거부 (Override)
```
AUTH_PERM:
MANAGER 역할 — board READ=Y, CREATE=Y
board:notice CREATE=N (명시적)
결과:
board:notice READ → 부모 상속 Y
board:notice CREATE → 명시적 N → N (공지 작성 불가)
board:data CREATE → 부모 상속 Y (자료실은 작성 가능)
→ 자식에 명시적 레코드가 있으면 부모 상속보다 우선한다.
```
#### 시나리오 3: 부모 접근 차단 → 자식 전체 차단
```
AUTH_PERM:
VIEWER 역할 — board READ=N
결과:
board:notice READ → 부모 READ=N → 강제 N (규칙 1)
board:notice CREATE → 부모 READ=N → 강제 N (규칙 1)
board:data READ → 부모 READ=N → 강제 N (규칙 1)
→ 부모의 READ가 N이면 자식의 모든 오퍼레이션이 강제 차단된다.
자식에 명시적 Y가 있어도 무시된다.
```
#### 시나리오 4: 서브리소스 개별 허용
```
AUTH_PERM:
USER 역할 — board READ=Y, CREATE=N
board:qna CREATE=Y (명시적)
결과:
board:notice CREATE → 부모 상속 N (공지 작성 불가)
board:data CREATE → 부모 상속 N (자료실 작성 불가)
board:qna CREATE → 명시적 Y → Y (Q&A는 작성 가능)
→ 부모에서 CUD를 기본 차단하고, 특정 서브리소스만 허용하는 패턴.
```
#### 내부 키 형식
permResolver에서 리소스와 오퍼레이션을 결합할 때 더블콜론(`::`)을 사용한다.
```
리소스 내부 경로: board:notice (싱글콜론)
리소스-오퍼레이션 결합: board:notice::READ (더블콜론, 내부 전용)
```
```typescript
// backend/src/roles/permResolver.ts
export function makePermKey(rsrcCode: string, operCd: string): string {
return `${rsrcCode}::${operCd}`
}
```
---
### 9. 새 CRUD API 추가 체크리스트
새 도메인의 CRUD API를 추가할 때 아래 순서대로 진행한다.
#### 백엔드
- [ ] `database/migration/NNN_{domain}.sql` 작성 (DDL + 초기 데이터)
- 테이블 생성 (IF NOT EXISTS)
- FK, CHECK 제약, 인덱스
- COMMENT
- 검증 SELECT
- [ ] DB 마이그레이션 실행 (`psql`로 직접 실행)
- [ ] `backend/src/{domain}/{domain}Service.ts` 작성
- 인터페이스 정의 (Item, CreateInput, UpdateInput)
- CRUD 함수 (list, get, create, update, delete)
- wingPool import, AuthError import
- 동적 WHERE/SET 빌드, 소유자 검증
- [ ] `backend/src/{domain}/{domain}Router.ts` 작성
- requireAuth + requirePermission 미들웨어
- POST /list, /detail, /create, /update, /delete
- 입력 검증, AuthError 분기, 500 에러 처리
- [ ] `backend/src/server.ts`에 라우터 등록
```typescript
import boardRouter from './board/boardRouter.js'
app.use('/api/board', boardRouter)
```
- [ ] 빌드 확인: `cd backend && npm run build`
#### 권한 등록 (필요 시)
- [ ] `AUTH_PERM_TREE`에 리소스 등록 (마이그레이션 SQL)
```sql
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD)
VALUES ('board:notice', 'board', '공지사항', 1, 2)
ON CONFLICT (RSRC_CD) DO NOTHING;
```
- [ ] `AUTH_PERM`에 역할별 권한 초기값 추가 (마이그레이션 SQL)
```sql
-- ADMIN: 모든 오퍼레이션 허용
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN)
SELECT r.ROLE_SN, 'board:notice', op.cd, 'Y'
FROM AUTH_ROLE r, (VALUES ('READ'),('CREATE'),('UPDATE'),('DELETE')) AS op(cd)
WHERE r.ROLE_CD = 'ADMIN'
ON CONFLICT DO NOTHING;
-- VIEWER: READ만 허용
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN)
SELECT r.ROLE_SN, 'board:notice', 'READ', 'Y'
FROM AUTH_ROLE r
WHERE r.ROLE_CD = 'VIEWER'
ON CONFLICT DO NOTHING;
```
#### 프론트엔드
- [ ] `frontend/src/tabs/{domain}/services/{domain}Api.ts` 작성
- 타입 정의 (interface)
- CRUD API 함수 (api.post 사용)
- [ ] 프론트 컴포넌트에서 mock 데이터 → API 호출로 전환
- [ ] `hasPermission()` 조건부 렌더링 적용
- CREATE 권한 → 글쓰기 버튼
- UPDATE 권한 → 수정 버튼
- DELETE 권한 → 삭제 버튼
- [ ] 빌드 확인: `cd frontend && npx tsc --noEmit`
---
## Part 2: 게시판 실전 튜토리얼
게시판(Board) CRUD API를 처음부터 끝까지 구현한 실전 예제.
Part 1의 규칙을 실제로 어떻게 적용하는지 단계별로 설명한다.
---
### Step 1: DB 테이블 설계
**파일**: `database/migration/006_board.sql`
```sql
CREATE TABLE IF NOT EXISTS BOARD_POST (
POST_SN SERIAL PRIMARY KEY,
CATEGORY_CD VARCHAR(20) NOT NULL,
TITLE VARCHAR(200) NOT NULL,
CONTENT TEXT,
AUTHOR_ID UUID NOT NULL,
VIEW_CNT INTEGER NOT NULL DEFAULT 0,
PINNED_YN CHAR(1) NOT NULL DEFAULT 'N',
USE_YN CHAR(1) NOT NULL DEFAULT 'Y',
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(),
MDFCN_DTM TIMESTAMPTZ,
CONSTRAINT FK_BOARD_AUTHOR FOREIGN KEY (AUTHOR_ID)
REFERENCES auth.AUTH_USER(USER_ID),
CONSTRAINT CK_BOARD_CATEGORY
CHECK (CATEGORY_CD IN ('NOTICE','DATA','QNA','MANUAL')),
CONSTRAINT CK_BOARD_PINNED CHECK (PINNED_YN IN ('Y','N')),
CONSTRAINT CK_BOARD_USE CHECK (USE_YN IN ('Y','N'))
);
CREATE INDEX IF NOT EXISTS IDX_BOARD_CATEGORY ON BOARD_POST(CATEGORY_CD);
CREATE INDEX IF NOT EXISTS IDX_BOARD_AUTHOR ON BOARD_POST(AUTHOR_ID);
CREATE INDEX IF NOT EXISTS IDX_BOARD_REG_DTM ON BOARD_POST(REG_DTM DESC);
```
**설계 포인트**:
- `wing` 스키마에 생성 (search_path 덕분에 쿼리에서 스키마 접두사 불필요)
- `AUTHOR_ID``auth.AUTH_USER(USER_ID)`를 cross-schema FK 참조
- `USE_YN`으로 논리 삭제 (물리 삭제 대신 `'N'`으로 변경)
- `CATEGORY_CD` CHECK 제약으로 유효값 강제
#### 카테고리 ↔ 리소스 매핑
| CATEGORY_CD | AUTH_PERM_TREE 리소스 | 정책 |
|---|---|---|
| `NOTICE` | `board:notice` | ADMIN/MANAGER만 CUD |
| `DATA` | `board:data` | MANAGER 이상 CUD |
| `QNA` | `board:qna` | 인증 사용자 CUD (본인 글만 UD) |
| `MANUAL` | `board:manual` | ADMIN만 CUD |
---
### Step 2: Service 구현
**파일**: `backend/src/board/boardService.ts`
#### 인터페이스 정의
```typescript
interface PostListItem {
sn: number
categoryCd: string
title: string
authorId: string
authorName: string
viewCnt: number
pinnedYn: string
regDtm: string
}
interface ListPostsInput {
categoryCd?: string
search?: string
page?: number
size?: number
}
interface ListPostsResult {
items: PostListItem[]
totalCount: number
page: number
size: number
}
```
#### 목록 조회 (페이징 + 필터 + 검색)
```typescript
export async function listPosts(input: ListPostsInput): Promise<ListPostsResult> {
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 bp.USE_YN = 'Y'`
const params: (string | number)[] = []
let paramIdx = 1
if (input.categoryCd) {
whereClause += ` AND bp.CATEGORY_CD = $${paramIdx++}`
params.push(input.categoryCd)
}
if (input.search) {
whereClause += ` AND (bp.TITLE ILIKE $${paramIdx} OR u.USER_NM ILIKE $${paramIdx})`
params.push(`%${input.search}%`)
paramIdx++
}
// 전체 건수
const countResult = await wingPool.query(
`SELECT COUNT(*) as cnt FROM BOARD_POST bp
JOIN AUTH_USER u ON bp.AUTHOR_ID = u.USER_ID ${whereClause}`,
params
)
const totalCount = parseInt(countResult.rows[0].cnt, 10)
// 목록 (상단고정 우선 → 등록일 내림차순)
const listParams = [...params, size, offset]
const listResult = await wingPool.query(
`SELECT bp.POST_SN as sn, bp.CATEGORY_CD as category_cd, bp.TITLE as title,
bp.AUTHOR_ID as author_id, u.USER_NM as author_name,
bp.VIEW_CNT as view_cnt, bp.PINNED_YN as pinned_yn, bp.REG_DTM as reg_dtm
FROM BOARD_POST bp
JOIN AUTH_USER u ON bp.AUTHOR_ID = u.USER_ID
${whereClause}
ORDER BY bp.PINNED_YN DESC, bp.REG_DTM DESC
LIMIT $${paramIdx++} OFFSET $${paramIdx}`,
listParams
)
// ... 결과 매핑 후 return
}
```
**핵심**: `JOIN AUTH_USER`로 cross-schema JOIN 수행 (작성자명 표시). 이것이 DB 통합의 핵심 이점.
#### 소유자 검증 패턴 (수정/삭제)
```typescript
export async function updatePost(
postSn: number,
input: UpdatePostInput,
requesterId: string // ← req.user.sub (JWT에서 추출)
): Promise<void> {
const existing = await wingPool.query(
`SELECT AUTHOR_ID as author_id FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'`,
[postSn]
)
if (existing.rows.length === 0) {
throw new AuthError('게시글을 찾을 수 없습니다.', 404)
}
// 본인 글만 수정 가능
if (existing.rows[0].author_id !== requesterId) {
throw new AuthError('본인의 게시글만 수정할 수 있습니다.', 403)
}
// ... 동적 SET 빌드 + UPDATE
}
```
#### 논리 삭제
```typescript
export async function deletePost(postSn: number, requesterId: string): Promise<void> {
// 소유자 검증 (위와 동일)
await wingPool.query(
`UPDATE BOARD_POST SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE POST_SN = $1`,
[postSn]
)
}
```
---
### Step 3: Router 구현
**파일**: `backend/src/board/boardRouter.ts`
#### 카테고리별 동적 리소스 결정
```typescript
const CATEGORY_RESOURCE: Record<string, string> = {
NOTICE: 'board:notice',
DATA: 'board:data',
QNA: 'board:qna',
MANUAL: 'board:manual',
}
```
#### 엔드포인트별 requirePermission 적용
```typescript
// 목록/상세: 부모 리소스 'board' READ
router.get('/', requireAuth, requirePermission('board', 'READ'), listHandler)
router.get('/:sn', requireAuth, requirePermission('board', 'READ'), getHandler)
// 작성: 카테고리별 서브리소스 CREATE (핵심!)
router.post('/', requireAuth, async (req, res, next) => {
const resource = CATEGORY_RESOURCE[req.body.categoryCd] || 'board'
requirePermission(resource, 'CREATE')(req, res, next)
}, createHandler)
// 수정/삭제: 부모 리소스 권한 + 서비스에서 소유자 검증
router.put('/:sn', requireAuth, requirePermission('board', 'UPDATE'), updateHandler)
router.delete('/:sn', requireAuth, requirePermission('board', 'DELETE'), deleteHandler)
```
**카테고리별 작성 권한의 원리**:
- POST `/api/board` 요청 시 body에 `categoryCd`가 포함
- 미들웨어에서 `CATEGORY_RESOURCE[categoryCd]`로 서브리소스 결정
- `board:notice` CREATE 권한이 없는 사용자는 공지 작성 불가
- `board:qna` CREATE 권한이 있으면 Q&A는 작성 가능
---
### Step 4: server.ts 등록
```typescript
import boardRouter from './board/boardRouter.js'
// API 라우트 — 업무
app.use('/api/board', boardRouter)
```
---
### Step 5: 프론트엔드 연동
#### API 서비스
**파일**: `frontend/src/tabs/board/services/boardApi.ts`
```typescript
import { api } from '@common/services/api';
export interface BoardPostItem {
sn: number;
categoryCd: string;
title: string;
authorId: string;
authorName: string;
viewCnt: number;
pinnedYn: string;
regDtm: string;
}
export interface BoardListResponse {
items: BoardPostItem[];
totalCount: number;
page: number;
size: number;
}
export async function fetchBoardPosts(params?: BoardListParams): Promise<BoardListResponse> {
const response = await api.get<BoardListResponse>('/board', { params });
return response.data;
}
export async function createBoardPost(input: CreateBoardPostInput): Promise<{ sn: number }> {
const response = await api.post<{ sn: number }>('/board', input);
return response.data;
}
```
#### 권한 기반 UI 분기
**파일**: `frontend/src/tabs/board/components/BoardView.tsx`
```tsx
import { useAuthStore } from '@common/store/authStore';
const hasPermission = useAuthStore((s) => s.hasPermission);
// 서브탭 기준 글쓰기 권한 리소스 결정
const getWriteResource = () => {
if (activeSubTab === 'all') return 'board';
return `board:${activeSubTab}`;
};
// 글쓰기 버튼 조건부 렌더링
{hasPermission(getWriteResource(), 'CREATE') && (
<button onClick={handleWriteClick}>글쓰기</button>
)}
```
---
### Step 6: 권한 시나리오 테스트
| 시나리오 | 역할 | 요청 | 결과 |
|---|---|---|---|
| ADMIN이 공지 작성 | ADMIN | POST `/api/board` `{categoryCd:"NOTICE"}` | 201 Created |
| USER가 공지 작성 | USER | POST `/api/board` `{categoryCd:"NOTICE"}` | 403 (board:notice CREATE 없음) |
| USER가 Q&A 작성 | USER | POST `/api/board` `{categoryCd:"QNA"}` | 201 (board:qna CREATE 있음) |
| VIEWER가 Q&A 작성 | VIEWER | POST `/api/board` `{categoryCd:"QNA"}` | 403 (board:qna CREATE 없음) |
| USER가 본인 글 수정 | USER | PUT `/api/board/11` (본인 글) | 200 |
| USER가 타인 글 수정 | USER | PUT `/api/board/1` (타인 글) | 403 (소유자 검증 실패) |
| ADMIN이 목록 조회 | ADMIN | GET `/api/board` | 200 (board READ 있음) |
---
### 관련 파일 전체 목록
| 위치 | 파일 | 설명 |
|---|---|---|
| DB | `database/migration/006_board.sql` | DDL + 초기 데이터 |
| 백엔드 | `backend/src/board/boardService.ts` | CRUD 비즈니스 로직 |
| 백엔드 | `backend/src/board/boardRouter.ts` | 라우터 + requirePermission |
| 백엔드 | `backend/src/server.ts` | boardRouter 등록 |
| 프론트 | `frontend/src/tabs/board/services/boardApi.ts` | API 서비스 |
| 프론트 | `frontend/src/tabs/board/components/BoardView.tsx` | 목록/상세/작성 통합 뷰 (API 연동) |
| 프론트 | `frontend/src/tabs/board/components/BoardWriteForm.tsx` | 게시글 작성/수정 폼 (API 호출) |
| 프론트 | `frontend/src/tabs/board/components/BoardDetailView.tsx` | 게시글 상세 보기 (API 호출) |