상품·주문 공유 상위 컨텍스트 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)
sellerTypeProduct의 판매자 유형(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-42category(물리 카테고리)·ownerId만 보유. 중고(개인)·브랜드(파트너)를 구분할 필드 없음. application/goods/usecase/CreateMyProductUseCase.kt#executeownershipGuard.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): applicationinfrastructure/presentation import 금지.
    • ADR-003 (ProvidedInterfaceContractTest.kt:27-33): CreateMyProductUseCase.execute(CreateMyProductCommand): ProductWithStock 시그니처 리플렉션 동결.

TO-BE

  • catalog/order 두 읽기 파사드 컨텍스트 신설(application.catalog·application.order + presentation.*, domain 레이어 없음 — dashboard와 동일).
  • 각 코어 도메인은 읽기 전용 조회 메서드만 추가(쓰기 로직·테이블 불변). 파사드는 코어 DomainService만 호출(R1/R3 위반 없음 — 파사드는 domain 슬라이스가 아니고 support 분류도 아님).
  • OrderTypedomain.common.order.OrderType(공유 커널)로 이관 — payment→order 역참조를 원천 차단하며 payment의 주문 분류 사유화를 해소.
  • Product.sellerType 추가 + 등록 경로 자동 판별(파트너 인증 컨텍스트) + 무중단 백필.

Architecture Benchmarking

제품/사례해결 방식참고할 패턴미참고 사유
당근마켓 통합 검색C2C 중고 + B2C 비즈프로필 매물을 하나의 Elasticsearch 인덱스에서 서빙. 원본은 각 도메인 소유, 검색 인덱스만 통합 (초당 1K, 2.7억 문서)“원본 데이터 소유는 도메인에, 조회만 통합” = 우리 읽기 파사드 방향과 동일. sellerType(C2C/비즈)을 리스팅 메타로만 노출별도 검색 엔진(ES) 도입은 로컬 단일 인스턴스·현재 규모에 과대. 지금은 in-memory 조합으로 충분
Amazon 1P/3P Marketplace1P(자체)·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.Xdomain.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 발행/구독·*PaymentEventWorkerorderType 필터는 그대로. 패키지 위치만 이동한다.

방안 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→CriteriaGET /api/catalog (permitAll)SearchCatalogUseCase
application.catalog.SearchCatalogUseCase통합 검색 오케스트레이션(thin)execute(criteria): CatalogSearchResponseCatalogCompositionService
application.catalog.CatalogCompositionService5개 도메인 병렬 조회·타임아웃·부분 실패·CatalogItem 매핑조합 로직(읽기, 소유 상태 없음)search(criteria): CatalogSearchResponse5개 코어 DomainService, AsyncTaskExecutor(framework bean)
presentation.order.OrderHistoryApiController통합 주문내역 진입점라우팅·인증 userId 추출GET /api/orders (authenticated)GetOrderHistoryUseCase
application.order.GetOrderHistoryUseCase통합 조회 오케스트레이션(thin)execute(userId, criteria): OrderHistoryResponseOrderCompositionService
application.order.OrderCompositionService4개 도메인 병렬 조회·타임아웃·부분 실패·OrderHistoryItem 매핑조합 로직(읽기)history(userId, criteria): OrderHistoryResponse4개 코어 DomainService, AsyncTaskExecutor
domain.common.order.OrderType주문 분류 공유 커널 enum주문 유형 값·displayNameenum(없음, 순수)
domain.goods.vo.SellerType판매자 유형 enumB2C/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 재사용”이 아니라 이름 조인이 포함된 읽기 프로젝션으로 확정한다(읽기 전용, 쓰기 로직 불변).

주문 도메인이름 소스컨텍스트 경계확보 방식
GoodsOrderGoodsOrderItem.productIdProduct.name둘 다 goods(동일)goods 읽기가 goods_order_itemsproducts 조인, 대표 상품명(+다건 시 “외 N건”)
TicketOrderlockedEventIdEvent.title둘 다 ticketing(동일)ticketing 읽기가 ticket_ordersevents 조인
ApplicationrecruitmentIdRecruitment.title둘 다 recruitment(동일)recruitment 읽기가 applicationsrecruitments 조인
BookingBooking.slotIdSlot(date, timeRange)둘 다 booking(동일)booking 읽기가 bookingsslots 조인 → "{date} {timeRange} 시설 예약" 자기 라벨
  • Booking 한계 명시(핵심 난점 결론): SlotfacilityId: 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}). FE resolveOrderRoute는 참조-아이템이 아니라 주문상세 화면(/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가 각 도메인 조회를 bounded AsyncTaskExecutor에 fan-out, 도메인당 300ms 타임아웃(NFR). 타임아웃/예외 도메인은 결과에서 제외하고 failedDomains에 기록. 전체 요청은 실패시키지 않는다(빈 도메인은 no-op). 각 조회는 독립 read-only(도메인 간 공유 트랜잭션 없음) — SecurityContext 의존 값(order의 userId)은 메인 스레드에서 먼저 해석해 각 task에 파라미터로 전달(자식 스레드 SecurityContext 미전파 대비).
  • 타임아웃 기준: 원본 도메인 응답 >300ms → 해당 도메인 제외(NFR). 합산 P95 500ms 목표.
  • 페이지네이션: 이질 소스 글로벌 페이지네이션은 각 도메인에서 상한 window(≤ (page+1)*size)를 가져와 createdAt desc in-memory merge 후 offset/limit 적용. 깊은 페이지네이션 한계는 Open Question(향후 CQRS).
  • 동시성/멱등: catalog/order는 읽기 전용 → 쓰기 락·멱등 키 불요. sellerType은 등록 시 1회 결정 후 불변(소급 재판별 없음, PRD Non-Goal) → 동시 쓰기 경합 없음. 백필 중 신규 등록은 이미 값이 채워져 백필 대상에서 제외(WHERE seller_type IS NULL).
  • 미인증 주문 조회(User Scenario 7): /api/ordersauthenticated() → 미인증 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=B2CNULL 행만 대상, 이미 값 있으면 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

레벨대상핵심 시나리오(해피·실패·엣지)
domainSellerType.fromPartnerAuthenticated, Product.create(sellerType)파트너 인증 true→B2B, false→B2C / Product가 sellerType 보유
domainOrderType(common.order)enum 값·displayName 불변 (이관 후 회귀)
applicationCatalogCompositionService5개 도메인 정상 조합 / 1개 타임아웃 시 나머지 4개 반환 + failedDomains 표기(FR-11) / 검색 결과 0건 empty / itemType·sellerType 필터 / createdAt 최신순 병합
applicationOrderCompositionService4개 도메인 조합 / 부분 실패 / orderType·status 필터 / paymentId 연계 노출 / 각 항목 title 노출
application/infra주문 이름 조인 (goods/ticketing/recruitment/booking 읽기)GoodsOrder→상품명, TicketOrder→이벤트명, Application→모집명, Booking→“date timeRange 시설 예약” 라벨 / 삭제·부재 참조 시 방어(빈 이름 fallback)
applicationCreateMyProductUseCase/GoodsDomainService.createProductsellerType이 Command→Product로 전달·저장
infrastructureAuthChannelResolverImpl파트너 principal→true, JWT principal→false (TestContainers/MockMvc)
infrastructureProgramCustomRepository(신규)keyword 검색·페이지네이션
presentationCatalogApiControllerGET /api/catalog permitAll 200 / 필터 파라미터 / 부분 실패 응답 형태
presentationOrderHistoryApiController인증 200 / 미인증 401(User Scenario 7)
presentationPartnerApiKeyAuthenticationFilter파트너 경유 시 principal.partnerAuthenticated=true 주입
architectureProvidedInterfaceContractTestsellerType 반영 후 GREEN 유지 + CreateMyProductCommand.sellerType 존재 assertion 추가(의도적 갱신)
architectureR1/R3/R4 슬라이스OrderType 이관·catalog/order 신설 후 전체 ArchUnit GREEN
scenarioPartnerOnboardingScenarioTest 계열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단계

  1. [듀얼라이트] 스키마: 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.
  2. [배치 데이터 마이그레이션] BE-11 Spring Batch 잡이 seller_type IS NULL 행을 청크(500~1000/청크, 청크 커밋) 로 읽어 B2C로 채운다. 테이블 전체 락 회피. 멱등(WHERE seller_type IS NULL)이라 재실행 안전. 롤백: 잡 중단(이미 채운 행은 유효, DROP COLUMN으로 완전 원복 가능).
  3. [데이터 검증] SELECT COUNT(*) FROM products WHERE seller_type IS NULL = 0 확인(검증 쿼리/배치 스텝). 0 아니면 다음 단계 진행 금지.
  4. [기능 배포] catalog/order 읽기 파사드(BE-07/08) + sellerType 노출 + SecurityConfig matcher(BE-09, /api/catalog permitAll·/api/orders authenticated). 기존 API 무변경(추가 API). 롤백: 파사드·matcher revert.
  5. [배포후 검증 → 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-08v1.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-09v1.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-08v1.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 계약 확정