wing-ops/docs/CRUD-API-GUIDE.md
htlee db7f435717 docs: CRUD-API-GUIDE 게시판 프론트엔드 파일 참조 갱신
- BoardListTable → BoardView/BoardWriteForm/BoardDetailView로 변경
- 권한 기반 UI 분기 코드 예시를 실제 구현과 일치하도록 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:33:19 +09:00

41 KiB

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

// 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할 것.

// 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

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

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

공통 컬럼 패턴

모든 운영 테이블에 포함하는 표준 컬럼:

USE_YN     CHAR(1)      NOT NULL DEFAULT 'Y',   -- 논리삭제 (Y=활성, N=삭제)
REG_DTM    TIMESTAMPTZ  NOT NULL DEFAULT NOW(),  -- 등록일시
MDFCN_DTM  TIMESTAMPTZ,                          -- 수정일시

DDL 작성 예시

-- 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 파일 상단에 반환 타입과 입력 타입을 정의한다.

// backend/src/{domain}/{domain}Service.ts

import { wingPool } from '../db/wingDb.js'
import { AuthError } from '../auth/authService.js'

// 목록/상세 조회 반환 타입
interface PostItem {
  postSn: number
  categoryCd: string
  title: string
  content: string | null
  authorId: string
  authorName: string
  viewCnt: number
  pinnedYn: string
  useYn: string
  regDtm: string
  mdfcnDtm: string | null
}

// 생성 입력 타입
interface CreatePostInput {
  categoryCd: string
  title: string
  content?: string
  authorId: string
  pinnedYn?: string
}

// 수정 입력 타입 (모든 필드 optional — 부분 업데이트)
interface UpdatePostInput {
  title?: string
  content?: string
  categoryCd?: string
  pinnedYn?: string
}

// 페이징 응답 타입
interface PagedResult<T> {
  items: T[]
  totalCount: number
  page: number
  size: number
}

wingPool 사용

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 빌드 패턴 (필터, 검색)

export async function listPosts(
  categoryCd?: string,
  search?: string,
  page: number = 1,
  size: number = 20,
): Promise<PagedResult<PostItem>> {
  // 동적 WHERE 조건
  const conditions: string[] = ["p.USE_YN = 'Y'"]
  const params: (string | number)[] = []
  let paramIdx = 1

  if (categoryCd) {
    conditions.push(`p.CATEGORY_CD = $${paramIdx++}`)
    params.push(categoryCd)
  }

  if (search) {
    conditions.push(`(p.TITLE ILIKE $${paramIdx} OR p.CONTENT ILIKE $${paramIdx})`)
    params.push(`%${search}%`)
    paramIdx++
  }

  const whereClause = conditions.join(' AND ')

  // totalCount 조회
  const countResult = await wingPool.query(
    `SELECT COUNT(*) as cnt FROM BOARD_POST p WHERE ${whereClause}`,
    params
  )
  const totalCount = parseInt(countResult.rows[0].cnt, 10)

  // 페이징 데이터 조회
  const offset = (page - 1) * size
  const dataParams = [...params, size, offset]

  const dataResult = await wingPool.query(
    `SELECT p.POST_SN as post_sn, p.CATEGORY_CD as category_cd,
            p.TITLE as title, p.CONTENT as content,
            p.AUTHOR_ID as author_id, u.USER_NM as author_name,
            p.VIEW_CNT as view_cnt, p.PINNED_YN as pinned_yn,
            p.USE_YN as use_yn, p.REG_DTM as reg_dtm, p.MDFCN_DTM as mdfcn_dtm
     FROM BOARD_POST p
     LEFT JOIN AUTH_USER u ON p.AUTHOR_ID = u.USER_ID
     WHERE ${whereClause}
     ORDER BY p.PINNED_YN DESC, p.REG_DTM DESC
     LIMIT $${paramIdx++} OFFSET $${paramIdx++}`,
    dataParams
  )

  const items: PostItem[] = dataResult.rows.map((row) => ({
    postSn: row.post_sn,
    categoryCd: row.category_cd,
    title: row.title,
    content: row.content,
    authorId: row.author_id,
    authorName: row.author_name,
    viewCnt: row.view_cnt,
    pinnedYn: row.pinned_yn,
    useYn: row.use_yn,
    regDtm: row.reg_dtm,
    mdfcnDtm: row.mdfcn_dtm,
  }))

  return { items, totalCount, page, size }
}

상세 조회

export async function getPost(postSn: number): Promise<PostItem> {
  const result = await wingPool.query(
    `SELECT p.POST_SN as post_sn, p.CATEGORY_CD as category_cd,
            p.TITLE as title, p.CONTENT as content,
            p.AUTHOR_ID as author_id, u.USER_NM as author_name,
            p.VIEW_CNT as view_cnt, p.PINNED_YN as pinned_yn,
            p.USE_YN as use_yn, p.REG_DTM as reg_dtm, p.MDFCN_DTM as mdfcn_dtm
     FROM BOARD_POST p
     LEFT JOIN AUTH_USER u ON p.AUTHOR_ID = u.USER_ID
     WHERE p.POST_SN = $1 AND p.USE_YN = 'Y'`,
    [postSn]
  )

  if (result.rows.length === 0) {
    throw new AuthError('게시글을 찾을 수 없습니다.', 404)
  }

  const row = result.rows[0]
  return {
    postSn: row.post_sn,
    categoryCd: row.category_cd,
    title: row.title,
    content: row.content,
    authorId: row.author_id,
    authorName: row.author_name,
    viewCnt: row.view_cnt,
    pinnedYn: row.pinned_yn,
    useYn: row.use_yn,
    regDtm: row.reg_dtm,
    mdfcnDtm: row.mdfcn_dtm,
  }
}

생성

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 빌드 패턴 (부분 업데이트)

export async function updatePost(
  postSn: number,
  input: UpdatePostInput,
  requesterId: string,
): Promise<void> {
  // 소유자 검증
  const existing = await wingPool.query(
    "SELECT AUTHOR_ID FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'",
    [postSn]
  )
  if (existing.rows.length === 0) {
    throw new AuthError('게시글을 찾을 수 없습니다.', 404)
  }
  if (existing.rows[0].author_id !== requesterId) {
    throw new AuthError('본인의 게시글만 수정할 수 있습니다.', 403)
  }

  // 동적 SET 빌드
  const sets: string[] = []
  const params: (string | number | null)[] = []
  let idx = 1

  if (input.title !== undefined) {
    sets.push(`TITLE = $${idx++}`)
    params.push(input.title)
  }
  if (input.content !== undefined) {
    sets.push(`CONTENT = $${idx++}`)
    params.push(input.content)
  }
  if (input.categoryCd !== undefined) {
    sets.push(`CATEGORY_CD = $${idx++}`)
    params.push(input.categoryCd)
  }
  if (input.pinnedYn !== undefined) {
    sets.push(`PINNED_YN = $${idx++}`)
    params.push(input.pinnedYn)
  }

  if (sets.length === 0) {
    throw new AuthError('수정할 항목이 없습니다.', 400)
  }

  // MDFCN_DTM 자동 갱신
  sets.push('MDFCN_DTM = NOW()')
  params.push(postSn)

  await wingPool.query(
    `UPDATE BOARD_POST SET ${sets.join(', ')} WHERE POST_SN = $${idx}`,
    params
  )
}

삭제 (논리삭제)

export async function deletePost(postSn: number, requesterId: string): Promise<void> {
  // 소유자 검증
  const existing = await wingPool.query(
    "SELECT AUTHOR_ID FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'",
    [postSn]
  )
  if (existing.rows.length === 0) {
    throw new AuthError('게시글을 찾을 수 없습니다.', 404)
  }
  if (existing.rows[0].author_id !== requesterId) {
    throw new AuthError('본인의 게시글만 삭제할 수 있습니다.', 403)
  }

  // 논리삭제: USE_YN = 'N'
  await wingPool.query(
    "UPDATE BOARD_POST SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE POST_SN = $1",
    [postSn]
  )
}

트랜잭션 패턴

여러 테이블을 동시에 변경해야 할 때:

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()
  }
}

에러 처리

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):

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 예시

// 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

입력 검증 패턴

핸들러 내부에서 필수 필드를 직접 체크한다.

// 필수 필드 검증
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
}

에러 응답 패턴

모든 핸들러에서 동일한 에러 처리 구조를 사용한다.

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 등록

// backend/src/server.ts

import boardRouter from './board/boardRouter.js'

// API 라우트 — 업무
app.use('/api/board', boardRouter)

req.user 구조 (JWT 페이로드)

requireAuth 통과 후 req.user에 담기는 정보:

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 함수를 분리한다.

// frontend/src/tabs/board/services/boardApi.ts

import { api } from '@common/services/api'

// 타입 정의
export interface PostItem {
  postSn: number
  categoryCd: string
  title: string
  content: string | null
  authorId: string
  authorName: string
  viewCnt: number
  pinnedYn: string
  useYn: string
  regDtm: string
  mdfcnDtm: string | null
}

export interface PostListResult {
  items: PostItem[]
  totalCount: number
  page: number
  size: number
}

// 목록 조회
export async function fetchPosts(params: {
  categoryCd?: string
  search?: string
  page?: number
  size?: number
}): Promise<PostListResult> {
  const response = await api.post<PostListResult>('/board/list', params)
  return response.data
}

// 상세 조회
export async function fetchPost(postSn: number): Promise<PostItem> {
  const response = await api.post<PostItem>('/board/detail', { postSn })
  return response.data
}

// 생성
export async function createPostApi(data: {
  categoryCd: string
  title: string
  content?: string
  pinnedYn?: string
}): Promise<{ postSn: number }> {
  const response = await api.post<{ postSn: number }>('/board/create', data)
  return response.data
}

// 수정
export async function updatePostApi(
  postSn: number,
  data: { title?: string; content?: string; categoryCd?: string; pinnedYn?: string },
): Promise<void> {
  await api.post('/board/update', { postSn, ...data })
}

// 삭제
export async function deletePostApi(postSn: number): Promise<void> {
  await api.post('/board/delete', { postSn })
}

Axios 인스턴스

// 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 분기

// frontend/src/tabs/board/components/PostList.tsx

import { useAuthStore } from '@common/store/authStore'

const PostList = () => {
  const { hasPermission } = useAuthStore()

  return (
    <div>
      <h2>게시판</h2>

      {/* CREATE 권한이 있을 때만 글쓰기 버튼 표시 */}
      {hasPermission('board:notice', 'CREATE') && (
        <button onClick={handleCreate}>글쓰기</button>
      )}

      {/* 목록 렌더링 */}
      {posts.map((post) => (
        <div key={post.postSn}>
          <span>{post.title}</span>

          {/* UPDATE 권한 + 본인 글일 때만 수정 버튼 */}
          {hasPermission('board:notice', 'UPDATE') && post.authorId === user?.id && (
            <button onClick={() => handleEdit(post.postSn)}>수정</button>
          )}

          {/* DELETE 권한 + 본인 글일 때만 삭제 버튼 */}
          {hasPermission('board:notice', 'DELETE') && post.authorId === user?.id && (
            <button onClick={() => handleDelete(post.postSn)}>삭제</button>
          )}
        </div>
      ))}

      {/* 페이징 */}
      <Pagination
        totalCount={totalCount}
        page={page}
        size={size}
        onPageChange={handlePageChange}
      />
    </div>
  )
}

TanStack Query 연동 (권장)

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_TREEAUTH_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  (더블콜론, 내부 전용)
// 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에 라우터 등록
    import boardRouter from './board/boardRouter.js'
    app.use('/api/board', boardRouter)
    
  • 빌드 확인: cd backend && npm run build

권한 등록 (필요 시)

  • AUTH_PERM_TREE에 리소스 등록 (마이그레이션 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)
    -- 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

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_IDauth.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

인터페이스 정의

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
}

목록 조회 (페이징 + 필터 + 검색)

export async function listPosts(input: ListPostsInput): Promise<ListPostsResult> {
  const page = input.page && input.page > 0 ? input.page : 1
  const size = input.size && input.size > 0 ? Math.min(input.size, 100) : 20
  const offset = (page - 1) * size

  let whereClause = `WHERE bp.USE_YN = 'Y'`
  const params: (string | number)[] = []
  let paramIdx = 1

  if (input.categoryCd) {
    whereClause += ` AND bp.CATEGORY_CD = $${paramIdx++}`
    params.push(input.categoryCd)
  }

  if (input.search) {
    whereClause += ` AND (bp.TITLE ILIKE $${paramIdx} OR u.USER_NM ILIKE $${paramIdx})`
    params.push(`%${input.search}%`)
    paramIdx++
  }

  // 전체 건수
  const countResult = await wingPool.query(
    `SELECT COUNT(*) as cnt FROM BOARD_POST bp
     JOIN AUTH_USER u ON bp.AUTHOR_ID = u.USER_ID ${whereClause}`,
    params
  )
  const totalCount = parseInt(countResult.rows[0].cnt, 10)

  // 목록 (상단고정 우선 → 등록일 내림차순)
  const listParams = [...params, size, offset]
  const listResult = await wingPool.query(
    `SELECT bp.POST_SN as sn, bp.CATEGORY_CD as category_cd, bp.TITLE as title,
            bp.AUTHOR_ID as author_id, u.USER_NM as author_name,
            bp.VIEW_CNT as view_cnt, bp.PINNED_YN as pinned_yn, bp.REG_DTM as reg_dtm
     FROM BOARD_POST bp
     JOIN AUTH_USER u ON bp.AUTHOR_ID = u.USER_ID
     ${whereClause}
     ORDER BY bp.PINNED_YN DESC, bp.REG_DTM DESC
     LIMIT $${paramIdx++} OFFSET $${paramIdx}`,
    listParams
  )
  // ... 결과 매핑 후 return
}

핵심: JOIN AUTH_USER로 cross-schema JOIN 수행 (작성자명 표시). 이것이 DB 통합의 핵심 이점.

소유자 검증 패턴 (수정/삭제)

export async function updatePost(
  postSn: number,
  input: UpdatePostInput,
  requesterId: string    // ← req.user.sub (JWT에서 추출)
): Promise<void> {
  const existing = await wingPool.query(
    `SELECT AUTHOR_ID as author_id FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'`,
    [postSn]
  )

  if (existing.rows.length === 0) {
    throw new AuthError('게시글을 찾을 수 없습니다.', 404)
  }

  // 본인 글만 수정 가능
  if (existing.rows[0].author_id !== requesterId) {
    throw new AuthError('본인의 게시글만 수정할 수 있습니다.', 403)
  }

  // ... 동적 SET 빌드 + UPDATE
}

논리 삭제

export async function deletePost(postSn: number, requesterId: string): Promise<void> {
  // 소유자 검증 (위와 동일)
  await wingPool.query(
    `UPDATE BOARD_POST SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE POST_SN = $1`,
    [postSn]
  )
}

Step 3: Router 구현

파일: backend/src/board/boardRouter.ts

카테고리별 동적 리소스 결정

const CATEGORY_RESOURCE: Record<string, string> = {
  NOTICE: 'board:notice',
  DATA: 'board:data',
  QNA: 'board:qna',
  MANUAL: 'board:manual',
}

엔드포인트별 requirePermission 적용

// 목록/상세: 부모 리소스 '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 등록

import boardRouter from './board/boardRouter.js'

// API 라우트 — 업무
app.use('/api/board', boardRouter)

Step 5: 프론트엔드 연동

API 서비스

파일: frontend/src/tabs/board/services/boardApi.ts

import { api } from '@common/services/api';

export interface BoardPostItem {
  sn: number;
  categoryCd: string;
  title: string;
  authorId: string;
  authorName: string;
  viewCnt: number;
  pinnedYn: string;
  regDtm: string;
}

export interface BoardListResponse {
  items: BoardPostItem[];
  totalCount: number;
  page: number;
  size: number;
}

export async function fetchBoardPosts(params?: BoardListParams): Promise<BoardListResponse> {
  const response = await api.get<BoardListResponse>('/board', { params });
  return response.data;
}

export async function createBoardPost(input: CreateBoardPostInput): Promise<{ sn: number }> {
  const response = await api.post<{ sn: number }>('/board', input);
  return response.data;
}

권한 기반 UI 분기

파일: frontend/src/tabs/board/components/BoardView.tsx

import { useAuthStore } from '@common/store/authStore';

const hasPermission = useAuthStore((s) => s.hasPermission);

// 서브탭 기준 글쓰기 권한 리소스 결정
const getWriteResource = () => {
  if (activeSubTab === 'all') return 'board';
  return `board:${activeSubTab}`;
};

// 글쓰기 버튼 조건부 렌더링
{hasPermission(getWriteResource(), 'CREATE') && (
  <button onClick={handleWriteClick}>글쓰기</button>
)}

Step 6: 권한 시나리오 테스트

시나리오 역할 요청 결과
ADMIN이 공지 작성 ADMIN POST /api/board {categoryCd:"NOTICE"} 201 Created
USER가 공지 작성 USER POST /api/board {categoryCd:"NOTICE"} 403 (board:notice CREATE 없음)
USER가 Q&A 작성 USER POST /api/board {categoryCd:"QNA"} 201 (board:qna CREATE 있음)
VIEWER가 Q&A 작성 VIEWER POST /api/board {categoryCd:"QNA"} 403 (board:qna CREATE 없음)
USER가 본인 글 수정 USER PUT /api/board/11 (본인 글) 200
USER가 타인 글 수정 USER PUT /api/board/1 (타인 글) 403 (소유자 검증 실패)
ADMIN이 목록 조회 ADMIN GET /api/board 200 (board READ 있음)

관련 파일 전체 목록

위치 파일 설명
DB database/migration/006_board.sql DDL + 초기 데이터
백엔드 backend/src/board/boardService.ts CRUD 비즈니스 로직
백엔드 backend/src/board/boardRouter.ts 라우터 + requirePermission
백엔드 backend/src/server.ts boardRouter 등록
프론트 frontend/src/tabs/board/services/boardApi.ts API 서비스
프론트 frontend/src/tabs/board/components/BoardView.tsx 목록/상세/작성 통합 뷰 (API 연동)
프론트 frontend/src/tabs/board/components/BoardWriteForm.tsx 게시글 작성/수정 폼 (API 호출)
프론트 frontend/src/tabs/board/components/BoardDetailView.tsx 게시글 상세 보기 (API 호출)