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
| # | 항목 | 채택 방안 | 완료 판정 |
|---|---|---|---|
| 1 | infrastructure 교차 Repository 주입 6파일 | 소비자 ACL Gateway interface는 유지, 구현체 의존을 공급자 Repository → 공급자 DomainService로 교체 | infrastructure에 타 컨텍스트 *Repository 주입 0건 |
| 2 | domain/common/Permission | user로 이관 + mcp에 ACL Gateway 신설 (이관만 하면 mcp가 R1·R3를 동시에 깬다) | domain/common에 @Entity/@Table 0건, ArchUnit GREEN |
| 3 | FR-8 미분류 5건 | 전부 support tier로 편입 + R3 스캔 루프를 DomainClassification에서 구동(하드코딩 제거) | 분류 합계 = domain 패키지 20, R3 커버리지 20/20 |
| 4 | docs/domain-context-map.md | 코드 실측 기준 전면 갱신 (마지막 wave 단독) | 문서-코드 정합 |
| 5 | T1 CreatePartnerUseCase | 동기 SAGA — 컨텍스트별 로컬 트랜잭션 3단 + 보상 + INACTIVE semantic lock (이벤트화·신규 상태값·신규 스키마 전부 미채택) | 한 @Transactional 안의 크로스 컨텍스트 쓰기 0건 |
Terminology
| 용어 | 정의 |
|---|---|
| 공급자 경유 (provider DomainService routing) | 소비자 infrastructure 어댑터가 공급자의 Repository(=테이블) 가 아니라 공급자의 DomainService(=공개 행위 계약) 를 호출하는 것 |
| semantic lock | SAGA 중간 상태를 “효력 없는 상태값”으로 표현해, 보상 실패 시 잔존 데이터가 무해하도록 만드는 기법 |
| R1~R4 | ArchUnit 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 → facility | FacilityOwnershipGatewayImpl.kt:22 | FacilityRepository | findById(facilityId) |
| booking → facility | FacilityScheduleGatewayImpl.kt:30 | FacilityRepository | findAllForBackfill(pageable) 페이지 루프 |
| community → booking | SlotInfoGatewayImpl.kt:14 | SlotRepository | findById(slotId) |
| facility → booking | SlotQueryGatewayImpl.kt:13 | SlotRepository | existsActiveByFacilityId(facilityId) |
| message → goods | GoodsProductGatewayImpl.kt:16 | ProductRepository | findByIdAndDeletedAtIsNull(productId) |
| notification → user | RecipientContactResolver.kt:13 | UserRepository | findById(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:31 | permissionRepository.findByName(name)?.id |
| security (infra) | McpTokenAuthenticationFilter.kt:25,93 | findAllByIds(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") 하드코딩이라, 상수에 도메인을 추가해도 스캔 대상이 자동으로 늘지 않는다.
④ 문서 stale — docs/domain-context-map.md가 virtualqueue·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)는 그대로 두고, 구현체 생성자 의존만 XxxRepository → XxxDomainService로 교체. 필요한 조회 메서드가 없으면 공급자 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/common → domain/user) | 엔티티·Repository interface를 user로 옮기고 import만 갱신 | 미채택 — 실행하면 깨진다. McpTokenDomainService(domain/mcp)가 PermissionRepository를 직접 주입한다. 이관 즉시 domain.mcp → domain.user 교차 import가 생겨 R1(도메인 교차 import 0건)과 R3(서브시스템→코어 동기 의존 금지) 두 규칙이 동시에 RED |
| B. 이관 + mcp ACL Gateway 신설 ★ 채택 | ① Permission·PermissionRepository를 domain/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만 예외: CreatePartnerUseCase가 domain.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가지를 먼저 고정한다.
CreatePartnerUseCase는 동기 admin API이고, 응답에 1회성 평문 API Key(issuedApiKey.plainKey)를 담는다. 재조회 수단이 없다(해시만 저장).- 연동 User는 실 로그인 계정이 아니라
partner+{uuid}@integration.local대리 계정이며 비밀번호는 32바이트 랜덤이고 반환되지 않는다. 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 라이프사이클 공개 계약 | slots | findBy(slotId): Slot? (신규), hasActiveSlots(facilityId): Boolean (신규) | SlotRepository, FacilityOwnershipGateway |
GoodsDomainService (domain/goods) | 상품·주문 공개 계약 | products 외 6테이블 | findOwnerIdBy(productId): Long (신규) | ProductRepository 외 |
UserDomainService (domain/user) | 사용자·롤 공개 계약 | users·user_roles | findEmailBy(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): CreatePartnerResponse | IntegrationAccountDomainService·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 순환 빈 위험 — 반드시 지킬 것)
FacilityOwnershipGatewayImpl은 FacilityDomainService를 주입한다. FacilityOwnerDomainService를 주입하면 안 된다.
FacilityOwnerDomainService → SlotQueryGateway(Impl) → SlotDomainService → FacilityOwnershipGateway(Impl) → ???
↑ FacilityOwnerDomainService 를 넣으면 순환
FacilityDomainService는 FacilityRepository·RegionResolveGateway만 의존하므로 되돌아오는 간선이 없다. 이 실수는 슬라이스 테스트로는 드러나지 않고 풀부팅에서만 터진다.
프로파일 정합: FacilityDomainService·FacilityOwnershipGatewayImpl·FacilityScheduleGatewayImpl은 모두 @Profile("!test-jpa")다. 의존을 교체해도 프로파일 조건은 그대로 유지한다. test-jpa 부팅은 TestJpaGatewayStubConfig의 익명 스텁이 담당하므로 영향받지 않는다.
실패 경로·동시성·멱등
항목 1~3 (조회 경유 전환) — 동작 계약 무변경이 원칙이다.
| 관심사 | 결정 |
|---|---|
| 예외 계약 | 소비자 도메인 예외를 그대로 유지한다. requireOwner는 SlotFacilityNotFoundException/UnauthorizedFacilityAccessException, findOwnerId는 ResourceNotFoundException("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=INACTIVE | INACTIVE User + SUSPENDED Partner | 무해 — 파트너 API 인증은 Partner.validateActive()에서 거부된다 |
| 위 보상마저 실패 | User=INACTIVE, Partner=ACTIVE | 활성 Partner + 비활성 연동 User | 낮음 — 파트너 API는 동작한다(코어 UseCase는 ownerUserId만 사용). admin이 기존 파트너 상태 변경 API로 정리 가능. 이 경로는 error 레벨 구조화 로그로 식별한다 |
- 거부 케이스:
activate는INACTIVE가 아닌 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테이블: 소유 컨텍스트 표기만common→user로 바뀐다. 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 / 상태 전이 거부 / 비활성 로그인 거부 |
| application | CreatePartnerUseCase SAGA | tx2 실패 → abandon 호출 검증 / tx3 실패 → partner 보상 검증 / 해피 패스에서 응답에 plainKey 포함 |
| infrastructure | 6개 Gateway 구현체 | 공급자 DomainService 모킹으로 변환·예외 매핑 검증 (Repository 모킹 금지 — 이번 전환의 본질이 의존 대상 교체다) |
| presentation | 변경 없음 | — |
| architecture | R1~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-A | CreatePartnerUseCase의 연동 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-C | airquality·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단계 후 |
|---|---|---|---|
| 1 | FacilityOwnershipGateway (booking→facility) | facility-booking 내부 | 해소 — 모듈 내부 호출 |
| 2 | FacilityScheduleGateway (booking→facility) | facility-booking 내부 | 해소 |
| 3 | SlotQueryGateway (facility→booking) | facility-booking 내부 | 해소 |
| 4 | SlotInfoGateway (community→booking) | community-post-message → facility-booking | 잔존 |
| 5 | GoodsProductGateway (message→goods) | community-post-message → goods | 잔존 |
| 6 | RecipientContactGateway (notification→user) | platform → user-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-28 | OQ-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으로 무중단 자명 |