feat(backend): DB 통합 + 게시판 CRUD API + RBAC 적용 가이드

- wing + wing_auth DB를 wing 단일 DB로 통합 (wing/auth 스키마 분리)
- wingPool 단일 Pool + search_path 설정, authPool 하위 호환 유지
- 게시판 BOARD_POST DDL + 초기 데이터 10건 마이그레이션
- boardService/boardRouter CRUD 구현 (페이징, 검색, 소유자 검증, 논리삭제)
- requirePermission 카테고리별 서브리소스 동적 적용 (board:notice, board:qna 등)
- 프론트엔드 boardApi 서비스 + BoardListTable mock→API 전환
- CRUD-API-GUIDE (범용 가이드 + 게시판 튜토리얼) 문서 작성

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
htlee 2026-02-28 18:37:14 +09:00
부모 8657190578
커밋 2b88455a30
11개의 변경된 파일2230개의 추가작업 그리고 248개의 파일을 삭제

파일 보기

@ -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<string, string> = {
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

파일 보기

@ -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<ListPostsResult> {
const page = input.page && input.page > 0 ? input.page : 1
const size = input.size && input.size > 0 ? Math.min(input.size, 100) : 20
const offset = (page - 1) * size
let whereClause = `WHERE bp.USE_YN = 'Y'`
const params: (string | number)[] = []
let paramIdx = 1
if (input.categoryCd) {
whereClause += ` AND bp.CATEGORY_CD = $${paramIdx++}`
params.push(input.categoryCd)
}
if (input.search) {
whereClause += ` AND (bp.TITLE ILIKE $${paramIdx} OR u.USER_NM ILIKE $${paramIdx})`
params.push(`%${input.search}%`)
paramIdx++
}
// 전체 건수
const countResult = await wingPool.query(
`SELECT COUNT(*) as cnt
FROM BOARD_POST bp
JOIN AUTH_USER u ON bp.AUTHOR_ID = u.USER_ID
${whereClause}`,
params
)
const totalCount = parseInt(countResult.rows[0].cnt, 10)
// 목록 (상단고정 우선, 등록일 내림차순)
const listParams = [...params, size, offset]
const listResult = await wingPool.query(
`SELECT bp.POST_SN as sn, bp.CATEGORY_CD as category_cd, bp.TITLE as title,
bp.AUTHOR_ID as author_id, u.USER_NM as author_name,
bp.VIEW_CNT as view_cnt, bp.PINNED_YN as pinned_yn,
bp.REG_DTM as reg_dtm
FROM BOARD_POST bp
JOIN AUTH_USER u ON bp.AUTHOR_ID = u.USER_ID
${whereClause}
ORDER BY bp.PINNED_YN DESC, bp.REG_DTM DESC
LIMIT $${paramIdx++} OFFSET $${paramIdx}`,
listParams
)
const items: PostListItem[] = listResult.rows.map((r: Record<string, unknown>) => ({
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<PostDetail> {
// 조회수 증가 + 상세 조회 (단일 쿼리)
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<void> {
// 게시글 존재 + 작성자 확인
const existing = await wingPool.query(
`SELECT AUTHOR_ID as author_id FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'`,
[postSn]
)
if (existing.rows.length === 0) {
throw new AuthError('게시글을 찾을 수 없습니다.', 404)
}
if (existing.rows[0].author_id !== requesterId) {
throw new AuthError('본인의 게시글만 수정할 수 있습니다.', 403)
}
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<void> {
// 게시글 존재 + 작성자 확인
const existing = await wingPool.query(
`SELECT AUTHOR_ID as author_id FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'`,
[postSn]
)
if (existing.rows.length === 0) {
throw new AuthError('게시글을 찾을 수 없습니다.', 404)
}
if (existing.rows[0].author_id !== requesterId) {
throw new AuthError('본인의 게시글만 삭제할 수 있습니다.', 403)
}
// 논리 삭제
await wingPool.query(
`UPDATE BOARD_POST SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE POST_SN = $1`,
[postSn]
)
}

파일 보기

@ -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 export const authPool = wingPool
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 async function testAuthDbConnection(): Promise<boolean> { export async function testAuthDbConnection(): Promise<boolean> {
try { return testWingDbConnection()
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
}
} }
export { authPool }

파일 보기

@ -2,19 +2,30 @@ import pg from 'pg'
const { Pool } = pg const { Pool } = pg
// ============================================================
// wing DB 통합 Pool (wing 스키마 + auth 스키마)
// - wing 스키마: 운영 데이터 (LAYER, BOARD_POST 등)
// - auth 스키마: 인증/인가 데이터 (AUTH_USER, AUTH_ROLE 등)
// - public 스키마: PostGIS 시스템 테이블만 유지
// ============================================================
const wingPool = new Pool({ const wingPool = new Pool({
host: process.env.WING_DB_HOST || 'localhost', host: process.env.DB_HOST || process.env.WING_DB_HOST || 'localhost',
port: Number(process.env.WING_DB_PORT) || 5432, port: Number(process.env.DB_PORT || process.env.WING_DB_PORT) || 5432,
database: process.env.WING_DB_NAME || 'wing', database: process.env.DB_NAME || process.env.WING_DB_NAME || 'wing',
user: process.env.WING_DB_USER || 'wing', user: process.env.DB_USER || process.env.WING_DB_USER || 'wing',
password: process.env.WING_DB_PASSWORD || 'Wing2026', password: process.env.DB_PASSWORD || process.env.WING_DB_PASSWORD || 'Wing2026',
max: 10, max: 20,
idleTimeoutMillis: 30000, idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000, connectionTimeoutMillis: 5000,
}) })
// 연결 시 search_path 자동 설정 (public 미사용)
wingPool.on('connect', (client) => {
client.query('SET search_path = wing, auth, public')
})
wingPool.on('error', (err) => { wingPool.on('error', (err) => {
console.error('[wingDb] 예기치 않은 연결 오류:', err.message) console.error('[db] 예기치 않은 연결 오류:', err.message)
}) })
export async function testWingDbConnection(): Promise<boolean> { export async function testWingDbConnection(): Promise<boolean> {
@ -22,10 +33,10 @@ export async function testWingDbConnection(): Promise<boolean> {
const client = await wingPool.connect() const client = await wingPool.connect()
await client.query('SELECT 1') await client.query('SELECT 1')
client.release() client.release()
console.log('[wingDb] wing 데이터베이스 연결 성공') console.log('[db] wing 데이터베이스 연결 성공 (wing + auth 스키마)')
return true return true
} catch (err) { } catch (err) {
console.warn('[wingDb] wing 데이터베이스 연결 실패:', (err as Error).message) console.warn('[db] wing 데이터베이스 연결 실패:', (err as Error).message)
return false return false
} }
} }

파일 보기

@ -4,7 +4,6 @@ import cors from 'cors'
import helmet from 'helmet' import helmet from 'helmet'
import rateLimit from 'express-rate-limit' import rateLimit from 'express-rate-limit'
import cookieParser from 'cookie-parser' import cookieParser from 'cookie-parser'
import { testAuthDbConnection } from './db/authDb.js'
import { testWingDbConnection } from './db/wingDb.js' import { testWingDbConnection } from './db/wingDb.js'
import layersRouter from './routes/layers.js' import layersRouter from './routes/layers.js'
import simulationRouter from './routes/simulation.js' import simulationRouter from './routes/simulation.js'
@ -14,6 +13,7 @@ import roleRouter from './roles/roleRouter.js'
import settingsRouter from './settings/settingsRouter.js' import settingsRouter from './settings/settingsRouter.js'
import menuRouter from './menus/menuRouter.js' import menuRouter from './menus/menuRouter.js'
import auditRouter from './audit/auditRouter.js' import auditRouter from './audit/auditRouter.js'
import boardRouter from './board/boardRouter.js'
import { import {
sanitizeBody, sanitizeBody,
sanitizeQuery, sanitizeQuery,
@ -136,6 +136,7 @@ app.use('/api/menus', menuRouter)
app.use('/api/audit', auditRouter) app.use('/api/audit', auditRouter)
// API 라우트 — 업무 // API 라우트 — 업무
app.use('/api/board', boardRouter)
app.use('/api/layers', layersRouter) app.use('/api/layers', layersRouter)
app.use('/api/simulation', simulationLimiter, simulationRouter) 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 () => { app.listen(PORT, async () => {
console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`) console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`)
// wing DB (운영 데이터) 연결 확인 // wing DB 연결 확인 (wing + auth 스키마 통합)
await testWingDbConnection() const connected = await testWingDbConnection()
// wing_auth DB (인증 데이터) 연결 확인
const connected = await testAuthDbConnection()
if (connected) { if (connected) {
// SETTING_VAL VARCHAR(500) → TEXT 마이그레이션 (메뉴 설정 JSON 확장 대응) // SETTING_VAL VARCHAR(500) → TEXT 마이그레이션 (메뉴 설정 JSON 확장 대응)
try { try {
const { authPool } = await import('./db/authDb.js') const { wingPool } = await import('./db/wingDb.js')
await authPool.query(`ALTER TABLE AUTH_SETTING ALTER COLUMN SETTING_VAL TYPE TEXT`) await wingPool.query(`ALTER TABLE AUTH_SETTING ALTER COLUMN SETTING_VAL TYPE TEXT`)
console.log('[migration] SETTING_VAL → TEXT 변환 완료')
} catch { } catch {
// 이미 TEXT이거나 권한 없으면 무시 // 이미 TEXT이거나 권한 없으면 무시
} }

파일 보기

@ -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;

파일 보기

@ -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;

파일 보기

@ -338,6 +338,8 @@ const mutation = useMutation({
## 6. 백엔드 API CRUD 규칙 ## 6. 백엔드 API CRUD 규칙
> 상세 가이드 + 게시판 실전 튜토리얼: **[CRUD-API-GUIDE.md](./CRUD-API-GUIDE.md)** 참조
### HTTP Method 정책 (보안 가이드 준수) ### HTTP Method 정책 (보안 가이드 준수)
- 보안 취약점 점검 가이드에 따라 **POST 메서드를 기본**으로 사용한다. - 보안 취약점 점검 가이드에 따라 **POST 메서드를 기본**으로 사용한다.
- GET은 단순 조회 중 민감하지 않은 경우에만 허용 (필요 시 POST로 전환). - GET은 단순 조회 중 민감하지 않은 경우에만 허용 (필요 시 POST로 전환).

1433
docs/CRUD-API-GUIDE.md Normal file

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -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 const CATEGORY_MAP: Record<string, string> = {
category: string NOTICE: '공지사항',
title: string DATA: '자료실',
author: string QNA: 'Q&A',
date: string MANUAL: '해경매뉴얼',
views: number };
isNotice?: boolean
} 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<string, string> = {
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 { interface BoardListTableProps {
onPostClick: (id: number) => void onPostClick: (id: number) => void;
onWriteClick: () => 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) { export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProps) {
const [searchTerm, setSearchTerm] = useState('') const hasPermission = useAuthStore((s) => s.hasPermission);
const [selectedCategory, setSelectedCategory] = useState<string>('전체')
const categories = ['전체', '공지사항', '자료실', 'Q&A'] const [posts, setPosts] = useState<BoardPostItem[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [page, setPage] = useState(1);
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [searchInput, setSearchInput] = useState('');
const [loading, setLoading] = useState(false);
const filteredPosts = mockPosts.filter((post) => { const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
const matchesCategory = selectedCategory === '전체' || post.category === selectedCategory
const matchesSearch = // 카테고리별 서브리소스 권한 확인 (전체 선택 시 board CREATE)
post.title.toLowerCase().includes(searchTerm.toLowerCase()) || const canWrite = selectedCategory
post.author.toLowerCase().includes(searchTerm.toLowerCase()) ? hasPermission(`board:${selectedCategory.toLowerCase()}`, 'CREATE')
return matchesCategory && matchesSearch : 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 ( return (
<div className="flex flex-col h-full bg-bg-0"> <div className="flex flex-col h-full bg-bg-0">
{/* Header with Search and Write Button */} {/* Header with Search and Write Button */}
<div className="flex items-center justify-between px-8 py-4 border-b border-border bg-bg-1"> <div className="flex items-center justify-between px-8 py-4 border-b border-border bg-bg-1">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Category Filters */}
<div className="flex gap-2"> <div className="flex gap-2">
{categories.map((category) => ( {CATEGORY_FILTER.map((cat) => (
<button <button
key={category} key={cat.label}
onClick={() => setSelectedCategory(category)} onClick={() => handleCategoryChange(cat.code)}
className={`px-4 py-2 text-sm font-semibold rounded transition-all ${ className={`px-4 py-2 text-sm font-semibold rounded transition-all ${
selectedCategory === category selectedCategory === cat.code
? 'bg-primary-cyan text-bg-0' ? 'bg-primary-cyan text-bg-0'
: 'bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1' : 'bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1'
}`} }`}
> >
{category} {cat.label}
</button> </button>
))} ))}
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Search Input */}
<input <input
type="text" type="text"
placeholder="제목, 작성자 검색..." placeholder="제목, 작성자 검색..."
value={searchTerm} value={searchInput}
onChange={(e) => setSearchTerm(e.target.value)} 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" 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 && (
<button <button
onClick={onWriteClick} onClick={onWriteClick}
className="px-6 py-2 text-sm font-semibold rounded bg-primary-cyan text-bg-0 hover:opacity-90 transition-opacity flex items-center gap-2" className="px-6 py-2 text-sm font-semibold rounded bg-primary-cyan text-bg-0 hover:opacity-90 transition-opacity flex items-center gap-2"
> >
<span></span> <span>+</span>
<span></span> <span></span>
</button> </button>
)}
</div> </div>
</div> </div>
{/* Board List Table */} {/* Board List Table */}
<div className="flex-1 overflow-auto px-8 py-6"> <div className="flex-1 overflow-auto px-8 py-6">
<table className="w-full border-collapse"> {loading ? (
<thead>
<tr className="border-b-2 border-border">
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-20"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-24"></th>
</tr>
</thead>
<tbody>
{filteredPosts.map((post) => (
<tr
key={post.id}
onClick={() => onPostClick(post.id)}
className="border-b border-border hover:bg-bg-2 cursor-pointer transition-colors"
>
<td className="px-4 py-4 text-sm text-text-1">
{post.isNotice ? (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-red-500/20 text-red-400">
</span>
) : (
post.id
)}
</td>
<td className="px-4 py-4">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold ${
post.category === '공지사항'
? 'bg-red-500/20 text-red-400'
: post.category === '자료실'
? 'bg-blue-500/20 text-blue-400'
: 'bg-green-500/20 text-green-400'
}`}
>
{post.category}
</span>
</td>
<td className="px-4 py-4">
<span
className={`text-sm ${
post.isNotice ? 'font-semibold text-text-1' : 'text-text-1'
} hover:text-primary-cyan transition-colors`}
>
{post.title}
</span>
</td>
<td className="px-4 py-4 text-sm text-text-2">{post.author}</td>
<td className="px-4 py-4 text-sm text-text-3">{post.date}</td>
<td className="px-4 py-4 text-sm text-text-3">{post.views}</td>
</tr>
))}
</tbody>
</table>
{filteredPosts.length === 0 && (
<div className="text-center py-20"> <div className="text-center py-20">
<p className="text-text-3 text-sm"> .</p> <p className="text-text-3 text-sm"> ...</p>
</div> </div>
) : (
<>
<table className="w-full border-collapse">
<thead>
<tr className="border-b-2 border-border">
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-20"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-24"></th>
</tr>
</thead>
<tbody>
{posts.map((post) => (
<tr
key={post.sn}
onClick={() => onPostClick(post.sn)}
className="border-b border-border hover:bg-bg-2 cursor-pointer transition-colors"
>
<td className="px-4 py-4 text-sm text-text-1">
{post.pinnedYn === 'Y' ? (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-red-500/20 text-red-400">
</span>
) : (
post.sn
)}
</td>
<td className="px-4 py-4">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold ${
CATEGORY_STYLE[post.categoryCd] || 'bg-gray-500/20 text-gray-400'
}`}
>
{CATEGORY_MAP[post.categoryCd] || post.categoryCd}
</span>
</td>
<td className="px-4 py-4">
<span
className={`text-sm ${
post.pinnedYn === 'Y' ? 'font-semibold text-text-1' : 'text-text-1'
} hover:text-primary-cyan transition-colors`}
>
{post.title}
</span>
</td>
<td className="px-4 py-4 text-sm text-text-2">{post.authorName}</td>
<td className="px-4 py-4 text-sm text-text-3">{formatDate(post.regDtm)}</td>
<td className="px-4 py-4 text-sm text-text-3">{post.viewCnt}</td>
</tr>
))}
</tbody>
</table>
{posts.length === 0 && (
<div className="text-center py-20">
<p className="text-text-3 text-sm"> .</p>
</div>
)}
</>
)} )}
</div> </div>
{/* Pagination */} {/* Pagination */}
<div className="flex items-center justify-center gap-2 px-8 py-4 border-t border-border bg-bg-1"> {totalPages > 1 && (
<button className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors"> <div className="flex items-center justify-center gap-2 px-8 py-4 border-t border-border bg-bg-1">
<button
</button> onClick={() => setPage((p) => Math.max(1, p - 1))}
<button className="px-3 py-1.5 text-sm rounded bg-primary-cyan text-bg-0 font-semibold"> disabled={page <= 1}
1 className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors disabled:opacity-40"
</button> >
<button className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors">
2 </button>
</button> {Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<button className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors"> <button
3 key={p}
</button> onClick={() => setPage(p)}
<button className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors"> className={`px-3 py-1.5 text-sm rounded ${
page === p
</button> ? 'bg-primary-cyan text-bg-0 font-semibold'
</div> : 'bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors'
}`}
>
{p}
</button>
))}
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors disabled:opacity-40"
>
</button>
</div>
)}
</div> </div>
) );
} }

파일 보기

@ -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<BoardListResponse> {
const response = await api.get<BoardListResponse>('/board', { params });
return response.data;
}
export async function fetchBoardPost(sn: number): Promise<BoardPostDetail> {
const response = await api.get<BoardPostDetail>(`/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<void> {
await api.put(`/board/${sn}`, input);
}
export async function deleteBoardPost(sn: number): Promise<void> {
await api.delete(`/board/${sn}`);
}