상품·주문 공유 상위 컨텍스트 TDD
Background
근거 PRD: 도메인 경계 재설계/20260708-상품주문-공유상위컨텍스트-prd.md (verdict PASS, v2). 진단 커밋 808d2101(= 현재 HEAD, origin/main 0/0 동기화).
20260708-전체시스템-architecture.md 병목 B4(“상품/주문 공유 추상 부재”)를 해소한다. 판매 대상(Product·LimitedDrop·Event·Program·Recruitment)과 주문(Booking·GoodsOrder·TicketOrder·Application)이 각 도메인에 분산돼, 사용자가 통합 검색·통합 주문내역을 볼 수 없다. PRD는 쓰기 aggregate 통합이 아닌 읽기 전용 통합 조회 파사드로 이 문제를 푼다(20260706 God-aggregate 우려 회피).
Overview
- 무엇을: catalog(상품 통합 검색)·order(주문 통합 조회) 두 개의 읽기 전용 상위 컨텍스트를 신설한다. 각 도메인의 쓰기 테이블·로직·API는 불변.
- 왜: 신규 상품 유형 추가 시 4곳 수정 → “조회 진입점 1곳 추가”로 완화. 사용자가 5개 화면 대신 1개 검색·1개 주문내역 화면을 쓴다.
- 어떻게: catalog/order 컨텍스트는
application+presentation만 갖고 domain 레이어를 두지 않는다(기존dashboard읽기 파사드와 동일 패턴). 각 코어 도메인의DomainService읽기 메서드를 병렬 호출·조합(API Composition) 해 단일 응답으로 매핑한다. 부수적으로Product.sellerType(B2C/B2B) 국소 쓰기 필드와OrderType소유권 이관을 처리한다.
Terminology
| 용어 | 정의 |
|---|---|
| catalog 컨텍스트 | 판매 대상 5종 통합 검색 읽기 파사드 (신규, domain 레이어 없음) |
| order 컨텍스트 | 주문/신청 4종 통합 조회 읽기 파사드 (신규, domain 레이어 없음) |
| 읽기 파사드 (read facade) | 여러 코어 도메인의 조회 결과를 조합만 하는 계층. 쓰기·상태 전이·정합 소유 없음 |
| API Composition | 요청 시점에 각 도메인을 조회해 in-memory join 하는 조합 패턴 (CQRS read model의 대안) |
| CatalogItem / OrderHistoryItem | 이질적 도메인 데이터를 정규화한 통합 응답 항목 (application DTO) |
| sellerType | Product의 판매자 유형(B2C 개인/중고, B2B 파트너/브랜드). 등록 시점 인증 컨텍스트로 자동 판별, 이후 불변 |
| AuthChannelResolver | ”이 요청이 파트너 API Key 인증을 경유했는가”를 노출하는 domain.common.security 계약 |
| 부분 실패 (partial failure) | 통합 조회 중 일부 도메인이 타임아웃/실패해도 나머지로 응답하고 실패 도메인을 메타데이터에 표기 |
| 공유 커널 (shared kernel) | 모든 도메인이 의존 가능한 domain.common — ArchUnit R1이 유일 예외로 허용 |
Define Problem
AS-IS
실제 코드(HEAD 808d2101) 기준.
- 판매 대상 5분산:
domain/goods/entity/Product.kt(name·category·price·status: ProductStatus{ACTIVE,INACTIVE}·ownerId),domain/goods/entity/LimitedDrop.kt(productId참조, 가격 없음,effectiveStatus(remaining)파생),domain/ticketing/entity/Event.kt(title·venue·startsAt·status: EventStatus, 가격은 좌석 단위),domain/facility/entity/Program.kt(name·price·capacity, status enum 없음),domain/recruitment/entity/Recruitment.kt(title·feeAmount·status: RecruitmentStatus). 이들을 아우르는 조회 지점 부재. - 주문 4분산:
booking/entity/Booking.kt(userId·slotId·status·paymentId),goods/entity/GoodsOrder.kt(userId·status·paymentId),ticketing/entity/TicketOrder.kt(userId·status·paymentId),recruitment/entity/Application.kt(applicantUserId·status·paymentId). 유일 통합 지점은payment/vo/OrderType.kt(enum BOOKING/TICKETING/GOODS/RECRUITMENT) + 각*PaymentEventWorker뿐 — 결제 확정 배선에만 쓰이고 사용자 조회 진입점 없음. - sellerType 부재:
Product.kt:20-42는category(물리 카테고리)·ownerId만 보유. 중고(개인)·브랜드(파트너)를 구분할 필드 없음.application/goods/usecase/CreateMyProductUseCase.kt#execute는ownershipGuard.authUserId()만 조회하므로, 요청이PartnerApiKeyAuthenticationFilter(infrastructure/security/PartnerApiKeyAuthenticationFilter.kt#injectSecurityContext)를 경유했는지 판별할 신호가 현재 UseCase에 노출돼 있지 않다. - OrderType 위치:
domain/payment/vo/OrderType.kt. payment가 주문 분류를 사유화한 형태.domain/payment/event/PaymentEvent.kt:39가 이를 참조하고, 총 19개 파일이payment.vo.OrderType를 import. - 강제 아키텍처 규칙(ArchUnit, 반드시 준수):
- R1 (
AggregateAndUseCaseRulesTest·ContextMapBaselineTest):com.sportsapp.domain.(*)..슬라이스는 서로 import 금지 —domain.common만 예외. - R3 (
SupportToCoreDependencyRulesTest): support/subsystem 도메인은 코어 동기 의존 금지.application.dashboard → 코어는 화이트리스트(읽기 조합 Conformist). - R4 (
SharedKernelPurityRulesTest):domain.common은 어떤 도메인도 import 금지. - 레이어(
LayerDependencyRulesTest):application은infrastructure/presentationimport 금지. - ADR-003 (
ProvidedInterfaceContractTest.kt:27-33):CreateMyProductUseCase.execute(CreateMyProductCommand): ProductWithStock시그니처 리플렉션 동결.
- R1 (
TO-BE
- catalog/order 두 읽기 파사드 컨텍스트 신설(
application.catalog·application.order+presentation.*, domain 레이어 없음 — dashboard와 동일). - 각 코어 도메인은 읽기 전용 조회 메서드만 추가(쓰기 로직·테이블 불변). 파사드는 코어
DomainService만 호출(R1/R3 위반 없음 — 파사드는 domain 슬라이스가 아니고 support 분류도 아님). OrderType을domain.common.order.OrderType(공유 커널)로 이관 — payment→order 역참조를 원천 차단하며 payment의 주문 분류 사유화를 해소.Product.sellerType추가 + 등록 경로 자동 판별(파트너 인증 컨텍스트) + 무중단 백필.
Architecture Benchmarking
| 제품/사례 | 해결 방식 | 참고할 패턴 | 미참고 사유 |
|---|---|---|---|
| 당근마켓 통합 검색 | C2C 중고 + B2C 비즈프로필 매물을 하나의 Elasticsearch 인덱스에서 서빙. 원본은 각 도메인 소유, 검색 인덱스만 통합 (초당 1K, 2.7억 문서) | “원본 데이터 소유는 도메인에, 조회만 통합” = 우리 읽기 파사드 방향과 동일. sellerType(C2C/비즈)을 리스팅 메타로만 노출 | 별도 검색 엔진(ES) 도입은 로컬 단일 인스턴스·현재 규모에 과대. 지금은 in-memory 조합으로 충분 |
| Amazon 1P/3P Marketplace | 1P(자체)·3P(외부셀러) 상품을 동일 검색/상세에 노출, 판매자 유형은 리스팅 메타데이터로만 구분, 재고·주문 로직은 유형별 분리 | sellerType을 등록 경로 기반으로 결정하고 조회에만 노출하는 우리 설계와 정확히 동일 | 정산·수수료 유형별 분기는 이번 범위 밖(Non-Goal) |
| API Composition vs CQRS Read Model (microservices.io, Distributed Data Management Patterns) | API Composition = 요청 시점 각 서비스 조회 후 in-memory join. CQRS = 이벤트로 사전 조합된 read model 구축(무 fan-out, 최종 일관성) | API Composition의 부분 실패 전략(“fail hard vs partial response — 있는 것만 반환하고 누락 표기”)을 FR-11에 그대로 채택 | CQRS read model(사전 조합 프로젝션)은 이벤트 동기화 인프라 + 최종 일관성 lag 부담. 현재 규모·P95 500ms 목표엔 과대 → Open Question(실측 후 판단) |
| DDD Bounded Context Facade (DDD Start!) | UI 서버가 여러 Bounded Context(카탈로그·리뷰)를 읽어 조합하는 파사드 역할 | 파사드가 여러 컨텍스트를 읽어 조합만 하고 쓰기 소유는 각 컨텍스트에 남기는 경계 | 별도 UI 서버 분리는 모놀리스 유지(Non-Goal) — 파사드를 application 레이어 컨텍스트로 실현 |
Possible Solutions
방안 1 — 통합 조회 구현 방식 (핵심 난점)
도메인 간 참조 금지(R1: domain.X가 domain.Y import 금지)를 위반하지 않고 5/4개 도메인을 어떻게 조합할 것인가.
| 방안 | 설명 | 왜 채택/미채택 |
|---|---|---|
| A. application-layer API Composition (채택) | catalog/order 컨텍스트를 dashboard처럼 application+presentation만으로 만들고(domain 레이어 없음), 파사드가 5/4개 코어 DomainService를 병렬 호출→ CatalogItem/OrderHistoryItem으로 매핑. 각 도메인엔 읽기 전용 조회 메서드만 additive 추가 | R1은 domain.* 슬라이스만 규율 → 파사드에 domain 슬라이스가 없어 위반 자체가 성립하지 않는다. R3는 support/subsystem만 스캔 → 파사드는 미분류라 스캔 대상 아님. application → domain(코어)은 이미 dashboard가 쓰는 합법 경로. 가장 단순하고 규칙 정합 |
| B. infrastructure 읽기 모델/프로젝션 (CQRS) | catalog_items·order_items 비정규화 테이블을 각 도메인 이벤트로 동기화, 파사드는 단일 테이블 조회 | 이벤트 동기화 outbox·중복·최종 일관성 lag·정합 재구축 부담. 현재 규모·NFR(P95 500ms) 달성에 병렬 조합으로 충분해 과대. 실측 후 필요 시 승격(Open Question) |
| C. DB UNION 뷰 | 5개 테이블을 UNION 하는 SQL 뷰로 단일 조회 | 이질 스키마(가격 단위·status·title 상이) UNION은 컬럼 강제 정규화로 도메인 결합. 뷰가 각 도메인 스키마 변경에 취약. 부분 실패 격리 불가(한 테이블 락이 전체 조회 차단) |
| D. 별도 검색 엔진(ES) | 당근마켓식 통합 인덱스 | 로컬 단일 인스턴스·현재 트래픽에 운영 복잡도 과대. Non-Goal(랭킹 고도화 제외)와도 배치 |
방안 2 — OrderType 소유권 이관 위치
FR-6은 “order 상위 컨텍스트로 이관”을 요구하나, ArchUnit R1 + 역참조 금지와 충돌한다.
| 방안 | 설명 | 왜 채택/미채택 |
|---|---|---|
domain.common.order.OrderType (채택) | 주문 분류 enum을 공유 커널로 승격. payment·4개 주문 도메인·order 파사드가 모두 합법적으로 참조 | payment(코어)가 이 enum을 domain 레이어(PaymentEvent)에서 참조하는데, domain은 domain.common만 import 가능. 모든 참조자가 합법인 유일한 위치가 domain.common이다. payment가 주문 분류를 사유화하던 냄새를 공유 커널로 승격해 제거 = 설계 개선. 논리적 스튜어드십은 order 컨텍스트 |
domain.order.vo.OrderType (미채택) | 신규 order 도메인 컨텍스트에 배치 | payment→order = 코어가 읽기 파사드를 역참조 → (a) R1 슬라이스 규칙 위반 (b) private-be-architecture-rule 역참조 금지 위반. 불가 |
application.order.vo.OrderType (미채택) | 파사드 application 레이어에 배치 | domain.payment(PaymentEvent)가 application.order 참조 = domain→application 레이어 역전. LayerDependencyRulesTest 위반. 불가 |
FR-6 “동작 불변”: enum 값(BOOKING/TICKETING/GOODS/RECRUITMENT·displayName)·Kafka 발행/구독·
*PaymentEventWorker의orderType필터는 그대로. 패키지 위치만 이동한다.
방안 3 — sellerType 판별 신호
b2c/b2b가 동일 CreateMyProductUseCase를 공유하므로(ADR-007), 신호는 “파트너 API Key 인증 경유 여부”다.
| 방안 | 설명 | 왜 채택/미채택 |
|---|---|---|
| 인증 컨텍스트 마커 (채택) | PartnerApiKeyAuthenticationFilter가 principal에 파트너 경유 표식(UserPrincipal.partnerAuthenticated=true)을 심고, domain.common.security.AuthChannelResolver.isPartnerAuthenticated()로 노출. Controller가 이 값으로 SellerType을 결정해 Command에 실어 전달 | PRD 정의(“등록 요청이 실제로 파트너 경로를 거쳤는가”)를 정확히 반영. goods가 partner 도메인을 참조하지 않음(마커는 security context에서 읽음 → R1 위반 없음). ON/OFF 두 경로를 한 배포로 검증 가능 |
| authUserId ↔ partner.linkedUserId 역조회 (미채택) | 등록 시 authUserId가 어떤 partner의 linkedUserId인지 repository 역조회 | 의미 오류: “이 요청이 API Key를 경유했는가”가 아니라 “이 사용자가 파트너인가”를 판별한다. 파트너 연동 사용자가 일반 JWT로 등록하면 B2B로 오판(User Scenario 3 위반). 또 goods→partner 도메인 결합(R1 위반) 발생. 불가 |
Detail Design
시스템 역할 경계
| 단위 | 역할 | 소유 데이터/책임 | 노출 인터페이스 | 의존 |
|---|---|---|---|---|
presentation.catalog.CatalogApiController | 통합 검색 HTTP 진입점 | 라우팅·Request→Criteria | GET /api/catalog (permitAll) | SearchCatalogUseCase |
application.catalog.SearchCatalogUseCase | 통합 검색 오케스트레이션(thin) | — | execute(criteria): CatalogSearchResponse | CatalogCompositionService |
application.catalog.CatalogCompositionService | 5개 도메인 병렬 조회·타임아웃·부분 실패·CatalogItem 매핑 | 조합 로직(읽기, 소유 상태 없음) | search(criteria): CatalogSearchResponse | 5개 코어 DomainService, AsyncTaskExecutor(framework bean) |
presentation.order.OrderHistoryApiController | 통합 주문내역 진입점 | 라우팅·인증 userId 추출 | GET /api/orders (authenticated) | GetOrderHistoryUseCase |
application.order.GetOrderHistoryUseCase | 통합 조회 오케스트레이션(thin) | — | execute(userId, criteria): OrderHistoryResponse | OrderCompositionService |
application.order.OrderCompositionService | 4개 도메인 병렬 조회·타임아웃·부분 실패·OrderHistoryItem 매핑 | 조합 로직(읽기) | history(userId, criteria): OrderHistoryResponse | 4개 코어 DomainService, AsyncTaskExecutor |
domain.common.order.OrderType | 주문 분류 공유 커널 enum | 주문 유형 값·displayName | enum | (없음, 순수) |
domain.goods.vo.SellerType | 판매자 유형 enum | B2C/B2B + fromPartnerAuthenticated(Boolean) 팩토리 | enum | (없음) |
domain.common.security.AuthChannelResolver | ”파트너 API Key 인증 경유 여부” 계약 | — | isPartnerAuthenticated(): Boolean | (interface, 구현 infra) |
각 코어 DomainService(goods/ticketing/facility/recruitment/booking) | 읽기 전용 조회 메서드 additive 추가 | 자기 도메인 데이터 조회 | catalog/order용 조회 메서드 | 자기 Repository |
서버 토폴로지: 신규 서버 없음. catalog/order 통합 조회는 요청-응답 동기 조회 → 기존 API 서버 단일 프로세스 내 application 컨텍스트로 충분하다. 병렬 fan-out은 프로세스 내 bounded thread pool(워커 서버 분리 불필요 — 조회 대상이 같은 DB, 네트워크 홉 없음). 워커/스케줄러/소켓 서버 분리는 이번 과제 특성(단순 읽기 조합)에 불필요. sellerType 백필은 일회성 Flyway 마이그레이션(별도 배치 서버 불필요, 데이터 규모 소량).
인터페이스 시그니처 (구현자 간 해석 차이 제거)
// domain.common.order — 공유 커널 (이관: payment.vo → common.order, 값·동작 불변)
enum class OrderType(val displayName: String) {
BOOKING("시설 예약"), TICKETING("티켓 예매"), GOODS("상품 주문"), RECRUITMENT("모집 참가"),
}
// domain.goods.vo — 신규
enum class SellerType {
B2C, B2B;
companion object {
fun fromPartnerAuthenticated(partnerAuthenticated: Boolean): SellerType =
if (partnerAuthenticated) B2B else B2C
}
}
// domain.common.security — 신규 (OwnershipGuard 형제, 구현은 infrastructure.security)
interface AuthChannelResolver {
fun isPartnerAuthenticated(): Boolean
}
// application.catalog.dto — 통합 검색 계약 (FE 소비)
data class CatalogSearchCriteria(
val keyword: String?, // 제목/이름 부분 일치
val itemType: CatalogItemType?,// PRODUCT/LIMITED_DROP/TICKET/PROGRAM/RECRUITMENT
val sellerType: SellerType?, // PRODUCT 한정 필터(옵션)
val page: Int, val size: Int, // 기본 page=0, size=20
)
enum class CatalogItemType { PRODUCT, LIMITED_DROP, TICKET, PROGRAM, RECRUITMENT }
data class CatalogItem(
val itemType: CatalogItemType,
val sourceId: Long, // 원본 도메인 PK
val title: String,
val price: BigDecimal?, // KRW 정규화. TICKET 등 목록 단위 가격 없으면 null
val sellerType: SellerType?, // PRODUCT만 값, 그 외 null
val status: String, // 원본 도메인 status enum name
val detailPath: String, // 원본 상세 경로 (예: "/products/{id}")
val createdAt: ZonedDateTime,
)
data class CatalogSearchResponse(
val items: List<CatalogItem>,
val page: Int, val size: Int,
val failedDomains: List<CatalogItemType>, // 타임아웃/실패 도메인 (부분 실패)
)
// application.order.dto — 통합 주문내역 계약 (FE 소비)
data class OrderHistoryCriteria(
val orderType: OrderType?, // 필터(옵션)
val status: String?, // 필터(옵션, 원본 status name)
val page: Int, val size: Int,
)
data class OrderHistoryItem(
val orderType: OrderType,
val sourceId: Long,
val title: String, // 사람이 읽는 표시명 — 각 주문 컨텍스트가 자기 데이터로 구성 (아래 "주문 표시명 확보 방식")
val status: String,
val paymentId: Long?,
val detailPath: String,
val createdAt: ZonedDateTime,
)
data class OrderHistoryResponse(
val items: List<OrderHistoryItem>,
val page: Int, val size: Int,
val failedDomains: List<OrderType>,
)가격 정규화(PRD Open Question 확정): price는 KRW BigDecimal 대표 판매가. Product=price, LimitedDrop=참조 Product의 price, Program=price, Recruitment=feeAmount(무료 시 0), TICKET=null(좌석 단위 가격이라 목록 대표가 없음 — FE는 “가격 상세 확인”). 통화 단일(KRW)이라 단위 필드 불요.
코어 도메인 읽기 메서드(additive, 읽기 전용) — 기존 재사용 + 신규 최소:
- goods Product: 기존
GoodsDomainService.search(category,keyword,priceMin,priceMax,pageable)재사용(status=ACTIVE 필터 +sellerType필터 파라미터 추가). - goods LimitedDrop:
LimitedDropDomainService에 catalog 조회 신규(활성 drop + Product join으로 title/price 구성). - ticketing Event: 기존
TicketingDomainService.listEvents(criteria=OPEN, pageable)재사용 +EventCriteria에 keyword 필터 additive. - facility Program:
ProgramCustomRepository(신규 QueryDSL) +ProgramDomainService.searchForCatalog(keyword,pageable)신규(Program은 status 없음 → 미삭제 전량 대상). - recruitment Recruitment: 기존
RecruitmentDomainService.listRecruitments(communityId=null)재사용 + status=OPEN·keyword 필터 catalog 조회 신규.
주문 표시명(title) 확보 방식 (보완 — no-technical-item-name·역참조 금지 준수)
OrderHistoryItem.title은 각 주문 컨텍스트가 자기 데이터로 구성한다. 실측 결과 어떤 주문 엔티티도 표시명 스냅샷을 보유하지 않으므로(GoodsOrder·TicketOrder·Application·Booking 모두 이름 컬럼 없음), 이름은 같은 컨텍스트 내 조인으로 확보한다. 따라서 주문측 읽기는 “기존 findByUserId 재사용”이 아니라 이름 조인이 포함된 읽기 프로젝션으로 확정한다(읽기 전용, 쓰기 로직 불변).
| 주문 도메인 | 이름 소스 | 컨텍스트 경계 | 확보 방식 |
|---|---|---|---|
| GoodsOrder | GoodsOrderItem.productId → Product.name | 둘 다 goods(동일) | goods 읽기가 goods_order_items→products 조인, 대표 상품명(+다건 시 “외 N건”) |
| TicketOrder | lockedEventId → Event.title | 둘 다 ticketing(동일) | ticketing 읽기가 ticket_orders→events 조인 |
| Application | recruitmentId → Recruitment.title | 둘 다 recruitment(동일) | recruitment 읽기가 applications→recruitments 조인 |
| Booking | Booking.slotId → Slot(date, timeRange) | 둘 다 booking(동일) | booking 읽기가 bookings→slots 조인 → "{date} {timeRange} 시설 예약" 자기 라벨 |
- Booking 한계 명시(핵심 난점 결론):
Slot은facilityId: String(불투명 외부 ID)·programId: Long?만 보유하고, 시설/프로그램의 사람이 읽는 이름은 facility 컨텍스트에 있다. Booking이 그 이름을 조회하면 R1(도메인 교차) 위반이고, 주문 시점 이름 스냅샷을 Slot/Booking에 심으려면 booking 쓰기 변경이 필요해 범위 밖(각 도메인 쓰기 불변). 따라서 Booking title은 자기 컨텍스트 데이터(date·timeRange)로 만든 서술형 라벨로 확정한다 —"BOOKING #id"같은 기술 식별자가 아니므로no-technical-item-name위반이 아니며, 어느 컨텍스트도 역참조하지 않는다. 시설/프로그램 이름 노출은 향후 booking 쓰기측 스냅샷 도입 과제로 Open Question에 남긴다. - 파사드는 이름을 만들지 않는다:
OrderCompositionService(application.order)는 dashboard처럼 facility를 읽을 수 있으나, Booking 이름을 facility에서 보강하지 않는다 — 각 주문 컨텍스트가 이름을 소유하는 원칙을 지키고 추가 조회 latency·결합을 피하기 위함. 파사드는 각 컨텍스트가 반환한 title을 매핑만 한다.
주문 항목 탭 네비게이션 결정 (2026-07-09, Option A — 사용자 확정)
초기 설계는 “주문내역 항목 탭 → detailPath로 원본 아이템 상세(facility/event/product/recruitment)로 이동”을 가정했다. 그러나 구현 중 구조적 모순이 드러났다: ① BOOKING의 참조 시설은 facilityId: String(불투명 외부 ID)이라 sourceId: Long으로 표현 불가, ② GOODS 주문은 다품목이라 “참조 아이템 1개 PK” 매핑이 성립하지 않음. 사용자가 **Option A(주문 상세 화면 신설)**로 확정했다.
OrderHistoryItem.sourceId= 주문 자신의 도메인 PK(Booking.id/GoodsOrder.id/TicketOrder.id/Application.id).detailPath= 주문 자신 경로(/bookings/{id}·/goods-orders/{id}·/ticket-orders/{id}·/applications/{id}). FEresolveOrderRoute는 참조-아이템이 아니라 주문상세 화면(/orders/{orderType}/{id})으로 라우팅한다.- 신규/보강 주문상세 API: recruitment는 단건 상세가 없어
GET /applications/{id}신설(본인 소유 검증). booking/ticketing/goods는 기존 단건 상세 GET 응답이 얇아(리스트보다 빈약) title·createdAt·paymentId·참조 엔티티 id를 자기 데이터로 additive 보강(no-technical-item-name·역참조 금지 준수). 주문상세 화면은 이 리치 응답 + 하단 “원본 보기”(참조 id 있을 때만)로 구성한다. - BOOKING facilityId 노출 (구현 중 정정, 2026-07-09): 위 “Booking 한계”는 시설의 사람이 읽는 이름이 facility 컨텍스트에 있어 조회 시 R1 위반이라는 것이다. 그러나
facilityId(String, 불투명 ID) 자체는 Booking 자기 aggregate인Slot.facilityId가 이미 보유하므로, Booking→Slot 조인만으로 자기 컨텍스트에서 얻을 수 있고 facility 컨텍스트를 역참조하지 않는다(R1 준수). 따라서 주문상세 응답은facilityId를 노출해 “원본 보기”(/facility/{facilityId})를 제공한다. 노출하지 않는 것은 시설/프로그램 이름(facility 컨텍스트 소유)이며, 이는 종전대로 booking이 만들지 않는다. Slot 삭제·부재 시 facilityId는 null(원본 보기 미제공), title은 기본 라벨.sourceId가 Long 불가라던 초기 우려는 sourceId=주문 PK(Option A)로 이미 해소됐고, facilityId는 라우팅 키가 아니라 상세 응답의 부가 필드다.
실패 경로·동시성·멱등
- 부분 실패(FR-11):
CatalogCompositionService/OrderCompositionService가 각 도메인 조회를 boundedAsyncTaskExecutor에 fan-out, 도메인당 300ms 타임아웃(NFR). 타임아웃/예외 도메인은 결과에서 제외하고failedDomains에 기록. 전체 요청은 실패시키지 않는다(빈 도메인은 no-op). 각 조회는 독립 read-only(도메인 간 공유 트랜잭션 없음) — SecurityContext 의존 값(order의 userId)은 메인 스레드에서 먼저 해석해 각 task에 파라미터로 전달(자식 스레드 SecurityContext 미전파 대비). - 타임아웃 기준: 원본 도메인 응답 >300ms → 해당 도메인 제외(NFR). 합산 P95 500ms 목표.
- 페이지네이션: 이질 소스 글로벌 페이지네이션은 각 도메인에서 상한 window(≤ (page+1)*size)를 가져와
createdAt descin-memory merge 후 offset/limit 적용. 깊은 페이지네이션 한계는 Open Question(향후 CQRS). - 동시성/멱등: catalog/order는 읽기 전용 → 쓰기 락·멱등 키 불요. sellerType은 등록 시 1회 결정 후 불변(소급 재판별 없음, PRD Non-Goal) → 동시 쓰기 경합 없음. 백필 중 신규 등록은 이미 값이 채워져 백필 대상에서 제외(
WHERE seller_type IS NULL). - 미인증 주문 조회(User Scenario 7):
/api/orders는authenticated()→ 미인증 401. userId는 JWT principal에서 추출. - OrderType 이관 실패 대비: 순수 패키지 이동 + import 갱신이라 런타임 동작 불변. 컴파일·전체 테스트 GREEN이 게이트.
상태 전이·불변 표
읽기 파사드는 상태 머신이 없다. 쓰기 변경은 sellerType 뿐이며 등록 시 1회 결정 후 불변이다.
| 현재 상태 × 이벤트 | 다음 상태 | 거부/비고 |
|---|---|---|
| (신규 Product 등록) × 파트너 API Key 인증 경유 | sellerType=B2B (고정) | 이후 소급 변경 없음 |
| (신규 Product 등록) × 일반 JWT 인증 | sellerType=B2C (고정) | 이후 소급 변경 없음 |
| sellerType=B2C × 판매자 B2B 전환 | sellerType=B2C (불변) | 소급 재판별 금지(Non-Goal) — 과거 상품 값 유지 |
| 기존 Product(값 NULL) × 백필 마이그레이션 | sellerType=B2C | NULL 행만 대상, 이미 값 있으면 skip |
Component Diagram
flowchart LR FE[FE 통합 검색/주문내역] subgraph Catalog["catalog 컨텍스트 (domain 레이어 없음)"] CC[CatalogApiController] CU[SearchCatalogUseCase] CS[CatalogCompositionService] CC --> CU CU --> CS end subgraph Order["order 컨텍스트 (domain 레이어 없음)"] OC[OrderHistoryApiController] OU[GetOrderHistoryUseCase] OS[OrderCompositionService] OC --> OU OU --> OS end subgraph Core["코어 도메인 DomainService (읽기 메서드 additive)"] DS[goods/ticketing/facility/recruitment/booking] end Common[domain.common.order.OrderType] FE --> CC FE --> OC CS -->|병렬 조회+타임아웃| DS OS -->|병렬 조회+타임아웃| DS OS --> Common
Sequence Diagram — 통합 검색(부분 실패 포함)
sequenceDiagram participant C as CatalogApiController participant U as SearchCatalogUseCase participant S as CatalogCompositionService participant D as 5개 DomainService C->>U: execute(criteria) U->>S: search(criteria) par 병렬 fan-out (각 300ms 타임아웃) S->>D: 각 도메인 조회 D-->>S: 결과 or 타임아웃/예외 end S->>S: CatalogItem 매핑 + createdAt 병합 + failedDomains 수집 S-->>U: CatalogSearchResponse U-->>C: response(items, failedDomains)
sellerType 판별 시퀀스
sequenceDiagram participant F as PartnerApiKeyAuthFilter participant Ctl as GoodsSellerApiController participant R as AuthChannelResolver participant UC as CreateMyProductUseCase participant DS as GoodsDomainService F->>F: 파트너 인증 시 principal.partnerAuthenticated=true Ctl->>R: isPartnerAuthenticated() R-->>Ctl: true/false Ctl->>Ctl: SellerType.fromPartnerAuthenticated(it) Ctl->>UC: execute(command with sellerType) UC->>DS: createProduct(..., sellerType) DS->>DS: Product.create(..., sellerType) 저장
ERD
erDiagram products { bigint id PK varchar name varchar category decimal price varchar status varchar seller_type "신규: B2C/B2B, expand-contract" bigint owner_id datetime created_at }
- 신규 컬럼
products.seller_type VARCHAR(10)1개만 추가. 다른 테이블 스키마 변경 없음(catalog/order는 읽기라 테이블 무신설). - catalog/order는 기존 테이블(products·limited_drops·events·programs·recruitments / bookings·goods_orders·ticket_orders·applications)을 읽기만 한다.
Testing Plan
| 레벨 | 대상 | 핵심 시나리오(해피·실패·엣지) |
|---|---|---|
| domain | SellerType.fromPartnerAuthenticated, Product.create(sellerType) | 파트너 인증 true→B2B, false→B2C / Product가 sellerType 보유 |
| domain | OrderType(common.order) | enum 값·displayName 불변 (이관 후 회귀) |
| application | CatalogCompositionService | 5개 도메인 정상 조합 / 1개 타임아웃 시 나머지 4개 반환 + failedDomains 표기(FR-11) / 검색 결과 0건 empty / itemType·sellerType 필터 / createdAt 최신순 병합 |
| application | OrderCompositionService | 4개 도메인 조합 / 부분 실패 / orderType·status 필터 / paymentId 연계 노출 / 각 항목 title 노출 |
| application/infra | 주문 이름 조인 (goods/ticketing/recruitment/booking 읽기) | GoodsOrder→상품명, TicketOrder→이벤트명, Application→모집명, Booking→“date timeRange 시설 예약” 라벨 / 삭제·부재 참조 시 방어(빈 이름 fallback) |
| application | CreateMyProductUseCase/GoodsDomainService.createProduct | sellerType이 Command→Product로 전달·저장 |
| infrastructure | AuthChannelResolverImpl | 파트너 principal→true, JWT principal→false (TestContainers/MockMvc) |
| infrastructure | ProgramCustomRepository(신규) | keyword 검색·페이지네이션 |
| presentation | CatalogApiController | GET /api/catalog permitAll 200 / 필터 파라미터 / 부분 실패 응답 형태 |
| presentation | OrderHistoryApiController | 인증 200 / 미인증 401(User Scenario 7) |
| presentation | PartnerApiKeyAuthenticationFilter | 파트너 경유 시 principal.partnerAuthenticated=true 주입 |
| architecture | ProvidedInterfaceContractTest | sellerType 반영 후 GREEN 유지 + CreateMyProductCommand.sellerType 존재 assertion 추가(의도적 갱신) |
| architecture | R1/R3/R4 슬라이스 | OrderType 이관·catalog/order 신설 후 전체 ArchUnit GREEN |
| scenario | PartnerOnboardingScenarioTest 계열 | B2B 파트너 API Key 등록 → sellerType=B2B 100% 판별(Success Metric) |
| scenario | 통합 검색 E2E | ”요가” 검색 → Product(B2C/B2B)·Program·Recruitment 혼합 최신순 반환(커버리지 5/5) |
- 테스트 프레임워크 Kotest. 테스트명에 티켓ID 금지(동작 설명 이름만).
Observability (PRD Operations 대응)
PRD Operations 3항목을 로그 포인트로 구체화한다. 알림 채널 연동은 개인 프로젝트 규모상 이번 범위에서 축소 — 외부 알림(Slack/PagerDuty) 대신 구조화 로그의 WARN/ERROR 레벨로 감지 가능하게 하고, 운영자가 로그로 확인한다(placeholder 아님, 명시적 축소 결정). 알림 자동화는 트래픽 증가 시 후속.
| PRD Operations 항목 | 로그 포인트 | 레벨/조건 |
|---|---|---|
| ① 통합 조회 도메인별 성공/실패/타임아웃 카운트 | CatalogCompositionService·OrderCompositionService가 도메인 조회 완료 시 구조화 로그 unified_read{api=catalog|order, domain, outcome=SUCCESS|TIMEOUT|ERROR, latencyMs} | SUCCESS=DEBUG, TIMEOUT/ERROR=WARN. 특정 도메인 반복 WARN = 상시 실패 신호(로그 집계로 감지) |
| ② sellerType 자동판별 결과·오류 | 등록 시 product_seller_type_resolved{productId, sellerType, partnerAuthenticated} INFO. 판별 신호와 저장값 불일치(방어적, 로직상 불가하나 검증) 시 seller_type_mismatch{...} | 정상=INFO, 불일치=WARN(감사 로그) |
| ③ 배치 백필 완료 검증 | BE-11 Spring Batch 잡 완료 후 검증 스텝 SELECT COUNT(*) WHERE seller_type IS NULL = 0. 잡 자체는 청크별 처리 건수 로그 | 0 아니면 잡 실패 리포트 + DB-02(NOT NULL) 게이트 차단 |
failedDomains응답 필드는 사용자 노출(FE가 “일부 결과 누락” 표시)이지 운영 관측이 아니다 — 관측은 위 WARN 로그가 담당한다(둘은 별개).
Release Scenario — 무중단 배포 (expand-contract)
배포 순서와 롤백을 단계별로 명시한다. 모든 단계는 하위 호환.
원칙 — 마이그레이션 내 대량 DML 금지: Flyway 마이그레이션에
UPDATE/DELETE대량 DML을 넣지 않는다(테이블 전체 락 = 운영 금지). seller_type 백필은 애플리케이션 배치(Spring Batch 청크) 로 분리해 청크 커밋으로 락을 회피한다. 스키마 마이그레이션은 컬럼 추가/제약 변경 등 DDL만 담당한다.
seller_type 롤아웃 — 듀얼라이트 → 배치 백필 → 검증 → 기능 배포 → 배포후 검증 → contract 5단계
- [듀얼라이트] 스키마:
V61__alter_products_add_seller_type.sql=ADD COLUMN seller_type VARCHAR(10) NULL(COMMENT,ALGORITHM=INSTANT) — 대량 UPDATE 제거. 코드: goods sellerType 쓰기(BE-03)가 신규/수정 상품에 seller_type를 쓰기 시작(기존 행은 NULL 유지) — 이 코드 배포가 듀얼라이트 단계다. 이 단계에서 함께 배포되는 무관 선행 코드(OrderType 이관 BE-01, auth 신호 BE-02)는 아래 병행 배포 노트 참조. 롤백:DROP COLUMN seller_type(nullable이라 데이터 손실 없이 안전) + 코드 revert. - [배치 데이터 마이그레이션] BE-11 Spring Batch 잡이
seller_type IS NULL행을 청크(500~1000/청크, 청크 커밋) 로 읽어 B2C로 채운다. 테이블 전체 락 회피. 멱등(WHERE seller_type IS NULL)이라 재실행 안전. 롤백: 잡 중단(이미 채운 행은 유효, DROP COLUMN으로 완전 원복 가능). - [데이터 검증]
SELECT COUNT(*) FROM products WHERE seller_type IS NULL= 0 확인(검증 쿼리/배치 스텝). 0 아니면 다음 단계 진행 금지. - [기능 배포] catalog/order 읽기 파사드(BE-07/08) + sellerType 노출 + SecurityConfig matcher(BE-09,
/api/catalogpermitAll·/api/ordersauthenticated). 기존 API 무변경(추가 API). 롤백: 파사드·matcher revert. - [배포후 검증 → contract] 정합성 재확인 후에야
V62__alter_products_seller_type_not_null.sql=MODIFY seller_type VARCHAR(10) NOT NULL적용(DB-02). NULL=0 검증 통과가 선행 게이트. 듀얼라이트로 신규 행도 값 보유. 롤백:MODIFY ... NULL로 제약 완화.
병행 배포 노트: OrderType 이관(BE-01)·auth 신호(BE-02)는 seller_type 롤아웃과 독립적인 무중단 코드 배포다 — 동작 불변(BE-01: 순수 이동, import 실측 프로덕션 19 + 테스트 30 = 49파일 grep -rln payment.vo.OrderType; Single Writer 확인: wave1 동시 티켓 BE-04·BE-05·BE-10과 교집합 ∅, RecruitmentDomainService만 겹쳐 BE-06은 wave2), auth 신호(BE-02: UserPrincipal.partnerAuthenticated 기본 false 하위 호환). 롤백은 각 코드 revert.
- 피처 플래그: catalog/order는 신규 추가 API라 기존 트래픽 영향 0 → 플래그 불요(엔드포인트 미노출 = 사실상 OFF). sellerType은 자동 판별이라 토글 대상 아님.
- 마이그레이션 번호 경합 주의: 동시 dev 머지 레이스로 Flyway 버전 중복 가능 — 머지 직전
V61/V62재확인(레포 최신 번호는 V60). - Spring Batch 도입: 레포에 Spring Batch 없음(grep 확인) → BE-11이
spring-boot-starter-batch의존성 + 메타데이터 스키마 초기화를 함께 도입한다(사용자 지정).
Open Questions
- catalog/order 캐시(Redis look-aside) 또는 CQRS read model 승격은 NFR P95 500ms 실측 후 판단(방안 1-B 보류). 현재는 API Composition으로 시작.
- 깊은 페이지네이션(in-memory merge의 window 상한)은 향후 read model 필요 시 재설계.
20260706/20260708아키텍처 문서의 recruitment·facility “누락” 판정 갱신은 별도 아키텍트 재검증(PRD Open Question 승계).- ADR-003 갱신(sellerType로 인한 Command 필드 추가 기록)을 이 TDD 승인 후 문서에 반영.
- Booking 주문 표시명: 현재 자기 데이터(date·timeRange) 라벨만 노출 가능 — 시설/프로그램 이름 노출은 booking 쓰기측에 주문 시점 이름 스냅샷을 심는 별도 과제 필요(각 도메인 쓰기 불변 원칙상 이번 범위 밖).
Document History
| 날짜 | 변경 내용 |
|---|---|
| 2026-07-08 | v1.2 — 운영 지적 반영: seller_type 백필을 마이그레이션 대량 DML(테이블 전체 락) → Spring Batch 청크 백필(BE-11 신규) 로 이전. Release Scenario를 듀얼라이트→배치백필→검증→기능배포→contract 5단계로 재작성 + “마이그레이션 내 대량 DML 금지” 원칙 명시. V61=컬럼추가만(INSTANT), DB-02 NOT NULL 게이트=백필완료+NULL0+기능배포+배포후검증. wave DAG에 BE-11(wave3, BE-03 후행) 추가 |
| 2026-07-09 | v1.3 — Option A(주문 상세 화면 신설, 사용자 확정): 주문내역 항목 탭이 원본 아이템이 아니라 주문 자신 상세로 이동. OrderHistoryItem.sourceId=주문 PK, detailPath=주문 자신 경로. 근거: BOOKING facilityId=String(Long sourceId 불가)·GOODS 다품목으로 “참조 아이템 1개” 매핑 붕괴. 신규 GET /applications/{id}(recruitment 주문상세) + booking/ticketing/goods 단건 상세 응답 additive 보강(title·createdAt·paymentId·참조 id, 자기 데이터). FE resolveOrderRoute→/orders/{orderType}/{id} + 주문상세 화면 신설. “주문 항목 탭 네비게이션 결정” 절 참조 |
| 2026-07-08 | v1.1 — senior-pm NEEDS_REVISION 반영: OrderHistoryItem.title 추가(각 주문 컨텍스트 자기 데이터 조인으로 확보, Booking은 date/timeRange 자기 라벨 + 한계 명시), Observability 섹션 추가(로그 포인트 구체화 + 알림 축소 결정), wave1 Single Writer 재확인(BE-01 파일 집합 grep 확정, ticketing/facility/booking 교집합 ∅) |
| 2026-07-08 | 최초 작성 — PRD v2 기반. 읽기 파사드=API Composition(dashboard 패턴), OrderType→domain.common(R1/역참조 회피), sellerType=인증 컨텍스트 마커. FE 소비 API 계약 확정 |