백엔드 MSA 분리 판단 아키텍처 진단 문서
Background / 진단 범위
- 요구사항 원문: “backend를 msa로 분리하려고 하는데, 도메인 바운디드 컨텍스트가 잘 나뉘어져 있는지 아키텍처를 어떻게 구성할 것인지부터 시작해줘”
- 진단 목적: MSA 분리가 지금 옳은 결정인가를 코드 실측으로 판정하고, 옳지 않다면 무엇이 옳은지 후보군으로 제시한다. MSA 결론을 미리 정해두고 정당화하지 않는다.
- 대상 트리:
/Users/biuea/sports-app-arch-msa(읽기 전용 worktree) - 참고 선행 산출물:
아키텍트/20260708-전체시스템-architecture.md아키텍트/20260706-사용자정의-도메인경계-갭분석-architecture.md도메인 경계 재설계/{PRD,TDD}.md+ADR/5건 (ADR-001 5계층 분류 / ADR-002 관계·의존 방향 / ADR-003 제공 UseCase 경계 / ADR-004 operator·mcp 격리 / ADR-005 ArchUnit fitness function)- 레포
docs/domain-context-map.md,requirement.md,domain-reference.md
트리 신선도 게이트 (진단 기준 커밋)
| 항목 | 값 |
|---|---|
| HEAD | f18434c3dfda050f3a4aae2513f11425ad853ce4 |
origin/main...HEAD (ahead/behind) | 0 / 0 |
| 워킹트리 | clean |
origin/main...origin/dev | 159 / 0 — dev는 main에 완전히 포함되며 159커밋 뒤. dev 기준 판단 금지 확인 |
과거 stale 트리 기반 orphan 오판 이력이 있어 게이트를 진단 앞단에 두었다.
⚠️ 브리핑 사실 정정 2건 (실측 결과)
| 브리핑 내용 | 실측 결과 | 근거 |
|---|---|---|
| ”layer→context→sub 패키지 재배치는 dev에만 머지됐고 main에는 미반영으로 보인다 → 설계 의도와 트렁크 현실의 괴리” | 오류. main에 반영돼 있다. git merge-base --is-ancestor e74c8a3d9 f18434c3 = YES (PR #193 PKG-2가 main 조상). 실제 트리도 application/ticketing/{dto,usecase}/처럼 서브패키지가 적용돼 있다 | b2c6e8f41 diff = application/ticketing/{ => dto}/CreateMyEventCommand.kt |
| ”패키지는 layer-first” | 맞다. 단 PKG-2는 layer-first를 바꾸는 작업이 아니었다. PKG-2는 layer/context/*.kt → layer/context/{dto,usecase,...}/*.kt로 컨텍스트 하위에 sub 그룹핑을 추가한 것이고, 최상위 layer-first는 처음부터 의도된 유지 대상이었다 | com/sportsapp/{application,domain,infrastructure,presentation} 최상위 4개 |
→ “설계 의도와 트렁크 현실의 괴리”는 존재하지 않는다. 패키지 구조는 설계대로다. 실제 괴리는 다른 곳(문서 stale·FR-8 분류 누락)에 있고 §7에 기록했다.
1. AS-IS 구조 조사
1-1. 배포 형태·모듈 (실측)
| 항목 | 실측값 | 근거 |
|---|---|---|
| Gradle 모듈 | 단일 모듈 (include 선언 0건) | backend/settings.gradle.kts — rootProject만 |
| 배포 단위 | 단일 컨테이너 backend (API + Kafka Consumer + 스케줄러 + WebSocket 통합) | docker-compose.yml·.dev.yml·.prod.yml 모두 backend 서비스 1개 |
| 수평 확장 자산 | nginx LB + backend replica | docker-compose.lb.yml (backend + nginx-lb) |
| 패키징 전략 | layer-first → context → sub (com/sportsapp/{layer}/{context}/{dto,usecase,...}) | 최상위 4 layer, PKG-2 적용 완료 |
| main Kotlin 파일 | 1,280개 | find src/main/kotlin -name "*.kt" |
| test Kotlin 파일 | 737개 | find src/test -name "*.kt" |
| REST Controller | 52개 | *ApiController.kt |
| EventWorker | 16개 | presentation/**/*EventWorker.kt |
| Flyway 마이그레이션 | 59개 / 테이블 45개 | src/main/resources/db/migration/ |
폴리글랏 영속화: MySQL 8.0(주 관계형) / MongoDB 7.0(facility 문서) / Redis 7(락·재고 게이트·좌석 락·대기열·JWT 블랙리스트, 13개 컴포넌트가 사용) / Kafka 3.7 KRaft 단일 브로커 / MinIO(S3 호환).
관측 스택 보유 (docker-compose.observability.yml): otel-collector · prometheus · tempo(분산 추적) · loki · promtail · grafana. → MSA 선행 인프라 중 분산 추적은 이미 있다.
1-2. 컨텍스트 인벤토리 + 코드 규모 (4개 레이어 합산 실측)
domain 패키지 기준 20개 컨텍스트 + common 공유 커널, 그 외 application 전용 4개(catalog·order·dashboard·image).
| 컨텍스트 | 파일 | 라인 | ADR-001 분류 | 비고 |
|---|---|---|---|---|
| goods | 150 | 4,873 | 코어 | Product·Cart·GoodsOrder·LimitedDrop·Stock. 최대 |
| mcp | 103 | 3,857 | 서브시스템 | AI 에이전트 운영 |
| message | 102 | 3,087 | 코어 | Room·Message, STOMP 실시간 |
| facility | 100 | 3,007 | 코어 | Facility(Mongo)·Program·OperatingHours·Holiday |
| booking | 91 | 2,668 | 코어 | Slot·Booking |
| ticketing | 87 | 2,476 | 코어 | Event·Seat·Ticket·TicketOrder |
| user | 62 | 1,268 | 코어 | User·Role·RBAC |
| recruitment | 61 | 1,841 | 코어 | Recruitment·Application (신규) |
| notification | 59 | 1,573 | 지원 | |
| featureflag | 58 | 1,815 | 미분류 | |
| community | 54 | 1,813 | 코어 | Community·CommunityMember |
| partner | 47 | 1,421 | 미분류 | B2B API 신원 |
| virtualqueue | 37 | 1,615 | 미분류 | 가상 대기열 (신규) |
| post | 37 | 1,157 | 코어 | |
| payment | 37 | 1,419 | 코어 | 공용 컨텍스트 |
| alerting | 36 | 1,262 | 지원 | |
| common | 31 | 656 | 공유 커널 | |
| operator | 16 | 487 | 지원 | |
| airquality | 14 | 616 | 미분류 | |
| weather | 10 | 372 | 지원 | |
| featuredemo | 7 | 148 | 미분류 | |
| dashboard / catalog / order / image | 7 / 7 / 6 / 5 | 267 / 353 / 263 / 93 | 조회·유틸 | application 전용, domain 레이어 없음 |
규모 판정: 최대 컨텍스트(goods)가 4,873라인이다. MSA 서비스 1개의 통상 하한(수만 라인)에 한참 못 미친다 — 20개로 쪼개면 평균 1,600라인짜리 서비스 20개가 된다.
1-3. 선행 문서 대비 해소된 갭 (20260706·20260708 → 현재)
이전 진단의 주요 지적이 상당수 해소됐다. 판정을 갱신한다.
| 이전 지적 | 현재 상태 | 근거 |
|---|---|---|
| 상품 ⊃ 중고/브랜드 구분 불가 | 해소 — SellerType{B2C,B2B} 판별 필드 도입, 인증 채널로 등록 시 1회 자동 결정 | domain/goods/vo/SellerType.kt, domain/goods/entity/Product.kt:52 |
| 시설상품(PT·클래스) 누락 | 해소 — Program aggregate 신설 | domain/facility/entity/Program.kt:23 |
| 모집(recruitment) 완전 누락 | 해소 — 독립 코어 컨텍스트 (61파일) | domain/recruitment/ |
| 시설 운영시간·휴무 누락 | 해소 — OperatingHours·Holiday VO | domain/facility/vo/{OperatingHours,Holiday}.kt |
OrderType가 payment 소유(역방향) | 해소 — domain.common.order로 공유 커널 승격, 4값(BOOKING/TICKETING/GOODS/RECRUITMENT) | domain/common/order/OrderType.kt |
| 상품·주문 상위 개념 부재 | 부분 해소 — catalog·order 읽기 조합 파사드 도입(쓰기 상위 aggregate는 의도적 미도입) | application/catalog/CatalogCompositionService.kt, application/order/OrderCompositionService.kt |
DomainClassification.core에 community 미등록 | 해소 — community·recruitment 등록됨 | SupportToCoreDependencyRulesTest.kt:19 |
| 마케팅 폭주 흡수 계층 부재 | 해소 — virtualqueue 컨텍스트 신설(37파일) | domain/virtualqueue/ |
1-4. AS-IS 컴포넌트 다이어그램
flowchart LR subgraph Ingress["인그레스 (단일 배포단위)"] Nginx[nginx LB] VQ[virtualqueue 입장토큰] LS[LoadSheddingFilter] end subgraph App["Spring 모놀리스 backend"] Ctrl[52 Controller] UC[UseCase 계층] Facade[catalog·order 조합파사드] DS[20 컨텍스트 DomainService] end subgraph Stores["단일 공유 저장소"] MySQL[(MySQL 45테이블)] Mongo[(MongoDB facility)] Redis[(Redis 락·큐)] end subgraph Bus["이벤트 백본"] Router[RoutingDomainEventPublisher] Kafka[Kafka 3토픽] end Nginx --> VQ VQ --> LS LS --> Ctrl Ctrl --> UC Ctrl --> Facade Facade --> DS UC --> DS DS --> MySQL DS --> Mongo DS --> Redis DS --> Router Router --> Kafka Kafka --> UC
1-5. 병목·안티패턴 (파일 근거)
| # | 항목 | 판정 | 근거 |
|---|---|---|---|
| B1 | 단일 배포 단위에 20 컨텍스트 + 52 컨트롤러 — b2c/마케팅/b2b/운영(mcp)이 같은 힙·스레드풀·커넥션풀 공유 | 임계점 근접 | SportsApplication.kt 단일 엔트리, compose backend 1서비스 |
| B2 | infrastructure Gateway 구현이 타 컨텍스트 Repository를 직접 주입 — 데이터 레벨 결합 9건/5쌍 | MSA 하드 블로커 | §2-2 표 (FacilityScheduleGatewayImpl.kt:6-9 등) |
| B3 | domain 엔티티가 곧 JPA 엔티티 — domain 42파일이 jakarta.persistence import. 스키마 변경이 도메인 모델에 직결 | 분리 시 마찰 | domain/goods/entity/Product.kt @Entity, grep -rl "import jakarta.persistence" domain/ = 42 |
| B4 | 공유 커널이 물리 테이블을 소유 — domain/common/Permission.kt가 @Table(name="permissions"), user의 role_permissions가 참조 | DB 분리 시 충돌점 | domain/common/Permission.kt:11 |
| B5 | FR-8 분류 누락 5건 — featureflag·partner·virtualqueue·airquality·featuredemo가 DomainClassification 어디에도 없음 → R3 규칙이 이들을 못 잡음 | 거버넌스 구멍 | SupportToCoreDependencyRulesTest.kt:19-21 (core 10 + support 4 + subsystem 1 = 15 / 실제 20) |
| B6 | docs/domain-context-map.md stale — virtualqueue 0회·catalog 0회 언급, recruitment↔payment/community 연동을 “BE-55·BE-60 예정”으로 기술하나 코드는 배선 완료 | 문서-코드 괴리 | 맵 최종 수정 1fbf8b4f6(7/7) 이후 virtualqueue·catalog·order 유입 |
순환 의존 0건, 컨텍스트 교차 JOIN 0건, 네이티브 SQL 0건(유일 2건은 Spring Batch 메타데이터 초기화). 구조 위생은 매우 좋다.
2. 컨텍스트 간 결합 실측표
2-1. 결합 축별 총량
| 축 | 건수 | 판정 |
|---|---|---|
| ① domain 레이어 교차 import (ArchUnit R1) | 0건 | 정합 — 도메인 모델은 완전 격리 |
| ② application 레이어 교차 참조 | 22쌍 / 48 import | 설계상 허용(ADR-002 rule #2) |
| ③ infrastructure 교차 참조 (타 컨텍스트 Repository 직접 주입) | 5쌍 / 9 import | MSA 블로커 |
| ④ 공유 테이블 / 교차 JOIN | 0건 (교차 JOIN), 단 공유 커널 테이블 1건(permissions) | 대체로 정합 |
| ⑤ Kafka 이벤트 | 3토픽 / 7구독 그룹 | 정합 |
⑥ 공유 커널 의존 (domain.common) | 전 컨텍스트 | ErrorStatus/BusinessException 119회 등 |
2-2. ③ infrastructure 데이터 레벨 결합 — 전수 (MSA 하드 블로커)
Gateway interface는 소비자 도메인에 정의(ACL 정합)되나, 구현체가 공급자의 Repository를 직접 주입해 남의 테이블을 읽는다. import는 domain interface를 향하므로 R1은 통과하지만, 스키마 결합은 그대로다.
| 소비자 → 공급자 | 파일:라인 | 주입 대상 | 분리 시 필요 조치 |
|---|---|---|---|
| booking → facility | infrastructure/booking/gateway/FacilityOwnershipGatewayImpl.kt:6 | FacilityRepository | 원격 조회 or 소유권 읽기모델 |
| booking → facility | infrastructure/booking/gateway/FacilityScheduleGatewayImpl.kt:6,7,8,9 | FacilityRepository + Facility + OperatingHours + TimeRange | 원격 조회 or 스케줄 읽기모델 복제 |
| community → booking | infrastructure/community/gateway/SlotInfoGatewayImpl.kt:3 | SlotRepository | 원격 조회 |
| facility → booking | infrastructure/facility/gateway/SlotQueryGatewayImpl.kt:3 | SlotRepository | 원격 조회 |
| message → goods | infrastructure/message/gateway/GoodsProductGatewayImpl.kt:4 | ProductRepository | 원격 조회 |
| notification → user | infrastructure/notification/gateway/RecipientContactResolver.kt:3 | user 도메인 타입 | 이벤트 payload에 연락처 동봉 or 원격 조회 |
→ 이 6개 파일이 MSA 분리 시 가장 먼저 깨진다. Gradle 멀티모듈로 쪼개는 순간 컴파일 에러가 난다(그래서 멀티모듈이 유용한 진단 도구다).
2-3. ② application 레이어 교차 참조 — 쌍별 (import 건수)
| 소비자 → 공급자 | 건수 | 성격 |
|---|---|---|
| community → message | 6 | 채팅방 조회(RoomContextQueryService) |
| booking → payment | 6 | 결제 개시(PaymentDomainService·PgInitiateCommand) |
| post → community | 5 | 멤버십 인가(requireActiveMember) |
| goods → payment | 4 | 결제 개시 |
| dashboard → ticketing / goods / booking / user / facility | 4/3/3/1/1 | 읽기 조합 (R3 화이트리스트) |
| catalog → goods / ticketing / recruitment / facility | 4/2/2/2 | 읽기 조합 파사드 |
| ticketing → payment | 3 | 결제 개시 |
| recruitment → payment | 3 | 결제 개시 |
| order → ticketing / recruitment / goods / booking | 2/2/2/2 | 읽기 조합 파사드 |
| recruitment → community | 2 | 커뮤니티 조회 |
| facility → booking | 2 | Program 회차 = Slot 생성 |
| partner → user | 1 | 연동 User 프로비저닝 (R3 화이트리스트) |
주목: catalog·order 파사드는 4개 DomainService를 병렬 fan-out + 도메인당 300ms 타임아웃 + 실패 도메인 부분 저하(failedDomains) 로 조합한다 — 이미 MSA의 API Composition 패턴 형태다.
근거:
application/catalog/CatalogCompositionService.kt:28(DOMAIN_TIMEOUT_MILLIS = 300L),application/order/OrderCompositionService.kt:26
2-4. ⑤ Kafka 이벤트 토폴로지 (Layer 2)
| 토픽 | 발행 | 구독 groupId | 근거 |
|---|---|---|---|
event.payment.payment.v1 | payment | booking-payment / goods-payment / ticketing-payment / recruitment-payment / notification-payment (5 팬아웃) | presentation/{booking,goods,ticketing,recruitment}/worker/*PaymentEventWorker.kt:21-22, notification/worker/NotificationEventWorker.kt:27-28 |
event.booking.booking.v1 | booking | notification-booking | NotificationEventWorker.kt:44-45 |
event.ticketing.ticket.v1 | ticketing | notification-ticketing | NotificationEventWorker.kt:60-61 |
공용 컨텍스트 역참조 0건 — payment는 발행만 하고 주문 컨텍스트를 모른다. when(orderType) 동기 디스패치 허브 없음. private-be-architecture-rule의 핵심 규칙을 준수한다.
2-5. 미확인 항목 (추측하지 않음)
| 항목 | 상태 |
|---|---|
| 실측 트래픽(RPS·P95·동시접속) | 미확인 — requirement.md는 목표치만 제시, 측정 아티팩트 없음 |
| 컨텍스트별 실제 부하 분포 | 미확인 — docker-compose.sim.yml(k6) 자산은 있으나 결과 미보유 |
| 컨텍스트별 DB 테이블 크기·증가율 | 미확인 |
3. 바운디드 컨텍스트 적합성 판정
3-1. 컨텍스트별 판정
판정 기준: 응집도(자기 데이터·규칙을 스스로 소유) / 소유 데이터(테이블 독점) / 독립 배포 가능성(교차 데이터 접근 없음).
| 컨텍스트 | 응집도 | 소유 데이터 | 독립 배포 | 판정 |
|---|---|---|---|---|
| payment | 높음 — 발행만, 주문 타입 모름 | payments 독점 | 가능 | PASS (모범) |
| virtualqueue | 높음 — EntryTokenGuard(common)로만 노출, 역참조 0 | Redis 전용 | 가능 | PASS (모범) |
| user | 높음 | users·roles·user_roles·role_permissions | 조건부 — permissions가 공유 커널 소유(B4) | PASS (조건부) |
| goods | 높음 | products·carts·cart_items·goods_orders·goods_order_items·stocks·limited_drops | 조건부 — message가 ProductRepository 역방향 접근 | PASS (조건부) |
| ticketing | 높음 | events·seats·tickets·ticket_orders | 가능 | PASS |
| recruitment | 높음 — 자체 취소수수료 정책 소유 | recruitments·applications | 가능 | PASS |
| community | 높음 | communities·community_members·community_bookings | 가능 | PASS |
| message | 높음 | rooms·messages·room_participants·room_invitations | 조건부 — goods 테이블 직접 읽기 | PASS (조건부) |
| post | 중간 — 인가를 community에 의존 | posts·comments | 조건부 | PASS (조건부) |
| mcp / alerting / notification / operator / featureflag | 높음 — 코어 역참조 0 | 각자 독점 | 가능 | PASS |
| facility ↔ booking | 낮음 — 양방향 데이터 접근 | facility=Mongo+regions, booking=slots·bookings | 불가 | 재경계 필요 |
| partner ↔ user | 낮음 — 단일 트랜잭션 쓰기 결합 | partner 3테이블 | 조건부 | 재경계 검토 |
| catalog / order / dashboard / image | 해당 없음 — Aggregate 없는 읽기 조합 | 없음 | 가능(무상태) | PASS (파사드) |
3-2. 재경계 필요 — facility ↔ booking (유일한 진짜 문제)
양방향 데이터 접근이 존재한다:
facility → booking:SlotQueryGatewayImpl.kt:3이SlotRepository주입booking → facility:FacilityScheduleGatewayImpl.kt:6-9가FacilityRepository+Facility엔티티 +OperatingHoursVO 주입facility → booking(application):CreateProgramSessionUseCase가@Transactional로 Program 읽기 + Slot 쓰기
라이프사이클도 얽혀 있다 — 시설의 운영시간·휴무가 슬롯 생성 규칙을 결정하고, Program(시설상품) 회차가 곧 Slot이다.
| 현재 경계 | 제안 경계 | 근거 |
|---|---|---|
| facility(시설·Program·운영시간, Mongo) / booking(Slot·Booking, MySQL) 별도 | 하나의 배포 단위 facility-booking으로 묶는다 (내부 모듈은 유지) | 변경 주기 동일(시설 운영 정책 변경 → 슬롯 규칙 변경), 양방향 데이터 접근, 단일 트랜잭션 쓰기(CreateProgramSessionUseCase) |
domain/common/Permission.kt가 permissions 테이블 소유 | user로 이관 — 공유 커널은 물리 테이블을 소유하지 않는다 | role_permissions(user 소유)가 유일 소비자. 공유 커널은 계약만 |
featureflag·partner·virtualqueue·airquality·featuredemo 미분류 | ADR-001 5계층에 편입 (제안: featureflag·virtualqueue=플랫폼/지원, partner=지원, airquality·featuredemo=지원) | FR-8 절차 미이행 → R3가 이들의 코어 역참조를 못 잡는다 |
3-3. 분리 시 트랜잭션이 쪼개지는 지점 (전수)
| # | 지점 | 현재 | 분리 시 | 난이도 |
|---|---|---|---|---|
| T1 | partner/usecase/CreatePartnerUseCase.kt | @Transactional 안에서 user 쓰기(register + assignRole×2) + partner 쓰기(createPartner) | 2컨텍스트 쓰기 → SAGA 또는 보상 트랜잭션 필수 | 높음 |
| T2 | facility/usecase/CreateProgramSessionUseCase.kt | @Transactional 안에서 facility 읽기 + booking 쓰기 | 원격 읽기 + 로컬 쓰기 (읽기 실패 시 중단) | 중간 |
| T3 | post/usecase/CreateCommunityPostUseCase.kt | @Transactional 안에서 community 인가 읽기 + post 쓰기 | 원격 인가 조회 + 로컬 쓰기 | 낮음 |
| T4 | post/usecase/AddCommentUseCase.kt:22 | 동일 (requireActiveMember 인가 읽기) | 동일 | 낮음 |
| T5 | 결제 개시 4종 — CreateBookingUseCase · CreateGoodsOrderUseCase · PurchaseTicketsUseCase · ApplyRecruitmentUseCase | 이미 클래스 레벨 @Transactional 없음 (PG 네트워크 호출을 트랜잭션에 감싸지 않으려는 의도적 설계) | 추가 작업 없음 — 이미 분리 완료 | 없음 |
| T6 | 결제 확정 (payment → 주문 4종) | 이미 Kafka 이벤트 + 멱등 구독 | 추가 작업 없음 — 이미 최종일관성 | 없음 |
| T7 | Gateway 6개 구현 (§2-2) | 로컬 Repository 호출 | 원격 호출 or 읽기모델 복제 | 중간 |
핵심: 돈이 걸린 경로(결제 개시·확정)는 이미 트랜잭션이 쪼개져 있고 이벤트 기반 최종일관성으로 동작한다(T5·T6). MSA 전환에서 가장 어려운 부분이 이미 끝나 있다. 남은 진짜 SAGA 대상은 T1 단 1건이다.
3-4. 사용자 정의 경계 정합 매트릭스 (존재 ≠ 경계정합)
requirement.md·domain-reference.md가 정의한 계층(상품 ⊃ 중고·브랜드·티켓·한정판·시설상품, 주문 = 상위 단일 개념)이 SSOT다. “그 이름의 패키지가 존재한다”로 판정하지 않고, 소유 관계(테이블 소유·동기 호출 방향·이벤트 흐름) 를 대조한다.
| 사용자 정의 | ① 존재 위치 (파일:라인) | ② 경계 판단 근거 | 판정 |
|---|---|---|---|
| 상품 (상위 개념) | application/catalog/dto/CatalogItem.kt:13 + CatalogItemType(PRODUCT/LIMITED_DROP/TICKET/PROGRAM/RECRUITMENT) | 상위 개념이 application 읽기 파사드로만 존재. grep -rn "SellableItem|abstract class Product" domain/ = 0 → 쓰기·소유 경계로서의 상위 타입은 여전히 없다. 5개 하위가 3개 독립 컨텍스트(goods·ticketing·facility·recruitment)에 분산 소유 | 부분일치 (읽기만 통합) |
| 상품 ⊃ 중고(b2c) | domain/goods/vo/SellerType.kt(B2C), domain/goods/entity/Product.kt:52 | goods(=상품 그룹핑) 내부 배치 + 판별 필드 존재. 이전 “구분 불가” 해소 | 일치 |
| 상품 ⊃ 브랜드(b2b) | 동일 SellerType.B2B, Product.kt:52 | 동일. 인증 채널로 등록 시 자동 결정, 이후 불변 | 일치 |
| 상품 ⊃ 한정판 | domain/goods/entity/LimitedDrop.kt (productId 참조) | goods 내부 회차 aggregate. 사용자 계층과 동일 그룹핑 | 일치 |
| 상품 ⊃ 티켓 | domain/ticketing/entity/{Event,Ticket,Seat,TicketOrder}.kt — 독립 최상위 패키지 | grep "import com.sportsapp.domain.goods" domain/ticketing/ = 0, 역방향 = 0. 자체 테이블 4개(events·seats·tickets·ticket_orders) 독점 소유. 사용자 계층은 상품⊃티켓인데 코드는 goods와 형제 독립 컨텍스트 | 경계 불일치 (계층 이탈) |
| 상품 ⊃ 시설상품(PT·클래스) | domain/facility/entity/Program.kt:23 (facilityId·price·capacity) | 신설됐으나 facility 산하에 배치. goods(=상품) 그룹핑 밖. 회차는 booking의 Slot이 소유 → 상품 하위가 아니라 facility 하위 | 경계 불일치 (계층 이탈) |
| 주문 (상위 개념) | application/order/OrderCompositionService.kt:34 + domain/common/order/OrderType.kt(4값) | OrderType가 공유 커널로 승격돼 통합축은 확보. 그러나 쓰기 주문 aggregate는 GoodsOrder·TicketOrder·Booking·Application 4분할 유지. 상위는 읽기 조합 파사드뿐 | 부분일치 (통합축 확보, 쓰기 분산) |
| 결제 | domain/payment/entity/Payment.kt, event.payment.payment.v1 발행 | 단일 컨텍스트 1:1, 주문 컨텍스트 역참조 0건 | 일치 |
| 운동시설 | domain/facility/entity/Facility.kt + OperatingHours·Holiday | 등록·소유자·운영시간을 facility가 소유 | 일치 |
| 시설 타임슬롯 예약 | domain/booking/entity/{Slot,Booking}.kt | 예약 라이프사이클을 booking이 소유. 단 facility와 양방향 데이터 접근(§3-2) | 부분일치 (경계 누수) |
| 모임 (모임장·유저 계층) | domain/community/entity/{Community,CommunityMember}.kt, CommunityRole(HOST/MEMBER), Visibility.PRIVATE | 멤버십·계층·전용 채팅 존재. 명칭만 사용자 “모임”≠코드 community | 부분일치 (명칭 불일치) |
| 커뮤니티 (종목별 게시·모집) | community(PUBLIC·SportCategory) + post + recruitment | 3개 컨텍스트에 분산되나 각 연동이 배선 완료(post→community 인가, recruitment→community 참조) | 부분일치 (컨텍스트 분산) |
| 유저 ⊃ b2c/b2b | domain/user/entity/User.kt + UserRoleName + domain/partner/entity/Partner.kt | 계정 타입이 아닌 RBAC 롤 + Partner 신원으로 표현. 한 유저의 다중 역할을 지원하므로 의도적 설계 | 부분일치 (의도적) |
| 채팅 | domain/message/ + RoomContextType(COMMUNITY/GOODS_PRODUCT) | 범용 컨텍스트 방 메커니즘을 community·goods가 재사용 | 일치 |
집계: 일치 6 / 부분일치 6 / 경계 불일치 2 (티켓·시설상품).
해석 — 경계 불일치 2건은 “고쳐야 할 결함”이 아니다. 20260706 §4 문제A에서 이미 판정했듯, 상품 5종은 라이프사이클이 이질적이다(중고=단건 C2C / 브랜드=SKU 재고 / 티켓=좌석재고+공연일 / 한정판=시간게이트 / 시설상품=슬롯·정원). 단일 aggregate로 통합하면 God-aggregate가 된다. 사용자 멘탈 모델의 “상품”은 읽기(카탈로그) 차원의 개념이고, 코드는 이를 catalog 파사드로 정확히 충족한다. 이 불일치는 유지가 옳고, 문서에 “의도된 불일치”로 못박는 것이 조치다.
4. MSA 분리 후보군 비교
4-0. 운영 현실 전제 (비용 판단의 기준) — 확정 제약 반영
| 항목 | 값 |
|---|---|
| 운영 인원 | 1인 |
| 배포 환경 | 로컬 docker compose 단일 호스트 (확정 제약 — k8s·클라우드 유료 인프라 도입 없음) |
| 호스트 스펙 (실측) | 10코어 / Docker VM 가용 메모리 15.6GiB |
| 현재 컨테이너 수 | 13개 (backend + MySQL + Mongo + Redis + Kafka + MinIO + mock 4종 + mailhog 등) |
| JVM 1개 실사용 메모리 (실측) | ~950MB (Xmx 768m, mem_limit 1.8g — 2.56g에서 OOM(137) 관측 후 하향) |
| 관측 스택 | otel + tempo(추적) + prometheus + loki + grafana — 보유 |
| 게이트웨이 | nginx LB만 (docker-compose.lb.yml) — 인증·라우팅은 앱 내 Spring Security |
| 서비스 디스커버리 | 없음 (compose DNS로 대체 가능) |
| 실측 트래픽 | 있음 — 상시 트래픽 시뮬레이터/실측-리포트.md (2026-07-06). §7-1 참조 |
근거:
상시 트래픽 시뮬레이터/실측-리포트.md“측정 환경”·“부록”·“부록 2 메모리 안정화”
4-0-1. 단일 호스트 제약이 만드는 물리적 상한 (모든 후보에 선행 적용)
확정 제약(단일 호스트) 아래에서 분리로 얻을 수 있는 것과 없는 것이 물리적으로 갈린다.
| 자원 | 분리해도 공유되는가 | 실측 근거 | 결론 |
|---|---|---|---|
| 호스트 CPU 총량 | 공유 (10코어 고정) | 4 replica가 피크에 합계 ~10.7코어 점유 = 이미 포화 | 독립 스케일아웃 실효 없음 — 서비스를 쪼개도 CPU 총량은 그대로. JVM 개수만 늘어 오버헤드 증가 |
| 호스트 메모리 | 공유 (15.6GiB 고정) | JVM 1개 ~950MB. 데이터스토어·mock 컨테이너가 별도 소비 | JVM 실용 상한 8~10개 — 현재 4 replica 사용 중이므로 여유 슬롯 4~6개. 20 컨텍스트 분리는 19GB 필요로 물리적 불가 |
| MySQL 인스턴스 | 공유 (별도 스키마로만 분리 가능) | 쓰기 병목이 HikariCP 풀 고갈(waiting=372) | 스키마를 나눠도 같은 mysqld 프로세스·같은 커넥션 상한 — 격리 효과 부분적 |
| 디스크 I/O · Docker 데몬 · 호스트 커널 | 공유 | — | 호스트 장애·디스크 포화는 분리로 격리 불가 |
| JVM 힙 / 스레드풀 / 배포 재기동 | 격리됨 | — | 분리로 실제 얻는 것은 이 3가지뿐 |
핵심 귀결: 실측된 병목 2개(① 앱 티어 CPU 포화 ② HikariCP 커넥션 풀 고갈)는 둘 다 단일 호스트에서 서비스를 쪼개도 해결되지 않는 종류다. ①은 CPU 총량이 고정이고, ②는 같은 MySQL을 공유하기 때문이다. 따라서 단일 호스트에서 MSA가 주는 이점은 “JVM 힙·스레드풀·배포 재기동 격리” 3가지로 제한된다.
데이터 분리의 실익과 한계 (같은 호스트 전제)
| 방식 | 실익 | 한계 |
|---|---|---|
| 같은 MySQL 인스턴스 내 별도 스키마 | 소유권 명확화, 교차 JOIN 물리적 차단, 미래 이전 준비 | 격리 없음 — 같은 mysqld의 CPU·버퍼풀·커넥션 상한 공유. 한 스키마의 슬로우 쿼리가 전체를 죽인다 |
| 별도 MySQL 컨테이너 | 커넥션 풀·버퍼풀 격리(실측 병목 ②에 유효) | 컨테이너당 메모리 +1GB급, 호스트 디스크·CPU는 여전히 공유. 15.6GiB 예산에서 2~3개가 상한 |
| 별도 호스트 | 진짜 격리 | 확정 제약으로 범위 밖 |
→ 데이터 분리는 “소유권 명확화”에는 유효하지만 “장애 격리”에는 단일 호스트에서 부분적으로만 유효하다. 별도 MySQL 컨테이너는 실측 병목 ②(풀 고갈)에 실효가 있으나 2~3개가 메모리 상한이다.
4-1. 후보 비교
ⓐ 현행 유지 + 패키지·거버넌스 정합만 회복
- 내용: 단일 모듈 유지. §2-2 데이터 결합 6파일 정리, FR-8 미분류 5건 편입,
permissions이관, context-map 갱신. - 얻는 것: 비용 거의 0(추정 2~3일). 경계 위생 회복. ArchUnit R3가 20개 컨텍스트 전부 커버.
- 잃는 것: 컴파일 타임 경계 강제는 여전히 없음(ArchUnit 런타임 테스트 의존). 미래 추출 시 같은 조사를 반복.
- 필요 인프라: 없음.
- 비용: 2~3일.
ⓑ Gradle 멀티모듈 모듈러 모놀리스 ★ 채택
- 내용: 단일 배포 유지, 빌드를 모듈로 분할. 제안 모듈:
common(공유 커널) /payment/goods/ticketing/facility-booking(§3-2 병합) /community-post-message/recruitment/user-partner/platform(virtualqueue·featureflag·notification·alerting·operator·mcp) /composition(catalog·order·dashboard) /bootstrap(앱 조립). 모듈 간 의존은 domain interface + 이벤트만 허용. - 얻는 것:
- 컴파일 타임 경계 강제 — §2-2의 6개 파일이 즉시 컴파일 에러로 드러난다. ArchUnit보다 강한 보증.
- 모듈별 명시적 API 표면 확정 → 추출이 기계적 작업이 됨.
- 737개 테스트의 증분·병렬 빌드 개선.
- 런타임 비용 0 — 컨테이너 여전히 1개.
- 롤백 쉬움 — 모듈 병합은 되돌리기 간단, 데이터·배포 변경 없음.
- 잃는 것: 빌드 스크립트 복잡도 증가.
domain/common의Permission엔티티(B4)·EntryTokenGuard등 공유 커널 정리가 선행돼야 함. 자원 격리·독립 배포는 여전히 없음. - 필요 인프라: 없음.
- 비용: 1~2주 (선행 결합 해소 포함).
ⓒ 선택적 서비스 추출 (strangler)
- 내용: ⓑ 완료 후 1~2개만 별도 배포. 추출 1순위 =
virtualqueue(§4-2), 2순위 =mcp. - 얻는 것: 마케팅 폭주 흡수 계층을 앱 본체와 자원 격리. mcp(운영 서브시스템)를 코어 라이프사이클에서 분리.
- 잃는 것: 컨테이너 +1
2, 원격 호출 지연·부분 실패 처리, 배포 파이프라인 23개. - 필요 인프라: API 게이트웨이(nginx 라우팅으로 대체 가능), 분산 추적(보유), 서비스 디스커버리(compose DNS로 대체 가능), 데이터 분리(virtualqueue는 Redis 전용이라 불필요).
- 비용: virtualqueue 추출 3~5일(경계가 이미
EntryTokenGuard하나) / mcp 추출 1주.
ⓓ 전면 MSA (20 컨텍스트 분리) ✗ 미채택
- 내용: 컨텍스트별 독립 서비스 + DB 분리 + 게이트웨이 + 디스커버리 + SAGA.
- 얻는 것: 이론상 독립 배포·자원 격리·기술 스택 자유.
- 잃는 것 (수치):
- 컨테이너 13 → 30+, 단일 호스트에서 JVM 20개 × 512MB = +10GB RAM.
- 인프라 비용 3.75~6배 (벤치마크 §5).
- MSA 이득은 개발자 10~15명 초과 팀에서 발현 — 현재 1인.
- 평균 서비스 규모 1,600라인 — 서비스가 아니라 함수 크기.
- MySQL 45테이블을 20개 스키마로 분할 → 듀얼라이트·백필·검증 사이클 20회.
- DORA: MSA 팀 90%가 결국 배치 동시 배포 → 분산 모놀리스(최악 조합) 위험.
- 필요 인프라: 게이트웨이 + 디스커버리 + 서비스 메시 + 컨피그 서버 + SAGA 오케스트레이터 + 스키마 20벌.
- 비용: 3~6개월 + 상시 운영 부담.
- 미채택 사유: 측정된 부하가 0인 상태에서 3.75~6배 비용과 SAGA 복잡도를 지불하는 것은 명백한 오버엔지니어링이다. 현재 병목은 “도메인이 안 나뉘어서”가 아니다 — 도메인은 이미 잘 나뉘어 있다(R1 0건, 교차 JOIN 0건). 분리해서 얻을 것이 없다.
4-2. 비교표
| 기준 | ⓐ 현행+정합 | ⓑ 멀티모듈 ★ | ⓒ 선택 추출 | ⓓ 전면 MSA |
|---|---|---|---|---|
| 처리량 개선 | 없음 | 없음 | 격리된 1~2개만 | 이론상 최대 |
| 지연 영향 | 없음 | 없음 | +원격 홉 1 | +원격 홉 다수 |
| 운영 복잡도 | 현행 | 현행(런타임 동일) | 중 | 매우 높음 |
| 구현 비용 | 2~3일 | 1~2주 | +3~5일/서비스 | 3~6개월 |
| 롤백 난이도 | 매우 쉬움 | 쉬움(모듈 병합) | 중 | 매우 어려움 |
| 추가 컨테이너 | 0 | 0 | +1~2 | +20 |
| 경계 강제력 | ArchUnit(런타임) | 컴파일 타임 | 컴파일+네트워크 | 네트워크 |
| 1인 운영 적합성 | ○ | ◎ | △ | ✗ |
| 판정 | 부분 채택(ⓑ의 선행 단계) | 채택 | 트리거 도달 시 | 미채택 |
4-2-1. 분리 동기 3개 × 후보 충족도 매트릭스 (사용자 확정 가중치)
사용자가 동기 3개를 모두 1순위로 확정했다. 세 동기는 최적 해가 각각 다르므로 따로 평가한다 — “MSA 하나로 셋 다 해결”은 성립하지 않는다.
충족도: ◎ 충분 / ○ 부분 / △ 미미 / ✗ 못 함 (또는 악화)
| 동기 | 실측된 문제 | ⓐ 현행+정합 | ⓑ 멀티모듈 | ⓒ 선택 추출 | ⓓ 전면 MSA | 분리 없이 얻는 대안 |
|---|---|---|---|---|---|---|
| ① 트래픽·확장성 | 앱 티어 CPU 바운드. 4 replica 합 ~10.7코어/10코어 호스트. 0에러 상한 ~500 rps (목표 20,000 TPS의 2.5%) | △ | △ | ○ (virtualqueue만) | ✗ 악화 — JVM 개수↑로 동일 CPU에서 오버헤드만 증가 | ◎ replica 증설 + HikariCP 풀 튜닝 — 실측상 3→4 replica + pool 10→40으로 knee가 60 → 250 |
| ② 배포 독립성·개발 속도 | 컴파일 2m 30s + 단위 테스트 3m 49s = 최소 6분 20초 전체 검증 (통합·E2E·detekt 제외) | △ | ◎ — 증분 빌드로 변경 모듈만 재컴파일·재테스트 | ○ | ○ (단 배포 파이프라인 20개 관리) | ○ 테스트 태그 분리·Gradle 캐시 (모듈 경계 없이는 효과 제한) |
| ③ 장애 격리 | 전역 세마포어 200을 한 컨텍스트가 독점 가능. 서킷브레이커 없음(spring-retry만). HikariCP 풀 전 컨텍스트 공유 | ○ | ○ (경계는 굳지만 런타임 동일) | ○ (JVM·스레드풀만, CPU·DB는 공유) | ○ (단일 호스트라 CPU·디스크·호스트는 여전히 공유) | ◎ 컨텍스트별 벌크헤드 + 서킷브레이커 + 타임아웃 — §4-2-2 |
4-2-2. 동기별 최소 비용 해법 (분리와 무관하게 각각 최적)
| 동기 | 최소 비용 해법 | 실측 근거 | 예상 비용 | 분리 대비 |
|---|---|---|---|---|
| ① | replica 증설 + HikariCP maximum-pool-size 상향(10→40) + connection-timeout 하향(30s→5s) | 실측 리포트 부록: 이 조합만으로 조회 knee 60 → 250~300 rps, 0에러 상한 ≥500 rps | 설정 변경 수시간 | MSA로는 얻을 수 없음 (CPU 총량 고정) |
| ①-예외 | virtualqueue 추출 | 대기열은 유입을 차단하는 것이 목적이라 CPU를 거의 안 쓴다. Redis 전용이라 DB 분리 불필요. 백엔드가 죽어도 대기실은 살아 있어야 의미가 있음 | 3~5일 | 분리가 실제로 유효한 유일 케이스 |
| ② | Gradle 멀티모듈 (ⓑ) | 컴파일 2m30s·단위테스트 3m49s가 컨텍스트 무관하게 전량 재실행. 모듈 경계가 있어야 Gradle이 변경분만 재빌드 | 1~2주 | 서비스 분리 없이 동일 효과 |
| ③ | ① 컨텍스트별 벌크헤드(전역 Semaphore 200 → 컨텍스트별 permit 분할) ② resilience4j 서킷브레이커 도입(현재 미보유) ③ 외부 호출 타임아웃 일괄 적용 ④ HikariCP 풀을 읽기/쓰기로 분리 | 현재 LoadSheddingFilter는 전역 세마포어 1개라 한 컨텍스트가 200 permit을 전부 소진 가능. catalog·order 파사드의 도메인당 300ms 타임아웃 + failedDomains 부분 저하가 이미 올바른 패턴 — 이를 전 경로로 확대 | 3~5일 | 단일 호스트에서는 분리보다 격리 효과가 큼 (CPU·DB 공유는 분리로 못 푸는데, 벌크헤드는 그 공유 자원의 배분을 통제) |
③에 대한 결정적 관찰: 단일 호스트에서 분리가 격리하는 것은 JVM 힙·스레드풀·배포 재기동뿐이다. 그런데 실측 붕괴 모드(F2: 과부하 시 60초 큐잉 → 재기동 전까지 영구 열화)는 스레드풀·커넥션 풀 고갈이 원인이고, 이것은 벌크헤드로 직접 해결된다. 실제로 F2는 이미
LoadSheddingFilter로 수정돼 있고, 남은 것은 그것을 전역 1개에서 컨텍스트별로 쪼개는 것이다 — 서비스 분리보다 훨씬 싸고 효과가 직접적이다.
4-3. 채택 결론
⚠️ 전제 변경으로 개정됨 (2026-07-28) — 사용자가 비용을 인지한 상태에서 물리 분리(4~5 서비스, DB 2단계)를 확정했다. 아래 채택 결론(모듈러 모놀리스·단일 배포 유지)은 폐기한다. 실행설계는
20260728-msa-물리분리-실행설계.md참조. 본 문서 §1~§3의 AS-IS 실측 데이터는 유효하며 그 설계의 근거로 계속 인용된다.
채택안은 단일 후보가 아니라 동기별 조합이다. 세 동기의 최적 해가 다르기 때문이다(§4-2-1).
| 우선순위 | 조치 | 대응 동기 | 근거 |
|---|---|---|---|
| 1 | HikariCP 풀 튜닝 + replica 증설 (설정 변경) | ① 트래픽 | 실측상 knee 60→250 |
| 2 | 컨텍스트별 벌크헤드 + 서킷브레이커 + 타임아웃 | ③ 장애 격리 | 실측 붕괴 모드(F2)의 직접 해법. 단일 호스트에서 분리보다 효과가 큼 |
| 3 | ⓑ Gradle 멀티모듈 (ⓐ를 0단계로 흡수) | ② 배포 속도 | 컴파일 2m30s + 단위테스트 3m49s를 변경 모듈로 한정 |
| 4 | ⓒ virtualqueue 추출 (트리거 도달 시) | ①-예외 | 분리가 실제로 유효한 유일 케이스 |
| — | ⓓ 전면 MSA — 미채택 | — | 단일 호스트에서 동기 3개 중 어느 것도 충족 못 함 |
ⓓ 미채택 근거를 동기별로 명시한다 (확정 제약 반영):
- ① 트래픽에 대해 — MSA는 도움이 안 되는 정도가 아니라 악화시킨다. 실측 병목은 앱 티어 CPU이고 호스트는 10코어 고정이다. 4 replica가 이미 ~10.7코어를 점유한다. 서비스를 20개로 쪼개면 같은 CPU 위에서 JVM 20개의 기본 오버헤드(힙·메타스페이스·GC 스레드·서블릿 스레드풀)가 중복돼 총 처리량이 떨어진다. 메모리는 20 × 950MB = 19GB > 15.6GiB로 애초에 기동 불가다.
- ② 배포 속도에 대해 — 멀티모듈이 같은 효과를 더 싸게 준다. 배포 파이프라인 20개·이미지 20개를 1인이 관리하는 비용이 증분 빌드 이득을 상쇄한다. 게다가 현재 배포는
docker build ... bootJar -x test라 배포 자체는 이미 테스트를 돌리지 않는다 — 느린 것은 배포가 아니라 로컬 검증 루프이고, 그건 모듈 경계로 해결된다. - ③ 장애 격리에 대해 — 단일 호스트에서는 격리 범위가 3가지로 제한된다. 분리로 얻는 것은 JVM 힙·스레드풀·배포 재기동뿐이고, 실측 병목인 CPU 포화와 MySQL 커넥션 고갈은 여전히 공유된다. 반면 벌크헤드는 그 공유 자원의 배분 자체를 통제하므로 단일 호스트에서 더 직접적이다.
- 경계는 이미 잘 나뉘어 있다. domain 교차 import 0건, 교차 JOIN 0건, 공용 컨텍스트 역참조 0건, 결제 경로는 이미 이벤트 기반 최종일관성. MSA로 고칠 경계 문제가 없다 — 실제 결함은 infrastructure 6개 파일의 데이터 결합뿐이고 이건 분리 없이 고친다.
- ⓑ는 MSA를 포기하는 선택이 아니라 준비하는 선택이다. 제약(단일 호스트)이 풀리거나 트리거가 도달했을 때 추출이 기계적 작업이 된다. 반대로 지금 ⓓ로 가면 되돌릴 수 없다.
단계적 채택 임계 조건 (측정 가능, OR 조건):
| 다음 단계 | 트리거 (전부 측정치 기준) |
|---|---|
| 1·2 → 3 (멀티모듈) | 즉시 착수 가능 (트리거 불요 — 런타임 무변경) |
| 3 → 4 (virtualqueue 추출) | ① 마케팅 스파이크 시 대기열 처리가 backend CPU 포화에 동반 열화하는 것이 관측, 또는 ② 대기열 입장 처리 P95 >500ms |
| 4 → 코어 추출 | ① HikariCP 풀 튜닝·replica 증설·벌크헤드를 전부 적용한 뒤에도 p95 SLA 미달이 지표로 증명, 그리고 ② 여유 JVM 슬롯(현재 4~6개) 내에서 가능한 범위일 것 |
| → ⓓ 전면 MSA | 단일 호스트 제약이 해제(다중 호스트·클라우드)되고 개발 인원 5명 초과. 제약이 유지되는 한 영구 미채택 |
5. 동일 제품군·경쟁사 아키텍처 벤치마킹
| # | 사례 | 해결 방식 | 참고 / 미참고 |
|---|---|---|---|
| 1 | Shopify (Core 모놀리스) | 커머스 전체를 단일 Rails 앱 “Shopify Core”로 유지하되, 내부를 비즈니스 도메인(billing·orders)별 컴포넌트로 분할. MSA를 검토했으나 “자체 문제를 새로 만든다”고 판단해 모듈러 모놀리스 채택. 경계 강제를 위해 자체 도구(Packwerk) 를 만듦 — 단일 테스트·배포 파이프라인 유지가 핵심 이득 | 참고 — ⓑ의 직접 근거. 우리 ArchUnit R1~R4가 Packwerk와 같은 역할이고(ADR-005도 이 벤치마크를 인용), 여기에 Gradle 모듈로 컴파일 타임 강제를 더하는 것이 다음 수순 |
| 2 | 오늘의집 (bucketplace) MSA Phase 1 | 모놀리스 → MSA 전환 시 Branch By Abstraction 사용: Aggregator 레이어로 레거시 API 호환 유지 → 신규 서비스 병행 개발 → 트래픽 점진 이동. 트래픽 섀도잉으로 레거시/신규 응답을 Kafka로 비교 검증. 인프라: Spring Cloud Gateway + gRPC/Protobuf + 분산추적. Phase 1에 3개월 소요 | 부분 참고 — Aggregator 패턴은 우리 catalog·order 조합 파사드가 이미 동일 역할. 섀도잉 검증은 ⓒ 추출 시 채택. 미참고: gRPC·Spring Cloud Gateway는 1인·단일호스트에 과함 |
| 3 | 모듈러 모놀리스 전환 통계 (2026) | MSA 조기 도입 시 인프라 지출 30~50% 증가 + DevOps 엔지니어 1~2명 추가 필요. 동일 기능 범위 기준 MSA 인프라 비용은 모놀리스 대비 3.75~6배. MSA 이득은 개발자 10~15명 초과 팀에서만 발현. DORA: MSA 팀 90%가 서비스를 배치 동시 배포 → 분산 모놀리스 | 참고 — ⓓ 미채택의 정량 근거. 1인 운영 + 실측 트래픽 0에서 3.75~6배 지출은 정당화 불가 |
| 4 | Spring Modulith / microservices-ready modulith | 바운디드 컨텍스트 단위로 모듈화하면 “나중에 추출하기 쉬움”. 추출 대비 통신 규칙: 도메인 이벤트 우선, 동기 호출이 필요하면 조회(query)로만 제한하고 모듈 경계의 명시적 계약을 경유 — 크로스 모듈 상태 변경 금지 | 참고 — 우리 현황과 정확히 일치. 이벤트(Kafka 3토픽) + 동기는 조회 위주. 위반은 §3-3 T1(CreatePartnerUseCase의 크로스 컨텍스트 쓰기) 1건뿐 → ⓑ 진행 시 최우선 정리 대상 |
Sources:
- Inside Shopify’s Modular Monolith (Milan Milanović)
- Shopify’s Modular Monolith Architecture (InfoQ)
- 오늘의집 MSA Phase 1. 백엔드 분리작업
- Microservices Too Expensive: Modular Monoliths Win 2026 (byteiota)
- Modular Monolith: 42% Ditch Microservices in 2026 (byteiota)
- Building a microservices-ready modulith (Albert Llousas)
- Building Modular Monoliths With Kotlin and Spring (JetBrains)
6. 장기 도메인 진화 계획
각 단계는 독립 배포 가능하고 롤백 지점을 가진다.
6-1. 단계별 계획
| 단계 | 내용 | 전제 조건 | 완료 판정 기준 | 롤백 지점 |
|---|---|---|---|---|
| 0단계 — 결합 해소·거버넌스 정합 (즉시) | ① §2-2 Gateway 6파일에서 타 컨텍스트 Repository 주입 제거 → 공급자 DomainService 경유로 전환 ② domain/common/Permission → user로 이관 ③ FR-8 미분류 5건(featureflag·partner·virtualqueue·airquality·featuredemo)을 DomainClassification에 편입 ④ docs/domain-context-map.md 갱신(virtualqueue·catalog·order 반영) | 없음 | ArchUnit 전체 GREEN + grep으로 infrastructure 교차 Repository 주입 0건 + DomainClassification 합계 = domain 패키지 수(20) | 코드 되돌리기 (스키마 무변경) |
| 1단계 — Gradle 멀티모듈화 | 11개 모듈로 분할(§4-1 ⓑ). 모듈 간 의존은 domain interface + 이벤트만. bootstrap 모듈이 앱 조립 | 0단계 완료 (미완 시 컴파일 실패) | ./gradlew build GREEN + 모듈 의존 그래프에 순환 0 + 컨테이너 수 13 유지(런타임 무변경) | settings.gradle.kts include 제거로 단일 모듈 복귀 |
| 2단계 — facility·booking 경계 재정리 | 두 컨텍스트를 하나의 모듈 facility-booking으로 병합(내부 패키지는 유지). 양방향 Gateway 2개 제거, CreateProgramSessionUseCase 트랜잭션을 모듈 내부로 | 1단계 완료 | 모듈 간 facility↔booking 참조 0건 + 기존 예약 시나리오 테스트 GREEN | 모듈 재분할 (코드 이동만) |
| 3단계 — virtualqueue 서비스 추출 (트리거 도달 시만) | 별도 컨테이너로 분리. 데이터 분리 불필요(Redis 전용). 경계는 EntryTokenGuard(HMAC 무상태 검증) 하나라 백엔드는 원격 호출 없이 토큰만 검증 | ⓒ 트리거 충족(§4-3) + 2단계 완료 | 대기열 E2E 시나리오 GREEN + 백엔드↔대기열 동기 호출 0건(HMAC 무상태) + 마케팅 부하 시 backend CPU 하락 관측 | 대기열 컨테이너 중지 + 피처 플래그 OFF (기존 플래그 virtualqueue.enabled 활용) |
| 4단계 — mcp 서브시스템 추출 (트리거 도달 시만) | 운영 서브시스템을 코어와 분리. mcp는 코어를 동기 호출하지 않으므로(ADR-004·R3) 경계가 이미 깨끗함 | 3단계 안정화 | mcp 관련 시나리오 GREEN + 코어 배포가 mcp 배포와 독립 | mcp 컨테이너를 backend에 재통합 |
| 5단계 — 코어 컨텍스트 추출 + DB 분리 (강한 트리거만) | goods 또는 ticketing 1개만 추출. 데이터 분리 5단계 필수(아래 6-2) | ⓓ 트리거 충족(개발 5명 초과 + 부하 측정) | 섀도 트래픽 응답 일치 + 독립 배포 2주 무사고 | 스키마는 expand-contract라 코드 롤백만으로 복귀 |
1~4단계는 데이터 마이그레이션이 없다 — 스키마 무변경이라 롤백이 전부 코드 되돌리기다. 데이터 분리는 5단계에서만 발생한다.
6-2. 5단계 데이터 분리 절차 (Flyway 인라인 백필 DML 금지)
goods를 예로, products·stocks·goods_orders 등 7테이블을 별도 스키마로 분리할 때:
| 단계 | 내용 | 롤백 지점 |
|---|---|---|
| 1. 듀얼라이트 | 신규 스키마에 테이블을 nullable로 생성(DDL, Flyway). 코드가 기존·신규 양쪽에 동시 기록 | 신규 경로를 아무도 읽지 않으므로 코드 되돌리기 |
| 2. 배치 백필 | Spring Batch 청크(PK 범위) 단위로 기존 데이터 이관. 청크마다 커밋, rate limit, 멱등(재실행 가능). Flyway UPDATE/INSERT SELECT 금지 — 테이블 전체 락으로 배포가 곧 장애 | 배치 중단 (신규 스키마는 미사용) |
| 3. 데이터 검증 | 검증 배치로 “누락 0건 · 기존≠신규 0건” 판정. 결과를 아티팩트로 남김 | 동일 |
| 4. 기능 배포 | 신규 스키마를 읽는 경로 배포 (피처 플래그 게이트) | 플래그 OFF |
| 5. 배포 후 검증 | 신규 경로 지표·샘플 확인 → 이상 시 플래그 OFF / 이전 태그 재기동. 안정화 후 구 테이블 제거(별도 마이그레이션) | 플래그 OFF |
6-3. 진화 단계 다이어그램
flowchart LR subgraph S0["0단계 결합해소"] Fix[Gateway 6파일 정리] Gov[FR-8 분류·문서] end subgraph S1["1단계 멀티모듈"] Mod[11 Gradle 모듈] end subgraph S2["2단계 경계정리"] FB[facility-booking 병합] end subgraph S34["3~4단계 선택추출"] VQ[virtualqueue 서비스] MCP[mcp 서비스] end subgraph S5["5단계 코어분리 (강트리거)"] Core[goods 또는 ticketing] DB[(전용 스키마)] end Fix --> Gov Gov --> Mod Mod --> FB FB -.->|부하 측정 트리거| VQ VQ -.-> MCP MCP -.->|인원 5명 초과| Core Core --> DB
점선 = 측정된 트리거 도달 시에만 진행. 실선 = 즉시 착수 경로.
7. 부속 진단 (역할 명세 필수 항목)
7-1. 트래픽 ↔ 구조 적합성
| 트래픽 지점 | 전제 (목표치, requirement.md:16,19) | 판정 | 근거 |
|---|---|---|---|
| b2c 상시 조회 | 3,000 TPS (목표) | 지금은 충분 | nginx LB + backend replica(docker-compose.lb.yml), Redis 캐시 13컴포넌트 |
| b2b 파트너 | 100 TPS (목표) | 지금은 충분 | 쓰기 위주 저부하 |
| 마케팅 폭주 | 20,000 TPS (목표) | 임계점 근접 → 대응 완료 | virtualqueue 신설(37파일)로 입장 제어 도입. 이전 진단의 “흡수 계층 부재” 해소. 단 효과 측정치 없음 |
| 단일 MySQL 쓰기 | 마케팅 시 주문 확정 | 지금은 충분 | 결제 확정이 이미 Kafka 비동기(§2-4), 대기열이 유입 총량 제한 |
| 모든 지점 공통 | — | 측정 부재가 최대 리스크 | docker-compose.sim.yml(k6) 자산 보유하나 결과 아티팩트 없음 → §8 Open Question |
“과잉” 판정 없음, “이미 초과” 판정 없음. 구조는 목표 트래픽에 대해 대체로 적합하며, MSA로 얻을 트래픽 이득이 현재 근거 위에 존재하지 않는다.
⚠️ 위 표는 초판(실측 미발견 상태) 판정이다. 아래 §7-1-0에서 실측 데이터로 전면 재판정했다.
7-1-0. 트래픽 실측 재판정 (상시 트래픽 시뮬레이터/실측-리포트.md, 2026-07-06)
정정: 초판은 “측정값 0”으로 기술했으나 실측 리포트가 존재한다. 아래가 확정 판정이다.
실측 결과 (조회 경로 GET /facilities, warm, constant-arrival-rate)
| 구성 | 목표 rps | 달성 rps | p95 | 에러율 | 판정 |
|---|---|---|---|---|---|
| 3 replica / pool 10 | 60 | 59.9 | 459ms | 0% | 지속 가능(knee) |
| 3 replica / pool 10 | 100 | 99.4 | 1.65s | 0% | 지연 열화 |
| 3 replica / pool 10 | 200+ | 붕괴 ~96/s | 60s(timeout) | 47~100% | 붕괴 |
| 4 replica / pool 40 | 200 | 199.7 | 122ms | 0% | 여유 |
| 4 replica / pool 40 | 300 | 298.5 | 922ms | 0% | knee 부근 |
| 4 replica / pool 40 | 500 | 498.4 | 1.03s | 0% | 0에러 상한 |
실측 병목 2건 — 둘 다 분리로 해결되지 않는다
| 병목 | 실측 근거 | 단일 호스트에서 분리로 해결? |
|---|---|---|
| ① 앱 티어 CPU 바운드 | backend 3 replica 피크 CPU 254~271%씩(합 ~7.8코어), 4 replica는 합 ~10.7코어 / 10코어 호스트. 반면 mysql 29 | ✗ 아니오 — CPU 총량 고정. 분리하면 JVM 오버헤드만 증가 |
| ② HikariCP 커넥션 풀 고갈(쓰기) | HikariPool-1 - Connection is not available, request timed out after 30018ms (total=10, active=9, idle=1, waiting=372). 400 rps 쓰기에서 5xx 3,972건 | ✗ 아니오 — 같은 MySQL 공유. 설정 튜닝(10→40)으로 이미 해결 |
확정 판정
| 트래픽 지점 | 목표 (requirement.md:16,19) | 실측 대비 | 판정 |
|---|---|---|---|
| b2c 상시 조회 | 3,000 TPS | 0에러 상한 ~500 rps = 목표의 17% | 이미 초과 (목표 기준) — 단일 호스트 물리 한계. 설계 문서도 “단일 로컬 머신에서는 물리적 미달 예상”으로 명시 |
| b2b 파트너 | 100 TPS | 실측 상한 내 | 지금은 충분 |
| 마케팅 폭주 | 20,000 TPS | 실측 상한의 2.5% | 이미 초과 (목표 기준) — 단 virtualqueue가 유입 자체를 차단하는 설계라 앱이 20,000 TPS를 받을 필요가 없다 |
| 오버셀 정확성 | 0건 | 4,206 요청 중 정확히 2,000건 입장, 오버셀 0건 | 정합 (실측 PASS) |
결론 3가지:
- 구조가 아니라 하드웨어가 상한이다. 데이터스토어는 여유인데 앱 JVM이 CPU를 다 쓴다. 실측 리포트 권장 조치 4번도 “3000/20000 TPS는 수평 확장 전제 — 앱 티어 CPU 바운드, 단일 머신 물리 한계 확인”.
- 가장 큰 개선은 이미 검증된 설정 튜닝이다 — 3 replica/pool 10 → 4 replica/pool 40으로 knee 60 → 250
300 rps(45배). MSA 분리로는 이만한 개선을 얻을 수 없다. - 잔여 정합성 결함 2건이 미해결이다 — F1 예약-주문 불일치(Redis 예약 2,000 vs DB 주문 1,357 = 643 슬롯 누수, under-sell), F3 “실패로 보이는 성공”(DB 커밋 1,357 vs 클라이언트 202 관측 228). MSA 분리보다 우선순위가 높다 → §8 OQ-7.
7-1-1. 장애 격리 현황 (분리 없이 얻는 대안의 AS-IS)
| 장치 | 현황 | 파일 근거 | 갭 |
|---|---|---|---|
| 로드 셰딩 (전역 벌크헤드) | 보유 — Semaphore(200) 초과 시 즉시 503 + Retry-After. 인증 필터 앞단 등록, /actuator/**·/healthz 제외, enabled 플래그로 즉시 OFF | infrastructure/loadshedding/LoadSheddingFilter.kt | 전역 1개 — 한 컨텍스트가 200 permit을 전부 소진 가능 → 컨텍스트별 분할 필요 |
| 조합 파사드 타임아웃 + 부분 저하 | 보유 (모범) — 도메인당 300ms 타임아웃, 실패 도메인은 제외하고 failedDomains에 기록 | CatalogCompositionService.kt:28, OrderCompositionService.kt:26 | catalog·order 2곳에만 적용 → 전 경로 확대 필요 |
| 재시도 | 부분 — spring-retry만 | build.gradle.kts:54 | — |
| 서킷브레이커 | 없음 | grep resilience4j build.gradle.kts = 0건 | 도입 필요 |
| 커넥션 풀 격리 | 없음 — 단일 HikariCP 풀을 전 컨텍스트 공유, maximum-pool-size 미설정(Spring 기본 10) | application.yml | 상향 + 읽기/쓰기 분리 필요 |
| Redis fail-open | 보유 — Redis 다운 시 대기열 통과(정합성은 MySQL 보장) | 가상대기열 TDD “핵심 실패 경로” | — |
→ **장애 격리는 “없다”가 아니라 “전역으로만 있고 컨텍스트별로 없다”**가 정확한 진단이다. 실측 붕괴 모드 F2는 이미 LoadSheddingFilter로 막혔고, 남은 작업은 그것을 컨텍스트별로 쪼개고 서킷브레이커를 더하는 것이다.
7-1-2. 배포·개발 속도 실측 (동기 ②의 근거) — 본 진단에서 직접 측정
커밋 f18434c3, 호스트 동일.
| 측정 항목 | 명령 | 소요 | exit |
|---|---|---|---|
| 전체 컴파일 (clean 후) | ./gradlew clean → compileKotlin compileTestKotlin | 2m 30s (150.8s) | 0 (BUILD SUCCESSFUL) |
| 단위 테스트 (domain + application, 컨테이너 없음) | ./gradlew test --tests "com.sportsapp.domain.*" --tests "com.sportsapp.application.*" | 3m 49s (230.2s) | 0 (BUILD SUCCESSFUL) |
| 합계 (최소 검증 루프) | — | ≈6분 20초 | — |
위 합계는 하한이다 — 제외: Testcontainers 통합 40파일, scenario E2E 89파일, @SpringBootTest 풀부팅 18파일, detekt, harnessCheck.
| 테스트 유형 | 파일 수 | 분리 시 영향 |
|---|---|---|
| 전체 테스트 파일 | 737 | — |
| 순수 단위 (MockK) | 323 | 모듈 경계로 즉시 분할 가능 |
| Testcontainers 사용 | 40 | 컨테이너 기동 비용이 지배적 |
@SpringBootTest 풀부팅 | 18 | 분리 후에도 통합 검증엔 전체 부팅 필요 |
| scenario E2E | 89 | 분리해도 E2E는 전체 대상 |
핵심: 한 컨텍스트만 고쳐도 main 1,280 + test 737 파일 전체가 재컴파일·재테스트 대상이다. 이것이 동기 ②의 실체이고, Gradle 멀티모듈(ⓑ)이 정확히 이 문제를 서비스 분리 없이 푼다. 참고로 배포 자체는 이미 테스트를 돌리지 않는다(backend/Dockerfile:18 — bootJar -x test -x detekt -x harnessCheck) — 느린 것은 배포가 아니라 로컬 검증 루프다.
7-2. 이벤트 아키텍처 적합성 (private-be-architecture-rule 기준)
| 관점 | 판정 | 근거 |
|---|---|---|
| ① 백본 두께 (알림 전용 쏠림?) | 양호 — 이전 대비 개선 | event.payment.payment.v1 구독자가 주문 4컨텍스트 + notification = 5 groupId 팬아웃. 20260706 시점의 “notification 100% 쏠림”은 해소됨 |
| ② 공용 컨텍스트 역참조 | 우수 — 규칙 준수 | payment는 발행만. OrderConfirmationGateway류 when(orderType) 허브 0건. 각 주문 컨텍스트가 자기 *PaymentEventWorker로 확정 |
| ③ Layer 1/2 판단 | 적절 | 결제 확정(유실 불가·내구성)=Kafka, 채팅방 provision·환불(같은 프로세스 파생)=Spring @TransactionalEventListener(AFTER_COMMIT). 오분류 0건 |
| ④ 멱등·순서·버저닝 | 양호 | eventId 멱등, aggregateId 파티션 키, .v1 접미사, sealed payload + @JsonTypeInfo(property="eventType") |
| ⑤ 토픽 네이밍 | 정합 | 3토픽 전부 event.{domain}.{sub-domain}.v{N} |
동기 결합 전환 후보: §2-2의 6개 Gateway. 단 대부분 읽기성 즉시 조회(예약 시 시설 스케줄 확인)라 이벤트화가 부적합하다. 지금 동기가 옳다 — 조기 이벤트화는 오버엔지니어링. 전환 트리거는 §6 3~5단계(배포 단위 분리) 도달 시이며, 그때 조회는 원격 API 또는 읽기모델 복제로, 쓰기는 이벤트로 전환한다.
예외 1건: partner → user 크로스 쓰기(§3-3 T1)는 읽기가 아니라 쓰기 오케스트레이션이다. Modulith 벤치마크(§5 #4)의 “크로스 모듈 상태 변경 금지” 위반에 해당하므로, ⓑ 진행 시 이벤트 기반(user 생성 → partner 구독) 또는 보상 트랜잭션으로 정리한다.
7-3. 코드 컨벤션 정합 조사 (grep 실측, 커밋 f18434c3)
정합 (위반 0건)
| 규칙 ID | 결과 |
|---|---|
no-jpa-query (@Query) | 0건 — QueryDSL CustomRepositoryImpl 사용 |
| no-consumer-record | 0건 — EventWorker가 DTO 직접 매핑 |
| no-crosscontext-raw-read (네이티브 SQL 교차 조회) | 0건 — JdbcTemplate 2건은 Spring Batch 메타데이터 초기화(BatchMetadataSchemaInitializer.kt:32) |
공용 컨텍스트 역참조 (when(orderType) 허브) | 0건 |
| 레이어 의존 방향 (R2) / domain 교차 참조 (R1) | 0건 — ArchUnit 7개 스펙이 강제 |
불일치 (파일:라인 + 룰ID) — 조사만 하고 수정하지 않는다.
| 룰 ID | 파일:라인 | 내용 | 심각도 | 조치 위임 |
|---|---|---|---|---|
| no-business-flow-in-infra (변형) | infrastructure/booking/gateway/FacilityScheduleGatewayImpl.kt:6-9 외 5파일 (§2-2 전수) | infrastructure 어댑터가 타 컨텍스트 Repository를 직접 주입해 남의 테이블을 읽음. import는 domain interface라 R1은 통과하나 스키마 결합은 잔존 — no-crosscontext-raw-read가 막으려는 것과 동일한 실질(수단만 JdbcTemplate → Repository) | p1 | §6 0단계 / private-senior-be |
| no-bean-config-wiring 회피 / no-conditional-on-property | application/facility/usecase/CreateProgramSessionUseCase.kt 외 20여건 | @Profile("!test-jpa")로 빈 등록 토글. 의도는 기능 토글이 아니라 JPA 슬라이스 테스트 컨텍스트 제외라 규칙 취지(무중단 롤백 불가)와는 무관하나, 규칙 문언상 @Profile 금지 대상 | p1(문맥 완화) | /private-review |
| domain 순수성 (JPA 침투) | domain/** 42파일이 import jakarta.persistence (예: domain/goods/entity/Product.kt @Entity) | domain 엔티티가 곧 JPA 엔티티. 컨벤션의 “Domain Entity와 JPA Entity 분리 시” 규칙을 적용하지 않은 상태 — 의도적 선택으로 보이나 §6 5단계 DB 분리 시 마찰 요인 | p2(관찰) | 결정 필요 → §8 OQ-4 |
| 공유 커널 순수성 (R4 변형) | domain/common/Permission.kt:11 @Table(name = "permissions") | 공유 커널이 물리 테이블을 소유. R4(common이 도메인을 import하지 않음)는 통과하나, 공유 커널이 스키마를 갖는 것은 모듈·서비스 분리 시 소유권 충돌 | p2 | §6 0단계 |
| FR-8 갱신 절차 미이행 | SupportToCoreDependencyRulesTest.kt:19-21 | DomainClassification 합계 15 vs 실제 domain 패키지 20 — featureflag·partner·virtualqueue·airquality·featuredemo 미분류. R3가 이들의 코어 역참조를 검사하지 못함 | p2 | §6 0단계 |
| 문서-코드 정합 | docs/domain-context-map.md | virtualqueue 0회·catalog/order 0회 언급. recruitment↔payment/community 연동을 “BE-55·BE-60 예정”으로 기술하나 코드는 배선 완료(ApplyRecruitmentUseCase, RecruitmentPaymentEventWorker) | p3 | doc-sync |
아키텍트는 조사·보고까지가 범위다. 위 항목의 코드 수정은
/private-review또는private-*-implementer로 위임한다.
7-4. TO-BE 컴포넌트 다이어그램 (ⓑ 채택안 반영)
flowchart LR subgraph Boot["bootstrap 모듈 (단일 배포)"] App[SportsApplication] end subgraph Comp["composition"] Cat[catalog·order·dashboard] end subgraph Core["코어 모듈"] Pay[payment] Goods[goods] Tick[ticketing] FB[facility-booking] Comm[community-post-message] Recr[recruitment] UsrP[user-partner] end subgraph Plat["platform"] VQ[virtualqueue·featureflag·notification·alerting·mcp] end subgraph SK["common 공유커널"] CK[OrderType·DomainEvent·계약만] end App --> Comp App --> Core App --> Plat Comp -->|읽기 조합| Core Core -->|이벤트만| Plat Core --> SK Plat --> SK
모듈 간 화살표는 domain interface + 이벤트로만 성립한다. permissions 테이블 이관 후 common은 물리 스키마를 갖지 않는다.
8. Open Questions (사용자 결정 필요)
| # | 질문 | 배경 | 선택지 |
|---|---|---|---|
| 해소 — 사용자가 동기 3개(트래픽·확장성 / 배포 독립성 / 장애 격리) 전부를 1순위로 확정. §4-2-1·§4-2-2에 반영 | 종결 | ||
해소 — 상시 트래픽 시뮬레이터/실측-리포트.md(2026-07-06)에 실측 존재. §7-1-0에 반영 | 종결 | ||
| OQ-2′ | 실측 리포트가 2026-07-06 기준이다. virtualqueue(7/11 도입) 이후 재측정할 것인가? | 실측 시점 이후 virtualqueue·recruitment·catalog/order가 유입됐다. 특히 virtualqueue 효과(다운스트림 도달 TPS 감소율)는 PRD가 실측을 요구하지만 아직 측정 안 됨. §4-3 트리거가 이 측정에 의존 | 재측정 후 진행 / 기존 실측으로 진행 |
| OQ-7 | 실측 결함 F1·F3를 MSA 논의보다 먼저 처리할 것인가? | F1: 643 슬롯 누수(Redis 예약 2,000 vs DB 주문 1,357 — under-sell), F3: “실패로 보이는 성공”(DB 커밋 1,357 vs 클라이언트 성공 228). 둘 다 정합성·정산 결함이고 아키텍처 변경과 무관하게 존재한다. 아키텍트 판단으로는 MSA·멀티모듈보다 우선순위가 높다 | 선처리(권장) / 병행 / 후순위 |
| OQ-8 | HikariCP 풀 튜닝을 코드베이스에 반영할 것인가? | 실측 부록에서 pool 10→40 + timeout 30s→5s로 knee가 4~5배 개선됐으나, 레포 application.yml에는 여전히 maximum-pool-size 설정이 없다(Spring 기본 10). 실측 튜닝이 git-ignored 오버레이에만 있고 본 코드에 미반영 | 즉시 반영(권장) / 유지 |
| OQ-3 | facility·booking을 하나의 모듈로 병합하는 데 동의하는가? | §3-2 유일한 재경계 필요 지점. 양방향 데이터 접근 + 단일 트랜잭션 쓰기. 병합하면 경계 위반이 사라지지만 컨텍스트 개수는 줄어든다 | 병합 / 양방향 Gateway를 단방향으로 정리해 분리 유지 |
| OQ-4 | domain 엔티티 = JPA 엔티티 구조를 유지할 것인가? | domain 42파일이 jakarta.persistence 의존(B3). 유지하면 §6 5단계 DB 분리 시 도메인 모델과 스키마가 함께 움직인다. 분리하면 POJO+매퍼 작성 비용이 1,280파일 규모에 발생 | 유지(권장 — 5단계는 먼 미래) / 지금 분리 |
| OQ-5 | 상품·주문의 쓰기 상위 aggregate를 만들 것인가? | §3-4에서 티켓·시설상품이 사용자 계층(상품 하위)과 불일치. 본 진단은 “라이프사이클 이질로 통합 미채택, 읽기 파사드로 충분”이 옳다고 판단하나, 이는 20260706 판정 계승이다 | 현행 유지(권장) / 통합 aggregate 재검토 |
| OQ-6 | virtualqueue 도입 효과가 측정됐는가? | 20260711에 21티켓으로 도입됐고 기본 OFF 상태다. 마케팅 폭주 대응의 핵심 자산인데 효과 측정치가 없다. §6 3단계 트리거가 이 측정에 의존한다 | 부하 테스트로 ON/OFF 비교 / 현행 유지 |
지금 할 일 vs 나중에
동기 3개(트래픽·배포속도·장애격리)에 각각 최소 비용 해법을 매핑한 우선순위다.
우선순위 1 — 정합성 결함 선처리 (아키텍처 변경과 무관, OQ-7)
- F1 슬롯 누수 643건 — Redis 예약 후 DB 저장 실패 시 예약 보상(취소) 누락 → under-sell.
- F3 “실패로 보이는 성공” — DB 커밋 1,357 vs 클라이언트 성공 관측 228. 정산·UX 혼선.
우선순위 2 — 동기 ① 트래픽 (설정 변경, 수시간) ★ 비용 대비 효과 최대
- HikariCP
maximum-pool-size10→40 +connection-timeout30s→5s를application.yml에 반영 (OQ-8) — 실측 검증됨. - backend replica 3→4 + JVM 힙 고정(Xmx 768m, mem_limit 1.8g) — 실측상 knee 60 → 250
300 rps (45배). MSA로는 얻을 수 없는 개선이다.
우선순위 3 — 동기 ③ 장애 격리 (3~5일, 분리 없이)
LoadSheddingFilter전역 Semaphore(200) → 컨텍스트별 permit 분할 — 한 컨텍스트의 permit 독점 차단.- resilience4j 서킷브레이커 도입 (현재 미보유) + 외부 호출 타임아웃 일괄 적용.
catalog·order의 300ms 타임아웃 +failedDomains부분 저하 패턴을 전 경로로 확대 — 이미 있는 모범 패턴의 확산.
우선순위 4 — 경계 위생 (§6 0단계, 2~3일)
- infrastructure 교차 Repository 주입 6파일 정리 —
FacilityOwnershipGatewayImpl·FacilityScheduleGatewayImpl·SlotInfoGatewayImpl·SlotQueryGatewayImpl·GoodsProductGatewayImpl·RecipientContactResolver. 유일한 실질 경계 결함이자 멀티모듈화의 선행 조건. domain/common/Permission→user이관 / FR-8 분류 누락 5건 편입 /docs/domain-context-map.md갱신 /CreatePartnerUseCase크로스 컨텍스트 쓰기 정리(T1).
우선순위 5 — 동기 ② 배포 속도 (1~2주, 승인 후)
- Gradle 멀티모듈 11개 분할 — 컴파일 2m30s + 단위테스트 3m49s를 변경 모듈로 한정. 런타임 무변경, 컨테이너 13 유지.
- facility·booking 모듈 병합 (OQ-3 결정 후).
트리거 도달 시에만
- virtualqueue 서비스 추출 — 분리가 실제로 유효한 유일 케이스(유입 차단이 목적, Redis 전용이라 DB 분리 불요). 트리거: 대기열 처리가 backend CPU 포화에 동반 열화 관측 또는 대기열 P95 >500ms
- mcp 서비스 추출 — 위 안정화 후. 단 여유 JVM 슬롯(4~6개) 예산 내에서만
- 코어 추출 — 우선순위 2·3을 전부 적용한 뒤에도 SLA 미달이 지표로 증명될 때
명시적 미채택 (단일 호스트 제약 하에서)
- 전면 MSA(20 컨텍스트 분리) — 동기 3개 중 어느 것도 충족하지 못한다: ① 트래픽은 악화(CPU 총량 고정 + JVM 오버헤드 증가, 메모리 19GB > 15.6GiB로 기동 자체가 불가) ② 배포 속도는 멀티모듈이 더 싸게 해결 ③ 장애 격리는 CPU·MySQL·호스트가 여전히 공유라 효과가 JVM 힙·스레드풀·재기동 3가지로 제한. 단일 호스트 제약이 해제되기 전까지 영구 미채택.
- API 게이트웨이·서비스 디스커버리·서비스 메시 선제 도입 — 단일 배포 단위에 홉만 추가
- 컨텍스트별 별도 MySQL 컨테이너 다수 도입 — 메모리 예산상 2~3개가 상한. 소유권 명확화는 같은 인스턴스 내 별도 스키마로 충분
- 상품·주문 통합 쓰기 aggregate — 라이프사이클 이질(God-aggregate 위험). 읽기 파사드로 충분
- 조기 이벤트화 — 현재 동기 Gateway는 읽기성 즉시 조회라 동기가 옳음
- domain/JPA 엔티티 분리 — 1,280파일 규모 비용 대비 이득이 먼 미래에만 발생
Document History
| 날짜 | 변경 내용 |
|---|---|
| 2026-07-28 | 개정 2 — 사용자 확정 제약 반영. 분리 동기 3개(트래픽·확장성 / 배포 독립성 / 장애 격리) 전부 1순위 + 단일 호스트 확정. 추가·정정: ① §4-0 운영 전제에 실측 호스트 스펙(10코어/15.6GiB, JVM 1개 실측-리포트.md 반영(3 replica knee 60 rps·붕괴 200+, 4 replica+pool40 knee 250~300·0에러 상한 500, 병목=앱 CPU 10.7코어/10코어 + HikariCP waiting=372, 오버셀 0건 PASS, 목표 20,000 TPS의 2.5%) ⑥ §7-1-1 장애 격리 AS-IS — LoadSheddingFilter 전역 세마포어·catalog/order 300ms 타임아웃 보유, 서킷브레이커 미보유 ⑦ §7-1-2 빌드·테스트 직접 측정 — 컴파일 2m30s·단위테스트 3m49s(둘 다 exit 0), 배포는 이미 -x test ⑧ Open Questions 갱신(OQ-1·OQ-2 종결, OQ-2′·OQ-7 F1/F3 결함 선처리·OQ-8 풀 튜닝 미반영 신설) ⑨ 실행 계획을 동기별 우선순위 5단계로 재작성 |
| 2026-07-28 | 최초 작성 — MSA 분리 판단 진단. 트리 신선도 게이트(f18434c3, 0/0) 선행. 브리핑 사실 정정 2건(PKG-2가 main에 포함됨 — “설계 의도와 트렁크 현실의 괴리” 없음). AS-IS 20컨텍스트 규모 실측, 결합 6축 실측표(domain 0 / application 22쌍 / infrastructure 5쌍 = MSA 블로커 / 교차 JOIN 0 / Kafka 3토픽 5팬아웃), 컨텍스트별 PASS·재경계 판정(facility↔booking 유일 재경계), 트랜잭션 분할 7지점(결제 경로 T5·T6은 이미 분리 완료, 진짜 SAGA는 T1 1건), 사용자 정의 경계 매트릭스 재판정(일치6/부분6/경계불일치2, 선행 갭 8건 해소 확인), MSA 후보 4개 비교 → ⓑ Gradle 멀티모듈 채택 / ⓓ 전면 MSA 미채택, 벤치마킹 4건(Shopify·오늘의집·MSA 비용 통계·Spring Modulith), 진화 6단계 + 데이터 분리 5단계, Open Questions 6건 |