모집·시설상품·소모임예약연동 TDD

Background

근거 PRD: 스포츠앱/모임·커뮤니티/20260706-모집-시설상품-소모임예약연동-prd.md (prd-reviewer PASS). 근거 아키텍처 결정(SSOT, 뒤집지 않음): 스포츠앱/아키텍트/20260706-사용자정의-도메인경계-갭분석-architecture.md §4 문제C/D/E·§5·§7 P2/P3·§8 이벤트.

진단 문서 §7 P2(모집)·P3(시설상품)·§10 소모임 예약 연동을 구현 설계로 확정한다. 3축이다: (B1) recruitment 신규 바운디드 컨텍스트, (B2) facility 확장(program·운영시간·자동 슬롯·슬롯 상태), (B3) community↔booking 소모임 예약 연동. 전부 기존 도메인 교차 import 금지(ArchUnit R1) 하에 ID 참조·Gateway ACL·이벤트로만 결합한다.

Overview

무엇을어떻게
B1 recruitment모집글 개설·정원 내 신청·단계별 취소 수수료 환불·개설자 취소 전액환불post/게시판을 오염시키지 않는 독립 라이프사이클(정원 상태머신·수수료 정책·신청)신규 domain/recruitment 컨텍스트. 결제는 기존 동기 OrderConfirmationGatewayOrderType.RECRUITMENT로 확장. 수수료는 CancellationPolicy 전략. community/post는 ID만 참조
B2 facility운영시간·휴무 등록 → 매일 향후 14일 슬롯 자동 생성(멱등), 슬롯 OPEN/CLOSED 수동 제어, PT·클래스 program운영자가 회차를 한 건씩 수동 입력하지 않게(FR-9), 강습형 상품 표현facility에 OperatingHours/Holiday VO. booking에 SlotStatus·자동 생성 스케줄러(멱등 skip). program은 facility 산하 신규 aggregate, 회차 예약은 기존 booking Slot 경로(OrderType.BOOKING) 재사용
B3 community↔booking방장이 기존 예약 Slot을 모임 활동으로 연결, 멤버 열람신규 예약 메커니즘 없이 기존 booking 재사용community에 CommunityBooking(slotId ID참조) + SlotInfoGateway ACL. 인가는 기존 requireActiveMember 재사용

Terminology

용어정의
Recruitment모집글 aggregate — 정원·참가비·활동일시·신청마감·상태(OPEN/CLOSED/CANCELLED) 소유
Application모집 신청 aggregate — 신청자·상태(PENDING/CONFIRMED/CANCELLED/REFUNDED)·결제 참조
CancellationPolicy마감 잔여기간→수수료율을 계산하는 전략 객체 (7일초과 0% / 3~7일 5% / 3일이내 10%)
Programfacility 산하 시설상품(PT·클래스) — 이름·설명·가격·정원·소요시간
OperatingHours요일별 운영시간 VO — 오픈·마감 시각·브레이크타임·슬롯단위·정원
Holiday시설 휴무일 VO — 특정 날짜 슬롯 미생성
SlotStatusSlot의 예약 가능 상태 — OPEN(신규 예약 허용)/CLOSED(신규 예약 차단, 기존 예약 유지)
CommunityBookingcommunity가 booking Slot을 ID로 연결한 모임 활동 링크

Define Problem

AS-IS (실코드 근거)

사실근거
모집 개념 전무domain/에 recruitment 패키지 0건 (진단 §AS-IS 실측1)
결제 확정/취소는 동기 when(OrderType) 디스패치OrderConfirmationGatewayImpl.kt:17-31 confirm/cancel 각각 when(orderType) BOOKING/GOODS/TICKETING. OrderType.kt:3-7 3종
결제 흐름 = 주문 PENDING 저장 + createPending Payment → initiatePg → PG 웹훅 → confirmWebhookorderConfirmationGateway.confirmCreateBookingUseCase.kt:20-61, PaymentDomainService.kt#confirmWebhook:191-224
부분 환불은 Layer1 이벤트로BookingDomainService.kt#refundBooking:174-192BookingRefundRequestedEvent(refundAmount 외부인자) → BookingRefundEventWorker.kt(AFTER_COMMIT) → PaymentRefundGateway.requestRefund(paymentId, amount, reason). StubPaymentRefundGateway는 항상 성공
Slot은 수동 1건 생성, 상태 컬럼 없음Slot.kt:16-83facilityId:String·date·timeRange:String·capacity. OPEN/CLOSED 없음. UNIQUE uq_slots_facility_date_time_range(V14)
예약 동시성 = 분산락+비관락+정원검증BookingDomainService.kt#requestBooking:42-92booking:slot:$slotId spinLock + findForUpdateById + activeCount >= capacitySlotFullException
Facility는 MongoDB 문서, 운영시간·휴무 필드 전무Facility.kt:22-55 @Document — code/name/gu/type/…/ownerUserId. operatingHours·holiday 없음
시설상품(program) 없음domain/facility/에 program 0건
community↔booking 참조 0건community 도메인에 booking import 0. 방장이 예약을 모임에 연결할 수단 없음
도메인 간 ACL 패턴 확립됨booking↔facility 양방향 Gateway: FacilityOwnershipGateway(booking, requireOwner) / SlotQueryGateway(facility, hasActiveSlots). 구현체는 infrastructure
거버넌스 상수에 community·recruitment 미등록SupportToCoreDependencyRulesTest.kt:18 core = [booking,facility,goods,payment,ticketing,user,post,message] — community 없음(FR-8 미이행), recruitment 신규

TO-BE

  • domain/recruitment 신규 컨텍스트: Recruitment + Application aggregate, CancellationPolicy 전략, 자체 PaymentRefundGateway interface(booking과 별개, R1). 결제는 OrderType.RECRUITMENT 동기 확장.
  • facility: OperatingHours/Holiday VO(Facility 문서 임베드), Program aggregate. booking: SlotStatus OPEN/CLOSED + programId 참조, 자동 슬롯 생성 스케줄러(멱등 skip).
  • community: CommunityBooking(slotId ID참조) + SlotInfoGateway ACL, 기존 인가 재사용.
  • 거버넌스: DomainClassification.core에 community + recruitment 등록(단일 통합 티켓, senior-be A의 community 등록과 같은 파일).

Architecture Benchmarking (의무)

제품/사례해결 방식참고할 패턴미참고 사유
프립(FRIP) — 취미·소모임 액티비티신청 마감 잔여기간별 단계 취소 수수료(결제후 1h 무료 → 마감 2일전 전액 → 마감 임박 50% → 이후 불가) [prd Benchmarking]마감 잔여기간→수수료율 단계 산정을 CancellationPolicy 전략에 그대로 채용(7일초과 0/3~7일 5/3일이내 10)프립의 세부 율(50%)은 채택 안 함 — PRD 확정 율(10% 상한)이 SSOT
네이버 예약(스마트플레이스) — 매장·시설 예약요일별 운영시간 등록 → 시간 단위 예약 슬롯 자동 생성, 브레이크타임은 자동 예약불가 [prd Benchmarking]운영시간+브레이크 → 슬롯단위(default 60분) 슬라이스 자동 생성, 휴무일 skip을 스케줄러에 채용실시간 재고 동기화·외부 채널 연동은 개인 프로젝트 규모 초과, 미채택
Toss Payments 부분 취소 API승인 후 부분 취소(잔액 환불) 지원, 멱등 키로 이중 취소 방지 [https://docs.tosspayments.com/reference]수수료 공제 후 (100−율)% 환불을 부분 환불로, 멱등키(paymentId·상태가드)로 이중환불 차단다중 부분취소·정산 분배는 Non-Goal(플랫폼 단일 귀속)
당근 동네생활 모임/구인모임(그룹)과 모집(신청·정원)을 별도 개념으로 분리 운영 [진단 §4 C 경쟁사비교]recruitment를 post/community와 별도 컨텍스트로 분리, ID 참조대규모 피드·검색 파사드는 스펙 없음(Non-Goal catalog)

Possible Solutions

방안 비교 — 결제 확정 경로 (B1)

방안설명왜 채택 / 미채택
A. 동기 OrderType.RECRUITMENT 확장OrderConfirmationGatewayImpl.when에 RECRUITMENT 분기 추가, RecruitmentDomainService.confirmApplication/cancelPendingApplication 주입. 기존 booking/goods/ticketing과 완전 동형채택 — PRD FR-6 확정. 즉시확정 UX(최종일관성 창 없음), 단일 트랜잭션 강일관성, 기존 3종과 동일 패턴이라 학습비용 0. OrderType 4종 임계선(§8(2)) 도달 수용
B. 비동기 PaymentCompletedEvent Kafka 구독recruitment가 payment.completed.v1 구독해 자기 확정미채택 — PRD·진단 §8(4)가 이 PRD 범위 밖으로 명시. 조기 이벤트화는 최종일관성 창·보상 트랜잭션·멱등 설계 부담(오버엔지니어링). 5종 초과 시 §8(3) 트리거로 재평가

방안 비교 — 취소 수수료 계산 (B1)

방안설명왜 채택 / 미채택
A. CancellationPolicy 전략 객체마감시각·현재시각으로 수수료율 계산하는 도메인 전략. 단계 로직을 한 곳에 캡슐화채택 — 진단 §4 문제D “정책을 전략 객체로” 원칙 재사용. 율 변경이 한 클래스에 국한, 단위 테스트 용이. recruitment 소유(booking 취소정책과 별개)
B. UseCase 내 if-else 분기UseCase가 잔여기간 계산·율 분기미채택 — UseCase 비즈니스 로직 금지(no-if-throw-in-usecase), 재사용·테스트 불가

방안 비교 — 자동 슬롯 생성 (B2)

방안설명왜 채택 / 미채택
A. in-process @Scheduled 배치 + 멱등 skip1일 1회, 향후 14일. 기존 (facility,date,timeRange) 존재분 skip, 신규 날짜분만 INSERT채택 — 상시 RPS<10·시설 소량 규모에 별도 워커·큐 과함(진단 §2). 기존 McpAnomalyScheduler 패턴 재사용. 멱등은 V14 UNIQUE와 정합(존재 조회 후 diff)
B. Kafka 지연/스케줄 토픽 or 별도 배치 서버분산 스케줄러·워커 프로세스 분리미채택 — 지금 규모에 인프라·운영 복잡도만 증가(단순함 우선). 다중 인스턴스 시 중복 실행 리스크는 향후 shedlock류로 대응(Open Questions)
C. 예약 시점 lazy 생성조회 요청 시 슬롯 생성미채택 — 조회 P95 300ms 목표 위반, 동시성·멱등 복잡

방안 비교 — 운영시간·휴무 저장 위치 (B2)

방안설명왜 채택 / 미채택
A. Facility(Mongo) 문서에 VO 임베드operatingHours: List<OperatingHours>, holidays: List<Holiday>를 시설 문서에 임베드채택(권고) — facility 소유 속성, 항상 시설과 함께 로드, 상한 있는 크기(요일7·휴무 유한). private-mongodb-convention 임베딩 기준 충족. 최종 저장 판단은 senior-dba
B. 별도 MySQL 테이블operating_hours/holidays 관계 테이블조건부 — 운영시간 이력·감사 요구 생기면. 지금은 불요(YAGNI)

방안 비교 — program 회차 예약 (B2)

방안설명왜 채택 / 미채택
A. Slot에 programId 추가, 기존 booking 경로 재사용프로그램 회차 = programId를 가진 Slot. 예약·동시성·결제는 기존 requestBooking+OrderType.BOOKING 그대로채택 — PRD FR-12 확정. 신규 OrderType 없음(§8(2) 임계 근거). 오버셀 방지는 검증된 분산락+비관락 재사용
B. program 전용 예약 aggregate·OrderType신규 예약 모델미채택 — 라이프사이클 동일(슬롯·정원), 중복. OrderType 5종 조기 증가

방안 비교 — 소모임 예약 연동 (B3)

방안설명왜 채택 / 미채택
A. CommunityBooking(slotId ID참조) + SlotInfoGateway ACLcommunity가 slotId만 보유, 표시용 시설·일시·정원은 Gateway로 booking 조회채택 — R1 준수(도메인 교차 import 0). 정원은 Slot 소유(FR-14, community 미관리)
B. community가 booking Entity 직접 참조Slot 객체 보유미채택 — R1 위반, 결합

Detail Design

시스템 역할 경계 (의무)

단위레이어역할소유 데이터/책임노출 인터페이스의존
API 서버 (단일)전체요청-응답 + in-process 스케줄러 동거REST
Recruitmentdomain/recruitment모집 상태머신·정원 게이트·개설자 취소정원·마감·상태·communityId(ID)행위 메서드(없음)
Applicationdomain/recruitment신청 상태전이·결제 참조·멱등 가드신청상태·paymentId행위 메서드(없음)
CancellationPolicydomain/recruitment마감 잔여기간→수수료율 계산단계 율 상수feeRateFor(deadline)(없음)
RecruitmentDomainServicedomain/recruitment조회+검증+실행 오케스트레이션, 분산락 정원 경합create/apply/confirmApplication/cancelApplication/cancelRecruitment/조회Repository·DomainEventPublisher·DistributedLock·PaymentRefundGateway(recruitment)
RecruitmentRefundGatewaydomain/recruitment환불 요청 추상화(booking과 별개 interface)requestRefund(paymentId, amount, reason)(없음, infra 구현)
OrderConfirmationGatewayImplinfrastructure/payment결제 확정/취소 디스패치에 RECRUITMENT 추가confirm/cancel(when)Recruitment/Booking/Goods/TicketingDomainService
OperatingHours/Holidaydomain/facility (VO)운영시간·휴무 표현, 하루 슬롯 시간대 슬라이스 계산오픈·마감·브레이크·슬롯단위·정원 / 날짜slotRangesFor(date)(없음)
Programdomain/facility시설상품 메타(가격·정원·소요시간)facilityId·ownerId·price·capacity·duration행위 메서드(없음)
SlotStatusdomain/bookingOPEN/CLOSED 상태전이 캡슐화canTransitTo(없음)
Slot (확장)domain/bookingprogramId?·status 추가, close()/open()상태행위 메서드(없음)
SlotGenerationDomainServicedomain/bookingfacility 일정 + 기존 슬롯 → 생성 대상 diff(멱등)generate(schedule, existing)SlotRepository
FacilityScheduleGatewaydomain/booking스케줄 가능 시설 운영시간·휴무 조회(ACL)findSchedulableFacilities()(infra 구현, Mongo facility 읽음)
GenerateSlotsSchedulerpresentation/booking1일1회 @Scheduled 트리거GenerateSlotsUseCase
CommunityBookingdomain/community모임↔예약 링크(slotId ID참조)communityId·slotId·linkedBy행위 메서드(없음)
SlotInfoGatewaydomain/community슬롯 표시정보(시설·일시·정원) 조회(ACL)findBy(slotId): SlotInfo?(infra 구현, booking SlotRepository 읽음)

서버 토폴로지

단일 Spring Boot 모놀리스(API 서버) 유지. 자동 슬롯 생성은 별도 워커/배치 서버가 아니라 API 서버 내 @Scheduled으로 동거한다. 근거: 상시 RPS<10, 배치 1일1회·향후14일 소량(진단 §2 booking/facility “적합(여유)”). 소켓 서버·큐 워커 분리는 트리거(진단 §7 P6, 측정 부하 초과) 전 오버엔지니어링. 다중 인스턴스 배포 시 스케줄러 중복 실행 방지는 향후 shedlock 도입(Open Questions) — 현재 단일 인스턴스라 불요.

인터페이스 시그니처 (구현자 해석 차이 제거)

// domain/recruitment/gateway/RecruitmentRefundGateway.kt  (booking의 PaymentRefundGateway와 별개 — R1)
interface RecruitmentRefundGateway {
    fun requestRefund(paymentId: Long, amount: java.math.BigDecimal, reason: String)
}
 
// domain/recruitment/policy/CancellationPolicy.kt
interface CancellationPolicy {
    /** 마감시각 대비 현재(now는 내부 해결)로 수수료율(0.00~1.00)을 반환. no-time-parameter 준수. */
    fun feeRateFor(applicationDeadline: java.time.ZonedDateTime): java.math.BigDecimal
}
 
// domain/recruitment/repository/RecruitmentRepository.kt
interface RecruitmentRepository {
    fun save(recruitment: Recruitment): Recruitment
    fun findById(id: Long): Recruitment?
    fun findForUpdateById(id: Long): Recruitment?          // 비관락 (정원 경합)
    fun findAll(communityId: Long?): List<Recruitment>
}
// domain/recruitment/repository/ApplicationRepository.kt
interface ApplicationRepository {
    fun save(application: Application): Application
    fun findById(id: Long): Application?
    fun countActiveByRecruitmentId(recruitmentId: Long): Int   // PENDING+CONFIRMED
    fun findByRecruitmentId(recruitmentId: Long): List<Application>
    fun findConfirmedByRecruitmentId(recruitmentId: Long): List<Application>
}
 
// domain/recruitment/service/RecruitmentDomainService.kt (핵심 시그니처)
fun create(command: CreateRecruitmentCommand): Recruitment
fun apply(recruitmentId: Long, applicantUserId: Long): Application          // PENDING 생성(분산락 정원 게이트)
fun confirmApplication(applicationId: Long, paymentId: Long): Application   // OrderConfirmationGateway.confirm 진입
fun cancelPendingApplication(applicationId: Long)                          // OrderConfirmationGateway.cancel 진입
fun cancelApplication(applicationId: Long, applicantUserId: Long)          // FR-4 단계 수수료 환불
fun cancelRecruitment(recruitmentId: Long, recruiterUserId: Long)         // FR-5 전원 전액환불
fun getRecruitment(recruitmentId: Long, requesterId: Long): Recruitment
fun findApplications(recruitmentId: Long, requesterId: Long): List<Application>
 
// domain/booking/gateway/FacilityScheduleGateway.kt  (booking 소유 DTO 반환 — facility import 금지)
interface FacilityScheduleGateway {
    fun findSchedulableFacilities(): List<FacilitySchedule>   // FacilitySchedule은 booking domain DTO
}
data class FacilitySchedule(
    val facilityId: String, val ownerUserId: Long,
    val weeklyHours: List<WeeklyHours>,     // dayOfWeek, open, close, breaks, slotDurationMinutes, capacity
    val holidays: List<java.time.LocalDate>,
)
// domain/booking/service/SlotGenerationDomainService.kt
fun generate(schedule: FacilitySchedule, windowDays: Int): Int   // 신규 생성 건수 반환, 존재분 skip
 
// domain/booking/entity/Slot.kt (확장)
fun close(requesterId: Long)   // OPEN→CLOSED, 소유 검증
fun open(requesterId: Long)    // CLOSED→OPEN
fun requireBookable()          // CLOSED면 SlotClosedException (requestBooking에서 호출)
 
// domain/community/gateway/SlotInfoGateway.kt
interface SlotInfoGateway {
    fun findBy(slotId: Long): SlotInfo?   // SlotInfo(facilityId, date, timeRange, capacity) — community domain DTO
}

REST API 계약 (신규 엔드포인트):

메서드경로설명인가
POST/recruitments모집 개설 (title/description/capacity/feeAmount/activityAt/applicationDeadline/communityId?)인증
GET/recruitments?communityId=모집 목록communityId 소속 시 멤버십
GET/recruitments/{id}모집 상세소속 community visibility
GET/recruitments/{id}/applications신청자 목록(빈 목록 정상)개설자
POST/recruitments/{id}/applications신청+결제 (paymentId/checkoutUrl 반환)인증
POST/recruitments/{id}/cancel개설자 모집 취소(전원 전액환불)개설자
GET/applications내 신청 목록 — 인증 principal 기준(FR-4 신청 취소 FE 경로 지탱). 본인 신청만 조회, X-User-Id/principal 인가본인
POST/applications/{id}/cancel신청자 취소(단계 수수료 환불)신청자
PUT/facilities/{facilityId}/operating-hours요일별 운영시간 등록시설 소유자
POST/DELETE/facilities/{facilityId}/holidays휴무일 추가/해제시설 소유자
GET/facilities/{facilityId}시설 상세 — FacilityResponseoperatingHours·holidays 임베드 노출(FR-7/8, app 운영시간 카드·web 폼 프리필). 전용 GET 불요(임베드)공개
POST/facilities/{facilityId}/programs시설상품 등록시설 소유자
GET/facilities/{facilityId}/programs시설상품 목록공개
GET/facilities/{facilityId}/slots?programId=회차 목록 — SlotResponsestatus·programId 노출 + programId 필터(FR-10 상태 배지·FR-12 program 회차만 노출). design-db B-Q6 인덱스 지원공개
PATCH/facilities/{facilityId}/slots/{slotId}/close회차 수동 마감시설 소유자
PATCH/facilities/{facilityId}/slots/{slotId}/open특별 회차 수동 오픈시설 소유자
POST/communities/{communityId}/bookings방장이 slotId 연결방장(requireHost)
GET/communities/{communityId}/bookings연결 예약 목록(빈 목록 정상)멤버(requireActiveMember)

DTO 흐름은 Request(presentation)→Command(application)→Entity(domain)→Response(application) 준수. program·소모임 예약 결제는 기존 /bookings 경로 재사용(신규 결제 엔드포인트 없음).

FE 의존 응답 필드 계약(additive, senior-pm NEEDS_REVISION 보강):

  • ApplicationResponse: id·recruitmentId·status·paymentId·appliedAt (내 신청 목록·취소 FE 지탱).
  • SlotResponse: 기존 필드 + status(OPEN/CLOSED)·programId(nullable) 추가.
  • FacilityResponse: 기존 필드 + operatingHours·holidays 임베드 노출.

클래스 역할 정의

도메인 모델

클래스명역할핵심 책임
Recruitment모집 aggregatecanApply()(OPEN+미마감+정원여유 질의), closeWhenFull(), cancelByHost(userId)(개설자 검증→CANCELLED), 정원/마감 검증 캡슐화
RecruitmentStatus상태 enumcanTransitTo: OPEN→CLOSED/CANCELLED, CLOSED→CANCELLED, CANCELLED→(없음)
Application신청 aggregateconfirm(paymentId)(PENDING→CONFIRMED 멱등), cancelByApplicant()(CONFIRMED→CANCELLED+환불이벤트), markRefunded(), cancelPending()
ApplicationStatus상태 enumPENDING→CONFIRMED/CANCELLED, CONFIRMED→CANCELLED/REFUNDED, 종료상태 전이불가
TieredCancellationPolicy전략 구현마감 잔여기간 계산(내부 ZonedDateTime.now()), 7일초과 0/3~7일 5%/3일이내 10% 반환
Program시설상품 aggregatecreate(가격≥0·정원≥1·소요분>0 검증), requireOwnedBy, 회차 생성용 정원·가격 노출
OperatingHours(VO)요일 운영시간slotRangesFor(date): List<TimeRange> — 브레이크 제외 슬롯단위 슬라이스
Holiday(VO)휴무일날짜 보유, 생성 대상 제외 판단
Slot(확장)예약 회차status: SlotStatus, programId: Long?, close/open(userId), requireBookable()
SlotStatusenumOPEN↔CLOSED
CommunityBooking모임 활동 링크create(communityId, slotId, linkedBy), 중복 링크 멱등 가드

서비스 클래스

클래스명역할입력 → 출력의존
RecruitmentDomainService모집·신청 오케스트레이션위 시그니처Recruitment/ApplicationRepository, DistributedLock, DomainEventPublisher, CancellationPolicy, RecruitmentRefundGateway
ApplyRecruitmentUseCase신청+결제 오케스트레이션Command → paymentId/checkoutUrlRecruitmentDomainService, PaymentDomainService
CancelApplicationUseCase신청자 취소Command → UnitRecruitmentDomainService
CancelRecruitmentUseCase개설자 취소Command → UnitRecruitmentDomainService
CreateRecruitmentUseCase/조회 UseCase들개설·조회Command → ResponseRecruitmentDomainService
SlotGenerationDomainService멱등 슬롯 생성 diffschedule → 생성건수SlotRepository
GenerateSlotsUseCase배치 오케스트레이션() → 요약SlotGenerationDomainService, FacilityScheduleGateway
ProgramDomainService시설상품 등록·조회Command → ProgramProgramRepository, FacilityOwnershipGateway(재사용)
CommunityBookingDomainService예약 연결·조회Command → CommunityBooking/목록CommunityBookingRepository, CommunityDomainService(인가), SlotInfoGateway

실패 경로·동시성·멱등 (해피 패스만 있으면 미완성)

관심사설계
신청 정원 동시성(FR-3, 오버부킹 0)RecruitmentDomainService.apply: 분산락 recruitment:$recruitmentId(spinLock, booking 패턴 재사용) + findForUpdateById(비관락) + countActiveByRecruitmentId < capacity 위반 시 RecruitmentFullException(409). 정원 도달 시 Recruitment.closeWhenFull()
program/소모임 회차 동시성(오버셀 0)변경 없음 — 기존 BookingDomainService.requestBookingbooking:slot:$slotId+findForUpdateById+capacity 그대로. CLOSED 게이트만 추가(Slot.requireBookable())
결제 확정 멱등Application.confirm()은 이미 CONFIRMED면 no-op(booking confirmBooking 패턴). PG 웹훅 재수신 안전
취소 환불 멱등(중복환불 금지)Application.cancelByApplicant()은 CANCELLED/REFUNDED 상태에서 재호출 시 상태 가드로 no-op. 환불 이벤트는 상태전이 성공 시 1회만 적재
환불 게이트 실패(PG 오류)RecruitmentRefundEventWorker(AFTER_COMMIT)가 예외를 catch·에러 로그(재시도 필요) — booking BookingRefundEventWorker 동형. 원 취소 트랜잭션은 롤백하지 않음. Operations 알람 대상
참가비 0원 신청ApplyRecruitmentUseCase: feeAmount==0이면 PG 생략, RecruitmentDomainService.confirmApplication 직접 호출(결제 없이 CONFIRMED). paymentId=null 허용
마감 후 취소(Scenario6)Application.cancelByApplicantRecruitment 마감 검증 위임 → 마감 후면 ApplicationCancellationClosedException(422)
자동 슬롯 배치 멱등(Scenario22)SlotGenerationDomainService.generate: 윈도우 내 기존 (facility,date,timeRange) Set 조회 → 미존재분만 Slot.create. V14 UNIQUE 충돌 0. skip은 실패 아님(로그·집계 별도)
배치 부분 실패시설 단위 try-catch — 한 시설 DB 오류가 다른 시설 생성 막지 않음. 실패 시설만 알람 집계(FR-9 Operations)
CLOSED 슬롯 예약(FR-10)requestBooking에서 slot.requireBookable()SlotClosedException(409). 기존 확정 예약은 유지(삭제 아님)
소모임 비멤버 열람(Scenario20)CommunityBookingDomainServiceCommunityDomainService.requireActiveMember 위임 → PRIVATE 비승인자 NotCommunityMemberException(403)

상태 전이 표

RecruitmentStatus

현재 × 이벤트다음거부
OPEN × 정원충족CLOSED
OPEN × 개설자취소CANCELLED
CLOSED × 개설자취소CANCELLED
CANCELLED × 모든이미 종료(422)
OPEN × 신청(정원여유·미마감)OPEN(신청 생성)마감후/정원초과 거부(409/422)

ApplicationStatus

현재 × 이벤트다음거부
PENDING × 결제확정CONFIRMED
PENDING × 결제취소/실패CANCELLED
CONFIRMED × 신청자취소(미마감)CANCELLED(환불이벤트)마감후 취소 거부(422)
CONFIRMED × 개설자모집취소CANCELLED(전액환불)
CANCELLED/REFUNDED × 재취소no-op(멱등)

SlotStatus

현재 × 이벤트다음거부
OPEN × 수동클로즈CLOSED
CLOSED × 수동오픈OPEN
CLOSED × 신규예약SlotClosedException(409)

Component Diagram (Mermaid flowchart LR)

flowchart LR
    subgraph Presentation
        RC[RecruitmentApiController]
        Sched[GenerateSlotsScheduler]
        RRW[RecruitmentRefundEventWorker]
        CBC[CommunityBookingApiController]
    end
    subgraph Application
        ARU[ApplyRecruitmentUseCase]
        GSU[GenerateSlotsUseCase]
        CBU[CommunityBookingUseCase]
    end
    subgraph Domain
        RDS[RecruitmentDomainService]
        SGS[SlotGenerationDomainService]
        Pol[CancellationPolicy]
        RDS --> Pol
    end
    subgraph Infra
        OCG[OrderConfirmationGatewayImpl]
        FSG[FacilityScheduleGatewayImpl]
        RRG[RecruitmentRefundGatewayImpl]
    end
    RC --> ARU --> RDS
    Sched --> GSU --> SGS
    CBC --> CBU
    OCG -.->|confirm/cancel RECRUITMENT| RDS
    GSU --> FSG
    RRW --> RRG

Sequence Diagram — 신청+결제 (FR-3/6)

sequenceDiagram
    participant C as Controller
    participant U as ApplyRecruitmentUseCase
    participant R as RecruitmentDomainService
    participant P as PaymentDomainService
    participant G as OrderConfirmationGateway
    C->>U: execute(apply command)
    U->>R: apply(recruitmentId, userId)
    R-->>U: Application(PENDING)
    U->>P: createPending(RECRUITMENT, applicationId, fee)
    U->>P: initiatePg(...)
    P-->>C: checkoutUrl / paymentId
    Note over P,G: PG 웹훅 → confirmWebhook
    P->>G: confirm(RECRUITMENT, applicationId, paymentId)
    G->>R: confirmApplication(applicationId, paymentId)

Sequence Diagram — 신청자 단계 취소 (FR-4)

sequenceDiagram
    participant C as Controller
    participant R as RecruitmentDomainService
    participant Pol as CancellationPolicy
    participant W as RecruitmentRefundEventWorker
    participant Gw as RecruitmentRefundGateway
    C->>R: cancelApplication(applicationId, userId)
    R->>Pol: feeRateFor(deadline)
    Pol-->>R: feeRate (0/0.05/0.10)
    R->>R: Application.cancelByApplicant() + refundEvent(amount*(1-rate))
    Note over R,W: AFTER_COMMIT
    R->>W: ApplicationRefundRequestedEvent
    W->>Gw: requestRefund(paymentId, refundAmount, reason)

ERD (요약 — DDL 전문은 마이그레이션/senior-dba)

erDiagram
    RECRUITMENTS ||--o{ APPLICATIONS : has
    RECRUITMENTS {
        bigint id PK
        varchar title
        int capacity
        decimal fee_amount
        datetime activity_at
        datetime application_deadline
        bigint community_id "nullable, ID참조"
        bigint recruiter_user_id
        varchar status
    }
    APPLICATIONS {
        bigint id PK
        bigint recruitment_id "ID참조"
        bigint applicant_user_id
        varchar status
        bigint payment_id "nullable"
    }
    PROGRAMS {
        bigint id PK
        varchar facility_id "ID참조(Mongo)"
        bigint owner_user_id
        decimal price
        int capacity
        int duration_minutes
    }
    SLOTS {
        bigint id PK
        varchar facility_id
        bigint program_id "nullable 신규"
        varchar status "OPEN/CLOSED 신규"
    }
    COMMUNITY_BOOKINGS {
        bigint id PK
        bigint community_id "ID참조"
        bigint slot_id "ID참조"
        bigint linked_by_user_id
    }
  • facility OperatingHours/Holiday는 Mongo facilities 문서 임베드(권고) — 별도 테이블 없음. senior-dba 최종 판단.
  • 참조는 전부 일반 컬럼(FK 없음, private-db-schema-convention). 시간 컬럼 DATETIME(6), BOOLEAN→TINYINT(1), ENUM→VARCHAR.
  • 인덱스: applications(recruitment_id, status), slots(facility_id, program_id), community_bookings(community_id) — 대상 쿼리 근거는 senior-dba design-db.

Testing Plan

레벨대상·핵심 시나리오
domainTieredCancellationPolicy(7일초과 0/3~7일 5/3일이내 10/경계값 정확히 7일·3일), Recruitment(정원충족 CLOSED·개설자취소·마감후 신청거부), Application(confirm 멱등·취소후 재취소 no-op·마감후 취소거부), SlotStatus.canTransitTo, Slot.requireBookable(CLOSED 거부), OperatingHours.slotRangesFor(브레이크 제외·경계), SlotGenerationDomainService.generate(기존 skip·신규만 생성)
applicationApplyRecruitmentUseCase(fee>0 결제경로·fee0 즉시확정), CancelApplicationUseCase(환불액=fee*(1-rate)), CancelRecruitmentUseCase(전원 전액), GenerateSlotsUseCase(시설별 부분실패 격리) — DomainService MockK
infrastructureRecruitment/Application/Program/CommunityBooking RepositoryImpl(TestContainers MySQL), FacilityScheduleGatewayImpl(Mongo 읽기), SlotInfoGatewayImpl, RecruitmentRefundGatewayImpl, OrderConfirmationGatewayImpl RECRUITMENT 분기
presentationRecruitmentApiController(인가·402/409/422), 슬롯 close/open PATCH, program 등록/목록, community bookings(멤버십 403), GenerateSlotsScheduler 멱등 통합
scenarioE2E: 개설→신청(결제)→단계취소 환불 / 정원경합 100건 오버부킹 0 / 운영시간등록→배치→슬롯생성→재실행 멱등 / 방장 예약연결→멤버열람·비멤버 거부

동시성 테스트: 신청·program 예약 각각 동시 100 요청 → 확정 ≤ capacity(오버부킹/셀 0 검증, Success Metrics 직결).

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

전 축이 additive(신규 패키지·nullable 컬럼·enum 추가)라 expand-contract 무중단.

단계작업전환 조건롤백
1. 스키마 먼저recruitments/applications/programs/community_bookings 테이블 생성, slots status(DEFAULT ‘OPEN’ nullable)·program_id(nullable) 컬럼 추가(ALGORITHM=INPLACE,LOCK=NONE), Mongo facility 임베드 필드는 문서 추가라 무중단마이그레이션 exit 0역방향 DDL(컬럼/테이블 DROP) — 코드 미배포 상태라 안전
2. 코드 배포(플래그 OFF)신규 도메인·컨트롤러 배포하되 recruitment.enabled=false·facility.autoslot.enabled=false·facility.program.enabled=false·community.booking.enabled=false. 컨트롤러는 @ConditionalOnProperty(community 패턴)로 빈 미등록 → 경로 404배포 성공이전 이미지 태그로 compose 재기동
3. OrderType.RECRUITMENT 활성enum·when 분기는 배포 즉시 유효(플래그 무관). RECRUITMENT 결제는 recruitment.enabled=true여야 진입점 생김enum 롤백 불필요(미사용 분기는 무해)
4. 축별 점진 오픈플래그를 축 단위로 ON: 먼저 community.booking(가장 단순)→facility program/autoslot→recruitment각 축 스모크 통과플래그 OFF 즉시 비활성(경로 404/스케줄러 정지)
5. 거버넌스DomainClassification.core에 community+recruitment 등록(테스트 상수, 런타임 무영향)ArchUnit 그린상수 되돌림

배포 순서: 스키마 먼저 → 코드(플래그 OFF) → 플래그 점진 ON. 자동 슬롯 스케줄러는 facility.autoslot.enabled=true일 때만 배치 실행(초기 OFF로 대량 슬롯 생성 사고 방지).

Observability

  • 지표: 일별 모집 개설/신청/취소 수, 신청·개설자 취소율, 자동생성 회차 수(시설별)·수동 open/close, program 예약·정원충족률, 소모임-예약 연동 수. dashboard 컨텍스트 주기 집계(신규 저장소 없음, Conformist 재사용).
  • 알람: 자동슬롯 배치 실패(신규 날짜분 미생성 = 실제 오류만, skip 제외), 신청/모집 취소 환불 실패(PG 오류) — 기존 NotificationEventWorker 계열 재사용.
  • 로그: 배치 실행당 시설별 생성/skip 건수, 환불 실패 시 paymentId·refundAmount(재시도 근거).

Open Questions

항목처리
다중 인스턴스 스케줄러 중복 실행현재 단일 인스턴스라 불요. 수평 확장 시 shedlock/DB 락 도입(진단 §2 트리거)
자동 슬롯 슬롯단위·기본정원OperatingHours에 slotDurationMinutes(default 60)·capacity 포함해 시설 설정으로. 값 미정 시 60분·정원1 기본
program 회차 예약 시 결제금액 신뢰기존 booking과 동일하게 command amount 사용(AS-IS 동작 계승). 서버측 program.price 강제는 별도 개선 과제

Document History

날짜변경 내용
2026-07-07최초 작성 — B1 recruitment(OrderType.RECRUITMENT 동기확장·CancellationPolicy 전략·환불 Layer1)·B2 facility(OperatingHours/Holiday VO·SlotStatus·자동슬롯 멱등 스케줄러·program)·B3 community↔booking(CommunityBooking·SlotInfoGateway). AS-IS 실코드 근거, 무중단 expand-contract, 거버넌스 통합 인지