Discourse 스타일의 포럼(Forums) 모듈입니다. 멀티 포럼/카테고리/토픽/댓글 구조와 목록 검색(q/in/fields), ETag 동시성 제어를 포함합니다.
- domain: 순수 비즈니스 모델/정책/예외
- persistence
- jpa: 엔티티 + Spring Data + Adapter
- jdbc: 목록/검색/프로젝션 전용 QueryRepository + JDBC CRUD Adapter
- service: 유스케이스/트랜잭션
- web: Controller/DTO/Mapper
- resources
- schema: PostgreSQL/MySQL DDL
- sql: SqlQuery SQLSet
- i18n: 포럼 모듈 메시지
- 관리자 전용:
*MgmtController - 일반 사용자용:
*Controller - 공개(인증 없음):
*PublicController - 본인 전용:
*MeController
- Forum: 생성/목록/상세/설정 변경
- Category: 생성/목록
- Topic: 생성/목록/상세/수정/삭제/상태변경
- Post: 생성/목록/수정/삭제
- Attachments: 댓글 첨부파일 업로드/목록/다운로드/삭제
- Membership: 게시판별 관리자/운영진/회원 관리
Public (사용자)
- Forum: 목록/상세 조회 (게시판 타입/정책에 따라 제한)
- Category: 목록 조회
- Topic: 생성/목록/상세/수정/삭제 (권한 기반)
- Post: 생성/목록/수정/삭제 (권한 기반)
- Attachment: 댓글 첨부파일 업로드/목록/다운로드/삭제 (권한 기반)
Admin (관리자)
- Forum: 생성, 설정 변경
- Category: 생성
- Topic: 생성/수정/삭제/상태 변경/핀/락
- Post: 생성/수정/삭제/숨김
- Attachment: 댓글 첨부파일 업로드/목록/다운로드/삭제
- Membership: 게시판별 멤버/역할 관리
- 사용자/관리자 모두
ApiResponse로 응답하며, 동일한 작업은 동일한 payload 구조를 사용합니다. - Forum 응답에는
viewType(UI/콘텐츠 모드)과 whitelist된properties만 노출됩니다. - Forum 목록 응답에도
viewType이 포함됩니다.
Public
- GET
/api/forums?q=&in=slug,name,description&page=&size=&sort= - GET
/api/forums/{forumSlug}(ETag) - GET
/api/forums/{forumSlug}/categories - POST
/api/forums/{forumSlug}/categories/{categoryId}/topics - POST
/api/forums/{forumSlug}/topics - GET
/api/forums/{forumSlug}/topics?q=&in=title,tags&fields=...&page=&size=&sort= - GET
/api/forums/{forumSlug}/topics?q=&in=title,tags&fields=...&page=&size=&sort=(TopicSummary 확장 필드 포함) - GET
/api/forums/{forumSlug}/topics/{topicId}(ETag) - PATCH
/api/forums/{forumSlug}/topics/{topicId}(If-Match) - DELETE
/api/forums/{forumSlug}/topics/{topicId}(If-Match) - POST
/api/forums/{forumSlug}/topics/{topicId}/posts - GET
/api/forums/{forumSlug}/topics/{topicId}/posts?page=&size=&sort= - PATCH
/api/forums/{forumSlug}/topics/{topicId}/posts/{postId}(If-Match) - DELETE
/api/forums/{forumSlug}/topics/{topicId}/posts/{postId}(If-Match) - POST
/api/forums/{forumSlug}/topics/{topicId}/posts/{postId}/attachments - GET
/api/forums/{forumSlug}/topics/{topicId}/posts/{postId}/attachments - GET
/api/forums/{forumSlug}/topics/{topicId}/posts/{postId}/attachments/{attachmentId} - GET
/api/forums/{forumSlug}/topics/{topicId}/posts/{postId}/attachments/{attachmentId}/thumbnail - GET
/api/forums/{forumSlug}/topics/{topicId}/posts/{postId}/attachments/{attachmentId}/download - DELETE
/api/forums/{forumSlug}/topics/{topicId}/posts/{postId}/attachments/{attachmentId}
Admin
- GET
/api/mgmt/forums?q=&in=slug,name,description&page=&size=&sort= - GET
/api/mgmt/forums/{forumSlug}(ETag) - POST
/api/mgmt/forums - PUT
/api/mgmt/forums/{forumSlug}/settings(If-Match) - POST
/api/mgmt/forums/{forumSlug}/categories - DELETE
/api/mgmt/forums/{forumSlug}/categories/{categoryId} - POST
/api/mgmt/forums/{forumSlug}/categories/{categoryId}/topics - GET
/api/mgmt/forums/{forumSlug}/topics?includeHidden=false&q=&in=title,tags&fields=...&page=&size=&sort= - PATCH
/api/mgmt/forums/{forumSlug}/topics/{topicId}/status(If-Match) - PATCH
/api/mgmt/forums/{forumSlug}/topics/{topicId}/pin(If-Match) - PATCH
/api/mgmt/forums/{forumSlug}/topics/{topicId}/lock(If-Match) - DELETE
/api/mgmt/forums/{forumSlug}/topics/{topicId}(If-Match) - POST
/api/mgmt/forums/{forumSlug}/topics/{topicId}/posts - PATCH
/api/mgmt/forums/{forumSlug}/topics/{topicId}/posts/{postId}/hide - DELETE
/api/mgmt/forums/{forumSlug}/topics/{topicId}/posts/{postId} - GET
/api/mgmt/forums/{forumSlug}/members?page=&size= - POST
/api/mgmt/forums/{forumSlug}/members - PATCH
/api/mgmt/forums/{forumSlug}/members/{userId} - DELETE
/api/mgmt/forums/{forumSlug}/members/{userId} - GET
/api/mgmt/forums/{forumSlug}/permissions - POST
/api/mgmt/forums/{forumSlug}/permissions - DELETE
/api/mgmt/forums/{forumSlug}/permissions
- Requires
attachment-service(or starter equivalent) andstudio.features.attachment.enabled=true. - Configure
studio.features.forums.attachments.object-typewith theforum_postobject type ID registered via the objecttype admin API (DB mode). Uploads/listing rely on this ID for policy + ownership. - Attachment endpoints sit under
/api/forums/{forumSlug}/topics/{topicId}/posts/{postId}/attachmentsand return adownloadUrlthat honorsstudio.features.forums.web.base-path. - Thumbnail endpoint is public (no auth) for UI thumbnails:
/thumbnailreturns 204 for non-image or unavailable thumbnails. - 권한은 forums 권한 시스템(ForumAuthz: Policy + ACL)으로 제어하며, 첨부파일 엔드포인트는 아래
PermissionAction을 사용합니다.- GET(목록/조회/다운로드):
READ_ATTACHMENT - POST/DELETE(업로드/삭제):
UPLOAD_ATTACHMENT
- GET(목록/조회/다운로드):
- 기본 정책은 게시판 타입별로 다음과 같이 동작합니다.
- COMMON/NOTICE:
READ_ATTACHMENT는 공개(ALLOW),UPLOAD_ATTACHMENT는 작성자/관리자만 허용 - SECRET:
READ_ATTACHMENT도 작성자/관리자만 허용 (권한 없으면 404로 숨김)
- COMMON/NOTICE:
- 다운로드를 사용자/역할 단위로 차단하려면 ACL 룰에서
READ_ATTACHMENT에DENY를 추가하세요. (예:ROLE_ANONYMOUSDENY → 로그인 사용자만 다운로드 가능)
- GET
/api/mgmt/forums/{forumSlug}/permissions/actions
관리자 UI가 보여줄PermissionAction전체 목록(이름/설명/displayName)을 가져갑니다. - GET
/api/mgmt/forums/{forumSlug}/permissions
현재 포럼/카테고리 조건의 ACL 룰을 조회합니다. - POST/PATCH/DELETE
/api/mgmt/forums/{forumSlug}/permissions
룰은PermissionAction,Effect(ALLOW/DENY),Ownership,SubjectType(ROLE|USER)등을 포함합니다. 프론트에서는role,subjectName/subjectId,priority등을 입력하며,identifierType=NAME이면subjectName을,identifierType=ID이면subjectId를 필수로 넣어야 DB 제약(ck_forum_acl_rule_subject_identifier)에 걸리지 않습니다. - GET
/api/mgmt/forums/{forumSlug}/permissions/check
관리자 시뮬레이터로action,role,ownerId,locked,userId,username을 넘겨 policy + ACL 평가 결과(allowed,policyDecision,aclDecision,denyReason)를 반환합니다. ForumMemberMgmtController: OWNER/ADMIN/MODERATOR/MEMBER 롤만 부여하며, 세부 액션은 ACL 룰로 제어됩니다.
- GET
/api/forums/{forumSlug}/authz
로그인한 사용자/역할 조합에서{forumSlug}게시판의PermissionAction별allowed여부만 리턴합니다. 일반 뷰에서 메뉴/버튼 노출을 판단할 때 메뉴가 사용할 수 있습니다. - GET
/api/forums/{forumSlug}/authz/simulate
일반 사용자도 접근 가능한 시뮬레이터이며,action,role,categoryId,ownerId,locked,userId,username을 받아 policy+ACL 결과를 리턴합니다. 관리자/permissions/check처럼 정책/ACL 평가 결과를 동일하게 돌려주지만, 게시판 뷰에서는 GET만 지원하므로 POST/PATCH/DELETE로 접근하면error.request.method.not-allowed가 발생합니다.
export const rolePermissionRows: RolePermissionRow[] = [
{
role: "OWNER",
label: "OWNER (소유자)",
basic:
"READ_*, CREATE_TOPIC, REPLY_POST, UPLOAD_ATTACHMENT (자신 글만), EDIT_TOPIC, DELETE_TOPIC, EDIT_POST, DELETE_POST (자신 글만)",
admin:
"HIDE_POST/LOCK_TOPIC/MANAGE_BOARD 등 관리자 액션은 ACL에서 ALLOW 필요",
note:
"ForumAccessResolver에서 OWNER는 ADMIN으로 매핑되어 정책/ACL 평가에서 관리자 후보군",
grantedActions: ["READ_BOARD", "READ_TOPIC_LIST", "READ_TOPIC_CONTENT", "READ_ATTACHMENT", "CREATE_TOPIC", "EDIT_TOPIC", "DELETE_TOPIC", "REPLY_POST", "UPLOAD_ATTACHMENT", "EDIT_POST", "DELETE_POST"],
adminActions: [],
},
{
role: "ADMIN",
label: "ADMIN (관리자)",
basic: "OWNER과 동일 + 첨부파일 업로드/삭제 포함 + 관리자 전용 요청(기본 DENY)",
admin: "관리자 전용 액션은 ACL에서 명시적으로 ALLOW",
note: "OWNER/ADMIN/studio.features.forums.authz.admin-roles가 동일하게 취급",
grantedActions: ["READ_BOARD", "READ_TOPIC_LIST", "READ_TOPIC_CONTENT", "READ_ATTACHMENT", "CREATE_TOPIC", "EDIT_TOPIC", "DELETE_TOPIC", "REPLY_POST", "UPLOAD_ATTACHMENT", "EDIT_POST", "DELETE_POST", "HIDE_POST", "MODERATE", "PIN_TOPIC", "LOCK_TOPIC", "MANAGE_BOARD"],
adminActions: ["HIDE_POST", "MODERATE", "PIN_TOPIC", "LOCK_TOPIC", "MANAGE_BOARD"],
},
{
role: "MODERATOR",
label: "MODERATOR (모더레이터)",
basic: "READ_*, CREATE_TOPIC, REPLY_POST, UPLOAD_ATTACHMENT (자신 글만), 조건부 EDIT_POST/DELETE_POST",
admin: "HIDE_POST, MANAGE_BOARD 등 관리자 액션을 ACL로 ALLOW하면 운영자 기능 수행",
note: "ForumAccessResolver.isAdmin에서 관리자처럼 처리되어 ACL만 추가하면 운영자 기능 가능",
grantedActions: ["READ_BOARD", "READ_TOPIC_LIST", "READ_TOPIC_CONTENT", "READ_ATTACHMENT", "CREATE_TOPIC", "REPLY_POST", "UPLOAD_ATTACHMENT", "EDIT_POST", "DELETE_POST"],
adminActions: [],
},
{
role: "MEMBER",
label: "MEMBER (일반 멤버)",
basic: "READ_*, CREATE_TOPIC, REPLY_POST (LOCKED 토픽 제한), UPLOAD_ATTACHMENT (자신 글만), 본인 글 EDIT/DELETE",
admin: "HIDE_POST 등은 기본 DENY → ForumAclRule로 추가",
note: "일반 사용자, 확장 권한은 ACL 룰로 제어",
grantedActions: ["READ_BOARD", "READ_TOPIC_LIST", "READ_TOPIC_CONTENT", "READ_ATTACHMENT", "CREATE_TOPIC", "REPLY_POST", "UPLOAD_ATTACHMENT", "EDIT_POST", "DELETE_POST"],
},
];
- 관리자 화면에서는
/api/mgmt/forums/{forumSlug}/permissions만 호출하고 일반/api/forums/...뷰에서는 이 엔드포인트를 직접 사용하지 마세요. 대신/api/forums/{forumSlug}/authz와/authz/simulate로 현재 사용자 권한을 확인합니다. /authz/simulate는 GET만 허용하므로 POST/PATCH/DELETE로 접근하면 405(error.request.method.not-allowed) 에러가 납니다.- 첨부파일은 본문(
READ_TOPIC_CONTENT)과 별개로READ_ATTACHMENT/UPLOAD_ATTACHMENT로 제어됩니다. 공개 글이라도 다운로드를 막고 싶으면READ_ATTACHMENT에DENY룰을 추가하세요. - ACL 룰 추가 요청의 INSERT 결과가
rule_id외에도board_id등이 섞여 있으면GeneratedKeyHolder.getKey()가InvalidDataAccessApiUsageException을 던집니다. 쿼리에서는RETURNING rule_id만 가져오거나GeneratedKeyHolder.getKeyMap()을 사용해 다중 키를 처리하세요.
q: 검색어in: 검색 대상 필드 목록 (예:title,tags)fields: 응답 필드 선택 (예:topicId,title,updatedAt,postCount,lastActivityAt)page,size,sort
createdById,createdBypostCountlastPostUpdatedAtlastPostUpdatedById,lastPostUpdatedBylastPostIdlastActivityAt(댓글이 있으면 마지막 댓글 수정일, 없으면 토픽 updatedAt)excerpt(마지막 댓글 내용 200자 요약)
- Topic 목록 기본 정렬:
lastActivityAt desc, topicId desc
includeHidden: 숨김 댓글 포함 여부 (기본값false)
updatedAtpostCountlastPostUpdatedAtlastPostUpdatedByIdlastPostIdlastActivityAt
{
"success": true,
"data": {
"content": [
{
"id": 101,
"title": "첫 번째 토픽",
"status": "OPEN",
"updatedAt": "2026-01-20T10:15:30+09:00",
"createdById": 7,
"createdBy": "alice",
"postCount": 5,
"lastPostUpdatedAt": "2026-01-22T09:40:10+09:00",
"lastPostUpdatedById": 12,
"lastPostUpdatedBy": "bob",
"lastPostId": 5501,
"lastActivityAt": "2026-01-22T09:40:10+09:00",
"excerpt": "마지막 댓글 요약 내용..."
}
],
"pageable": {
"pageNumber": 0,
"pageSize": 20
},
"totalElements": 1,
"totalPages": 1
}
}{
"success": true,
"data": {
"content": [
{
"slug": "general",
"name": "General",
"viewType": "GENERAL",
"updatedAt": "2026-01-22T08:10:00+09:00",
"topicCount": 12,
"postCount": 58,
"lastActivityAt": "2026-01-22T09:40:10+09:00",
"lastActivityById": 12,
"lastActivityBy": "bob",
"lastActivityType": "POST",
"lastActivityId": 5501
}
],
"pageable": {
"pageNumber": 0,
"pageSize": 20
},
"totalElements": 1,
"totalPages": 1
}
}- 조회 응답에 ETag(
W/"{version}") 반환 - 수정/상태변경 요청은
If-Match필요 If-Match누락: 428 Precondition RequiredIf-Match유효하지 않음: 412 Precondition Failed
{
"success": false,
"error": {
"code": "error.http.precondition.required",
"message": "If-Match header is required"
}
}{
"success": false,
"error": {
"code": "error.http.precondition.failed",
"message": "Invalid If-Match header"
}
}viewType:GENERAL | GALLERY | VIDEO | LIBRARY | NOTICE(기본값GENERAL)- 저장/응답 properties는 whitelist 키만 허용/노출
viewTypemedia.allowedExtlibrary.maxFileMb
{
"success": false,
"error": {
"message": "unknown forum properties: someKey"
}
}- createdById, createdBy, createdAt
- updatedById, updatedBy, updatedAt
- SQL 파일:
src/main/resources/sql/forums-sqlset.xml - 동적 구문은
<dynamic>+ Freemarker 사용
- PostgreSQL:
src/main/resources/schema/forums-postgres.sql - MySQL:
src/main/resources/schema/forums-mysql.sql - 테이블 prefix:
tb_application_
./gradlew build
- 경로:
starter/src/main/java/studio/one/application/forums/autoconfigure/ForumsAutoConfiguration.java - 활성화:
features.forums.enabled=true
features.forums.enabled=true
features.forums.web.enabled=true
studio.features.forums.web.base-path=/api/forums
studio.features.forums.web.mgmt-base-path=/api/mgmt/forums
studio.features.forums.cache.enabled=true
studio.features.forums.cache.list-ttl=60s
studio.features.forums.cache.detail-ttl=5m
studio.features.forums.cache.list-max-size=10000
studio.features.forums.cache.detail-max-size=50000
studio.features.forums.cache.record-stats=true
studio.features.forums.authz.admin-roles=ROLE_ADMIN,ADMIN
studio.features.forums.authz.secret-list-visible=false
features.forums.persistence=jdbc| Key | 설명 | 기본값 |
|---|---|---|
features.forums.enabled |
forums 기능 활성화 | false |
features.forums.web.enabled |
forums 웹 컨트롤러 활성화 | true |
studio.features.forums.web.base-path |
forums 공개 API 기본 경로 | /api/forums |
studio.features.forums.web.mgmt-base-path |
forums 관리자 API 기본 경로 | /api/mgmt/forums |
studio.features.forums.cache.enabled |
forums 캐시 사용 여부 | true |
studio.features.forums.cache.list-ttl |
forums 목록 캐시 TTL | 60s |
studio.features.forums.cache.detail-ttl |
forums 상세 캐시 TTL | 5m |
studio.features.forums.cache.list-max-size |
forums 목록 캐시 최대 항목 수 | 10000 |
studio.features.forums.cache.detail-max-size |
forums 상세 캐시 최대 항목 수 | 50000 |
studio.features.forums.cache.record-stats |
forums 캐시 통계 수집 | true |
studio.features.forums.authz.admin-roles |
관리자 역할 목록 (쉼표 구분) | ROLE_ADMIN,ADMIN |
studio.features.forums.authz.secret-list-visible |
SECRET 게시판 목록 노출 여부 | false |
features.forums.persistence |
persistence 선택 (jpa or jdbc) |
글로벌 기본값 |
features.forums.entity-packages |
JPA 엔티티 스캔 패키지 | studio.one.application.forums.persistence.jpa.entity |
- COMMON: 누구나 읽기, 회원 쓰기
- NOTICE: 읽기 누구나, 쓰기는 관리자/운영진만
- SECRET: 본문은 작성자/관리자만, 목록 노출은 설정으로 제어
- ADMIN_ONLY: 관리자/운영진만 목록/본문 접근
| ForumType | Actor | READ_BOARD | READ_TOPIC | CREATE_TOPIC | EDIT_TOPIC | DELETE_TOPIC | REPLY_POST | EDIT_POST | DELETE_POST |
|---|---|---|---|---|---|---|---|---|---|
| COMMON | anonymous | Y | Y | N | N | N | N | N | N |
| COMMON | member | Y | Y | Y | Y* | Y* | Y | Y* | Y* |
| COMMON | admin | Y | Y | Y | Y | Y | Y | Y | Y |
| NOTICE | anonymous | Y | Y | N | N | N | N | N | N |
| NOTICE | member | Y | Y | N | N | N | N | N | N |
| NOTICE | admin | Y | Y | Y | Y | Y | Y | Y | Y |
| SECRET | anonymous | N | N | N | N | N | N | N | N |
| SECRET | member | Y** | Y** | Y | Y* | Y* | Y | Y* | Y* |
| SECRET | admin | Y | Y | Y | Y | Y | Y | Y | Y |
| ADMIN_ONLY | anonymous | N | N | N | N | N | N | N | N |
| ADMIN_ONLY | member | N | N | N | N | N | N | N | N |
| ADMIN_ONLY | admin | Y | Y | Y | Y | Y | Y | Y | Y |
| *작성자/관리자만 허용, **작성자/관리자 및 멤버십/설정에 따라 목록 노출 |
OWNER: 작성자 본인 글에 대한EDIT_*/DELETE_*가 정책에 따라 허용되며,HANDLE/운영 액션(MANAGE_BOARD,MODERATE,HIDE_POST등)은 ACL로role=OWNER+action조합을effect=ALLOW로 명시해야 제공됩니다.ADMIN: 일반 게시글/댓글 작업과 관리자 전용 기능 모두 기본적으로 허용됩니다. ACL 계산에서도 가장 먼저 확인하며,studio.features.forums.authz.admin-roles에 있는 다른 역할도 같은 권한을 가집니다.MODERATOR: 일반적인 게시글 쓰기/답글/목록 조회는 MEMBERSHIP과 동일하지만,HIDE_POST/LOCK_TOPIC등 고급 액션은 기본적으로 DENY입니다. ACL로role=MODERATOR+action을ALLOW하면 관리자처럼 기능을 수행합니다.MEMBER: 일반적인 글 읽기/쓰기/댓글과 본인 글에 대한 수정/삭제는 허용되며, NOTICE/SECRET처럼 일부 게시판이나 LOCKED 토픽에서는 제한됩니다. 부족한 기능은 ACL(role=MEMBER+action)을 추가하면 해결됩니다.anonymous: 포럼/목록/본문 읽기만 허용되며, 특정 사용자에 대해 추가 권한을 주려면subjectType=USER/identifierType조합으로 ACL 룰을 등록하십시오.
- Topic 관리:
status,pin,lock(관리자) - Post 관리:
hide(관리자)
- 테이블:
tb_application_forum_member - 역할:
OWNER,ADMIN,MODERATOR,MEMBER - 권한 계산: 글로벌 роли + 게시판별 멤버십을 합쳐서 평가
forums.listforums.bySlugforums.categories.byForumforums.topics.byIdforums.topics.list.{forumSlug}forums.posts.list.{topicId}
- TTL은 CacheManager 설정에서 조정합니다.
- 목록 캐시는 짧은 TTL(예: 30
60초), 상세 캐시는 중간 TTL(예: 35분)로 시작하는 것을 권장합니다. - forums 모듈은
studio.features.forums.cache.list-ttl,studio.features.forums.cache.detail-ttl로 기본 TTL을 조정합니다. - Caffeine CacheManager 사용 시에만 TTL/max-size/통계 옵션이 적용됩니다.
- 관리 범위:
forumSlug단위로 읽기/쓰기/삭제 권한을 부여/회수합니다. - 대상(SID): 사용자(
principal) 또는 역할(role)을 기준으로 설정합니다. - 권한:
read,write,delete,admin중 하나를 지정합니다.
권한 부여 예시 (특정 사용자에게 읽기 권한 부여)
{
"sidType": "principal",
"sid": "user123",
"permission": "read",
"granting": true
}권한 부여 예시 (역할에 쓰기 권한 부여)
{
"sidType": "role",
"sid": "ROLE_FORUM_MODERATOR",
"permission": "write",
"granting": true
}권한 회수 예시 (특정 사용자 읽기 권한 회수)
{
"sidType": "principal",
"sid": "user123",
"permission": "read",
"granting": true
}