diff --git a/backend/src/board/boardRouter.ts b/backend/src/board/boardRouter.ts new file mode 100644 index 0000000..e854c5c --- /dev/null +++ b/backend/src/board/boardRouter.ts @@ -0,0 +1,137 @@ +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() + +// 카테고리 → 리소스 매핑 +const CATEGORY_RESOURCE: Record = { + NOTICE: 'board:notice', + DATA: 'board:data', + QNA: 'board:qna', + MANUAL: 'board:manual', +} + +// ============================================================ +// GET /api/board — 게시글 목록 +// ============================================================ +router.get('/', requireAuth, requirePermission('board', 'READ'), async (req, res) => { + try { + const { categoryCd, search, page, size } = req.query + const result = await listPosts({ + categoryCd: categoryCd 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('[board] 목록 조회 오류:', err) + res.status(500).json({ error: '게시글 목록 조회 중 오류가 발생했습니다.' }) + } +}) + +// ============================================================ +// GET /api/board/:sn — 게시글 상세 +// ============================================================ +router.get('/:sn', requireAuth, requirePermission('board', 'READ'), async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10) + if (isNaN(sn)) { + res.status(400).json({ error: '유효하지 않은 게시글 번호입니다.' }) + return + } + const post = await getPost(sn) + 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: '게시글 조회 중 오류가 발생했습니다.' }) + } +}) + +// ============================================================ +// POST /api/board — 게시글 작성 (카테고리별 CREATE 권한) +// ============================================================ +router.post('/', requireAuth, async (req, res, next) => { + const resource = CATEGORY_RESOURCE[req.body.categoryCd] || 'board' + requirePermission(resource, 'CREATE')(req, res, next) +}, async (req, res) => { + try { + const { categoryCd, title, content, pinnedYn } = req.body + + if (!categoryCd || !title) { + res.status(400).json({ error: '카테고리와 제목은 필수입니다.' }) + return + } + + 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: '게시글 작성 중 오류가 발생했습니다.' }) + } +}) + +// ============================================================ +// PUT /api/board/:sn — 게시글 수정 (소유자 검증은 서비스에서) +// ============================================================ +router.put('/:sn', requireAuth, requirePermission('board', '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, pinnedYn } = req.body + await updatePost(sn, { title, content, 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: '게시글 수정 중 오류가 발생했습니다.' }) + } +}) + +// ============================================================ +// DELETE /api/board/:sn — 게시글 삭제 (논리 삭제, 소유자 검증) +// ============================================================ +router.delete('/:sn', requireAuth, requirePermission('board', 'DELETE'), async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10) + if (isNaN(sn)) { + res.status(400).json({ error: '유효하지 않은 게시글 번호입니다.' }) + return + } + + await deletePost(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('[board] 삭제 오류:', err) + res.status(500).json({ error: '게시글 삭제 중 오류가 발생했습니다.' }) + } +}) + +export default router diff --git a/backend/src/board/boardService.ts b/backend/src/board/boardService.ts new file mode 100644 index 0000000..1b554a7 --- /dev/null +++ b/backend/src/board/boardService.ts @@ -0,0 +1,243 @@ +import { wingPool } from '../db/wingDb.js' +import { AuthError } from '../auth/authService.js' + +// ============================================================ +// 인터페이스 +// ============================================================ + +interface PostListItem { + sn: number + categoryCd: string + title: string + authorId: string + authorName: string + viewCnt: number + pinnedYn: string + regDtm: string +} + +interface PostDetail extends PostListItem { + content: string | null + mdfcnDtm: string | null +} + +interface ListPostsInput { + categoryCd?: string + search?: string + page?: number + size?: number +} + +interface ListPostsResult { + items: PostListItem[] + totalCount: number + page: number + size: number +} + +interface CreatePostInput { + categoryCd: string + title: string + content?: string + authorId: string + pinnedYn?: string +} + +interface UpdatePostInput { + title?: string + content?: string + pinnedYn?: string +} + +// ============================================================ +// CRUD 함수 +// ============================================================ + +const VALID_CATEGORIES = ['NOTICE', 'DATA', 'QNA', 'MANUAL'] + +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 + ) + + const items: PostListItem[] = listResult.rows.map((r: Record) => ({ + sn: r.sn as number, + categoryCd: r.category_cd as string, + title: r.title as string, + authorId: r.author_id as string, + authorName: r.author_name as string, + viewCnt: r.view_cnt as number, + pinnedYn: r.pinned_yn as string, + regDtm: r.reg_dtm as string, + })) + + return { items, totalCount, page, size } +} + +export async function getPost(postSn: number): Promise { + // 조회수 증가 + 상세 조회 (단일 쿼리) + const result = await wingPool.query( + `UPDATE BOARD_POST SET VIEW_CNT = VIEW_CNT + 1 + WHERE POST_SN = $1 AND USE_YN = 'Y' + RETURNING POST_SN as sn, CATEGORY_CD as category_cd, TITLE as title, + CONTENT as content, AUTHOR_ID as author_id, + VIEW_CNT as view_cnt, PINNED_YN as pinned_yn, + REG_DTM as reg_dtm, MDFCN_DTM as mdfcn_dtm`, + [postSn] + ) + + if (result.rows.length === 0) { + throw new AuthError('게시글을 찾을 수 없습니다.', 404) + } + + const row = result.rows[0] + + // 작성자명 조회 + const authorResult = await wingPool.query( + 'SELECT USER_NM as name FROM AUTH_USER WHERE USER_ID = $1', + [row.author_id] + ) + + return { + sn: row.sn, + categoryCd: row.category_cd, + title: row.title, + content: row.content, + authorId: row.author_id, + authorName: authorResult.rows[0]?.name || '알 수 없음', + viewCnt: row.view_cnt, + pinnedYn: row.pinned_yn, + regDtm: row.reg_dtm, + mdfcnDtm: row.mdfcn_dtm, + } +} + +export async function createPost(input: CreatePostInput): Promise<{ sn: number }> { + if (!VALID_CATEGORIES.includes(input.categoryCd)) { + throw new AuthError('유효하지 않은 카테고리입니다.', 400) + } + + if (!input.title || input.title.trim().length === 0) { + throw new AuthError('제목은 필수입니다.', 400) + } + + 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 sn`, + [input.categoryCd, input.title.trim(), input.content || null, input.authorId, input.pinnedYn || 'N'] + ) + + return { sn: result.rows[0].sn } +} + +export async function updatePost( + postSn: number, + input: UpdatePostInput, + requesterId: string +): 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) + } + + 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 (input.pinnedYn !== undefined) { + sets.push(`PINNED_YN = $${idx++}`) + params.push(input.pinnedYn) + } + + if (sets.length === 0) { + throw new AuthError('수정할 항목이 없습니다.', 400) + } + + sets.push('MDFCN_DTM = NOW()') + params.push(postSn) + + await wingPool.query( + `UPDATE BOARD_POST SET ${sets.join(', ')} WHERE POST_SN = $${idx}`, + params + ) +} + +export async function deletePost(postSn: number, requesterId: string): 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) + } + + // 논리 삭제 + await wingPool.query( + `UPDATE BOARD_POST SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE POST_SN = $1`, + [postSn] + ) +} diff --git a/backend/src/db/authDb.ts b/backend/src/db/authDb.ts index d82613c..68d06d9 100644 --- a/backend/src/db/authDb.ts +++ b/backend/src/db/authDb.ts @@ -1,33 +1,13 @@ -import pg from 'pg' +// ============================================================ +// 하위 호환: authPool → wingPool re-export +// DB 통합으로 wing_auth DB가 wing DB의 auth 스키마로 이전됨. +// 기존 코드에서 authPool을 import하는 곳에서 에러 없이 동작하도록 유지. +// 신규 코드는 wingDb.ts의 wingPool을 직접 import할 것. +// ============================================================ +import { wingPool, testWingDbConnection } from './wingDb.js' -const { Pool } = pg - -const authPool = new Pool({ - host: process.env.AUTH_DB_HOST || 'localhost', - port: Number(process.env.AUTH_DB_PORT) || 5432, - database: process.env.AUTH_DB_NAME || 'wing_auth', - user: process.env.AUTH_DB_USER || 'wing_auth', - password: process.env.AUTH_DB_PASSWORD || 'WingAuth2026', - max: 10, - idleTimeoutMillis: 30000, - connectionTimeoutMillis: 5000, -}) - -authPool.on('error', (err) => { - console.error('[authDb] 예기치 않은 연결 오류:', err.message) -}) +export const authPool = wingPool export async function testAuthDbConnection(): Promise { - try { - const client = await authPool.connect() - await client.query('SELECT 1') - client.release() - console.log('[authDb] wing_auth 데이터베이스 연결 성공') - return true - } catch (err) { - console.warn('[authDb] wing_auth 데이터베이스 연결 실패:', (err as Error).message) - return false - } + return testWingDbConnection() } - -export { authPool } diff --git a/backend/src/db/wingDb.ts b/backend/src/db/wingDb.ts index 83648a7..5e257d4 100644 --- a/backend/src/db/wingDb.ts +++ b/backend/src/db/wingDb.ts @@ -2,19 +2,30 @@ import pg from 'pg' const { Pool } = pg +// ============================================================ +// wing DB 통합 Pool (wing 스키마 + auth 스키마) +// - wing 스키마: 운영 데이터 (LAYER, BOARD_POST 등) +// - auth 스키마: 인증/인가 데이터 (AUTH_USER, AUTH_ROLE 등) +// - public 스키마: PostGIS 시스템 테이블만 유지 +// ============================================================ const wingPool = new Pool({ - host: process.env.WING_DB_HOST || 'localhost', - port: Number(process.env.WING_DB_PORT) || 5432, - database: process.env.WING_DB_NAME || 'wing', - user: process.env.WING_DB_USER || 'wing', - password: process.env.WING_DB_PASSWORD || 'Wing2026', - max: 10, + host: process.env.DB_HOST || process.env.WING_DB_HOST || 'localhost', + port: Number(process.env.DB_PORT || process.env.WING_DB_PORT) || 5432, + database: process.env.DB_NAME || process.env.WING_DB_NAME || 'wing', + user: process.env.DB_USER || process.env.WING_DB_USER || 'wing', + password: process.env.DB_PASSWORD || process.env.WING_DB_PASSWORD || 'Wing2026', + max: 20, idleTimeoutMillis: 30000, connectionTimeoutMillis: 5000, }) +// 연결 시 search_path 자동 설정 (public 미사용) +wingPool.on('connect', (client) => { + client.query('SET search_path = wing, auth, public') +}) + wingPool.on('error', (err) => { - console.error('[wingDb] 예기치 않은 연결 오류:', err.message) + console.error('[db] 예기치 않은 연결 오류:', err.message) }) export async function testWingDbConnection(): Promise { @@ -22,10 +33,10 @@ export async function testWingDbConnection(): Promise { const client = await wingPool.connect() await client.query('SELECT 1') client.release() - console.log('[wingDb] wing 데이터베이스 연결 성공') + console.log('[db] wing 데이터베이스 연결 성공 (wing + auth 스키마)') return true } catch (err) { - console.warn('[wingDb] wing 데이터베이스 연결 실패:', (err as Error).message) + console.warn('[db] wing 데이터베이스 연결 실패:', (err as Error).message) return false } } diff --git a/backend/src/server.ts b/backend/src/server.ts index b29e946..ac252f9 100755 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -4,7 +4,6 @@ import cors from 'cors' import helmet from 'helmet' import rateLimit from 'express-rate-limit' import cookieParser from 'cookie-parser' -import { testAuthDbConnection } from './db/authDb.js' import { testWingDbConnection } from './db/wingDb.js' import layersRouter from './routes/layers.js' import simulationRouter from './routes/simulation.js' @@ -14,6 +13,7 @@ import roleRouter from './roles/roleRouter.js' import settingsRouter from './settings/settingsRouter.js' import menuRouter from './menus/menuRouter.js' import auditRouter from './audit/auditRouter.js' +import boardRouter from './board/boardRouter.js' import { sanitizeBody, sanitizeQuery, @@ -136,6 +136,7 @@ app.use('/api/menus', menuRouter) app.use('/api/audit', auditRouter) // API 라우트 — 업무 +app.use('/api/board', boardRouter) app.use('/api/layers', layersRouter) app.use('/api/simulation', simulationLimiter, simulationRouter) @@ -173,17 +174,13 @@ app.use((err: Error, _req: express.Request, res: express.Response, _next: expres app.listen(PORT, async () => { console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`) - // wing DB (운영 데이터) 연결 확인 - await testWingDbConnection() - - // wing_auth DB (인증 데이터) 연결 확인 - const connected = await testAuthDbConnection() + // wing DB 연결 확인 (wing + auth 스키마 통합) + const connected = await testWingDbConnection() if (connected) { // SETTING_VAL VARCHAR(500) → TEXT 마이그레이션 (메뉴 설정 JSON 확장 대응) try { - const { authPool } = await import('./db/authDb.js') - await authPool.query(`ALTER TABLE AUTH_SETTING ALTER COLUMN SETTING_VAL TYPE TEXT`) - console.log('[migration] SETTING_VAL → TEXT 변환 완료') + const { wingPool } = await import('./db/wingDb.js') + await wingPool.query(`ALTER TABLE AUTH_SETTING ALTER COLUMN SETTING_VAL TYPE TEXT`) } catch { // 이미 TEXT이거나 권한 없으면 무시 } diff --git a/database/migration/005_db_consolidation.sql b/database/migration/005_db_consolidation.sql new file mode 100644 index 0000000..61262a5 --- /dev/null +++ b/database/migration/005_db_consolidation.sql @@ -0,0 +1,45 @@ +-- ============================================================ +-- 마이그레이션 005: DB 통합 (wing + wing_auth → wing 단일 DB) +-- +-- 스키마 구조: +-- wing — 운영 데이터 (LAYER, BOARD_POST, HNS_SUBSTANCE 등) +-- auth — 인증/인가 데이터 (AUTH_USER, AUTH_ROLE 등) +-- public — PostGIS 시스템 테이블만 유지 (spatial_ref_sys) +-- +-- 실행 순서: +-- 1. 이 SQL을 wing DB에서 실행 (스키마 생성 + 테이블 이동) +-- 2. wing_auth DB 덤프 → auth 스키마로 복원 (별도 쉘) +-- 3. search_path 설정 +-- ============================================================ + +-- Step 1: 명시적 스키마 생성 +CREATE SCHEMA IF NOT EXISTS wing; +CREATE SCHEMA IF NOT EXISTS auth; + +-- Step 2: 기존 public 운영 테이블을 wing 스키마로 이동 +-- (PostGIS 시스템 테이블 spatial_ref_sys, topology는 public에 유지) +ALTER TABLE IF EXISTS public.layer SET SCHEMA wing; +ALTER TABLE IF EXISTS public.hns_substance SET SCHEMA wing; + +-- Step 3: 기본 search_path 설정 (DB 레벨) +-- wing 사용자가 스키마 접두사 없이 양쪽 테이블 접근 가능 +ALTER DATABASE wing SET search_path = wing, auth; + +-- Step 4: wing 사용자에게 auth 스키마 권한 부여 +-- (wing_auth 데이터 복원 후 적용) +GRANT USAGE ON SCHEMA auth TO wing; +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA auth TO wing; +GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA auth TO wing; +ALTER DEFAULT PRIVILEGES IN SCHEMA auth GRANT ALL ON TABLES TO wing; +ALTER DEFAULT PRIVILEGES IN SCHEMA auth GRANT ALL ON SEQUENCES TO wing; + +-- Step 5: wing 스키마 기본 권한 +GRANT ALL PRIVILEGES ON SCHEMA wing TO wing; +ALTER DEFAULT PRIVILEGES IN SCHEMA wing GRANT ALL ON TABLES TO wing; +ALTER DEFAULT PRIVILEGES IN SCHEMA wing GRANT ALL ON SEQUENCES TO wing; + +-- 검증 +SELECT schemaname, tablename +FROM pg_tables +WHERE schemaname IN ('wing', 'auth') +ORDER BY schemaname, tablename; diff --git a/database/migration/006_board.sql b/database/migration/006_board.sql new file mode 100644 index 0000000..efc6e08 --- /dev/null +++ b/database/migration/006_board.sql @@ -0,0 +1,61 @@ +-- ============================================================ +-- 마이그레이션 006: 게시판 (BOARD_POST) +-- wing 스키마에 생성, auth.AUTH_USER FK 참조 +-- ============================================================ + +-- Step 1: 게시판 테이블 +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')) +); + +COMMENT ON TABLE BOARD_POST IS '게시판 게시글'; +COMMENT ON COLUMN BOARD_POST.CATEGORY_CD IS '카테고리: NOTICE=공지, DATA=자료실, QNA=Q&A, MANUAL=해경매뉴얼'; +COMMENT ON COLUMN BOARD_POST.PINNED_YN IS '상단고정 여부'; +COMMENT ON COLUMN BOARD_POST.USE_YN IS '사용여부 (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); + +-- Step 2: 초기 데이터 (기존 프론트엔드 mockPosts 이전) +-- 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 BOARD_POST (CATEGORY_CD, TITLE, CONTENT, AUTHOR_ID, VIEW_CNT, PINNED_YN, REG_DTM) VALUES + ('NOTICE', '시스템 업데이트 안내', '시스템 업데이트 관련 안내사항입니다.', v_admin_id, 245, 'Y', '2025-02-15'::timestamptz), + ('NOTICE', '2025년 방제 교육 일정 안내', '2025년도 방제 교육 일정을 안내합니다.', v_admin_id, 189, 'Y', '2025-02-14'::timestamptz), + ('DATA', '방제 매뉴얼 업데이트 (2025년 개정판)', '2025년 개정판 방제 매뉴얼입니다.', v_admin_id, 423, 'N', '2025-02-10'::timestamptz), + ('QNA', 'HNS 대기확산 분석 결과 해석 문의', 'HNS 분석 결과 해석 방법을 문의합니다.', v_admin_id, 156, 'N', '2025-02-08'::timestamptz), + ('DATA', '2024년 유류오염사고 통계 자료', '2024년도 유류오염사고 통계 자료를 공유합니다.', v_admin_id, 312, 'N', '2025-02-05'::timestamptz), + ('QNA', '유출유 확산 예측 알고리즘 선택 기준', '확산 예측 시 알고리즘 선택 기준을 문의합니다.', v_admin_id, 267, 'N', '2025-02-03'::timestamptz), + ('DATA', '해양오염 방제 장비 운용 가이드', '방제 장비 운용 가이드 문서입니다.', v_admin_id, 534, 'N', '2025-01-28'::timestamptz), + ('QNA', 'SCAT 조사 방법 관련 질문', 'SCAT 현장 조사 방법에 대해 질문합니다.', v_admin_id, 198, 'N', '2025-01-25'::timestamptz), + ('DATA', 'HNS 물질 안전보건자료 (MSDS) 모음', 'HNS 물질별 MSDS 자료 모음입니다.', v_admin_id, 645, 'N', '2025-01-20'::timestamptz), + ('QNA', '항공촬영 드론 운용 시 주의사항', '드론 운용 시 주의할 점을 문의합니다.', v_admin_id, 221, 'N', '2025-01-15'::timestamptz) + ON CONFLICT DO NOTHING; + END IF; +END $$; + +-- 검증 +SELECT POST_SN, CATEGORY_CD, TITLE, VIEW_CNT, PINNED_YN FROM BOARD_POST ORDER BY POST_SN; diff --git a/docs/COMMON-GUIDE.md b/docs/COMMON-GUIDE.md index 2700234..561f4ca 100644 --- a/docs/COMMON-GUIDE.md +++ b/docs/COMMON-GUIDE.md @@ -338,6 +338,8 @@ const mutation = useMutation({ ## 6. 백엔드 API CRUD 규칙 +> 상세 가이드 + 게시판 실전 튜토리얼: **[CRUD-API-GUIDE.md](./CRUD-API-GUIDE.md)** 참조 + ### HTTP Method 정책 (보안 가이드 준수) - 보안 취약점 점검 가이드에 따라 **POST 메서드를 기본**으로 사용한다. - GET은 단순 조회 중 민감하지 않은 경우에만 허용 (필요 시 POST로 전환). diff --git a/docs/CRUD-API-GUIDE.md b/docs/CRUD-API-GUIDE.md new file mode 100644 index 0000000..95c46e4 --- /dev/null +++ b/docs/CRUD-API-GUIDE.md @@ -0,0 +1,1433 @@ +# 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/BoardListTable.tsx` + +```tsx +import { useAuthStore } from '@common/store/authStore'; + +const hasPermission = useAuthStore((s) => s.hasPermission); + +// 카테고리별 서브리소스 CREATE 권한 확인 +const canWrite = selectedCategory + ? hasPermission(`board:${selectedCategory.toLowerCase()}`, 'CREATE') + : hasPermission('board', 'CREATE'); + +// 글쓰기 버튼 조건부 렌더링 +{canWrite && ( + +)} +``` + +--- + +### 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/BoardListTable.tsx` | 목록 UI (API 연동) | diff --git a/frontend/src/tabs/board/components/BoardListTable.tsx b/frontend/src/tabs/board/components/BoardListTable.tsx index f186bdd..a0e1a12 100755 --- a/frontend/src/tabs/board/components/BoardListTable.tsx +++ b/frontend/src/tabs/board/components/BoardListTable.tsx @@ -1,247 +1,245 @@ -import { useState } from 'react' +import { useState, useEffect, useCallback } from 'react'; +import { useAuthStore } from '@common/store/authStore'; +import { fetchBoardPosts, type BoardPostItem } from '../services/boardApi'; -interface BoardPost { - id: number - category: string - title: string - author: string - date: string - views: number - isNotice?: boolean -} +// 카테고리 코드 ↔ 표시명 매핑 +const CATEGORY_MAP: Record = { + NOTICE: '공지사항', + DATA: '자료실', + QNA: 'Q&A', + MANUAL: '해경매뉴얼', +}; + +const CATEGORY_FILTER: { label: string; code: string | null }[] = [ + { label: '전체', code: null }, + { label: '공지사항', code: 'NOTICE' }, + { label: '자료실', code: 'DATA' }, + { label: 'Q&A', code: 'QNA' }, +]; + +const CATEGORY_STYLE: Record = { + NOTICE: 'bg-red-500/20 text-red-400', + DATA: 'bg-blue-500/20 text-blue-400', + QNA: 'bg-green-500/20 text-green-400', + MANUAL: 'bg-yellow-500/20 text-yellow-400', +}; + +const PAGE_SIZE = 20; interface BoardListTableProps { - onPostClick: (id: number) => void - onWriteClick: () => void + onPostClick: (id: number) => void; + onWriteClick: () => void; } -const mockPosts: BoardPost[] = [ - { - id: 1, - category: '공지사항', - title: '시스템 업데이트 안내', - author: '관리자', - date: '2025-02-15', - views: 245, - isNotice: true - }, - { - id: 2, - category: '공지사항', - title: '2025년 방제 교육 일정 안내', - author: '관리자', - date: '2025-02-14', - views: 189, - isNotice: true - }, - { - id: 3, - category: '자료실', - title: '방제 매뉴얼 업데이트 (2025년 개정판)', - author: '김철수', - date: '2025-02-10', - views: 423 - }, - { - id: 4, - category: 'Q&A', - title: 'HNS 대기확산 분석 결과 해석 문의', - author: '이영희', - date: '2025-02-08', - views: 156 - }, - { - id: 5, - category: '자료실', - title: '2024년 유류오염사고 통계 자료', - author: '박민수', - date: '2025-02-05', - views: 312 - }, - { - id: 6, - category: 'Q&A', - title: '유출유 확산 예측 알고리즘 선택 기준', - author: '정수진', - date: '2025-02-03', - views: 267 - }, - { - id: 7, - category: '자료실', - title: '해양오염 방제 장비 운용 가이드', - author: '최동현', - date: '2025-01-28', - views: 534 - }, - { - id: 8, - category: 'Q&A', - title: 'SCAT 조사 방법 관련 질문', - author: '강지은', - date: '2025-01-25', - views: 198 - }, - { - id: 9, - category: '자료실', - title: 'HNS 물질 안전보건자료 (MSDS) 모음', - author: '윤성호', - date: '2025-01-20', - views: 645 - }, - { - id: 10, - category: 'Q&A', - title: '항공촬영 드론 운용 시 주의사항', - author: '송미래', - date: '2025-01-15', - views: 221 - } -] - export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProps) { - const [searchTerm, setSearchTerm] = useState('') - const [selectedCategory, setSelectedCategory] = useState('전체') + const hasPermission = useAuthStore((s) => s.hasPermission); - const categories = ['전체', '공지사항', '자료실', 'Q&A'] + const [posts, setPosts] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [page, setPage] = useState(1); + const [selectedCategory, setSelectedCategory] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + const [searchInput, setSearchInput] = useState(''); + const [loading, setLoading] = useState(false); - const filteredPosts = mockPosts.filter((post) => { - const matchesCategory = selectedCategory === '전체' || post.category === selectedCategory - const matchesSearch = - post.title.toLowerCase().includes(searchTerm.toLowerCase()) || - post.author.toLowerCase().includes(searchTerm.toLowerCase()) - return matchesCategory && matchesSearch - }) + const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); + + // 카테고리별 서브리소스 권한 확인 (전체 선택 시 board CREATE) + const canWrite = selectedCategory + ? hasPermission(`board:${selectedCategory.toLowerCase()}`, 'CREATE') + : hasPermission('board', 'CREATE'); + + const loadPosts = useCallback(async () => { + setLoading(true); + try { + const result = await fetchBoardPosts({ + categoryCd: selectedCategory || undefined, + search: searchTerm || undefined, + page, + size: PAGE_SIZE, + }); + setPosts(result.items); + setTotalCount(result.totalCount); + } catch { + setPosts([]); + setTotalCount(0); + } finally { + setLoading(false); + } + }, [selectedCategory, searchTerm, page]); + + useEffect(() => { + loadPosts(); + }, [loadPosts]); + + const handleCategoryChange = (code: string | null) => { + setSelectedCategory(code); + setPage(1); + }; + + const handleSearch = () => { + setSearchTerm(searchInput); + setPage(1); + }; + + const handleSearchKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') handleSearch(); + }; + + const formatDate = (dtm: string) => { + return new Date(dtm).toLocaleDateString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + }; return (
{/* Header with Search and Write Button */}
- {/* Category Filters */}
- {categories.map((category) => ( + {CATEGORY_FILTER.map((cat) => ( ))}
- {/* Search Input */} setSearchTerm(e.target.value)} + value={searchInput} + onChange={(e) => setSearchInput(e.target.value)} + onKeyDown={handleSearchKeyDown} className="px-4 py-2 text-sm bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none w-64" /> - {/* Write Button */} - + {canWrite && ( + + )}
{/* Board List Table */}
- - - - - - - - - - - - - {filteredPosts.map((post) => ( - onPostClick(post.id)} - className="border-b border-border hover:bg-bg-2 cursor-pointer transition-colors" - > - - - - - - - - ))} - -
번호분류제목작성자작성일조회수
- {post.isNotice ? ( - - 공지 - - ) : ( - post.id - )} - - - {post.category} - - - - {post.title} - - {post.author}{post.date}{post.views}
- - {filteredPosts.length === 0 && ( + {loading ? (
-

검색 결과가 없습니다.

+

불러오는 중...

+ ) : ( + <> + + + + + + + + + + + + + {posts.map((post) => ( + onPostClick(post.sn)} + className="border-b border-border hover:bg-bg-2 cursor-pointer transition-colors" + > + + + + + + + + ))} + +
번호분류제목작성자작성일조회수
+ {post.pinnedYn === 'Y' ? ( + + 공지 + + ) : ( + post.sn + )} + + + {CATEGORY_MAP[post.categoryCd] || post.categoryCd} + + + + {post.title} + + {post.authorName}{formatDate(post.regDtm)}{post.viewCnt}
+ + {posts.length === 0 && ( +
+

검색 결과가 없습니다.

+
+ )} + )}
{/* Pagination */} -
- - - - - -
+ {totalPages > 1 && ( +
+ + {Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => ( + + ))} + +
+ )}
- ) + ); } diff --git a/frontend/src/tabs/board/services/boardApi.ts b/frontend/src/tabs/board/services/boardApi.ts new file mode 100644 index 0000000..128cd8c --- /dev/null +++ b/frontend/src/tabs/board/services/boardApi.ts @@ -0,0 +1,75 @@ +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 BoardPostDetail extends BoardPostItem { + content: string | null; + mdfcnDtm: string | null; +} + +export interface BoardListResponse { + items: BoardPostItem[]; + totalCount: number; + page: number; + size: number; +} + +export interface BoardListParams { + categoryCd?: string; + search?: string; + page?: number; + size?: number; +} + +export interface CreateBoardPostInput { + categoryCd: string; + title: string; + content?: string; + pinnedYn?: string; +} + +export interface UpdateBoardPostInput { + title?: string; + content?: string; + pinnedYn?: string; +} + +// ============================================================ +// API 함수 +// ============================================================ + +export async function fetchBoardPosts(params?: BoardListParams): Promise { + const response = await api.get('/board', { params }); + return response.data; +} + +export async function fetchBoardPost(sn: number): Promise { + const response = await api.get(`/board/${sn}`); + return response.data; +} + +export async function createBoardPost(input: CreateBoardPostInput): Promise<{ sn: number }> { + const response = await api.post<{ sn: number }>('/board', input); + return response.data; +} + +export async function updateBoardPost(sn: number, input: UpdateBoardPostInput): Promise { + await api.put(`/board/${sn}`, input); +} + +export async function deleteBoardPost(sn: number): Promise { + await api.delete(`/board/${sn}`); +}