post-community 연동 TDD

Background

근거 PRD: /Users/biuea/Desktop/dpdpdndn/프로젝트/스포츠앱/모임·커뮤니티/20260706-post-community-연동-prd.md (검수 PASS). 근거 아키텍트 결정(SSOT): /Users/biuea/Desktop/dpdpdndn/프로젝트/스포츠앱/아키텍트/20260706-사용자정의-도메인경계-갭분석-architecture.md — §5(계층 재분류), §9(거버넌스 정합), §10(즉시착수: docs stale 정정·DomainClassification.core 등록·설계원칙 문서화).

community는 완성된 바운디드 컨텍스트다(Entity 2·DomainService 12메서드·UseCase 10·Repository 3·이벤트 3·VO 4·테스트 22). postcommunityId·SportCategory가 0건인 전역 게시판이다(domain/post/entity/Post.kt#Post). 두 컨텍스트는 도메인 교차 import 0건이다. 이 과제는 새 도메인을 만들지 않고 두 기존 컨텍스트를 연동해 모임 소속 게시글·종목별 분류·문서/거버넌스 정합을 구현한다.

Overview

  • 무엇을: post에 communityId(선택 소속)·sportCategory(선택 종목)를 추가하고, 모임 소속 게시글의 열람/작성/공지 인가를 community의 기존 멤버십·가시성 규칙으로 재사용한다. FR-8 문서·거버넌스 정합을 함께 처리한다.
  • : 완성된 community를 재사용해 모임 전용 소통·종목별 탐색을 열되, post 도메인을 오염시키지 않고 ArchUnit R1(도메인 교차 참조 0건)을 깨지 않는다.
  • 어떻게 (핵심 3결정):
    1. R1 배선 = application 레이어 오케스트레이션. post·community 인가 재사용은 application 레이어 UseCase가 PostDomainService+CommunityDomainService를 함께 호출한다. domain.postdomain.community를 절대 import하지 않는다. (R3 화이트리스트 확장(옵션 ②)은 미채택 — 근거는 Possible Solutions.)
    2. SportCategorydomain.common.vo로 이전 (공유 커널). post가 종목 필드를 R1-합법으로 보유하기 위한 순수 위치 이전(값·의미 불변).
    3. 검색 가시성은 post 소유 컬럼(global_listed)으로 비정규화 — PRIVATE 모임 게시글이 전역 피드에 새지 않게 한다. community를 검색 hot path에서 참조하지 않는다.

Terminology

용어정의
전역 게시글(global post)community_id IS NULL 게시글. 기존 게시판 동작, 인가 없음 (FR-4)
모임 소속 게시글(community post)community_id non-null. 소속 모임의 가시성·멤버십으로 인가 (FR-1~3)
종목(SportCategory)스포츠 종목 분류 VO. community가 개설 시 보유, post도 선택 보유 (FR-5). 공유 커널로 이전
전역 피드 노출(global_listed)게시글이 전역 GET /posts 피드/종목 검색에 노출되는지 여부(post 소유 boolean). 전역·PUBLIC모임=true, PRIVATE모임=false
R1ArchUnit 규칙 — domain.Xdomain.Y를 import 금지(domain.common만 예외). ContextMapBaselineTest·AggregateAndUseCaseRulesTestSlicesRuleDefinition.notDependOnEachOther()로 강제
R3ArchUnit 규칙 — 지원/서브시스템 도메인이 코어를 동기 의존 금지. SupportToCoreDependencyRulesTest. post·community 모두 core

Define Problem

AS-IS (실제 코드 근거)

  • Post(domain/post/entity/Post.kt:19-70)는 userId/title/content/type(PostType)만 보유. communityId·sportCategory 필드 0건. 팩토리 create(userId, title, content, type=FREE).
  • PostType(domain/post/vo/PostType.kt) = FREE/NOTICE/QUESTION/REVIEW. 종목 개념 없음.
  • PostSearchCriteria(domain/post/dto/PostSearchCriteria.kt) = type/userId/keyword뿐. PostCriteria(application/post/dto/PostCriteria.kt:9-30) 동일 + 페이징(MAX_PAGE_SIZE=100).
  • PostCustomRepositoryImpl#buildPredicate(infrastructure/post/mysql/PostCustomRepositoryImpl.kt:26-36) = deletedAt.isNull + type/userId/keyword 술어. 종목·모임 필터 없음.
  • 인가: PostApiController(presentation/post/controller/PostApiController.ktCommentApiController는 작성 시 X-User-Id만 받고 인가 검사 0건. GET /posts/{id}는 requester 헤더조차 없음.
  • community: CommunityDomainService(domain/community/service/CommunityDomainService.kt)가 인가 재사용의 열쇠다 — requireActiveMember(communityId, requesterId)(:163, 실패 시 NotCommunityMemberException), getCommunity(communityId, requesterId)(:130, 공개→통과/비공개→requireActiveMember). Community(domain/community/entity/Community.kt)는 isPublic()(:60), requireHost(userId)(:63), sportCategory(:40, val 불변), visibility(val 불변) 보유. isHostedBy(userId): Boolean 질의는 없음(host 판별은 requireHost throw 또는 currentHostUserId raw 비교뿐).
  • SportCategory(domain/community/vo/SportCategory.kt) = 12종. domain.community.vo 소속 — post가 import하면 R1 위반.
  • 거버넌스: DomainClassification.core(SupportToCoreDependencyRulesTest.kt:18)에 community 누락. docs/domain-context-map.md:20-21,203,225가 community를 “(VO만)·미완성”으로 stale 서술 + message/post 분류 불일치(:203 Supporting vs DomainClassification.core).
  • 스키마: posts(V9__create_posts_comments.sql) + type(V10__add_post_type.sql). community_id·sport_category 컬럼 없음.

R1 긴장 (검수가 남긴 필수 해소 대상)

ContextMapBaselineTest.kt:31·AggregateAndUseCaseRulesTest.kt:27-37SlicesRuleDefinition.slices().matching("com.sportsapp.domain.(*)..").notDependOnEachOther()domain 하위 컨텍스트 간 import 0건을 강제한다(post·community 둘 다 core). 따라서:

  • domain.postdomain.community.service.CommunityDomainService·domain.community.vo.SportCategory·domain.community.entity.Community를 import하면 R1 위반으로 빌드가 깨진다.
  • 두 규칙 모두 domain.commonignoreDependency. application 레이어는 이 슬라이스 규칙의 대상이 아니다(스캔 범위 com.sportsapp.domain.(*)) — 즉 application의 core→core 오케스트레이션은 R1 미규제.
  • SupportToCoreDependencyRulesTest(R3)는 notification/operator/weather/mcp/alerting만 스캔한다 — post·community는 core라 스캔 대상이 아니고, 화이트리스트(dashboard/partner)와 무관하다.

TO-BE

  • PostcommunityId: Long?·sportCategory: SportCategory?(공유 커널)·globalListed: Boolean을 보유. 팩토리 2종: create(...)(전역, sportCategory 선택) / createInCommunity(...)(모임, NOTICE→host 규칙 내장, globalListed=공개여부).
  • 인가/상속 오케스트레이션은 application UseCase가 담당(domain.postdomain.community import 0건 유지). community-측 인가는 CommunityDomainService기존 public API(requireActiveMember/getCommunity)만 사용 → CommunityDomainService.kt 무수정(B의 community↔booking 작업과 파일 충돌 회피).
  • 검색 가시성은 posts.global_listed 비정규화 컬럼으로 전역 피드에서 PRIVATE 모임 게시글을 제외.
  • 문서·거버넌스 정합(FR-8).

Architecture Benchmarking (의무)

동일 과제(그룹 소속 콘텐츠 + 멤버십 기반 인가 + 공유 분류 개념)의 아키텍처 사례.

제품/사례해결 방식참고할 패턴미참고 사유
Slack — 채널 게시 권한 (slack.com/help/360004635551)채널(=모임)이 public/private + “누가 게시 가능한가”를 채널 소유 정책으로 게이트. 열람은 채널 가시성, 작성은 멤버십·역할로 분리 판정열람=가시성 / 작성=멤버십·역할 분리 판정 → FR-2(열람: PUBLIC 비멤버 허용/PRIVATE ACTIVE만)·FR-3(작성: ACTIVE만) 구조와 1:1. 인가는 게시물이 아닌 채널(=community)이 소유 → 우리도 인가를 community에 위임OAuth scope·앱 설치 권한 모델은 API 위임 인가라 이 과제(사용자 멤버십)와 무관
Discord — 채널 권한 (support.discord.com/…/10543994968087)서버(그룹) 역할 계층 위에 채널별 override. 역할(HOST/MEMBER 유사)로 게시·관리 권한 부여역할 계층으로 공지/관리 권한 분리 → FR-7(NOTICE=HOST만) 구조 참고. 역할 판정은 그룹(=community)이 소유채널별 세분 override는 지금 스펙(모임 단위 단일 가시성)에 과함(오버엔지니어링)
Strava Clubs (support.strava.com/…/18288273144973)클럽에 sport type 1개 지정 → 피드가 종목 필터. 게시글이 Announcements(운영자만)/Member Posts(멤버)로 작성 권한 분리종목별 피드 분류(FR-5/6) + 공지 작성권한 분리(FR-7) → 우리 SportCategory 상속·NOTICE host 규칙과 동형클럽=종목 1:1 강제는 우리(전역 게시글은 종목 선택·모임 게시글은 상속)와 다름 — 선택적 분류로 완화
DDD Shared Kernel (contextmapper.org/docs/shared-kernel, deviq.com/…/shared-kernel)여러 컨텍스트가 공유하는 최소 모델 조각(enum·식별자·VO)을 명시적 공유 커널로 분리, “작게 유지”SportCategory를 domain.common(공유 커널)으로 최소 이전 → 두 코어가 R1-합법으로 동일 enum 재사용. “kernel tiny” 원칙 준수(값 1개 enum만 이전)공유 커널에 인가 로직·엔티티까지 넣는 확장은 결합도 증가 → 인가는 application 오케스트레이션으로, 커널엔 순수 enum만

Possible Solutions

방안 A — R1 배선 (인가 재사용을 어디에)

방안설명왜 채택미채택 사유
A-1 application 오케스트레이션 (채택)post UseCase가 PostDomainService+CommunityDomainService(기존 public API)를 함께 호출. domain.post는 community 무참조. 선례: application.dashboard(다중 코어 조합), application.partner→domain.userR1 슬라이스는 domain만 규제 → application core→core는 합법. community 인가를 community가 소유(Slack/Discord 패턴). CommunityDomainService 무수정으로 B와 파일 충돌 0
A-2 R3 화이트리스트에 post→community 추가 (옵션 ②)SupportToCoreDependencyRulesTest에 예외 등록미채택 — R3는 support/subsystem→core만 스캔(post·community는 core라 애초에 대상 아님). 실제 R1 블로커는 domain 슬라이스 규칙이고 거긴 화이트리스트 기구가 없다(domain.common만 ignore). 화이트리스트 추가는 아무 효과 없음. domain.post→domain.community import 자체가 불가
A-3 domain.post가 primitive만 받는 협력post 도메인 메서드가 community 사실을 원시값(Boolean/enum)으로 수령부분 채택(보강) — 인가 판정은 A-1, 종목·host 사실은 primitive로 전달전면 단독으론 부족(오케스트레이터가 A-1 필요)

방안 B — SportCategory 재사용

방안설명왜 채택미채택 사유
B-1 domain.common.vo로 이전 (채택)순수 enum을 공유 커널로 relocation. community/post/(후속 recruitment)가 동일 타입 참조post가 R1-합법으로 typed 필드 보유. 값·의미 불변(Non-Goal 준수). 공유 커널 “tiny” 원칙
B-2 post에 신규 종목 enum 정의post 전용 SportCategory 복제미채택 — FR-5 “신규 enum 정의 금지” 위반. 이중 관리·불일치
B-3 post가 종목을 String 보유문자열 컬럼, 경계에서 파싱미채택no-stringified/타입안전 위반, FR-5 “VO 재사용” 위반, 필터 취약

방안 C — 검색 가시성 (PRIVATE 모임 게시글 전역 피드 차단)

방안설명왜 채택미채택 사유
C-1 post 소유 global_listed 비정규화 (채택)작성 시 전역·PUBLIC=true / PRIVATE=false 저장. 전역 검색은 global_listed=true 술어R1-안전(post 자기 컬럼), 인덱스 친화, community를 hot path에서 무참조, CommunityDomainService 무수정. visibility 불변(val)이라 staleness 불가한계: PRIVATE 멤버가 자기 모임 게시글을 전역 종목검색엔 못 봄(모임 목록 엔드포인트로는 봄) → Open Questions 문서화
C-2 application이 publicCommunityIds 조회 후 IN 필터community에서 공개 id 집합 받아 쿼리미채택(지금)CommunityDomainService 신규 메서드 필요(B와 파일 충돌) + 무한 성장 id 집합. 멤버 private 게시글까지 검색 노출 이득은 있으나 지금 규모에 과함. 공개 커뮤니티 수 증가 시 승격 트리거
C-3 전역 검색에서 모임 게시글 전면 제외community_id null만 검색미채택 — FR-6 “모임 소속·비소속 무관” 위반(Scenario 5 PUBLIC 모임 게시글 누락)

단순함 우선: CommunityDomainService·community_visibility 타입 컬럼 신설을 피하고, 기존 community public API + post 소유 boolean 1개로 P0/P1을 충족한다. C-2 승격은 “멤버의 private 게시글을 전역 검색에 노출” 요구가 실제로 잡힐 때.

Detail Design

시스템 역할 경계 (의무)

단위역할소유 데이터/책임노출 인터페이스의존
API 서버 (단일 모놀리스)요청-응답 처리전체REST— (§아키텍트 §2 “적합”: 워커·소켓·스케줄러 분리 불요. 인가는 동기 요청-응답이라 API 서버 1개로 충분)
Post (domain.post)게시글 rich modeltitle/content/type/communityId?/sportCategory?/globalListedcreate/createInCommunity/changePost/addCommentcommon(SportCategory)
Comment (domain.post)댓글 modelcontent, postIdcreate/delete
PostDomainService (domain.post)조회+생성+검색 오케스트레이션createPost/createCommunityPost/getPost/getDetail/search/addComment/listCommentsPostRepository·CommentRepository·PostCustomRepository
PostCustomRepository(Impl)동적 검색 쿼리술어 조합findByCriteria(criteria, pageable)QueryDSL
Community (domain.community)모임 rich modelvisibility/sportCategory/hostUserIdisPublic/requireHost/isHostedBy(신규)/sportCategorycommon — 무변경(1 질의 메서드 추가만)
CommunityDomainService (domain.community)멤버십·가시성 인가멤버십requireActiveMember/getCommunity (기존)무수정
application UseCase (application.post)R1 배선점 — post·community 인가 오케스트레이션트랜잭션 경계execute()PostDomainService + CommunityDomainService
*ApiController (presentation.post)라우팅·Request→Command·인가 헤더 수집RESTUseCase

R1 배선 (필수 명시 — 회귀 방지)

flowchart LR
    subgraph presentation["presentation.post"]
        Ctl[PostApiController]
        CPC[CommunityPostApiController]
    end
    subgraph application["application.post — R1 배선점"]
        CCPU[CreateCommunityPostUseCase]
        GPU[GetPostUseCase]
        LCP[ListCommunityPostsUseCase]
    end
    subgraph domainpost["domain.post"]
        PDS[PostDomainService]
        PostE[Post]
    end
    subgraph domaincomm["domain.community"]
        CDS[CommunityDomainService]
        CommE[Community]
    end
    subgraph common["domain.common.vo"]
        SC[SportCategory]
    end
    Ctl --> CCPU
    CPC --> LCP
    CCPU --> PDS
    CCPU -->|requireActiveMember/getCommunity| CDS
    GPU --> PDS
    GPU -->|getCommunity 읽기인가| CDS
    PDS --> PostE
    CDS --> CommE
    PostE -.->|typed 필드| SC
    CommE -.->|typed 필드| SC
  • domain.post → domain.community 화살표 0건 (R1 준수). 교차는 오직 application.post → domain.community(합법).
  • CreateCommunityPostUseCase가 community에서 얻은 사실(sportCategory, isHostedBy, isPublic)을 primitive로 PostDomainService.createCommunityPost(...)에 전달 → post 도메인은 community 타입 무참조(SportCategory는 common).

인터페이스 시그니처 (확정)

// domain.common.vo.SportCategory  — 이전(값 불변): SOCCER..ETC 12종
 
// domain.post.entity.Post  (신규 필드 + 팩토리)
val currentCommunityId: Long?          // get()-only 노출
val currentSportCategory: SportCategory?
val isGlobalListed: Boolean
companion object {
    fun create(userId: Long, title: String, content: String,
               type: PostType = PostType.FREE, sportCategory: SportCategory? = null): Post   // 전역, globalListed=true
    fun createInCommunity(userId: Long, title: String, content: String, type: PostType,
                          communityId: Long, sportCategory: SportCategory?,
                          authorIsHost: Boolean, communityIsPublic: Boolean): Post
    // 내부 규칙: type==NOTICE && !authorIsHost → NoticeRequiresHostException;
    //           sportCategory=인자(모임 상속값), globalListed=communityIsPublic
}
 
// domain.community.entity.Community  (질의 1개 추가)
fun isHostedBy(userId: Long): Boolean = hostUserId == userId   // 순수 질의, throw 없음
 
// domain.post.dto.PostSearchCriteria  (필드 추가)
data class PostSearchCriteria(
    val type: PostType?, val userId: Long?, val keyword: String?,
    val communityId: Long?,            // 특정 모임 필터 (FR-6)
    val sportCategory: SportCategory?, // 종목 필터 (FR-6)
    val globalFeedOnly: Boolean,       // true=전역 피드(global_listed=true 강제), false=모임 목록(인가는 상위에서)
)
 
// domain.post.service.PostDomainService  (메서드 추가/확장)
fun createPost(userId: Long, title: String, content: String,
               type: PostType, sportCategory: SportCategory?): Post
fun createCommunityPost(userId: Long, title: String, content: String, type: PostType,
                        communityId: Long, sportCategory: SportCategory?,
                        authorIsHost: Boolean, communityIsPublic: Boolean): Post
// search/getPost/getDetail/addComment/listComments — 기존 시그니처 유지(criteria만 확장)
 
// CommunityDomainService — 기존 그대로 재사용 (신규 메서드 없음)
//   requireActiveMember(communityId, requesterId)     — 작성/비공개열람 게이트
//   getCommunity(communityId, requesterId): Community  — 공개→통과/비공개→requireActiveMember

클래스 역할 정의

도메인 모델

클래스명역할핵심 책임
Post게시글communityId/sportCategory/globalListed 보유. NOTICE→host 규칙(모임 소속 시), 전역 노출 판정
Community모임isHostedBy 질의 추가(기존 유지)

서비스 클래스 (application UseCase — R1 배선)

클래스명역할입력 → 출력의존
CreatePostUseCase (확장)전역 게시글 작성(sportCategory 선택)CreatePostCommand → PostPostDomainService
CreateCommunityPostUseCase (신규)모임 게시글 작성 인가+종목상속Command → PostPost+CommunityDomainService
AddCommentUseCase (확장)댓글 작성(모임이면 ACTIVE 인가)Command → CommentPost+CommunityDomainService
GetPostUseCase (확장)상세 열람(모임이면 가시성 인가)(postId, requesterId?) → (Post, Comments)Post+CommunityDomainService
ListCommentsUseCase (확장)댓글 목록(모임이면 가시성 인가)(postId, requesterId?, page, size) → PagePost+CommunityDomainService
SearchPostsUseCase (확장)전역 피드 검색(종목/모임 필터, global_listed=true)PostCriteria → PagePostDomainService
ListCommunityPostsUseCase (신규)모임 게시글 목록(가시성 인가 후 전체)(communityId, requesterId?, criteria) → PagePost+CommunityDomainService

실패 경로·동시성·멱등

관심사설계
인가 실패community 비공개·비멤버·PENDING_APPROVAL 열람/작성 → NotCommunityMemberException(기존 매핑 403, FR-2/NFR). NOTICE 비-host 작성 → NoticeRequiresHostException(신규, 403)
requester 부재(GET 상세/목록 X-User-Id 없음)헤더 optional. 전역 게시글 → 통과. PUBLIC 모임 → 통과. PRIVATE 모임 → requesterId=null이 requireActiveMember에서 비멤버 처리 → 403
존재하지 않는 community_id로 작성getCommunityResourceNotFoundException(404). community_id는 FK 없는 소프트참조라 정합은 앱 레벨(작성 시 검증)
동시성게시글 작성/댓글은 단순 INSERT — 쓰기 경합 없음(락 불요). 인가 판정은 읽기. community visibility·sportCategory는 val 불변 → 상속값 race 없음
멱등신규 이벤트·Kafka 발행 없음(Layer 1/2 미사용) — 동기 요청-응답만. 멱등 키 불요. (부가 인터랙션·알림은 PRD Non-Goal)
부분 실패단일 트랜잭션(UseCase @Transactional). 인가 실패는 커밋 전 throw → 게시글 미생성. 롤백 자동
전역 게시글 하위호환communityId null 경로는 인가·상속 분기 진입 안 함(FR-4). 기존 create/검색 동작 불변

상태 전이 표

이 과제는 신규 상태 머신을 도입하지 않는다(게시글은 생성/소프트삭제만, community 멤버십 상태는 기존). 인가는 현재 멤버십 상태 기반 판정 매트릭스로 표현:

게시글 소속 × 요청자 상태열람(FR-2)작성/댓글(FR-3)NOTICE 작성(FR-7)
전역(communityId=null)허용허용허용(제한 없음, 하위호환)
PUBLIC 모임 · 비멤버허용거부(403)거부(403)
PUBLIC/PRIVATE 모임 · ACTIVE MEMBER허용허용거부(403)
PUBLIC/PRIVATE 모임 · ACTIVE HOST허용허용허용
PRIVATE 모임 · 비멤버/PENDING/LEFT/KICKED거부(403)거부(403)거부(403)

Sequence Diagram — 모임 게시글 작성 (FR-3/5/7)

sequenceDiagram
    participant C as PostApiController
    participant U as CreateCommunityPostUseCase
    participant CDS as CommunityDomainService
    participant PDS as PostDomainService
    participant P as Post
    C->>U: execute(command: userId, communityId, type, ...)
    U->>CDS: requireActiveMember(communityId, userId)
    CDS-->>U: ok (아니면 403)
    U->>CDS: getCommunity(communityId, userId)
    CDS-->>U: community (sportCategory, isHostedBy, isPublic)
    U->>PDS: createCommunityPost(..., sportCategory, authorIsHost, communityIsPublic)
    PDS->>P: createInCommunity(...)
    P-->>PDS: post (NOTICE면 host 아니면 403)
    PDS-->>U: saved post
    U-->>C: Post

Sequence Diagram — 상세 열람 인가 (FR-2)

sequenceDiagram
    participant C as PostApiController
    participant U as GetPostUseCase
    participant PDS as PostDomainService
    participant CDS as CommunityDomainService
    C->>U: execute(postId, requesterId?)
    U->>PDS: getDetail(postId)
    PDS-->>U: (post, comments)
    alt post.communityId != null
        U->>CDS: getCommunity(communityId, requesterId)
        CDS-->>U: 공개→통과 / 비공개 비멤버→403
    end
    U-->>C: (post, comments)

API 계약 (FE 소비 — Request → Command → Response)

기존 엔드포인트는 additive 확장(하위호환), 신규 1개. 인가 실패는 403.

메서드경로변경인가
POST/postsRequest에 communityId?·sportCategory? 추가. communityId 유무로 UseCase 분기전역=인증만 / 모임=ACTIVE 멤버(NOTICE=HOST)
GET/posts쿼리 communityId?·sportCategory? 추가. 전역 피드(global_listed=true)없음(공개 피드)
GET/posts/{id}X-User-Id 헤더 optional 추가모임 소속 시 가시성(FR-2)
POST/posts/{postId}/comments시그니처 불변(X-User-Id 인가 반영)모임 소속 시 ACTIVE(FR-3)
GET/posts/{postId}/commentsX-User-Id optional 추가모임 소속 시 가시성(FR-2)
GET/communities/{communityId}/posts신규 — 모임 게시글 목록(sportCategory?·page·size)가시성(FR-2, PRIVATE 멤버는 자기 글 열람)
// POST /posts  Request
{ "title": String, "content": String, "type": "FREE|NOTICE|QUESTION|REVIEW",
  "communityId": Long?,          // null=전역
  "sportCategory": "SOCCER|...|ETC"? }   // 전역 게시글만 자체 지정. 모임 게시글은 무시(모임값 상속)
// PostResponse (목록·작성)
{ "id": Long, "userId": Long, "title": String, "type": PostType,
  "communityId": Long?, "sportCategory": SportCategory?, "createdAt": ZonedDateTime }
// PostDetailResponse (상세) = PostResponse + content + comments[]
  • FR-5 상속: 모임 게시글 작성 시 Request의 sportCategory는 무시하고 소속 모임 sportCategory를 상속(TDD Detail Design). FE는 모임 게시글 작성 폼에서 종목 선택을 숨긴다.
  • X-User-Id는 기존 커스텀 인증 헤더 규약을 그대로 사용(신규 인증 모델 없음).

ERD

erDiagram
    COMMUNITIES ||--o{ POSTS : "soft ref (community_id, nullable)"
    POSTS ||--o{ COMMENTS : has
    POSTS {
        bigint id PK
        bigint user_id
        varchar title
        varchar content
        varchar type
        bigint community_id "NULL=전역 (FK 없음)"
        varchar sport_category "NULL=미지정"
        tinyint global_listed "전역 피드 노출"
        datetime deleted_at
    }
    COMMUNITIES {
        bigint id PK
        varchar visibility
        varchar sport_category
        bigint host_user_id
    }
  • posts.community_id·sport_category는 nullable, FK 없음(정합 앱 레벨, private-db-schema-convention).
  • global_listed TINYINT(1) NOT NULL DEFAULT 1 — 기존 전역 게시글 전부 1(정상). ENUM/BOOLEAN 금지 규칙 준수(TINYINT).
  • 인덱스 대상 쿼리(상세는 senior-dba design-db): 종목필터 (sport_category, deleted_at), 모임목록 (community_id, deleted_at), 전역피드 (global_listed, deleted_at) 후보. 실제 인덱스는 senior-dba가 쿼리 근거로 확정.

Testing Plan

RED-first. 프레임워크 Kotest(BehaviorSpec/DescribeSpec/FunSpec), 통합은 TestContainers(MySQL).

레벨대상핵심 케이스(해피·실패·엣지)
domainPost.createInCommunityNOTICE+host→생성 / NOTICE+비host→NoticeRequiresHostException / FREE+비host→생성 / PUBLIC→globalListed=true / PRIVATE→false / sportCategory 상속
domainPost.createsportCategory 지정/미지정, communityId=null, globalListed=true
domainCommunity.isHostedByhost userId→true / 타 userId→false
domainSportCategory(common)12종 값 유지(이전 후 위치만 변경, SportCategoryTest 이관)
applicationCreateCommunityPostUseCaseACTIVE 작성 성공 / 비멤버→403 / PENDING→403 / NOTICE 비host→403 / sportCategory 모임값 상속
applicationGetPostUseCase전역 통과 / PUBLIC 비멤버 통과 / PRIVATE 비멤버→403 / requesterId null+PRIVATE→403
applicationAddCommentUseCase전역 댓글(인가없음) / PRIVATE 멤버 댓글 / PRIVATE 비멤버→403
applicationListCommentsUseCasePUBLIC 비멤버 목록 / PRIVATE 비멤버→403
applicationSearchPostsUseCase종목필터=RUNNING → 전역+PUBLIC모임 러닝글 / PRIVATE모임글 제외 / 필터없음 전체(종목 null 포함) / 빈 결과 정상
applicationListCommunityPostsUseCasePUBLIC 비멤버 목록 / PRIVATE 멤버 목록 / PRIVATE 비멤버→403 / 0건 빈 목록
infrastructurePostCustomRepositoryImplcommunity_id·sport_category·global_listed 술어 조합 실 DB 검증 / global_listed=true가 PRIVATE 제외
presentationPostApiControllerPOST 모임/전역 분기, 403 매핑, GET 상세 X-User-Id optional
presentationCommunityPostApiControllerGET /communities/{id}/posts 403·빈목록
architecture (회귀 게이트)ArchUnit 전체R1 유지: AggregateAndUseCaseRulesTest·ContextMapBaselineTest — domain.post→domain.community import 0건. SportCategory→common 후 SharedKernelPurityRulesTest 통과(common이 core 무참조). DomainClassification.core에 community 포함(BE-50)
scenarioE2E가입→PRIVATE 모임 게시글 작성→멤버 열람→비멤버 403→종목검색

R1 회귀 보장(설계 근거): 신규 코드에서 domain/post/**의 어떤 파일도 com.sportsapp.domain.community.*를 import하지 않는다(SportCategory는 common). 교차 참조는 application/post/**에만 존재(슬라이스 규칙 비대상). 따라서 기존 R1 ArchUnit 2종이 그대로 GREEN을 유지한다 — 이 사실을 Testing Plan의 강제 게이트로 둔다.

Release Scenario — 무중단 배포 (의무)

expand-contract. 단일 모놀리스·단일 트랜잭션이라 이벤트/듀얼라이트 불요.

단계작업전환 조건롤백
1. 스키마 먼저postscommunity_id(NULL)·sport_category(NULL)·global_listed(TINYINT NOT NULL DEFAULT 1) 추가. nullable 2종은 단일 마이그레이션, global_listed는 기존행=전역=1이 정상값이라 DEFAULT로 단일 추가 안전(ALGORITHM=INPLACE,LOCK=NONE)마이그레이션 exit 0역방향 DDL로 3컬럼 DROP(코드 미배포라 안전)
2. SportCategory 공유커널 이전 + 코드 배포SportCategory→common relocation, Post 필드/팩토리, application 오케스트레이션, 신규 엔드포인트. 내부 리팩토링이라 단일 바이너리 원자 배포빌드+ArchUnit GREEN이전 이미지 태그로 compose 재기동
3. FR-8 문서·거버넌스DomainClassification.core += community(BE-50 공동), context-map 갱신, 설계원칙 문서ArchUnit·CI GREEN상수/문서 revert
  • 하위 호환: communityId null 경로는 기존 API·응답과 동일(FR-4). 기존 FE는 신규 필드 무시하면 무변경 동작. 응답에 communityId/sportCategory 추가는 additive(하위호환).
  • 피처 플래그 불요: 신규 필드·엔드포인트는 additive, 기존 경로 무변경이라 위험 창 없음. (원한다면 신규 엔드포인트를 @ConditionalOnProperty로 게이트 가능 — 지금은 불요로 판단)

Open Questions

  • 검색 가시성 한계(C-1 채택 결과): PRIVATE 모임 ACTIVE 멤버가 자기 모임 게시글을 전역 종목검색(GET /posts?sportCategory=)에서는 못 본다(전역 피드 제외). 대신 GET /communities/{id}/posts(인가 목록)에서 본다. “멤버는 전역 검색에서도 자기 private 게시글을 봐야 한다” 요구가 확정되면 C-2(publicCommunityIds+내 소속 id IN 필터)로 승격. PRD Open Questions “열람 인가는 현재 멤버십 상태로 판정”과 정합.
  • 댓글 인가 = 게시글 인가(전제 유지): FR-2/3을 댓글에 동일 적용(PRD Open Questions 전제 준수).
  • 탈퇴 후 열람: 게시글 유지, 열람 인가는 조회 시점 멤버십으로 재판정(PRD 기본값 준수) — 별도 설계 없음.

Document History

날짜변경 내용
2026-07-07최초 작성 — R1 배선(application 오케스트레이션, 옵션② 미채택 근거), SportCategory→common 이전, global_listed 비정규화, 17 BE 티켓(BE-20~36), FR-8 거버넌스는 공동 BE-50 참조