사용자 정의 vs 코드 도메인 경계 갭 분석 아키텍처 문서
- 대상: sports-application (스포츠앱), 백엔드 단일 모듈 모놀리스
- 관점: 도메인 경계 적합성 (확장성·재사용성·통합)
- 작성: 2026-07-06
- 근거 입력:
docs/domain-context-map.md,스포츠앱/도메인 경계 재설계/{PRD,TDD}.md,스포츠앱/채팅 시스템/20260704-채팅시스템고도화-prd.md, 실제 코드(backend/src/main/kotlin/com/sportsapp/**) - 성격: 의사결정 문서 (오버엔지니어링 경계 = 1급 책임). 상세 TDD·티켓은 senior-be/dba로 이어집니다.
⚠️ 정정 고지 (2026-07-06 재진단, stale 트리 오판 정정)
이 문서의 초판·1차 재검증(§1 row 14·15, §1-1 cold 재검증 “모임” 행, §5, §7 P1, §10 즉시착수 1)은 origin/dev보다 뒤처진 stale 로컬 트리에서 수행돼 community를 “VO 4개만 있는 orphan(본체 미구축)“으로 오판정했습니다. 실제 현재 dev에는 community가 완성된 바운디드 컨텍스트로 존재합니다.
재진단 트리 신선도 근거 (분석 전 자체 검증, 필수 게이트):
| 검증 | 결과 |
|---|---|
git rev-list --left-right --count HEAD...origin/dev | 0 0 (HEAD == origin/dev) |
| HEAD | 5ff07c7c 2026-07-06 21:15 [BE-14] 채팅 응답 보강 |
git diff --stat HEAD -- backend | 변경 0 (backend 워킹트리 clean) |
| community 본체 도입 커밋 | eac1c925 커뮤니티 도메인(개설·가입·역할·승인) BE-08 — 초판이 놓친 커밋 |
stale 영향 범위: community 한 도메인에만 국한됩니다. 나머지 도메인(goods·ticketing·payment·booking·facility·user/partner·post)의 load-bearing 판정은 fresh tree에서 전부 재현됨(§AS-IS·§1 row 113·1618 유효). goods/booking/facility의 7월 커밋은 부하테스트 fix·지역 확장이라 도메인 경계 무변경, payment/ticketing/post는 6/4 패키지 마이그레이션 이후 무변경.
정정된 판정 요지 (상세는 §1 재정정 표·§10 재작성):
| 항목 | 초판(오판) | 재진단(정정) |
|---|---|---|
| community 본체 | VO 4개 orphan, Entity/Repo/Service 0 | 완성 컨텍스트 — Entity 2·DomainService(12메서드)·UseCase 10·Repository 3·이벤트 3·예외 6·VO 4·테스트 22 |
| 모임(폐쇄형) | 누락(스캐폴딩만) | 부분일치 — 멤버십·HOST/MEMBER 계층·PRIVATE 승인흐름·전용 채팅방 존재, 소모임 예약·모임 게시글 미연동 |
| 커뮤니티(개방형) | 부분일치(post+VO) | 부분일치 — PUBLIC 멤버십·SportCategory(종목별)·채팅 존재, 종목별 게시글·모집·체육관 예약선택 미연동 |
| 채팅↔community 연동 | 진행 중(VO만 예약) | 구현 완료 — CommunityChatIntegrationEventWorker(Layer 1 AFTER_COMMIT, 이벤트+ID 연결) |
| §10 즉시착수 1 (community 본체 설계) | 미착수 | 완료 — 실제 남은 즉시 착수로 재작성 |
아래 본문에서 오판정 행에는 [정정] 표기로 원문과 정정 내용을 병기합니다.
Background
사용자가 원본 요구사항으로 정의한 8개 도메인(유저·모임·커뮤니티·운동시설·상품·결제·주문 + 채팅)이 있고, 코드에는 19개 도메인 패키지가 있습니다. 두 목록은 명칭·경계가 어긋나 있어 “사용자가 1급 개념으로 본 것이 코드에는 어디에 있는가”를 정밀 대조했습니다.
이미 도메인 경계 재설계 TDD가 코드 기준 12개 도메인을 5계층으로 분류하고 ArchUnit 규칙(R1~R4)으로 교차 참조 0건을 강제하는 거버넌스를 확립했습니다. 이 문서는 그 위에서 “사용자 멘탈 모델과의 갭” 이라는 다른 축을 다룹니다 — 코드 내부 정합성이 아니라 제품이 정의한 개념과의 정합성입니다.
AS-IS 실측 (코드 근거)
| 코드 도메인 | Aggregate Root | 핵심 사실 (파일 근거) |
|---|---|---|
| goods | Product, Cart, GoodsOrder, LimitedDrop, Stock | Product.kt category = EQUIPMENT/APPAREL/FOOTWEAR/ACCESSORY, ownerId: Long만 보유 — 중고/브랜드 구분 없음. LimitedDrop.kt는 productId 참조하는 별도 회차 Aggregate (한정판) |
| ticketing | Event, TicketOrder, Seat, Ticket | Event.kt ownerId 보유, 좌석 재고 모델. TicketOrder.kt lockedSeatIds: List<Long> |
| booking | Booking, Slot | Slot.kt 소유자가 timeRange(HH:mm-HH:mm)·capacity로 1건씩 수동 생성. Booking.kt 상태 전이만, 취소 정책 객체 없음 |
| facility | Facility (MongoDB) | 공공데이터 임포트 카탈로그. ownerUserId 있음. 운영시간·휴무·자동 오픈/클로즈 필드 전무 |
| payment | Payment | OrderConfirmationGateway.kt confirm/cancel(orderType, orderId, paymentId) — OrderType = BOOKING/TICKETING/GOODS |
| post | Post, Comment | PostType.kt = FREE/NOTICE/QUESTION/REVIEW — RECRUITMENT 없음 |
| message | Room, Message, RoomParticipant | Room.kt contextType(COMMUNITY/GOODS_PRODUCT)·contextId 소프트 참조 + createForContext() |
| community | [정정] Community, CommunityMember (완성 컨텍스트) | Community.kt(name/description/visibility/sportCategory/hostUserId + 방장 위임·이벤트 적재), CommunityMember.kt(role/status/joinedAt, approve/kick/leave/rejoin/promote/demote 상태전이 캡슐화), CommunityDomainService.kt(create/join/approve/kick/leave/transfer + 조회 6종 = 12메서드), Repository 3(Community/CommunityMember/Custom), 이벤트 3(Created/MemberJoined/MemberLeft), 예외 6, VO 4(HOST/MEMBER·PUBLIC/PRIVATE·ACTIVE/PENDING_APPROVAL/LEFT/KICKED·SportCategory 12종). 채팅 연동 워커까지 구현 |
| user | User, Role, UserRole | UserRoleName = USER/ADMIN/FACILITY_OWNER/EVENT_HOST/GOODS_SELLER/OPERATIONS_MANAGER — b2c/b2b는 RBAC 롤로 표현 |
| partner | Partner, PartnerApiKey | Partner.kt linkedUserId — b2b API 연동은 User에 연결된 별도 신원 |
결정적 실측 3건:
- 모집(구인/구직) 개념 —
grep -rni "recruit|모집|applicant"결과 0건. 완전 부재. - 취소·환불 정책 —
Booking.refund()은refundAmount를 외부에서 받은 인자로만 사용(BookingDomainService.kt:174). “하루 전 무료·체육관별 상이” 정책 객체 없음, 하드코딩 상태 머신뿐. - [정정]
초판: community VO 4개는 채팅 TDD 첫 산출물, 도메인 본체는 미구현(진행 중)→ 정정: community 본체는 구현 완료.eac1c925 BE-08로 Entity·Service·Repository·이벤트가 들어왔고,a701840b BE-09로 컨텍스트 방 provisioning(CommunityChatIntegrationEventWorker)이 붙었으며,5ff07c7c BE-14로 컨텍스트 방 이름(CommunityCreatedEvent.name)까지 확장됨. RoomcontextType.COMMUNITY가 실제 소비됨(예약이 아니라 사용 중).
1. 도메인 일치 매트릭스 (사용자 정의 계층 vs 코드 배치)
범례: 일치 / 부분일치 / 경계불일치 / 누락 / 코드전용(사용자 정의에 없음)
판정 원칙 (재작성 계기): 이전 매트릭스는 “개념이 코드에 존재하는가”만 보고 티켓을 일치로 오판정했습니다. 사용자가 정의한 계층(상품 ⊃ 중고·브랜드·한정판·티켓·시설상품)이 경계의 SSOT이므로, “존재”가 아니라 “사용자 계층·경계와 코드 배치가 같은가” 로 판정합니다. 하위 유형이 상위(상품) 그룹핑 밖 독립 최상위에 놓이면 경계불일치, 상위 개념 자체가 코드에 없으면 별도 행으로 누락을 기록합니다.
각 판정에 근거 2종을 답니다 — ① 존재 위치(코드에 있는 곳) + ② 경계 일치 판단 근거(사용자 계층과의 배치 대조).
| # | 사용자 정의 개념 (계층) | 코드 위치 | 판정 | ① 존재 위치 (파일:라인) | ② 경계 일치 판단 근거 (파일:라인 / grep) |
|---|---|---|---|---|---|
| 1 | 유저 ⊃ b2c/b2b | user + partner | 부분일치 | domain/user/entity/User.kt, UserRoleName(USER/ADMIN/FACILITY_OWNER/EVENT_HOST/GOODS_SELLER/OPERATIONS_MANAGER), domain/partner/entity/Partner.kt(linkedUserId) | 유저 상위는 존재하나 b2c/b2b 하위 1급 타입 없음 — RBAC 롤로 대체. 사용자 계층의 “b2c/b2b” 구분이 계정 타입이 아닌 롤로 흡수됨(경계는 유지, 하위 판별만 부분) |
| 2 | 상품 (상위 개념 자체) | (없음) | 누락 | — | grep -rni "Sellable|abstract class Product|SellableItem|공통 Catalog" domain/ = 0건. goods·ticketing을 아우르는 공통 상위 타입/추상 부재 (domain/goods/gateway/ReservationResult.kt:13의 sealed interface는 예약 결과 타입일 뿐 상품 상위 아님) — 사용자의 “상품” 상위 개념이 코드에 앵커 없음 |
| 3 | 상품 ⊃ 중고(b2c) | goods.Product | 부분일치 | domain/goods/entity/Product.kt:20(Product), vo/ProductCategory.kt:3(EQUIPMENT/APPAREL/FOOTWEAR/ACCESSORY) | Product는 goods(=상품 그룹핑) 안에 있어 위치 경계는 정합. 그러나 Product.kt:20-42 생성자 필드에 중고/브랜드 판별 필드(condition·used·brand) 없음 — category는 물품종류만. 하위 구분 미표현 |
| 4 | 상품 ⊃ 브랜드(b2b) | goods.Product | 부분일치 | domain/goods/entity/Product.kt:20, Stock(재고) | 3번과 동일 aggregate. Product.kt:42 ownerId: Long만 보유 — b2b 판별은 소유자 롤(GOODS_SELLER)로만. 위치는 정합, 하위 판별 필드 없음 |
| 5 | 상품 ⊃ 한정판 | goods.LimitedDrop | 일치 | domain/goods/entity/LimitedDrop.kt:33(private constructor), LimitedDropStatus.kt, service/LimitedDropDomainService.kt | LimitedDrop.kt:35 val productId: Long — goods 산하에서 Product를 참조하는 회차 aggregate. 사용자 계층(상품⊃한정판)과 동일하게 goods(=상품) 그룹핑 내부에 배치. 경계 정합 |
| 6 | 상품 ⊃ 티켓 | ticketing (독립 최상위) | 경계불일치 | domain/ticketing/entity/Event.kt:19(Event), Ticket.kt, Seat.kt, TicketOrder.kt — 독립 최상위 패키지 domain/ticketing/ | grep "import com.sportsapp.domain.goods" domain/ticketing/ = 0건, 역방향 goods→ticketing = 0건. Event.kt:19 class Event가 Product 상속·참조 없이 ownerId(Event.kt:38) 자체 보유. 사용자 계층은 상품⊃티켓인데 코드는 티켓을 goods와 형제인 독립 최상위로 둠 → 계층 불일치 |
| 7 | 상품 ⊃ 시설상품(PT·클래스) | (없음) | 누락 | — | find domain/facility = program/lesson/PT/class 파일 0. grep -rni "program|lesson|pilates|personal.training" facility 실질 매치 0건(매치는 “application” 언급·data class 키워드뿐). PT/클래스 판매 개념 부재 |
| 8 | 주문 (단일) | goods.GoodsOrder + ticketing.TicketOrder + booking.Booking | 부분일치 | domain/goods/entity/GoodsOrder.kt, domain/ticketing/entity/TicketOrder.kt, domain/booking/entity/Booking.kt | 사용자는 주문을 단일 개념으로 보나 코드는 3분할. domain/payment/vo/OrderType.kt(BOOKING/TICKETING/GOODS) + OrderConfirmationGateway로 결제 접점만 통합. 상위 “주문” 경계는 없고 판매 타입별 분산 |
| 9 | 결제 | payment.Payment | 일치 | domain/payment/entity/Payment.kt, gateway/OrderConfirmationGateway.kt(confirm/cancel(orderType,orderId,paymentId)) | 사용자 정의 “결제” 단일 개념과 코드 payment 단일 컨텍스트가 1:1. 3개 판매 도메인의 공급자 허브·ACL 역콜백. 경계 정합 |
| 10 | 운동시설 등록 | facility.Facility (MongoDB) | 일치 | domain/facility/entity/Facility.kt:22(Facility), service/PublicFacilityImportService.kt, FacilityOwnerDomainService.kt | Facility.kt:82-86 assignOwner/isOwnedBy(ownerUserId). 등록·소유자 지정 개념이 facility에 정합 배치 |
| 11 | 시설 타임슬롯 예약 | booking.Slot + Booking | 부분일치 | domain/booking/entity/Slot.kt:16(private constructor), Booking.kt | Slot.kt:24 timeRange: String(HH:mm-HH:mm)·capacity(Slot.kt:38)로 수동 1건 생성. 예약 개념은 존재하나 사용자가 정의한 “타임슬롯” 자동 생성 흐름 미비 |
| 12 | 시설 자동 오픈/클로즈·휴무·수동 제어 | (없음) | 누락 | — | Facility.kt:22-58 생성자 필드 = code/name/gu/type/address/location/parking/tel/homePage/eduYn/meta/ownerUserId/sido·sigungu. operatingHours·holiday·openTime/closeTime 필드 전무. 스케줄러 없음 |
| 13 | 체육관 취소 정책(하루 전 무료·시설별 상이) | (없음) | 누락 | — | Booking.refund()이 refundAmount를 외부 인자로만 수령(domain/booking/service/BookingDomainService.kt:174). CancellationPolicy 정책 객체·시간 기준·수수료율 부재 |
| 14 | 모임 (모임장·유저 계층, 소모임 예약, 모임 게시글) | community (PRIVATE) | [정정] 부분일치 ( | domain/community/entity/Community.kt:42(hostUserId)·CommunityMember.kt:42(role), CommunityRole.kt(HOST/MEMBER), CommunityVisibility.PRIVATE(승인 후 가입), CommunityChatIntegrationEventWorker.kt:45-100(전용 방 자동 provision/join/leave) | [정정]: 모임장·유저 계층(HOST/MEMBER) ✅, 유저·그룹 채팅(자동 전용 방) ✅, PRIVATE=폐쇄형 승인흐름 ✅ 존재. Community.kt:68 transferHostTo로 방장 위임까지. 남은 갭: 소모임 예약(booking 연동)·모임 게시글(post 연동) 미연결. 모임장 중간 계층(MANAGER)은 HOST/MEMBER 2계층뿐이나 사용자가 “확장 가능”으로 명시 → YAGNI 정당(누락 아님) |
| 15 | 커뮤니티 (종목별 게시글, 모집, 체육관 예약 선택) | community (PUBLIC) + post | 부분일치 (판정 유지, 근거 정정) | domain/community/entity/Community.kt:34-40(visibility·sportCategory), SportCategory.kt(12종목), CommunityVisibility.PUBLIC(즉시 가입), domain/post/vo/PostType.kt(FREE/NOTICE/QUESTION/REVIEW) | [정정]: 개방형 멤버십(PUBLIC)·종목별 분류(SportCategory 12종) ✅, 검색(findPublicByKeyword) ✅ 존재. 남은 갭: 종목별 게시글 연동(post ID참조 없음)·모집(recruitment 부재)·체육관 예약선택(booking 연동 없음) 미구현. post는 범용 게시판이라 community↔post 결합 아직 0 |
| 16 | 커뮤니티 모집(구인/구직, 정원, 취소 10% 수수료) | (없음) | 누락 | — | grep -rni "recruit|모집|applicant" domain/ 실질 매치 0건(매치는 “application” 레이어·ApplicationEvent뿐). recruitment/신청/정원 개념 전무 |
| 17 | 유저간/그룹 채팅 | message.Room/Message | 일치 (근거 강화) | domain/message/vo/RoomContextType.kt(COMMUNITY/GOODS_PRODUCT), application/message/usecase/{Provision,Join,Leave}ContextRoomUseCase.kt | [정정]: 초판 “실시간 STOMP 확장 진행 중”은 stale. 현재 message가 범용 컨텍스트 방 메커니즘(Provision/Join/Leave UseCase + RoomContextType)을 제공하고, community가 이를 이벤트 기반(Layer 1)으로 재사용 — 채팅 백본이 실제로 2개 도메인(community 이벤트연동·goods GOODS_PRODUCT)에 재사용됨. 사용자 정의 “모임·그룹 채팅”과 정합 |
| 18 | — (사용자 정의에 없음) | mcp, alerting, featureflag, featuredemo, operator, weather, airquality, notification | 코드전용 | domain/{mcp,alerting,featureflag,featuredemo,operator,weather,airquality,notification} | 사용자 8개 정의 도메인에 없는 운영·플랫폼·부가 도메인. 인프라·운영 관심사라 정상(경계 판정 대상 아님) |
판정 기준: 사용자 정의 계층(상품 ⊃ 중고·브랜드·한정판·티켓·시설상품)이 SSOT이며, 개념 존재가 아니라 경계·계층 일치로만 일치로 판정한다.
재집계 [정정, fresh tree]: 일치 4(한정판·결제·운동시설등록·채팅) / 부분일치 7(유저·중고·브랜드·주문·시설타임슬롯예약·모임·커뮤니티) / 경계불일치 1(티켓) / 누락 5(상품상위·시설상품·시설운영시간·취소정책·모집) / 코드전용 1(그룹행: 8개 운영 도메인).
초판: 부분일치 6, 누락 6(…·모임·모집)→ 정정: 모임이누락(스캐폴딩만)→부분일치로 이동해 부분일치 6→7, 누락 6→5. 모집(recruitment)만 여전히 완전 누락(grep -rli "recruit|모집|구인|구직" domain/= 0, fresh tree 재확인).
핵심 갭 2종:
- 경계불일치 — 티켓: 사용자는 상품의 하위 유형으로 보나 코드는
ticketing을 goods와 형제인 독립 최상위 도메인으로 둠(goods↔ticketing import 0건). 이전 매트릭스가 “티켓이 코드에 존재한다”만 보고일치로 오판정한 지점을 바로잡음. - 누락 — “상품” 상위 개념 자체: goods·ticketing·(부재하는)시설상품을 아우르는 공유 상위 추상(Sellable/공통 카탈로그)이 grep 0건. 사용자 멘탈 모델의 최상위 그룹핑이 코드에 앵커 없음. 단 §4 문제A 판정대로 통합 aggregate 신설은 미채택(라이프사이클 이질) — 통합축은 payment.OrderType이며, 이 “누락”은 결함이 아니라 의도된 분산의 부산물로 해석함.
1-1. Cold 재검증 (2026-07-06, 독립 스폰 — 존재≠경계정합 회귀 확인)
답을 흘리지 않은 cold 스폰이 §1 매트릭스의 load-bearing 판정을 코드로 직접 재검증했습니다. 모든 판정이 재현됨 — 특히 티켓이 일치가 아니라 경계불일치로 재확인되어 “존재=일치” 회귀가 없음을 검증했습니다.
| 판정 | 재검증 명령 / 근거 | 결과 |
|---|---|---|
티켓 경계불일치 (독립 최상위) | grep "import com.sportsapp.domain.goods" domain/ticketing/ = 0, 역방향 = 0. Event.kt:19-39 Product 상속·참조 없이 ownerId 자체 보유 | 재현 — goods와 형제 독립 도메인 확정 |
한정판 일치 (goods 하위) | LimitedDrop.kt:35 val productId: Long, 패키지 domain/goods/entity/ | 재현 — 상품(goods) 그룹핑 내부 정합 |
중고/브랜드 부분일치 | Product.kt:20-42 필드 = name/category/price/description/imageUrl/status/ownerId. condition·used·brand 판별 필드 없음, ProductCategory는 물품종류(EQUIPMENT/APPAREL/…)만 | 재현 — 위치 정합, 하위 판별 필드 없음 |
상품 상위 개념 누락 | grep -rni "sellable|abstract class Product|SellableItem" domain/ = 0 | 재현 |
시설상품(PT·클래스) 누락 | grep -rni "program|lesson|pilates|수업|클래스" domain/facility/ = 0 | 재현 |
시설 운영시간·휴무 누락 | grep -rni "operatingHour|openTime|closeTime|holiday|휴무" domain/facility/ = 0 | 재현 |
모집(구인/구직) 누락 | grep -rni "recruit|모집|구인|구직" domain/ (application 제외) = 0 | 재현 |
취소 정책 누락 | BookingDomainService.kt:174 refundBooking(..., refundAmount: BigDecimal, ...) 외부 인자 수령. CancellationPolicy 0건 | 재현 |
누락(스캐폴딩만)부분일치 | stale 오판. fresh tree 재실측: group/gathering/meetup 별도 패키지 0개(모임 전용 도메인은 없음, 이 부분만 재현)이나 community 본체 존재 — find domain/community -type f = 19파일(entity 2·service 1·repository 3·event 3·exception 6·vo 4), CommunityChatIntegrationEventWorker.kt 외부 연동 있음(orphan 아님) | 정정 — 모임은 별도 도메인이 아니라 community(PRIVATE)로 표현됨. “orphan/본체 0” 판정 철회 |
| OrderType 3분기 (확장 마찰) | OrderConfirmationGatewayImpl.kt:18-29 when(orderType) × 2(confirm/cancel), OrderType=BOOKING/TICKETING/GOODS | 재현 — 타입 추가 시 선형 증가 확인 |
재검증 결론: §1 매트릭스는 사용자 정의 계층(상품 ⊃ 중고·브랜드·한정판·티켓·시설상품)을 SSOT로 정확히 판정함. “패키지가 존재하니 일치”라는 이전 오판 패턴이 재발하지 않음.
⚠️ 단, 위 표 자체가 stale 트리 기반이라 “모임” 행은 오판이었음 (2026-07-06 재진단에서 정정). fresh tree(HEAD=5ff07c7c)에서 community 본체가 확인돼 모임을
부분일치로 정정함. 티켓/상품상위/시설상품/시설운영시간/모집/취소정책 등 community 외 판정은 fresh tree에서 전부 재현됨(§정정 고지 stale 영향 범위 참조). 이 재검증 표는 community 오판을 걸러내지 못한 것이 한계 — cold 스폰도 같은 stale 트리를 봤기 때문. 트리 신선도 게이트를 재검증 앞단에 두는 것이 재발 방지책(§10 반영).
2. 트래픽↔구조 적합성 진단 (3단계 verdict)
개인 프로젝트로 실측 트래픽은 없습니다. 추정 전제(사용자 규모·기능 특성 기반, “추정” 명시):
| 지표 | 추정 | 근거 |
|---|---|---|
| 상시 RPS | 10 미만 | 개인 프로젝트, 단일 브로커 로컬 docker compose |
| 마케팅/한정판 피크 | 수백 RPS 순간 | 마케팅 이벤트 고부하 대응 PRD 존재 = LimitedDrop 오버셀 대응이 이미 설계됨 |
| 데이터 증가율 | 낮음 | 거래·게시글 소량 |
전체 verdict: 적합 (과잉 아님, 부족 아님)
- 상시 RPS <10에 단일 모놀리스 + 단일 MySQL + MongoDB + 단일 Redis + 단일 Kafka 브로커는 정확히 규모에 맞음. 모놀리스 분리·다중 인스턴스·읽기 복제는 지금 과잉(조기 도입).
- 단 한정판/마케팅 피크(수백 RPS 순간, 재고 경합)는 특정 컨텍스트에서 임계점 근접 — 이 부분만 이미 Redis 락·예약 스토어로 선제 대응됨(§3 캐싱 참조).
컨텍스트별 판정 (부하 특성이 갈림)
3단계 = 지금은 충분(적합) / 임계점 근접 / 이미 초과.
| 컨텍스트 | 부하 특성 | 판정 | 근거 수치·코드 | 다음 단계 임계 조건 |
|---|---|---|---|---|
| ticketing (티켓·좌석 오픈) | 순간 스파이크 + 좌석 경합 | 임계점 근접 | 피크 수백 RPS 추정, SeatLockStoreImpl.kt(Redis 좌석 락) 이미 존재 | 락 경합으로 P95 예약 지연 >1s 관측 시 → 재고/좌석 서비스 분리·큐 버퍼링 |
| goods (한정판 드롭) | 오픈 순간 스파이크 + 재고 차감 경합 | 임계점 근접 | DropReservationStoreImpl.kt·PopularProductsRedisRepository.kt·LimitedDrop 오버셀 감지 이미 존재 | 재고 차감 경합으로 오버셀 이벤트 빈발 시 → 재고 원장 분리 |
| payment (결제 확정) | 판매 스파이크 동반 + 외부 PG 지연 | 임계점 근접(동반) | 판매 3종의 공급자, PG 왕복이 지배적 | PG 타임아웃 누적·확정 콜백 적체 시 → 결제 워커 분리·비동기 확정 |
| booking / facility | 낮은 상시, 스파이크 없음 | 적합(여유) | 슬롯 수동 생성, 예약 소량 | 시설 전국 확장으로 슬롯 조회 P95 >500ms 시 → 조회 캐싱 |
| community / message / post | 낮은 상시, 팬아웃형 쓰기 | 적합(여유) | 채팅 STOMP 세션, 상시 소량 | 동시 소켓 세션 수천 초과 시 → 소켓 계층 수평 확장 + Redis pub/sub 브로커 릴레이 |
| notification / dashboard / weather / airquality | 비동기 소비·읽기 집계 | 적합(여유) | Kafka 소비·읽기 조합 | 대시보드 집계 쿼리 P95 >2s 시 → 사전 집계 읽기 모델 |
→ 구조 판정의 기본 자세: 상시 부하 기준 현행 구조는 적합하며, 통합/분리 대공사는 대부분 조기 도입입니다. 유일하게 관측할 지점은 ticketing/payment/goods의 피크 경합이고, 이는 이미 Redis 락·예약 스토어로 1차 방어됨.
3. 인프라 구조적 해결책 판정 (지금 불필요 + 도입 트리거)
각 패턴을 “지금 판정 / 도입 트리거(측정 가능 조건) / 미도입 리스크”로 명시합니다. 선제 도입은 오버엔지니어링으로 경계합니다. Redis·Kafka는 compose에 이미 있어 코드 실사용처를 grep 실측해 “도입됨/부분/확장 여지”로 구분했습니다.
| 패턴 | 지금 판정 | 실측 사용처 (파일 근거) | 도입/승격 트리거 | 미도입 리스크 |
|---|---|---|---|---|
| 캐싱 (Redis) | 이미 도입됨 | look-aside: PopularProductsRedisRepository.kt·AirQualityRedisCache.kt·CacheConfig.kt. 락: RedisDistributedLock.kt·SeatLockStoreImpl.kt. 예약: DropReservationStoreImpl.kt. 세션/토큰: RedisJwtBlacklistStore.kt·RedisRefreshTokenRepository.kt. 플래그: RedisFeatureFlagCacheStore.kt. 쿨다운: AlertCooldownRepositoryImpl.kt | 확장 여지: booking 슬롯 조회 캐싱(전국 확장 시), dashboard 사전 집계 | 해당 없음 — 핵심 경합 지점은 이미 커버 |
| 큐 버퍼링 (Kafka) | 이미 도입됨(Layer 2) | 발행 KafkaDomainEventPublisher.kt, 소비 NotificationEventWorker.kt, 설정 KafkaProducerConfig.kt. payment/booking/ticketing → notification 비동기 | 확장 여지: 티켓 오픈 시 예약 요청을 큐로 버퍼링(선착순 완화) | 피크 시 동기 처리 적체 — 단 상시 부하선 문제 없음 |
| 로드밸런싱 / 수평 확장 | 지금 불필요 | 단일 인스턴스 (docker compose) | 단일 인스턴스 CPU >70% 지속 또는 P95 SLA 초과 관측 | 없음 — 상시 RPS <10 |
| API 게이트웨이 | 지금 불필요 | Spring Security 필터가 인증·라우팅 담당, 단일 앱 | 앱이 2개 이상 배포 단위로 쪼개져 공통 인증·라우팅·rate-limit 필요 시 | 없음 — 단일 앱에 게이트웨이는 홉 추가만 |
| BFF (Backend for Frontend) | 지금 불필요 | web/app이 동일 REST 소비 (공용 API·useChatSocket 등) | web·app 응답 형태가 크게 갈려 조합 로직 중복 심화 시 | 없음 — 현재 응답 형태 공유로 충분 |
| CQRS / 읽기 모델 분리 | 부분 도입(읽기 조합) | dashboard가 읽기 전용 조합(Conformist). 쓰기와 분리된 별도 저장소는 아직 없음 | 집계 쿼리 P95 >2s 또는 쓰기 경합 시 → 사전 집계 읽기 모델(§6 catalog/order-query) | 없음 — 집계량 소량 |
| MSA 물리 분리 | 지금 불필요 (명시 미채택) | 모놀리스 유지 (도메인 경계 재설계 PRD Non-Goals) | ticketing/payment 피크 부하가 측정상 모놀리스 임계 초과 시에만 (§7 P6) | 없음 — 조기 분리는 분산 트랜잭션·운영 비용만 유발 |
핵심: 필요한 인프라(캐싱·큐·락)는 경합 지점에 이미 정확히 도입돼 있고, 나머지(LB·게이트웨이·BFF·MSA)는 상시 부하선에서 전부 불필요합니다. 도입 트리거는 모두 측정 가능한 관측치로 걸어 두었습니다.
4. 구조적 해결책 — 도메인 통합/분리 문제별 후보 비교
문제 A. 상품이 5종으로 분산 (중고/브랜드/한정판/티켓/시설상품)
사용자는 상품을 단일 상위 개념으로 봅니다. 코드는 goods(물품+한정판)·ticketing(티켓)에 흩어져 있고 시설상품은 없습니다.
| 후보 | 설명 | 처리량/복잡도 | 판정 |
|---|---|---|---|
| A-1 단일 Product 아그리게잇 통합 | Sellable 하나로 5종 흡수, 타입별 필드 다형화 | 라이프사이클 이질성(중고=단건 C2C, 브랜드=SKU 재고, 티켓=좌석재고+공연, 한정판=시간게이트, 시설상품=슬롯)을 한 aggregate에 밀어넣어 God-aggregate·다형 필드 폭증 | 미채택 — 통합 이득(공통 CRUD) < 이질성 비용. 오버엔지니어링 |
| A-2 현행 분산 유지 + 판매 접점만 통합 | goods/ticketing 쓰기 모델 유지, payment OrderType·OrderConfirmationGateway가 이미 결제 접점 통합 | 추가 비용 0, 이미 동작 | 채택(현행 유지) — 결제라는 진짜 공통 지점은 이미 ACL로 통합됨 |
| A-3 통합 Catalog 읽기 모델(조회 파사드) | 교차 타입 검색·통합 피드가 실제로 필요해질 때 dashboard처럼 읽기 전용 조합 컨텍스트 신설 | 읽기 조합만, 쓰기 소유권 불변 | 조건부 채택 — 통합 상품 피드/검색 화면이 스펙에 잡히면 |
| A-4 시설상품(PT·클래스) 신규 모델링 | facility 산하 program(수업 회차) 또는 신규 컨텍스트 | 신규 기능, goods 재사용 부적합(슬롯·정원 기반) | 조건부 채택 — 시설상품 FR이 잡히면. goods에 넣지 말 것 |
결론: 상품 통합 아그리게잇은 만들지 않습니다. “상품”은 사용자 멘탈의 상위 개념일 뿐, 코드에선 결제 접점(OrderType)이 이미 통합축입니다. 시설상품은 goods가 아니라 facility/booking 계열에 새로 모델링 — 슬롯·정원·스케줄이 본질이라 물품 커머스와 다릅니다.
경쟁사 비교:
- 당근마켓 — 중고(C2C)와 비즈프로필 상품(B2C)을 별도 도메인으로 유지, 통합은 검색/피드 읽기 레이어에서만. A-2/A-3 근거. (당근 채팅/거래 구조)
- 쿠팡/네이버 스마트스토어 — 상품(카탈로그)과 예약형 상품(로컬/클래스)을 서로 다른 판매 모델로 분리 운영. 단일 aggregate 통합 안 함.
문제 B. 주문이 3분할 (GoodsOrder / TicketOrder / Booking)
| 후보 | 설명 | 판정 |
|---|---|---|
| B-1 단일 Order 아그리게잇 통합 | 3종을 다형 line-item 하나로 | 미채택 — 아이템 구조 이질(품목 리스트 vs 좌석락 vs 단일 슬롯). 다형 라인아이템 = 복잡도 폭증, 지금 규모에 과함 |
| B-2 현행 3분할 + OrderType 접점 유지 | 이미 payment가 OrderType + OrderConfirmationGateway로 확정/취소 통합 | 채택(현행 유지) — 결제/확정이라는 공통 관심사는 이미 통합됨 |
| B-3 통합 “내 주문” 조회 파사드 | 유저의 주문 이력 교차 조회가 필요할 때 읽기 전용 Query 컨텍스트(dashboard 패턴) | 조건부 채택 — 통합 주문 이력 화면이 잡히면. 쓰기 aggregate 아님 |
결론: 통합 Order 쓰기 aggregate는 미채택. 통합이 진짜 필요해지는 트리거는 주문 레벨 횡단 관심사(통합 환불 정책, 쿠폰/프로모션이 타입 교차 적용, 통합 주문 상태 추적)가 스펙에 등장할 때입니다. 그전까지 B-2 유지가 옳습니다.
경쟁사 비교:
- Shopify — 물리 상품/디지털/구독을 Order 하위 line-item 다형으로 통합하지만, 이는 대규모·단일 커머스 도메인 전제. 개인 프로젝트 규모엔 부적용(미참고). (Shopify 모놀리스)
- Uber Eats/배민 — 주문(음식)과 예약은 별도 도메인 유지, 결제만 공유. B-2 근거.
문제 C. 모임/커뮤니티/모집 신규 컨텍스트
사용자는 모임(폐쇄형 소모임: 모임장·유저 계층, 소모임 예약, 모임 내 게시글) 과 커뮤니티(개방형: 종목별 게시글, 모집, 체육관 예약 선택) 를 구분합니다. 채팅 고도화 PRD는 둘을 “커뮤니티(동아리·모임)” 하나로 합쳐 진행 중(VO만 존재).
| 후보 | 설명 | 판정 |
|---|---|---|
| C-1 모임·커뮤니티를 2개 컨텍스트로 분리 | 폐쇄형·개방형을 별도 aggregate | 미채택(현 단계) — 멤버십·역할(HOST/MEMBER)·채팅 연동이 동일. 조기 분리 = 중복. Visibility(PUBLIC/PRIVATE)로 한 컨텍스트에서 구분 가능 |
| C-2 단일 community 컨텍스트 (Visibility로 개방/폐쇄 구분) | 채팅 PRD 노선. Community + CommunityMember aggregate, Room contextType 연동 | 채택 — VO·Room enum 이미 예약됨. 진행 중 로드맵과 정합 |
| C-3 recruitment(모집)을 별도 컨텍스트 | 모집 게시글·신청·정원·취소 10% 수수료를 Recruitment+Application aggregate로. post/community ID 참조, payment로 수수료 | 채택(후속 단계) — post(범용 콘텐츠)·community(멤버십)와 라이프사이클 다름. payment 수수료 접점 필요 |
모임장·유저 중간 계층 확장: 현재 CommunityRole은 HOST/MEMBER 2계층. 사용자는 “중간 계층 확장 가능”이라 했으나 지금 MANAGER 계층 선제 도입은 YAGNI — enum 확장은 저비용이므로 실제 운영 위임 요구가 생길 때 추가. 현행 2계층 유지가 옳습니다.
모집을 어디에 둘 것인가: post에 넣으면(RECRUITMENT PostType 추가) 게시판이 정원·신청·수수료·상태 전이를 떠안아 오염됩니다. recruitment는 결제(수수료)·정원 상태 머신·신청 라이프사이클을 소유하는 독립 컨텍스트가 맞습니다. post/community는 ID로만 참조.
경쟁사 비교:
- 네이버 밴드 — 모임(밴드) 단위 멤버십 + 전용 그룹채팅 1:1 종속. C-2 근거(채팅 PRD FR-4와 동일 패턴). (밴드)
- 소모임/프립 — 모임(그룹)과 모집(신청·정원·환불 수수료)을 별도 개념으로 운영. 신청 취소 수수료 정책 존재. C-3 근거.
문제 D. 체육관 취소 정책 확장성 (하드코딩 → 정책 객체)
현재 Booking.refund(refundAmount)은 금액을 외부 인자로 받고, cancel()은 상태 전이만 검사. “하루 전 무료·시설별 상이”가 코드에 없습니다.
| 후보 | 설명 | 판정 |
|---|---|---|
| D-1 지금 취소 정책 엔진 구축 | CancellationPolicy 전략 객체 + 시설별 설정 | 미채택(지금) — 취소 수수료 기능 자체가 아직 스펙에 없음. 소비처 없는 정책 엔진 = 오버엔지니어링 |
| D-2 기능 도입 시 정책을 VO/전략으로 | 취소 FR이 잡히면 CancellationPolicy(취소 가능 시점·수수료율)를 facility가 소유, booking이 조회해 환불액 계산 | 채택(설계 원칙 예약) — 전략 패턴. refundAmount 외부 주입을 정책 계산으로 대체. 시설별 상이 = 시설이 정책 소유 |
결론: 지금 만들지 않되, 설계 원칙을 못박습니다 — 취소/환불 수수료가 스펙에 들어오는 순간 금액을 인자로 받지 말고 CancellationPolicy 전략 객체로 계산하며, 정책 소유권은 facility(체육관별 상이)에 둡니다. 모집 취소 10% 수수료도 같은 패턴을 recruitment 컨텍스트에서 재사용.
문제 E. 시설 운영시간·자동 슬롯·휴무 (누락)
Slot이 수동 1건 생성이라 “실내 시설 자동 오픈/클로즈 + 휴무 + 수동 오픈/클로즈”가 불가.
| 후보 | 판정 |
|---|---|
| E-1 facility에 운영시간(OperatingHours)·휴무(Holiday) VO 추가 + 스케줄러가 Slot 자동 생성 | 채택(후속) — 운영시간은 시설 속성(facility 소유), 슬롯 생성은 booking. 수동 오픈/클로즈는 Slot 상태 필드 |
지금은 갭으로 기록만. 시설 예약 고도화 FR이 잡힐 때 착수.
문제 F. b2c/b2b 유저 표현
현재 RBAC 롤(GOODS_SELLER/EVENT_HOST/FACILITY_OWNER) + Partner(API 연동 신원). 별도 계정 타입 없음.
| 후보 | 판정 |
|---|---|
| F-1 b2c/b2b 계정 타입 1급 분리 | 미채택 — 한 유저가 중고 판매(b2c)도 하고 브랜드 판매(b2b)도 할 수 있음. 타입 분리는 이 다중성을 깨뜨림 |
| F-2 현행 RBAC 롤 + Partner 유지 | 채택 — 롤은 능력(capability) 부여라 다중 역할 자연 지원. b2b API 통합만 Partner로 분리(적절). 소유권은 userId 네임스페이스 일원화 |
결론: 현행이 확장상 옳습니다. b2c/b2b는 “계정 종류”가 아니라 “유저가 가진 롤 + (선택) Partner 연동”으로 표현하는 것이 다중 역할·점진 승격에 유리.
5. 바운디드 컨텍스트 재설계 제안 (현재 → 제안)
| 현재 경계 | 제안 경계 | 근거(변경 주기·소유·라이프사이클) |
|---|---|---|
| [정정] community 컨텍스트 (본체 구축 완료) | 본체 유지 + 콘텐츠·모집·예약 연동 추가 | |
| 모집 개념 없음 | recruitment 신규 컨텍스트 (Recruitment + Application) | 정원 상태 머신·신청 라이프사이클·취소 수수료(payment)를 소유. post/community와 다른 변경 주기 |
| 상품 5종 분산 | 분산 유지 + (조건부) catalog 읽기 파사드 | 라이프사이클 이질. 통합축은 이미 payment.OrderType |
| 시설상품(PT·클래스) 없음 | facility 산하 program 개념 (신규 시) | 슬롯·정원 기반 — goods 아님 |
| 주문 3분할 | 분산 유지 + (조건부) 통합 주문 조회 파사드 | 아이템 구조 이질. 결제 접점만 통합 |
| 취소 정책 하드코딩 | CancellationPolicy 전략(facility 소유) — 기능 도입 시 | 체육관별 상이 = 시설이 정책 소유 |
| user/partner | 현행 유지 | RBAC + Partner가 b2c/b2b 다중성에 적합 |
| post (범용) | 현행 유지 — 모임 게시글은 community가 ID 참조 | post 오염 방지 |
계층 재분류 (기존 도메인 경계 재설계 TDD 5분류에 신규 편입)
| 분류 | 도메인 | 신규/변경 |
|---|---|---|
| Core(거래) | booking, facility, goods, payment, ticketing, user, post, message, community | community를 Core로 편입(사용자 대상 핵심 자산·멤버십 소유). [정정] community 본체는 구현 완료 — 단 DomainClassification.core 상수에는 미등록(§10 즉시착수 2) |
| Core(거래, 후속) | recruitment | 모집·신청 aggregate. payment Customer 관계 |
| Supporting | notification, operator, weather, alerting | 불변 |
| Subsystem | mcp | 불변 |
| Shared Kernel | common | 불변 |
| Read/Utility | dashboard, image, (조건부) catalog, order-query | 통합 조회 파사드는 여기로 |
6. 사용자 정의 도메인 앵커 재정렬 (TO-BE)
사용자가 원본으로 정의한 8개 도메인(유저 / 모임 / 커뮤니티 / 운동시설 / 상품 / 결제 / 주문 / 채팅)을 최상위 앵커로 두고, 그 아래 현재 코드 도메인이 어떻게 매핑·편입되는지 정렬합니다. 목표 기준은 확장·재사용·통합입니다.
사용자 도메인 → 코드 도메인 매핑
| 사용자 도메인 (앵커) | 편입되는 코드 도메인 | 통합/재사용 판정 | 확장 여지 |
|---|---|---|---|
| 유저 | user (신원·RBAC) + partner (b2b API 연동) | 통합 유지 — b2c/b2b는 롤 조합으로 표현, 계정 타입 분리 안 함 | 신규 롤 enum 추가로 확장 (저비용) |
| 모임 (폐쇄형) | community (Visibility=PRIVATE) + message(전용 방) + post(모임 게시글, ID참조) | community 한 컨텍스트로 개방/폐쇄 겸용 — 조기 2분할 안 함 | HOST/MEMBER → MANAGER 중간 계층은 필요 시 enum 확장 |
| 커뮤니티 (개방형) | community (Visibility=PUBLIC) + post(종목별 게시글) + recruitment(모집) + booking(체육관 예약 선택) | 모임과 같은 community 컨텍스트, 모집만 recruitment로 분리 | 종목 카테고리(SportCategory) 이미 VO 존재 |
| 운동시설 | facility (마스터) + booking (슬롯·예약) | 통합 유지 — 시설 속성/예약 라이프사이클 분리가 옳음 | OperatingHours·Holiday VO(facility), 자동 슬롯 생성(booking) |
| 상품 | goods(중고·브랜드·한정판) + ticketing(티켓) + facility program(시설상품, 신규) | 통합 aggregate 안 함 — 통합축은 payment.OrderType. 필요 시 catalog 읽기 파사드만 | 시설상품(PT·클래스)은 facility 계열 신규 |
| 주문 | goods.GoodsOrder + ticketing.TicketOrder + booking.Booking | 통합 aggregate 안 함 — 확정/취소는 OrderConfirmationGateway로 이미 통합. 필요 시 order-query 읽기 파사드 | 통합 환불 정책 등장 시 order-query |
| 결제 | payment | 그대로 (공급자 허브, ACL 역콜백) | — |
| 채팅 | message (Room contextType/contextId) | 범용 확장 메커니즘 재사용 — community 1번째, goods 2번째 연동 | booking·ticketing 연동은 후속 |
TO-BE 컨텍스트 맵 (사용자 도메인 앵커, Mermaid LR)
flowchart LR subgraph 유저["유저 (user·partner)"] user end subgraph 모임커뮤["모임·커뮤니티 (community)"] community recruitment end subgraph 채팅콘텐츠["채팅·콘텐츠 (message·post)"] message post end subgraph 상품주문["상품·주문 (분산 유지)"] goods ticketing booking end subgraph 시설["운동시설 (facility)"] facility end subgraph 결제["결제 (payment)"] payment end goods -->|OrderType C/S| payment ticketing -->|OrderType C/S| payment booking -->|OrderType C/S| payment recruitment -->|취소수수료 C/S| payment facility -->|SlotQuery ACL| booking community -->|contextId 소프트참조| message community -->|게시글 ID참조| post recruitment -->|모집글 ID참조| post user -->|소유 userId 네임스페이스| 상품주문
- 실선: 동기(app 오케스트레이션/Gateway ACL). 점선/ID참조: 도메인 교차 import 금지(ArchUnit R1 유지, ID만).
- 통합 없이 재사용: 결제(OrderType)·채팅(contextType)·게시글(ID참조)이 여러 사용자 도메인에 재사용되는 축. 쓰기 aggregate 통합은 하지 않고 이 재사용 접점만 유지·확장.
- catalog·order-query 읽기 파사드는 조건부(트리거 도달 시)라 이 맵에서 생략.
7. 장기 도메인 진화 계획 (사용자 정의 도메인 기준, 단계 + 트리거)
각 단계를 사용자 정의 도메인에 앵커링합니다 (진화 수준: 모듈 내 도메인 추가 → 서비스 분리 → DB 분리).
| 단계 | 사용자 도메인 앵커 | 작업 | 진화 수준 | 트리거 조건 | 선행 조건 |
|---|---|---|---|---|---|
| P0 (완료) | 전체 | 컨텍스트 맵 + ArchUnit 거버넌스 | 문서+규칙 | — | 완료됨 |
| P1 (완료) [정정] | 모임·커뮤니티, 채팅 | CommunityChatIntegrationEventWorker Layer 1)·컨텍스트 방 이름(BE-14). 테스트 22 | 모듈 내 도메인 추가 | ✅ 커밋 eac1c925/a701840b/5ff07c7c | community를 Core 관계표 등록·ArchUnit R1 포함은 잔여 확인 필요(§10) |
| P2 | 커뮤니티(모집) | recruitment 신규 컨텍스트 (모집·신청·정원·취소 수수료) | 모듈 내 도메인 추가 | 모집 FR 스펙 확정 시 | payment Customer 관계 등록, CancellationPolicy 전략 도입 |
| P3 | 운동시설, 상품 | 시설상품(program: PT·클래스) + 시설 운영시간·자동 슬롯·휴무 | facility/booking 확장 | 시설 예약 고도화 FR 시 | OperatingHours VO(facility 소유), 스케줄러 슬롯 생성 |
| P4 | 운동시설(예약 취소) | CancellationPolicy 전략(체육관별 취소 정책) | booking/facility 확장 | 취소 수수료 기능 스펙 시 | refundAmount 외부주입 → 정책 계산 전환 |
| P5 | 상품, 주문 | 통합 catalog / order-query 읽기 파사드 | Read 컨텍스트 추가 | 교차 타입 검색·통합 주문 이력 화면 스펙 시 | dashboard 패턴 재사용, 쓰기 aggregate 불변 |
| P6 (조건부·먼 미래) | 상품(티켓), 결제 | 고부하 컨텍스트(ticketing/payment) 별도 배포·DB 분리 | 서비스 분리 → DB 분리 | 측정된 피크 부하가 단일 모놀리스 임계 초과(한정판 드롭 시 P95 지연 급증 관측) | 마케팅 고부하 PRD 실측 트리거. expand-contract로 무중단 |
무중단 전환 원칙: P1~P5는 모두 모놀리스 내 도메인 추가/확장 — 신규 패키지 추가 → import 갱신 → (필요 시) 구 경로 제거의 expand-contract 순서. P6만 물리 분리로, 반드시 실측 부하 트리거 후에만.
장기 도메인 진화 단계 다이어그램 (경계 변화, Mermaid LR)
flowchart LR subgraph P0["P0 현재 (모놀리스)"] core0["코어 12도메인 + community VO"] end subgraph P1P2["P1~P2 (도메인 추가)"] comm["community 본체"] recr["recruitment"] end subgraph P3P5["P3~P5 (확장·읽기모델)"] prog["시설 program·운영시간"] read["catalog·order-query 파사드"] end subgraph P6["P6 (조건부 물리분리)"] split["ticketing·payment 분리 배포"] end core0 -->|채팅 로드맵| P1P2 P1P2 -->|기능 FR 트리거| P3P5 P3P5 -.->|측정 부하 트리거만| P6
8. 이벤트 아키텍처 적합성 진단 (private-be-architecture-rule 기준)
§9의 “규칙 정합”과 별개로, 확장·재사용·통합 관점에서 이벤트 백본의 설계 적합성을 진단합니다.
(1) 토폴로지 실측 (파일 근거)
Layer 분기 근거: AbstractDomainEvent.topic이 non-null이면 Kafka(Layer 2), null이면 Spring(Layer 1) — RoutingDomainEventPublisher.kt가 event.topic 유무로 분기(@Primary).
| 발행 이벤트 | Layer | 토픽/전달 | 구독자 (파일) | 멱등 키 |
|---|---|---|---|---|
| PaymentCompletedEvent | 2 | payment.completed.v1 | notification NotificationEventWorker.kt | paymentId |
| BookingConfirmedEvent | 2 | booking.confirmed.v1 | notification NotificationEventWorker.kt | bookingId |
| TicketIssuedEvent | 2 | ticket.issued.v1 | notification NotificationEventWorker.kt | ticketOrderId |
| BookingRefundRequestedEvent | 1 | Spring AFTER_COMMIT | booking BookingRefundEventWorker.kt | — |
| AlertProcessingRequestedEvent | 1 | Spring AFTER_COMMIT | alerting AlertProcessingEventWorker.kt | — |
| AlertDeliveryReadyEvent | 1 | Spring AFTER_COMMIT | notification 경유 AlertDeliveryEventWorker.kt | — |
| NotificationDispatchRequestedEvent | 1 | Spring AFTER_COMMIT | notification NotificationDispatchEventWorker.kt | — |
| McpAnomalyDetectedEvent | 1 | Spring AFTER_COMMIT (topic=null 명시) | mcp McpAnomalyEventWorker.kt | — |
| LimitedDropOversoldEvent | 1 | Spring AFTER_COMMIT | goods LimitedDropOversoldEventWorker.kt | — |
| FeatureFlagChangedEvent | 1 | Spring AFTER_COMMIT | featureflag FeatureFlagChangedEventWorker.kt | — |
동기(이벤트 아님) 결합: booking/goods/ticketing → payment.createPending(app 오케스트레이션), payment → OrderConfirmationGateway.confirm/cancel의 when(OrderType) 콜백(OrderConfirmationGatewayImpl.kt).
flowchart LR subgraph Producers["코어 발행"] payment booking ticketing goods end subgraph L2["Layer 2 · Kafka .v1"] t1["payment.completed"] t2["booking.confirmed"] t3["ticket.issued"] end subgraph L1["Layer 1 · Spring AFTER_COMMIT"] refund["booking refund"] oversold["goods oversold"] anomaly["mcp anomaly"] end subgraph Consumers["구독"] notification end booking -->|topic| t1 payment -->|topic| t1 ticketing -->|topic| t3 booking -->|topic| t2 t1 --> notification t2 --> notification t3 --> notification booking -->|topic=null| refund goods -->|topic=null| oversold ticketing -->|anomaly| anomaly payment -.->|OrderConfirmationGateway when| booking
(2) 설계 적합성 판정 (4항목)
| # | 진단 | 판정 | 근거 |
|---|---|---|---|
| ① 이벤트 백본이 얇은가 | Kafka 구독자가 notification 단일 도메인에 100% 쏠림 — 3개 토픽 전부 NotificationEventWorker만 소비. 사실상 “알림 전용 파이프” | 얇음 (재사용 여력만 있고 실사용 1곳) | 백본 자체는 건강(발행자-구독자 무결합, 구독자가 자체 DTO 보유). 단 재사용 폭이 아직 0 — recruitment/활동피드가 붙기 전까지 Kafka 도입 이득이 notification 하나에 그침 |
| ② 이벤트로 끊어야 할 결합이 동기로 남았나 | payment↔order가 양방향 동기 — 주문→createPending, payment→when(OrderType) 콜백. 상품/주문 타입 추가(시설상품 PT·클래스) 시 OrderType enum + confirm/cancel 2개 when 분기 + Gateway 생성자 DomainService 주입이 선형 증가 | 확장 마찰 있으나 지금은 허용 | OrderConfirmationGatewayImpl.kt when 분기 2곳. 타입 3개(BOOKING/GOODS/TICKETING)까진 관리 가능. 4~5개 초과 시 마찰 가시화 → (3) 전환 트리거 |
| ③ Layer 1/2 판단이 적절한가 | payment→notification(무관 도메인)=Kafka ✅, 같은 컨텍스트 파생(booking refund·goods oversold·mcp anomaly)=Spring ✅, mcp는 topic=null 주석으로 의도 명시 ✅ | 적절 (오분류 0건) | 무관 도메인을 Spring으로 묶거나 같은 컨텍스트를 근거 없이 Kafka로 쪼갠 사례 없음 |
| ④ 멱등·순서·최종일관성·스키마·재사용이 확장에 견디나 | eventId(UUID 기본)·occurredAt(UTC) 공통 필드. Consumer 멱등=NotificationDomainService.findByEventId dedup. 멱등 키는 비즈니스 키(paymentId) + push는 "$paymentId:push"로 분리 → 채널 충돌 없음. 스키마 .v1 3종, 파괴 변경은 새 버전 토픽 규칙 | 견딤 (양호) | 순서: 토픽 key 설계는 계약 문서 확인 필요(현재 단일 소비자라 순서 리스크 낮음). 재사용: 구독자 자체 DTO(presentation/notification/worker/PaymentCompletedEvent.kt)라 producer 도메인 미import — 신규 구독자 추가가 안전 |
(3) payment↔order 동기 결합 전환 후보 + 트리거
| 후보 | 설명 | 판정 |
|---|---|---|
| 현행 동기 유지 | 주문→createPending, payment→OrderConfirmationGateway 콜백 | 채택(지금) — 모놀리스 단일 트랜잭션의 강일관성·단순함. 결제 확정이 즉시 주문 상태에 반영돼야 UX가 자연스러움. 이벤트화하면 최종일관성 창(주문이 잠시 PENDING) + 멱등·보상 트랜잭션 설계 부담 |
| 이벤트 기반 전환 | payment가 PaymentCompleted 발행 → 각 주문 컨텍스트가 구독해 자기 확정 처리 (when 분기 소멸) | 미채택(지금) / 전환 트리거 예약 — 조기 이벤트화는 오버엔지니어링 |
전환 트리거 (측정 가능 조건, OR):
OrderType가 4개 초과로 늘어when분기·Gateway 의존 누적이 확장 마찰이 될 때 (시설상품·구독·번들 등 추가 시).- 결제-주문 확정에 최종일관성이 허용 가능해질 때(주문 즉시확정 UX 요구 완화).
- 결제 워커를 별도 배포로 분리(§7 P6)해야 할 부하가 관측될 때.
전환 시 부담: 각 주문 컨텍스트가 PaymentCompleted를 구독해 멱등(eventId 기반) 확정 + 실패 시 보상(재고 복원·좌석 해제) + 주문 PENDING 노출 UX 처리. 이 부담이 when 분기 유지 비용보다 커지는 시점이 전환점입니다.
(4) 백본 확장 여지 — 장기 계획(§7) 연결
기존 .v1 이벤트를 신규 컨텍스트가 구독만 추가해 재사용(발행자 무변경):
| 신규 컨텍스트 (단계) | 재사용/구독할 이벤트 | 연결 |
|---|---|---|
| recruitment (P2) | PaymentCompleted(취소 수수료 결제 확정) — 자체 구독자 DTO 추가 | payment 발행 무변경, Kafka 백본 재사용 2번째 사례 |
| community 활동 피드 (P1 이후) | booking.confirmed·ticket.issued·모집 완료 — 피드 항목 생성 | Kafka 구독자 추가로 백본 “알림 전용” 탈피 (①의 얇음 해소) |
| 실시간 dashboard (P5) | 코어 .v1 3종 구독 → 사전 집계 읽기 모델 갱신 | CQRS 읽기 모델을 이벤트로 갱신(현 Conformist 폴링 대체) |
→ ①의 “백본이 얇다”는 P1~P5에서 구독자가 늘며 자연 해소됩니다. 지금 선제로 토픽·구독자를 늘리지 않고, 신규 컨텍스트가 실제 착수될 때 구독자만 추가하는 것이 정답입니다.
9. 코드 컨벤션 정합 조사 (grep 실측)
private-be-code-convention 금지 패턴·레이어 규칙과 private-be-architecture-rule(Layer1/2 이벤트) 정합을 실측했습니다. 수정하지 않고 조사만 — 발견 시 /private-review로 연결하십시오.
ArchUnit이 커버하는 범위 (기존 R1~R4, 자동 회귀)
| 규칙 | 대상 | 현재 상태 |
|---|---|---|
| R1 | domain.X → domain.Y 교차 import 금지 | 0건 (정합) — 실측 `grep “import com.sportsapp.domain.” domain/ |
| R2 | domain → infra/application/presentation 금지 | 정합 (ArchUnit LayerDependencyRulesTest) |
| R3 | 지원/서브시스템 → 코어 동기 금지 (dashboard·partner 화이트리스트) | 정합 |
| R4 | common 공유 커널 순수성 | 정합 |
ArchUnit이 커버 못 하는 컨벤션 (grep 실측 결과)
| 룰 ID | 실측 명령 결과 | 판정 | 근거 |
|---|---|---|---|
no-double-bang (!!) | 0건 | 정합 | — |
no-jpa-query (@Query 비-락) | 0건 | 정합 | QueryDSL CustomImpl 사용 |
no-lob (@Lob) | 0건 | 정합 | TicketOrder.kt는 @Type(JsonStringType::class) 사용 확인 |
no-consumer-record (ConsumerRecord<String,String>) | 0건 | 정합 | NotificationEventWorker가 DTO 직접 매핑 |
no-local-datetime (LocalDateTime/Instant/Clock) | 2건 | 정합(허용 예외) | JwtTokenProvider.kt:64(infra), JwtIssuer.kt:13(domain gateway) — JWT 만료는 Instant 외부 토큰 의미론. 도메인 상태 시간 아님. p3 수준 관찰 대상 |
no-optional-return (Optional<>) | 3건 | 정합(프레임워크 SPI) | SecurityAuditorAware.kt:12·MongoAuditorAware.kt:14(AuditorAware.getCurrentAuditor)·ZonedDateTimeProvider.kt:19(DateTimeProvider.getNow) — 전부 Spring이 강제하는 SPI 시그니처, infra 레이어. 도메인 반환 아님 |
| ApplicationEventPublisher/KafkaTemplate 직접 주입 (app/domain) | 1건 | 정합 | DomainEventPublisher.kt:8 주석 언급만. 실제 주입은 infra(KafkaDomainEventPublisher·SpringDomainEventPublisher)로 은닉 |
| no-junit (테스트 JUnit) | 4건 | 부분 위반(p3 관찰) | architecture/*RulesTest.kt 3건은 ArchUnit 관용상 JUnit @Test 사용(ArchUnit 표준 러너), HealthEndpointIntegrationTest.kt 1건. 신규 비즈니스 테스트 아님 — Kotest 전환 여지이나 강한 위반 아님 |
[정정 추가] community 컨텍스트 컨벤션 정합 (fresh tree 실측)
초판이 놓친 community 본체를 컨벤션 기준으로 실측했습니다.
| 항목 | 상태 | 근거 |
|---|---|---|
| R1 도메인 교차 참조 | 정합 | grep "import com.sportsapp.domain." domain/community/(common·community 제외) = 0. 채팅 연동도 이벤트+ID로만(CommunityChatIntegrationEventWorker는 presentation→application.message) |
| Rich Domain Model | 정합 | Community.kt/CommunityMember.kt private 생성자 + create/reconstitute 팩토리, private var 필드, 상태 전이 캡슐화(MembershipStatus.canTransitTo), 시간 인자 미전달(approve() 내부 ZonedDateTime.now()) |
| UseCase 규칙 | 정합 | UseCase 10개가 CommunityDomainService만 호출(Repository 직접 주입 없음) |
| 이벤트 Layer 판단 | 정합 | community→채팅(같은 앱 연관 컨텍스트)=Layer 1 Spring @TransactionalEventListener(AFTER_COMMIT) + @Async ✅. 무관 도메인 Kafka 오분류 없음 |
| 거버넌스 등록 | 미이행(p2 관찰) | DomainClassification.core(SupportToCoreDependencyRulesTest.kt:18)에 community 미등록 — FR-8 위반. R1/R2 컨벤션은 자동 커버하나 분류 상수·context-map 갱신 누락(§10 즉시착수 2) |
이벤트 아키텍처 정합 (Layer 1/2 판단)
| 항목 | 상태 |
|---|---|
| Layer 2 (Kafka, 무관 도메인) | 정합 — KafkaDomainEventPublisher(발행)·NotificationEventWorker(소비, presentation). payment/booking/ticketing → notification 비동기 단방향 |
| Layer 1 (Spring ApplicationEvent, 같은 컨텍스트) | 정합 — SpringDomainEventPublisher가 DomainEventPublisher interface 뒤로 은닉. RoutingDomainEventPublisher가 topic 유무로 Layer 1/2 분기 |
| 발행자-구독자 무결합 | 정합 — 서비스가 DomainEventPublisher interface로만 발행, KafkaTemplate·ApplicationEventPublisher 직접 주입 없음 |
컨벤션 조사 결론
심각(p0~p2) 위반 0건. 발견은 전부 p3 수준 관찰 대상 2종:
JwtIssuer/JwtTokenProvider의Instant— 외부 토큰 의미론이라 허용 판단 (도메인 시간 아님).- 테스트 4건 JUnit
@Test— ArchUnit 관용 3건 + 헬스 1건, 신규 비즈니스 로직 테스트 아님.
→ 코드베이스는 private-be-code-convention·private-be-architecture-rule에 실질 정합. 상기 p3 2종의 처리 판단은 /private-review로 넘깁니다(이 문서는 조사까지).
10. 지금 할 일 vs 나중에
즉시 착수 [정정 — community 본체는 이미 완료, 실제 남은 항목으로 재작성]
초판 1. community 컨텍스트 본체 설계 → 완료 (eac1c925/a701840b/5ff07c7c). 아래는 실측으로 확인한 실제 잔여 항목입니다.
docs/domain-context-map.mdstale 정정 (최우선 — 이번 오판의 근본 원인) —docs/domain-context-map.md:20이 여전히community= “(VO만) … 커뮤니티 (구축 중)”,:225“아직 VO만 존재하는 미완성 컨텍스트”로 기술. 이 문서가 SSOT 입력으로 쓰여 직전 진단(및 cold 재검증)을 오도했습니다. 완성 컨텍스트(Entity 2·Service·Repo·이벤트·워커)로 갱신해야 문서-코드 정합. (senior-be 또는 doc-sync)DomainClassification에 community 분류 등록 (FR-8 미이행 시정) —SupportToCoreDependencyRulesTest.kt:18core = listOf("booking","facility","goods","payment","ticketing","user","post","message")에 community 없음. FR-8(“신규 도메인 추가 시 이 목록에 분류를 반영”)을 지키지 못한 상태. R1(교차참조)·R2(레이어)는slices().matching("com.sportsapp.domain.(*)..")·domain..컨벤션 기반이라 community를 이미 자동 강제(교차 import 실측 0건)하나, R3 분류·화이트리스트 상수에서 누락 → 지원 도메인이 community를 동기 의존해도 R3가 못 잡음.core리스트에 community 추가. (private-review 또는 senior-be)- 부수:
domain-context-map.md:203은 Community&Chat(message/community/post)을 Supporting으로 분류하나DomainClassification.core는 message·post를 core로 둠 → 두 문서 분류 불일치도 함께 정합.
- 부수:
- 설계 원칙 문서화 (초판 3번 유지) — ① 상품 통합 aggregate 금지(결제 OrderType이 통합축) ② 주문 통합 쓰기 aggregate 금지 ③ 모집은 post 아닌 recruitment 소유 ④ 취소 정책은 전략 객체(facility 소유), refundAmount 외부주입 지양 ⑤ b2c/b2b는 롤+Partner 유지 ⑥ 모임·커뮤니티는 community 단일 컨텍스트 + Visibility 겸용 유지(조기 2분할 금지, 이미 구현됨).
- 재발 방지 — 진단 파이프라인에 트리 신선도 게이트 선행 — 이번 오판은 cold 재검증(§1-1)조차 같은 stale 트리를 봐서 걸러내지 못함. 도메인 경계 진단 시작 전
git rev-list --left-right --count HEAD...origin/dev == 0/0확인을 의무 선행 단계로 고정(이 재진단 문서 상단 게이트가 그 예시).
트리거 도달 시 (지금은 미착수 — 오버엔지니어링 경계)
- community 콘텐츠·예약 연동 — 모임 게시글(community↔post ID참조)·소모임 예약(community↔booking)·종목별 게시글. community 본체는 있으나 이 3개 연동이 부분일치의 잔여 갭 (모임/커뮤니티 게시판·예약 FR 확정 시)
- recruitment 컨텍스트 (모집 FR 확정 시) + CancellationPolicy 전략 (취소 수수료 스펙 시). recruitment는 community를 ID 참조
- 시설상품 program + 시설 운영시간/자동 슬롯/휴무 (시설 예약 고도화 시)
- 통합 catalog/order-query 읽기 파사드 (교차 조회 화면 스펙 시)
- ticketing/payment 물리 분리 (측정된 피크 부하 임계 초과 시에만)
- CommunityRole 중간 계층(MANAGER) — 사용자 정의 “중간 계층 확장 가능” 실제 위임 요구 발생 시 enum 확장(저비용)
명시적 미채택 (지금 규모에 과함)
- 단일 Product 통합 aggregate / 단일 Order 통합 aggregate → 라이프사이클 이질, God-aggregate 위험
- 모임·커뮤니티 2개 컨텍스트 분리 → Visibility로 겸용 가능
- CommunityRole 중간 계층(MANAGER) 선제 도입 → YAGNI, enum 확장은 저비용이라 필요 시
- b2c/b2b 계정 타입 1급 분리 → 유저 다중 역할성 훼손
Document History
| 날짜 | 변경 내용 |
|---|---|
| 2026-07-06 | 최초 작성 — 사용자 정의 vs 코드 도메인 갭 매트릭스, 상품·주문 통합 판정, 모임/모집 신규 컨텍스트 제안, 취소정책 확장 원칙, 장기 진화 6단계 |
| 2026-07-06 | 보완 — §2 트래픽↔구조 3단계 verdict(컨텍스트별), §3 인프라 패턴 판정(캐싱·큐 도입됨/LB·게이트웨이·BFF·MSA 불필요+트리거), §6 사용자 정의 도메인 앵커 재정렬(매핑표+TO-BE 맵), §7 진화 계획 사용자 도메인 앵커+단계 다이어그램, §9 코드 컨벤션 grep 실측(심각 위반 0, p3 2종) |
| 2026-07-06 | 보완 2 — §8 이벤트 아키텍처 적합성 진단 신설(토폴로지 실측·흐름 다이어그램, 4항목 설계 판정, payment↔order 전환 트리거, 백본 확장 여지 §7 연결). 후속 섹션 재번호(코드 컨벤션 §9, 지금 할 일 §10) |
| 2026-07-06 | §1 매트릭스 전면 재작성 — 사용자 확정 계층(상품 ⊃ 중고·브랜드·한정판·티켓·시설상품)을 SSOT로, “개념 존재”가 아닌 “경계·계층 일치”로 재판정. 티켓 일치→경계불일치(ticketing↔goods import 0건), “상품 상위 개념” 누락 별도 행 추가, 각 행 근거 2종(존재 위치+경계 판단) 파일:라인 부착. 범례에 경계불일치 추가, 재집계(일치4/부분6/경계불일치1/누락6/코드전용1) |
| 2026-07-06 | §1-1 Cold 재검증 추가 — 답 미노출 독립 스폰이 11개 load-bearing 판정을 코드로 재확인. 티켓 경계불일치·한정판 일치·상품상위/시설상품/시설운영시간/모집/취소정책/모임 누락·OrderType 3분기 전부 재현. “존재=일치” 회귀 없음 검증 |
| 2026-07-06 | stale 트리 재진단·정정 — 초판·1차 cold 재검증이 origin/dev보다 뒤처진 stale 로컬 트리에서 수행돼 community를 “VO 4개 orphan/미구축”으로 오판정한 것을 정정. 트리 신선도 게이트 선행 실행(HEAD=5ff07c7c == origin/dev, rev-list 0/0, backend clean). 실측 재확인: community는 완성 컨텍스트(Entity 2·DomainService 12메서드·UseCase 10·Repository 3·이벤트 3·예외 6·VO 4·테스트 22, CommunityChatIntegrationEventWorker Layer 1 채팅 연동). 정정: §정정 고지 배너 신설, §AS-IS community 행·실측 3번, §1 매트릭스 row 14(모임 누락→부분일치)·15·17, 재집계(부분일치 6→7·누락 6→5), §1-1 모임 행 판정 철회, §5·§7 P1(완료)·§9(community 컨벤션 정합 표 추가)·§10(즉시착수 재작성: domain-context-map.md stale 정정·DomainClassification.core 등록·신선도 게이트 의무화). community 외 도메인(티켓/상품상위/모집/시설운영시간/취소정책 등)은 fresh tree에서 전부 재현돼 판정 유지 |