진단 문서 §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 컨텍스트. 결제는 기존 동기 OrderConfirmationGateway를 OrderType.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 재사용
수수료 공제 후 (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 배치 + 멱등 skip
1일 1회, 향후 14일. 기존 (facility,date,timeRange) 존재분 skip, 신규 날짜분만 INSERT
단일 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.ktinterface CancellationPolicy { /** 마감시각 대비 현재(now는 내부 해결)로 수수료율(0.00~1.00)을 반환. no-time-parameter 준수. */ fun feeRateFor(applicationDeadline: java.time.ZonedDateTime): java.math.BigDecimal}// domain/recruitment/repository/RecruitmentRepository.ktinterface 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.ktinterface 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): Recruitmentfun 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): Recruitmentfun 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.ktfun generate(schedule: FacilitySchedule, windowDays: Int): Int // 신규 생성 건수 반환, 존재분 skip// domain/booking/entity/Slot.kt (확장)fun close(requesterId: Long) // OPEN→CLOSED, 소유 검증fun open(requesterId: Long) // CLOSED→OPENfun requireBookable() // CLOSED면 SlotClosedException (requestBooking에서 호출)// domain/community/gateway/SlotInfoGateway.ktinterface SlotInfoGateway { fun findBy(slotId: Long): SlotInfo? // SlotInfo(facilityId, date, timeRange, capacity) — community domain DTO}
RecruitmentDomainService.apply: 분산락 recruitment:$recruitmentId(spinLock, booking 패턴 재사용) + findForUpdateById(비관락) + countActiveByRecruitmentId < capacity 위반 시 RecruitmentFullException(409). 정원 도달 시 Recruitment.closeWhenFull()
program/소모임 회차 동시성(오버셀 0)
변경 없음 — 기존 BookingDomainService.requestBooking의 booking: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 허용
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.