community는 완성된 바운디드 컨텍스트다(Entity 2·DomainService 12메서드·UseCase 10·Repository 3·이벤트 3·VO 4·테스트 22). post는 communityId·SportCategory가 0건인 전역 게시판이다(domain/post/entity/Post.kt#Post). 두 컨텍스트는 도메인 교차 import 0건이다. 이 과제는 새 도메인을 만들지 않고 두 기존 컨텍스트를 연동해 모임 소속 게시글·종목별 분류·문서/거버넌스 정합을 구현한다.
Overview
무엇을: post에 communityId(선택 소속)·sportCategory(선택 종목)를 추가하고, 모임 소속 게시글의 열람/작성/공지 인가를 community의 기존 멤버십·가시성 규칙으로 재사용한다. FR-8 문서·거버넌스 정합을 함께 처리한다.
왜: 완성된 community를 재사용해 모임 전용 소통·종목별 탐색을 열되, post 도메인을 오염시키지 않고 ArchUnit R1(도메인 교차 참조 0건)을 깨지 않는다.
어떻게 (핵심 3결정):
R1 배선 = application 레이어 오케스트레이션. post·community 인가 재사용은 application 레이어 UseCase가 PostDomainService+CommunityDomainService를 함께 호출한다. domain.post는 domain.community를 절대 import하지 않는다. (R3 화이트리스트 확장(옵션 ②)은 미채택 — 근거는 Possible Solutions.)
SportCategory를 domain.common.vo로 이전 (공유 커널). post가 종목 필드를 R1-합법으로 보유하기 위한 순수 위치 이전(값·의미 불변).
검색 가시성은 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
인가: PostApiController(presentation/post/controller/PostApiController.kt)·CommentApiController는 작성 시 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).
ContextMapBaselineTest.kt:31·AggregateAndUseCaseRulesTest.kt:27-37이 SlicesRuleDefinition.slices().matching("com.sportsapp.domain.(*)..").notDependOnEachOther()로 domain 하위 컨텍스트 간 import 0건을 강제한다(post·community 둘 다 core). 따라서:
두 규칙 모두 domain.common만 ignoreDependency. application 레이어는 이 슬라이스 규칙의 대상이 아니다(스캔 범위 com.sportsapp.domain.(*)) — 즉 application의 core→core 오케스트레이션은 R1 미규제.
SupportToCoreDependencyRulesTest(R3)는 notification/operator/weather/mcp/alerting만 스캔한다 — post·community는 core라 스캔 대상이 아니고, 화이트리스트(dashboard/partner)와 무관하다.
인가/상속 오케스트레이션은 application UseCase가 담당(domain.post↔domain.community import 0건 유지). community-측 인가는 CommunityDomainService의 기존 public API(requireActiveMember/getCommunity)만 사용 → CommunityDomainService.kt 무수정(B의 community↔booking 작업과 파일 충돌 회피).
검색 가시성은 posts.global_listed 비정규화 컬럼으로 전역 피드에서 PRIVATE 모임 게시글을 제외.
여러 컨텍스트가 공유하는 최소 모델 조각(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.user
R1 슬라이스는 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)가 동일 타입 참조
단순함 우선: CommunityDomainService·community_visibility 타입 컬럼 신설을 피하고, 기존 community public API + post 소유 boolean 1개로 P0/P1을 충족한다. C-2 승격은 “멤버의 private 게시글을 전역 검색에 노출” 요구가 실제로 잡힐 때.
Detail Design
시스템 역할 경계 (의무)
단위
역할
소유 데이터/책임
노출 인터페이스
의존
API 서버 (단일 모놀리스)
요청-응답 처리
전체
REST
— (§아키텍트 §2 “적합”: 워커·소켓·스케줄러 분리 불요. 인가는 동기 요청-응답이라 API 서버 1개로 충분)
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: Booleancompanion 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?): Postfun 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 규칙(모임 소속 시), 전역 노출 판정
헤더 optional. 전역 게시글 → 통과. PUBLIC 모임 → 통과. PRIVATE 모임 → requesterId=null이 requireActiveMember에서 비멤버 처리 → 403
존재하지 않는 community_id로 작성
getCommunity가 ResourceNotFoundException(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)
ACTIVE 작성 성공 / 비멤버→403 / PENDING→403 / NOTICE 비host→403 / sportCategory 모임값 상속
application
GetPostUseCase
전역 통과 / PUBLIC 비멤버 통과 / PRIVATE 비멤버→403 / requesterId null+PRIVATE→403
application
AddCommentUseCase
전역 댓글(인가없음) / PRIVATE 멤버 댓글 / PRIVATE 비멤버→403
application
ListCommentsUseCase
PUBLIC 비멤버 목록 / PRIVATE 비멤버→403
application
SearchPostsUseCase
종목필터=RUNNING → 전역+PUBLIC모임 러닝글 / PRIVATE모임글 제외 / 필터없음 전체(종목 null 포함) / 빈 결과 정상
application
ListCommunityPostsUseCase
PUBLIC 비멤버 목록 / PRIVATE 멤버 목록 / PRIVATE 비멤버→403 / 0건 빈 목록
infrastructure
PostCustomRepositoryImpl
community_id·sport_category·global_listed 술어 조합 실 DB 검증 / global_listed=true가 PRIVATE 제외
presentation
PostApiController
POST 모임/전역 분기, 403 매핑, GET 상세 X-User-Id optional
presentation
CommunityPostApiController
GET /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)
scenario
E2E
가입→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. 스키마 먼저
posts에 community_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 참조