0단계 — 결합 해소·거버넌스 정합 TDD (설계 보강분)

이 문서는 신규 설계가 아니라 기존 진단서의 0단계 실행 설계 보강분이다. 근거 설계: 아키텍트/20260728-msa-분리-architecture.md §2-2 / §3-2 / §3-3 / §6-1 / §7-3 / “지금 할 일”. 진단서에 이미 확정된 내용(AS-IS 실측·후보 비교·벤치마킹·진화 단계)은 재작성하지 않고 인용한다. 보강 대상은 진단서가 “설계 판단 필요”로 남긴 4가지다 — ① 공급자 경유 전환의 구체 계약 ② Permission 이관 시 mcp 파급 ③ FR-8 분류 tier 배정과 R3 스캔 구동 방식 ④ T1 크로스 컨텍스트 쓰기 해소 방안.

Background

MSA 전면 분리는 미채택(진단서 §4-1 ⓓ), ⓑ Gradle 멀티모듈 모듈러 모놀리스 채택. 그 0단계가 본 문서 범위다. 0단계는 MSA와 무관하게 옳은 일이며, 1단계(멀티모듈화)의 전제 조건이다 — 0단계 미완 시 1단계에서 컴파일이 깨진다.

범위 확정 (사용자 결정)

  • 진행 범위 = 0단계만. 1단계(Gradle 멀티모듈화)·2단계(facility-booking 병합)는 이번 범위 밖.
  • OQ-3 = 병합 채택 — facility·booking은 장차 한 모듈이 된다. 따라서 두 컨텍스트 사이 Gateway 4개를 원격 호출 대비 계약으로 과설계하지 않는다. 타 컨텍스트 Repository 직접 주입 제거까지가 조치 범위이고, 읽기모델 복제·이벤트화는 하지 않는다(진단서 §7-2 “지금 동기가 옳다”).
  • 게이트웨이·디스커버리·SAGA 오케스트레이터 도입 금지.

Overview

#항목채택 방안완료 판정
1infrastructure 교차 Repository 주입 6파일소비자 ACL Gateway interface는 유지, 구현체 의존을 공급자 Repository → 공급자 DomainService로 교체infrastructure에 타 컨텍스트 *Repository 주입 0건
2domain/common/Permissionuser로 이관 + mcp에 ACL Gateway 신설 (이관만 하면 mcp가 R1·R3를 동시에 깬다)domain/common@Entity/@Table 0건, ArchUnit GREEN
3FR-8 미분류 5건전부 support tier로 편입 + R3 스캔 루프를 DomainClassification에서 구동(하드코딩 제거)분류 합계 = domain 패키지 20, R3 커버리지 20/20
4docs/domain-context-map.md코드 실측 기준 전면 갱신 (마지막 wave 단독)문서-코드 정합
5T1 CreatePartnerUseCase동기 SAGA — 컨텍스트별 로컬 트랜잭션 3단 + 보상 + INACTIVE semantic lock (이벤트화·신규 상태값·신규 스키마 전부 미채택)@Transactional 안의 크로스 컨텍스트 쓰기 0건

Terminology

용어정의
공급자 경유 (provider DomainService routing)소비자 infrastructure 어댑터가 공급자의 Repository(=테이블) 가 아니라 공급자의 DomainService(=공개 행위 계약) 를 호출하는 것
semantic lockSAGA 중간 상태를 “효력 없는 상태값”으로 표현해, 보상 실패 시 잔존 데이터가 무해하도록 만드는 기법
R1~R4ArchUnit fitness function (ADR-005). R1=domain 교차 import 금지 / R2=레이어 방향 / R3=지원·서브시스템→코어 동기 의존 금지 / R4=공유 커널 순수성
FR-8신규 도메인 추가 시 DomainClassification에 분류를 반영하는 절차 (ADR-001)

Define Problem

AS-IS (실제 코드 확인, 커밋 f18434c3)

① 교차 Repository 주입 6파일 — Gateway interface는 소비자 도메인에 정의돼 R1은 통과하나, 구현체가 공급자 테이블을 직접 읽는다.

소비자 → 공급자파일주입 대상호출
booking → facilityFacilityOwnershipGatewayImpl.kt:22FacilityRepositoryfindById(facilityId)
booking → facilityFacilityScheduleGatewayImpl.kt:30FacilityRepositoryfindAllForBackfill(pageable) 페이지 루프
community → bookingSlotInfoGatewayImpl.kt:14SlotRepositoryfindById(slotId)
facility → bookingSlotQueryGatewayImpl.kt:13SlotRepositoryexistsActiveByFacilityId(facilityId)
message → goodsGoodsProductGatewayImpl.kt:16ProductRepositoryfindByIdAndDeletedAtIsNull(productId)
notification → userRecipientContactResolver.kt:13UserRepositoryfindById(userId)?.email

RecipientContactResolver는 추가로 ACL interface조차 없다 — notification domain에 계약이 없고 infrastructure 구상 클래스를 EmailChannelGateway.kt:18·SmsChannelGateway.kt:21이 직접 주입한다.

② 공유 커널의 물리 테이블 소유domain/common/Permission.kt:11@Table(name = "permissions"). 소비자는 3곳이다.

소비자파일사용
mcp (domain)McpTokenDomainService.kt:31permissionRepository.findByName(name)?.id
security (infra)McpTokenAuthenticationFilter.kt:25,93findAllByIds(ids) → name 파싱
user (infra)PermissionRepositoryImpl.kt / PermissionJpaRepository.kt구현체는 이미 infrastructure/user/mysql에 있다

→ 구현체 위치가 이미 user다. 즉 소유자는 user인데 계약만 common에 남아 있다.

③ FR-8 미분류 5건SupportToCoreDependencyRulesTest.kt:19-21 합계 15 vs 실제 domain 패키지 20. 추가로 R3 스캔 루프가 listOf("notification","operator","weather","mcp","alerting") 하드코딩이라, 상수에 도메인을 추가해도 스캔 대상이 자동으로 늘지 않는다.

④ 문서 staledocs/domain-context-map.mdvirtualqueue·catalog·order 0회 언급. 이미 제거된 OrderConfirmationGateway를 현재 구조로 기술. 토픽명이 구 형식(payment.completed.v1, 실제는 event.payment.payment.v1). recruitment 연동을 “BE-55·BE-60 예정”으로 기술하나 코드는 배선 완료.

⑤ T1 크로스 컨텍스트 단일 트랜잭션 쓰기CreatePartnerUseCase.kt:32-38

@Transactional
  userDomainService.register(...)        // user 컨텍스트 쓰기 (users + user_roles)
  userDomainService.assignRole(...) x2   // user 컨텍스트 쓰기
  partnerDomainService.createPartner(..) // partner 컨텍스트 쓰기 (partners + partner_api_keys)

진단서 §3-3 T1 = 유일한 실제 SAGA 대상. 나머지 T5·T6(결제 경로)는 이미 분리 완료다.

TO-BE

  • infrastructure 어댑터는 공급자 DomainService만 안다. 공급자 테이블 스키마를 모른다.
  • domain/common은 물리 스키마를 갖지 않는다. mcp는 user를 모른다(ACL Gateway 경유).
  • DomainClassification이 20개 도메인을 전부 분류하고, R3 스캔이 그 상수에서 구동된다.
  • 크로스 컨텍스트 쓰기는 컨텍스트별 로컬 트랜잭션 + 보상으로 분리된다.

Architecture Benchmarking

진단서 §5의 4개 벤치마크(Shopify 모듈러 모놀리스 / 오늘의집 MSA Phase 1 / MSA 비용 통계 2026 / Spring Modulith)를 계승한다. 0단계 실행 설계에 직접 적용되는 2건만 재인용한다.

사례해결 방식0단계에 적용할 패턴참고하지 않는 부분
Spring Modulith / microservices-ready modulith (출처)모듈 간 통신은 ① 도메인 이벤트 우선 ② 동기는 조회(query)로만, 모듈의 명시적 계약 경유 ③ 크로스 모듈 상태 변경 금지②를 항목 1에 그대로 적용 — 6개 Gateway는 전부 조회성이므로 동기 유지가 옳고, 계약을 Repository(테이블)가 아니라 DomainService(행위)로 올린다. ③을 항목 5(T1)에 적용Spring Modulith 라이브러리(@ApplicationModule) 도입은 미채택 — 1단계 Gradle 모듈이 같은 강제를 컴파일 타임에 제공하므로 중복
Shopify Core (Packwerk) (출처)MSA 대신 모듈러 모놀리스. 경계 강제를 위해 자체 정적 검사 도구를 만들고, 위반을 0으로 유지하는 것을 지속 작업으로 취급항목 3에 적용 — 분류 상수가 스캔을 구동해야 도구가 자라난다. “상수는 추가했는데 스캔은 하드코딩”은 Packwerk가 경계한 전형적 구멍자체 도구 신규 개발 미채택 — ArchUnit R1~R4가 이미 같은 역할

Possible Solutions

항목 1 — 교차 Repository 주입 해소

방안설명판정
A. 공급자 DomainService 경유 ★ 채택ACL Gateway interface(소비자 domain)는 그대로 두고, 구현체 생성자 의존만 XxxRepositoryXxxDomainService로 교체. 필요한 조회 메서드가 없으면 공급자 DomainService에 조회 메서드를 추가한다채택 — 변경 표면이 6파일 + 공급자 4개 DomainService로 국한. 결합이 “테이블 스키마”에서 “공개 행위 계약”으로 한 단계 올라가 1단계 멀티모듈화 시 그대로 모듈 API가 된다
B. 읽기모델 복제 (이벤트로 소비자 컨텍스트에 사본 유지)공급자가 이벤트 발행 → 소비자가 자기 테이블에 사본 보관미채택 — 6건 전부 읽기성 즉시 조회(예약 시 시설 스케줄 확인 등)라 최종일관성이 오답이다. 사본 테이블 6개 + 백필 + 정합 검증을 지금 지불할 근거 0 (진단서 §7-2 “지금 동기가 옳다”)
C. application 레이어로 조합 이전 (컨벤션 no-crosscontext-raw-read 권장형)UseCase가 공급자 DomainService를 호출해 받고, application 매퍼가 소비자 값 객체로 변환부분 미채택 — 원칙상 가장 깨끗하나, 6건 중 4건(requireOwner·hasActiveSlots·findOwnerId·emailOf)은 소비자 DomainService 내부의 가드 호출이라 application으로 끌어올리면 호출부 20+곳을 고쳐야 한다. 0단계는 구조 정리이지 대규모 리팩터가 아니다. 방안 A로 스키마 결합을 먼저 끊고, C는 2단계(facility-booking 병합) 때 자연 해소되는 것을 기다린다
D. 원격 호출 계약(HTTP/gRPC) 선제 도입지금부터 원격 호출을 가정한 인터페이스미채택 — 사용자 결정 2번. facility·booking은 장차 한 모듈이 된다. 원격 대비 과설계 금지

notification → user 만 예외 처리: 진단서는 “이벤트 payload에 연락처 동봉”도 후보로 제시하나 미채택한다. 알림 이벤트 payload에 이메일 평문을 실으면 Kafka 보존 기간 동안 PII가 브로커에 잔존해 노출면이 넓어진다. 발송 직전 동기 조회가 PII 수명을 가장 짧게 유지한다. 단 이 케이스만 ACL interface 자체가 없으므로 RecipientContactGateway를 notification domain에 신설한다.

항목 2 — Permission 이관

방안설명판정
A. 단순 이관 (domain/commondomain/user)엔티티·Repository interface를 user로 옮기고 import만 갱신미채택 — 실행하면 깨진다. McpTokenDomainService(domain/mcp)가 PermissionRepository를 직접 주입한다. 이관 즉시 domain.mcp → domain.user 교차 import가 생겨 R1(도메인 교차 import 0건)과 R3(서브시스템→코어 동기 의존 금지) 두 규칙이 동시에 RED
B. 이관 + mcp ACL Gateway 신설 ★ 채택Permission·PermissionRepositorydomain/user로 이관 ② user에 PermissionDomainService 신설(조회 계약) ③ mcp domain에 McpPermissionGateway ACL interface 정의, 구현체는 infrastructure/mcp/gateway에서 PermissionDomainService 주입 ④ McpTokenAuthenticationFilter(infrastructure)는 PermissionDomainService를 직접 주입(infra는 R3 스캔 대상 밖)채택 — 항목 1과 동일한 패턴(소비자 ACL interface + 공급자 DomainService 경유)이라 규칙이 하나로 수렴한다
C. Permission을 common에 두되 @Entity만 제거하고 user에 JPA 엔티티 별도 정의공유 커널에 순수 계약만 남김미채택 — domain/JPA 엔티티 분리를 이 한 클래스에만 적용하면 나머지 42파일과 구조가 갈린다. OQ-4(전면 분리)는 “유지 권장”으로 판정됐다

스키마 무변경 — 테이블·컬럼·데이터 모두 그대로다. Flyway 마이그레이션 없음. 이관은 Kotlin 패키지 이동뿐이다.

항목 3 — FR-8 분류 편입

tier 배정 (ADR-001 5계층 어휘 유지, 신규 tier 만들지 않음):

도메인tier근거
featureflag지원(support)코어 보조 플랫폼 기능. 자기 데이터 소유, 코어 역참조 0건(실측)
virtualqueue지원(support)인그레스 입장 제어. Redis 전용, 코어 역참조 0건. EntryTokenGuard(common)로만 노출
partner지원(support)B2B 신원. 코어 거래 aggregate 아님
airquality지원(support)weather와 동형 — Entity 0개, 외부 API 조회 VO 중심 generic subdomain
featuredemo지원(support)데모 부가 도메인

실측 선행 확인 (없으면 편입 즉시 RED): featureflag·virtualqueue·airquality·featuredemo의 domain·application 패키지가 참조하는 com.sportsapp.domain|application.*를 전수 추출한 결과 자기 자신 + domain.common 외 0건. → R3 스캔에 넣어도 GREEN.

partner만 예외: CreatePartnerUseCasedomain.user.service.UserDomainService를 의존한다. 이는 ADR-002 rule 4의 사전 등록된 R3 화이트리스트 2건 중 ②이고, 전용 테스트(application.partner 는 domain.user 를 제외한 코어 도메인을 동기 의존하지 않는다)가 이미 존재한다. 따라서 partner는 일반 스캔 루프에서 명시적으로 제외하고 전용 테스트가 계속 담당한다. 제외를 상수(R3_WHITELISTED)로 드러내 “왜 빠졌는지”가 코드에 남게 한다.

스캔 구동 방식 교체 (핵심): 루프를 listOf("notification","operator","weather","mcp","alerting") 하드코딩에서 (DomainClassification.support + DomainClassification.subsystem) - DomainClassification.R3_WHITELISTED로 바꾼다. 이렇게 해야 “상수에 추가 = 스캔에 추가”가 성립하고 FR-8 절차가 실제로 작동한다(Shopify/Packwerk 교훈).

커버리지 산식: core 10 + support 9 + subsystem 1 = 20 = domain/ 하위 패키지 수(common 제외). 이 항등식을 테스트로 고정한다.

항목 5 — T1 크로스 컨텍스트 쓰기 (가장 어려운 판단)

전제 사실 3가지를 먼저 고정한다.

  1. CreatePartnerUseCase동기 admin API이고, 응답에 1회성 평문 API Key(issuedApiKey.plainKey)를 담는다. 재조회 수단이 없다(해시만 저장).
  2. 연동 User는 실 로그인 계정이 아니라 partner+{uuid}@integration.local 대리 계정이며 비밀번호는 32바이트 랜덤이고 반환되지 않는다.
  3. UserStatus(ACTIVE/INACTIVE/SUSPENDED)는 현재 어디서도 게이트로 쓰이지 않는다User.create가 ACTIVE를 쓰는 것이 유일한 사용처다(실측).
방안설명판정
A. 동기 SAGA — 컨텍스트별 로컬 트랜잭션 + 보상 + INACTIVE semantic lock ★ 채택UseCase에서 클래스·메서드 @Transactional을 제거하고, 트랜잭션 경계를 컨텍스트별 DomainService 메서드로 내린다. tx1(user) → tx2(partner) → tx3(user)의 3단 로컬 트랜잭션. 중간 상태를 INACTIVE로 표현해 실패 잔존물을 무해화채택 — API 계약·응답·admin 워크플로 무변경. 크로스 컨텍스트 단일 트랜잭션 쓰기 0건 달성. 신규 상태값·신규 컬럼·신규 테이블·신규 토픽 전부 불필요
B. 이벤트 기반 (user 생성 → partner 구독)user가 UserCreated 발행, partner가 구독해 Partner 생성미채택 — 4가지 이유. ① API Key 1회 반환 계약이 깨진다(비동기라 응답 시점에 Key가 없음) → admin API 재설계 + Key 재조회 경로 신설이 파생된다 ② partner가 모든 user 생성 이벤트를 구독해 “연동 계정인가”를 필터해야 한다 — user 컨텍스트가 partner를 알거나(역참조) payload에 partner 전용 플래그를 심어야 한다(공용 컨텍스트 오염) ③ 유령 User 문제가 악화된다 — 동기라면 즉시 보상하지만, 비동기 실패는 감지·복구 경로를 따로 만들어야 한다 ④ 단일 DB·단일 프로세스라 Kafka 내구성 이득이 0이다. 진단서 §7-2 “조기 이벤트화는 오버엔지니어링”
C. 크로스 컨텍스트 쓰기 자체 제거 — linkedUserId를 입력으로 받음연동 User 생성을 user 컨텍스트 admin API로 분리하고, partner는 기존 userId를 받기만 함미채택(단, 최선의 장기 형태) — 구조적으로 가장 깨끗하다. application.partner → domain.user 의존이 사라져 R3 화이트리스트 예외 1건이 통째로 소멸한다. 그러나 admin 워크플로가 1콜 → 2콜로 바뀌는 제품 계약 변경이라 0단계(구조 정리, 계약 무변경) 범위 밖이다. → Open Questions OQ-A로 올린다
D. 신규 상태값 PENDING 추가semantic lock 전용 상태 신설미채택 — 기존 INACTIVE가 “비활성” 의미를 이미 정확히 담는다. enum 값 추가는 상태 전이표·전수 분기·문서를 늘리는 순비용이다. 단순함 우선
E. 고아 정리 배치 (Spring Batch)미완료 연동 계정을 주기 삭제미채택 — 잔존물이 INACTIVE + 랜덤 비밀번호 + 로그인 차단으로 이미 무해하다. 발생 빈도는 admin이 파트너를 만들다 DB가 죽는 경우뿐이다. 배치 1개를 운영 자산으로 늘릴 근거가 없다. 대신 실패 시 구조화 로그로 식별 가능하게만 한다

Detail Design

시스템 역할 경계

서버 토폴로지는 현행 단일 배포 단위 유지다. 0단계는 스키마·런타임·컨테이너를 하나도 바꾸지 않는다(진단서 §6-1 “코드 되돌리기, 스키마 무변경”). 워커·스케줄러·소켓 서버 분리는 진단서 §6 3~5단계에서 측정된 트리거 도달 시에만 검토한다 — 지금 분리하면 측정 없이 컨테이너만 늘어난다.

단위역할소유 데이터/책임노출 인터페이스의존
FacilityDomainService (domain/facility)facility 조회·등록 공개 계약facilities(Mongo)findBy(id): Facility? (신규), findAllSchedulable(): List<Facility> (신규)FacilityRepository
SlotDomainService (domain/booking)slot 라이프사이클 공개 계약slotsfindBy(slotId): Slot? (신규), hasActiveSlots(facilityId): Boolean (신규)SlotRepository, FacilityOwnershipGateway
GoodsDomainService (domain/goods)상품·주문 공개 계약products 외 6테이블findOwnerIdBy(productId): Long (신규)ProductRepository
UserDomainService (domain/user)사용자·롤 공개 계약users·user_rolesfindEmailBy(userId): String? (신규)UserRepository
PermissionDomainService (domain/user) 신규권한 마스터 조회 공개 계약permissions (common에서 이관)findIdByName(name): Long?, findNamesByIds(ids): Map<Long, String>PermissionRepository(user로 이관)
IntegrationAccountDomainService (domain/user) 신규연동 전용 대리 계정 프로비저닝users·user_roles (연동 계정 한정)provision(adminId, roleNames): User, activate(userId), abandon(userId)UserRepository·RoleRepository·UserRoleRepository·PasswordEncoder
McpPermissionGateway (domain/mcp) 신규mcp 관점 권한 조회 ACL없음(계약)findPermissionIdBy(name): Long?
RecipientContactGateway (domain/notification) 신규알림 수신처 조회 ACL없음(계약)emailOf(userId): String?
infrastructure *GatewayImpl공급자 DomainService ↔ 소비자 DTO 변환만없음소비자 domain의 Gateway interface 구현공급자 DomainService
CreatePartnerUseCase (application/partner)파트너 생성 오케스트레이션 + 보상없음execute(command): CreatePartnerResponseIntegrationAccountDomainService·PartnerDomainService·OwnershipGuard

Infra 어댑터의 책임 축소가 핵심이다. no-business-flow-in-infra 관점에서 FacilityScheduleGatewayImpl페이지네이션 루프(while(true) { findAllForBackfill(pageable) })는 조회 정책이므로 공급자 DomainService(findAllSchedulable)로 내린다. 어댑터에는 DTO 변환만 남는다.

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

// domain/facility/service/FacilityDomainService.kt — 추가
fun findBy(id: String): Facility?                    // getById 와 달리 없으면 null (소비자가 자기 예외로 매핑)
fun findAllSchedulable(): List<Facility>             // ownerUserId != null && operatingHours 비어있지 않음, 페이징 루프 내장
 
// domain/booking/service/SlotDomainService.kt — 추가
fun findBy(slotId: Long): Slot?
fun hasActiveSlots(facilityId: String): Boolean
 
// domain/goods/service/GoodsDomainService.kt — 추가
fun findOwnerIdBy(productId: Long): Long             // 없으면 ResourceNotFoundException("Product", productId) — 기존 동작 보존
 
// domain/user/service/UserDomainService.kt — 추가
fun findEmailBy(userId: Long): String?               // findById 와 달리 없으면 null (알림은 예외 전파 금지)
 
// domain/user/service/PermissionDomainService.kt — 신규
fun findIdByName(name: String): Long?
fun findNamesByIds(ids: List<Long>): Map<Long, String>
 
// domain/mcp/gateway/McpPermissionGateway.kt — 신규 (mcp 소유 ACL)
fun findPermissionIdBy(permissionName: String): Long?
 
// domain/notification/gateway/RecipientContactGateway.kt — 신규 (notification 소유 ACL)
fun emailOf(userId: Long): String?
 
// domain/user/service/IntegrationAccountDomainService.kt — 신규
fun provision(adminId: Long, roleNames: List<String>): User   // INACTIVE 생성 + 롤 부여, 한 트랜잭션
fun activate(userId: Long)                                     // INACTIVE -> ACTIVE
fun abandon(userId: Long)                                      // INACTIVE 유지 확정 (보상, 멱등)

의존 방향 주의 (Spring 순환 빈 위험 — 반드시 지킬 것)

FacilityOwnershipGatewayImplFacilityDomainService를 주입한다. FacilityOwnerDomainService를 주입하면 안 된다.

FacilityOwnerDomainService → SlotQueryGateway(Impl) → SlotDomainService → FacilityOwnershipGateway(Impl) → ???
                                                                                                          ↑ FacilityOwnerDomainService 를 넣으면 순환

FacilityDomainServiceFacilityRepository·RegionResolveGateway만 의존하므로 되돌아오는 간선이 없다. 이 실수는 슬라이스 테스트로는 드러나지 않고 풀부팅에서만 터진다.

프로파일 정합: FacilityDomainService·FacilityOwnershipGatewayImpl·FacilityScheduleGatewayImpl은 모두 @Profile("!test-jpa")다. 의존을 교체해도 프로파일 조건은 그대로 유지한다. test-jpa 부팅은 TestJpaGatewayStubConfig의 익명 스텁이 담당하므로 영향받지 않는다.

실패 경로·동시성·멱등

항목 1~3 (조회 경유 전환) — 동작 계약 무변경이 원칙이다.

관심사결정
예외 계약소비자 도메인 예외를 그대로 유지한다. requireOwnerSlotFacilityNotFoundException/UnauthorizedFacilityAccessException, findOwnerIdResourceNotFoundException("Product", id). 공급자 예외가 소비자 밖으로 새면 안 된다
null 계약SlotInfoGateway.findBy는 없을 때 null(예외 전파 금지 — 기존 계약). RecipientContactGateway.emailOf도 null
동시성전부 읽기 전용. 락 불필요
멱등조회라 해당 없음
트랜잭션조회 메서드에 @Transactional 추가 금지 — 호출부(UseCase) 경계를 그대로 상속한다

항목 5 (T1 SAGA) — 상태 전이와 실패 잔존물을 전부 표로 고정한다.

tx1 (user)     : User(INACTIVE) 생성 + GOODS_SELLER·EVENT_HOST 롤 부여
tx2 (partner)  : Partner(ACTIVE) 생성 + PartnerApiKey 발급
tx3 (user)     : User INACTIVE -> ACTIVE
현재 상태 × 이벤트다음 상태잔존물위험도
— × tx1 성공User=INACTIVE
User=INACTIVE × tx1 실패없음없음(트랜잭션 롤백)없음
User=INACTIVE × tx2 성공User=INACTIVE, Partner=ACTIVE
User=INACTIVE × tx2 실패User=INACTIVE (보상 abandon 호출, 멱등)INACTIVE 연동 User 1건무해 — 로그인 차단 + 랜덤 비밀번호 미반환 + 롤은 부여됐으나 인증 경로가 열리지 않음
User=INACTIVE, Partner=ACTIVE × tx3 성공User=ACTIVE, Partner=ACTIVE정상 완료
User=INACTIVE, Partner=ACTIVE × tx3 실패Partner=SUSPENDED (보상), User=INACTIVEINACTIVE User + SUSPENDED Partner무해 — 파트너 API 인증은 Partner.validateActive()에서 거부된다
위 보상마저 실패User=INACTIVE, Partner=ACTIVE활성 Partner + 비활성 연동 User낮음 — 파트너 API는 동작한다(코어 UseCase는 ownerUserId만 사용). admin이 기존 파트너 상태 변경 API로 정리 가능. 이 경로는 error 레벨 구조화 로그로 식별한다
  • 거부 케이스: activateINACTIVE가 아닌 User를 받으면 상태 전이를 거부한다(이미 ACTIVE면 no-op = 멱등, SUSPENDED면 예외).
  • semantic lock을 실효화한다: 현재 UserStatus는 어디서도 게이트가 아니다. 로그인 경로(AuthDomainService)에 비활성 계정 로그인 거부를 추가해야 “INACTIVE = 무해”가 설계 주장이 아니라 사실이 된다. 기존 데이터는 전부 ACTIVE이므로(실측: User.create가 ACTIVE만 씀, 다른 쓰기 없음) 회귀 위험이 없다.
  • 멱등: admin이 실패 후 재요청하면 새 연동 User + 새 Partner가 생성된다. 이전 실패 잔존물은 INACTIVE로 남는다. 요청 단위 멱등키는 도입하지 않는다 — admin 수동 조작이고, 중복 파트너는 admin이 상태 변경 API로 처리 가능하며, 멱등키 도입은 partners 스키마 변경을 유발한다(0단계 스키마 무변경 원칙 위반).
  • 동시성: 연동 계정 이메일은 UUID 기반이라 충돌 없음. 파트너명 중복은 기존 동작을 유지한다(본 작업에서 제약을 신설하지 않는다).
  • 트랜잭션 전파: CreatePartnerUseCase에서 @Transactional제거한다. 결제 개시 4종(CreateBookingUseCase 등)이 이미 쓰는 “UseCase 레벨 트랜잭션 없음” 패턴과 동일한 선례를 따른다. 각 DomainService 메서드가 자기 트랜잭션을 연다.

Component Diagram

flowchart LR
    subgraph Consumer["소비자 컨텍스트"]
        CG[ACL Gateway interface]
        CDS[소비자 DomainService]
    end
    subgraph Infra["infrastructure 어댑터"]
        GI[XxxGatewayImpl]
    end
    subgraph Provider["공급자 컨텍스트"]
        PDS[공급자 DomainService]
        PR[(공급자 Repository)]
    end
    CDS --> CG
    GI -.->|implements| CG
    GI --> PDS
    PDS --> PR
    GI -->|"AS-IS 제거 대상"| PR

GI → PR 실선이 AS-IS이고 0단계에서 끊는다. GI → PDS가 TO-BE다.

Sequence Diagram — T1 SAGA

sequenceDiagram
    participant C as PartnerAdminApiController
    participant U as CreatePartnerUseCase
    participant I as IntegrationAccountDomainService
    participant P as PartnerDomainService
    C->>U: execute(command)
    U->>I: provision(adminId, roles)
    I-->>U: User(INACTIVE)
    U->>P: createPartner(name, userId)
    alt tx2 실패
        U->>I: abandon(userId)
        U-->>C: 예외 전파
    else tx2 성공
        P-->>U: Partner + IssuedApiKey
        U->>I: activate(userId)
        U-->>C: CreatePartnerResponse(plainKey)
    end

ERD

변경 없음. 0단계는 테이블·컬럼·인덱스·데이터를 하나도 바꾸지 않는다.

  • permissions 테이블: 소유 컨텍스트 표기만 commonuser로 바뀐다. DDL 무변경(Kotlin 패키지 이동뿐).
  • users.status: 기존 VARCHAR(20) + 기존 enum 값(ACTIVE/INACTIVE/SUSPENDED)만 사용한다. 신규 값 없음.
  • Flyway 마이그레이션 0건. 따라서 데이터 마이그레이션 5단계(듀얼라이트→배치 백필→검증→배포→배포 후 검증)는 해당 없음이다.

Testing Plan

TDD 강제 — 프로덕션 코드보다 실패하는 테스트가 먼저. 전 레이어 Kotest(JUnit 금지).

레벨범위핵심 시나리오
domain공급자 DomainService 신규 조회 메서드, IntegrationAccountDomainService, PermissionDomainService, User 상태 전이, AuthDomainService 비활성 차단없는 id → null / 상태 전이 거부 / 비활성 로그인 거부
applicationCreatePartnerUseCase SAGAtx2 실패 → abandon 호출 검증 / tx3 실패 → partner 보상 검증 / 해피 패스에서 응답에 plainKey 포함
infrastructure6개 Gateway 구현체공급자 DomainService 모킹으로 변환·예외 매핑 검증 (Repository 모킹 금지 — 이번 전환의 본질이 의존 대상 교체다)
presentation변경 없음
architectureR1~R4 + FR-8 커버리지DomainClassification 합계 = 20 / R3 스캔이 상수에서 구동되는지 / 미분류 도메인 편입 후 GREEN
scenario (풀부팅)@SpringBootTest 최소 1개모든 티켓 필수 게이트. DI 배선 변경이라 순환 빈·빈 이름 충돌은 풀부팅에서만 드러난다

false-GREEN 방지: 각 티켓은 “전환 후에도 기존 동작이 같다”만 검증하면 부족하다. “공급자 Repository를 더 이상 주입하지 않는다” 를 직접 검증한다 — 생성자 파라미터 타입 단언 또는 ArchUnit 규칙 중 하나로.

Release Scenario — 무중단 배포

본 0단계는 무중단 배포가 자명하게 성립한다. 근거를 명시한다.

항목
스키마 변경없음 (Flyway 파일 0건)
외부 API 계약 변경없음 (요청·응답 DTO 무변경, CreatePartnerResponse 필드 동일)
이벤트 계약 변경없음 (토픽·payload 무변경)
런타임 토폴로지 변경없음 (컨테이너 13개 유지)
피처 플래그불필요 — 변경이 내부 배선뿐이라 토글할 동작 분기가 없다. 플래그를 도입하면 오히려 두 배선을 동시 유지해야 해 결합이 늘어난다

배포 순서: 티켓별 숏텀 브랜치를 origin/main에서 따고, 리뷰 통과 즉시 각자 main으로 머지한다. main 머지 = dev 배포. 티켓 간 배포 순서 제약이 없다(각 티켓이 자기 완결적으로 컴파일·부팅된다).

단계별 전환 조건

단계전환 조건
wave 1 머지티켓별 테스트 GREEN + 풀부팅 @SpringBootTest 1개 통과 + private-code-reviewer APPROVED/COMMENT
wave 2 머지wave 1 전량 머지된 최신 main 기준 재분기 + 동일 게이트
0단계 완료 판정grep으로 infrastructure 교차 Repository 주입 0건 ② domain/common@Entity/@Table 0건 ③ DomainClassification 합계 20 ④ ArchUnit 전체 GREEN ⑤ 문서-코드 정합

롤백 방법 (단계마다)

단계롤백
개별 티켓해당 머지 커밋 revert. 스키마·데이터가 없으므로 코드 되돌리기만으로 완전 복귀
0단계 전체각 티켓 revert (순서 무관). 진단서 §6-1 롤백 지점 “코드 되돌리기(스키마 무변경)“와 동일
T1(항목 5) 배포 후 이상CreatePartnerUseCase 커밋 revert → 기존 단일 트랜잭션 복귀. 그 사이 생성된 연동 User는 ACTIVE 상태로 정상 동작하므로 데이터 정합 문제 없음

플래그 제거 시점: 신규 플래그를 도입하지 않으므로 해당 없음.

Open Questions — 전건 확정 (2026-07-28)

#질문결정근거
OQ-ACreatePartnerUseCase의 연동 User 생성을 user 컨텍스트 admin API로 분리할 것인가? (방안 C)방안 A 유지 — 0단계는 SAGA·보상으로 처리. 방안 C는 폐기하지 않고 1단계 후보로 승격해 이월한다방안 C는 admin 워크플로가 1콜 → 2콜로 바뀌는 제품 계약 변경이다. 0단계의 전제는 “내부 배선만 바꾸고 외부 계약 무변경”이고, 그래서 무중단·롤백이 코드 되돌리기로 끝난다. 계약을 건드리면 그 전제가 깨진다. 다만 application.partner → domain.user 의존 소멸 = R3 화이트리스트 예외 1건 소멸은 실제 이득이므로 1단계에서 재검토한다
OQ-B크로스 컨텍스트 조합을 application 레이어로 올리는 정합(no-crosscontext-raw-read 권장형)을 언제 처리할 것인가?2단계까지 대기. 단 2단계 이후 잔존분을 아래에 확정 명시한다6건 중 3건이 facility-booking 병합으로 자연 해소되는데 지금 호출부 20+곳을 건드리는 것은 낭비다
OQ-Cairquality·featuredemo를 support가 아니라 별도 tier로 둘 것인가?support 편입weather가 이미 support인데 동형인 airquality를 다르게 두면 일관성이 깨진다. ADR-001 개정은 5계층 자체를 흔드는 일이라 이 티켓의 범위가 아니다

OQ-B 부속 — 2단계(facility-booking 병합) 이후 잔존 게이트웨이 (수치 정정)

정정: 본 문서 초안의 OQ-B에 “4건 중 3건 자연 해소”로 적었으나 부정확했다. 진단서 §4-1 ⓑ의 11개 모듈 분할안에 6건을 대조한 정확한 결과는 아래와 같다 — 3건 해소 / 3건 잔존이다.

#게이트웨이소비자 모듈 → 공급자 모듈 (§4-1 ⓑ 기준)2단계 후
1FacilityOwnershipGateway (booking→facility)facility-booking 내부해소 — 모듈 내부 호출
2FacilityScheduleGateway (booking→facility)facility-booking 내부해소
3SlotQueryGateway (facility→booking)facility-booking 내부해소
4SlotInfoGateway (community→booking)community-post-messagefacility-booking잔존
5GoodsProductGateway (message→goods)community-post-messagegoods잔존
6RecipientContactGateway (notification→user)platformuser-partner잔존

잔존 3건은 전부 단방향 읽기이고 양방향 데이터 접근이 없다(양방향이던 facility↔booking만 병합으로 해소된다). 따라서 2단계 이후에도 모듈 경계를 넘는 정상 협력으로 남으며, 별도 조치 없이 유지해도 무방하다. application 레이어 이전(OQ-B 권장형)이나 이벤트화는 진단서 §6 3~5단계에서 배포 단위가 실제로 갈릴 때 다시 판단한다.

Document History

날짜변경 내용
2026-07-28설계 결함 정정 — 항목 5 tx3 보상을 PartnerStatus.INACTIVE로 기술했으나 그 enum 값은 존재하지 않는다(ACTIVE/SUSPENDED 2값). 컴파일 불가능한 지시였고 구현 단계에서 발견돼 SUSPENDED로 정정. 의도(보상 후 파트너 인증 거부)는 Partner.validateActive()로 동일하게 성립
2026-07-28OQ-A/B/C 전건 확정 반영 (A=방안 A 유지 + 방안 C를 1단계 후보로 이월 / B=2단계 대기 / C=support 편입). OQ-B 수치 정정 — “4건 중 3건 해소”는 부정확, 11개 모듈 분할안 대조 결과 3건 해소·3건 잔존(SlotInfo·GoodsProduct·RecipientContact)이며 잔존 3건은 전부 단방향 읽기라 조치 불요임을 명시
2026-07-28최초 작성 — 진단서 0단계 실행 설계 보강. 항목 1 공급자 경유 채택(읽기모델 복제·application 이전·원격 계약 미채택), 항목 2 Permission 이관 시 mcp R1/R3 동시 파괴 발견 → ACL Gateway 동반 신설, 항목 3 tier 배정 + R3 스캔 구동 방식 교체(하드코딩 제거)·미분류 4건 코어 역참조 0건 실측 확인·partner 화이트리스트 명시, 항목 5 T1을 동기 SAGA + INACTIVE semantic lock으로 채택(이벤트화·PENDING 신설·정리 배치 미채택) + 실패 6케이스 상태 전이표 + 로그인 게이트 실효화. 스키마 변경 0·API 계약 변경 0으로 무중단 자명