# 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 { 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> { // 동적 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 { 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 { // 소유자 검증 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 { // 소유자 검증 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 { const response = await api.post('/board/list', params) return response.data } // 상세 조회 export async function fetchPost(postSn: number): Promise { const response = await api.post('/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 { await api.post('/board/update', { postSn, ...data }) } // 삭제 export async function deletePostApi(postSn: number): Promise { 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 (

게시판

{/* CREATE 권한이 있을 때만 글쓰기 버튼 표시 */} {hasPermission('board:notice', 'CREATE') && ( )} {/* 목록 렌더링 */} {posts.map((post) => (
{post.title} {/* UPDATE 권한 + 본인 글일 때만 수정 버튼 */} {hasPermission('board:notice', 'UPDATE') && post.authorId === user?.id && ( )} {/* DELETE 권한 + 본인 글일 때만 삭제 버튼 */} {hasPermission('board:notice', 'DELETE') && post.authorId === user?.id && ( )}
))} {/* 페이징 */}
) } ``` #### 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 { 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 { 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 { // 소유자 검증 (위와 동일) 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 = { 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 { const response = await api.get('/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') && ( )} ``` --- ### 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 호출) |