Hexagonal Architecture + DDD Workshop

이커머스 상품/재고 도메인으로 배우는 헥사고날 아키텍처와 DDD

Spring Boot 3.2JPAKafkaDDDPorts & AdaptersHands-on
약 4-5시간
13 단계
워크샵 개요

학습 목표

  • • 헥사고날 아키텍처(Ports & Adapters) 이해
  • • DDD 전술적 패턴 (Aggregate, Value Object, Domain Event)
  • • Spring Boot 3.x 멀티 모듈 프로젝트 구성
  • • Kafka를 활용한 이벤트 기반 통신
  • • 레이어별 테스트 전략

구현 도메인

  • Product: 상품 생성, 가격 변경, 상태 관리
  • Inventory: 재고 입고, 예약, 확정/취소
  • Event Flow: ProductCreated → Stock 초기화

기술 스택

  • • Java 21, Spring Boot 3.2, Gradle Kotlin DSL
  • • PostgreSQL, Spring Data JPA
  • • Apache Kafka, Spring Kafka
  • • Testcontainers, JUnit 5
시스템 아키텍처
┌─────────────────────────────────────────────────────────────────────────────┐
│                              API Gateway                                     │
└─────────────────────────────────┬───────────────────────────────────────────┘
                                  │
          ┌───────────────────────┴───────────────────────┐
          │                                               │
          ▼                                               ▼
┌─────────────────────────┐                   ┌─────────────────────────┐
│    Product Service      │                   │   Inventory Service     │
│    (Port 8081)          │                   │    (Port 8082)          │
├─────────────────────────┤                   ├─────────────────────────┤
│  ┌───────────────────┐  │                   │  ┌───────────────────┐  │
│  │   REST Adapter    │  │                   │  │   REST Adapter    │  │
│  │   (Driving)       │  │                   │  │   (Driving)       │  │
│  └─────────┬─────────┘  │                   │  └─────────┬─────────┘  │
│            │            │                   │            │            │
│  ┌─────────▼─────────┐  │                   │  ┌─────────▼─────────┐  │
│  │   Application     │  │                   │  │   Application     │  │
│  │   (Use Cases)     │  │                   │  │   (Use Cases)     │  │
│  └─────────┬─────────┘  │                   │  └─────────┬─────────┘  │
│            │            │                   │            │            │
│  ┌─────────▼─────────┐  │                   │  ┌─────────▼─────────┐  │
│  │     Domain        │  │                   │  │     Domain        │  │
│  │  (Product, Price) │  │                   │  │  (Stock, Qty)     │  │
│  └─────────┬─────────┘  │                   │  └─────────┬─────────┘  │
│            │            │                   │            │            │
│  ┌─────────▼─────────┐  │                   │  ┌─────────▼─────────┐  │
│  │   JPA Adapter     │  │                   │  │   JPA Adapter     │  │
│  │   (Driven)        │  │                   │  │   (Driven)        │  │
│  └─────────┬─────────┘  │                   │  └─────────┬─────────┘  │
└────────────┼────────────┘                   └────────────┼────────────┘
             │                                             │
             ▼                                             ▼
      ┌──────────────┐                              ┌──────────────┐
      │  PostgreSQL  │                              │  PostgreSQL  │
      │  (products)  │                              │   (stocks)   │
      └──────────────┘                              └──────────────┘

                    ┌─────────────────────────────┐
                    │         Apache Kafka        │
                    │  ┌───────────────────────┐  │
                    │  │  product-created      │──┼──▶ Inventory Consumer
                    │  │  product-price-changed│  │
                    │  │  stock-reserved       │  │
                    │  └───────────────────────┘  │
                    └─────────────────────────────┘
0
왜 DDD와 헥사고날 아키텍처인가?
에릭 에반스와 반 버논의 관점에서 DDD의 본질과 헥사고날 아키텍처가 필요한 이유를 이해합니다.

소프트웨어의 본질적 복잡성 - 에릭 에반스의 통찰

2003년 'Domain-Driven Design: Tackling Complexity in the Heart of Software'를 통해 에릭 에반스는 소프트웨어 개발의 가장 큰 도전이 기술이 아닌 '도메인의 복잡성'임을 밝혔습니다.

# 에릭 에반스의 핵심 통찰
# ═══════════════════════════════════════════════════════════════════════════

"소프트웨어의 심장부에 있는 복잡성을 다루는 것이 우리의 진정한 도전이다."
                                        - Eric Evans, 2003

# 전통적인 개발의 문제점:
# ─────────────────────────────────────────────────────────────────────────

┌─────────────────────────────────────────────────────────────────────────┐
│                    전통적인 계층형 아키텍처                               │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│   ┌─────────────────────────────────────────────────────────────────┐  │
│   │                    Presentation Layer                           │  │
│   │              (Controller, View, DTO)                            │  │
│   └─────────────────────────────────────────────────────────────────┘  │
│                              │                                          │
│                              ▼                                          │
│   ┌─────────────────────────────────────────────────────────────────┐  │
│   │                    Business Layer                               │  │
│   │         (Service - 여기에 모든 로직이 집중됨!)                    │  │
│   │                                                                 │  │
│   │   public void createOrder(OrderDTO dto) {                       │  │
│   │       // 검증 로직                                               │  │
│   │       if (dto.getItems().isEmpty()) throw new Exception();      │  │
│   │       // 비즈니스 로직                                           │  │
│   │       int total = 0;                                            │  │
│   │       for (ItemDTO item : dto.getItems()) {                     │  │
│   │           total += item.getPrice() * item.getQuantity();        │  │
│   │       }                                                         │  │
│   │       // 영속성 로직                                             │  │
│   │       orderRepository.save(toEntity(dto));                      │  │
│   │   }                                                             │  │
│   └─────────────────────────────────────────────────────────────────┘  │
│                              │                                          │
│                              ▼                                          │
│   ┌─────────────────────────────────────────────────────────────────┐  │
│   │                    Data Access Layer                            │  │
│   │              (Repository, Entity - 빈혈 모델)                    │  │
│   │                                                                 │  │
│   │   @Entity                                                       │  │
│   │   public class Order {                                          │  │
│   │       private Long id;                                          │  │
│   │       private List<Item> items;                                 │  │
│   │       // Getter, Setter만 존재 - 행위 없음!                      │  │
│   │   }                                                             │  │
│   └─────────────────────────────────────────────────────────────────┘  │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

# 이 구조의 문제점:
# 1. 빈혈 도메인 모델 (Anemic Domain Model)
#    - Entity는 데이터만 담는 그릇
#    - 비즈니스 로직이 Service에 흩어져 있음
#    - 도메인 지식이 코드에 드러나지 않음
#
# 2. 기술 중심 설계
#    - 패키지가 기술 기준으로 나뉨 (controller, service, repository)
#    - 비즈니스 개념이 코드 구조에 반영되지 않음
#
# 3. 변경의 어려움
#    - 하나의 비즈니스 규칙 변경 시 여러 레이어 수정 필요
#    - 테스트하기 어려움 (DB, 외부 시스템 의존)

DDD의 핵심 철학 - 도메인이 중심이다

DDD는 단순한 설계 패턴이 아닙니다. 비즈니스 전문가와 개발자가 같은 언어로 소통하고, 그 언어가 코드에 그대로 반영되는 것이 핵심입니다.

# DDD의 핵심 철학
# ═══════════════════════════════════════════════════════════════════════════

"도메인 모델은 특정 다이어그램이 아니다. 
 다이어그램이 전달하고자 하는 아이디어다.
 코드가 표현하는 것이고, 팀원들의 머릿속에 있는 것이다."
                                        - Eric Evans

# 1. 유비쿼터스 언어 (Ubiquitous Language)
# ─────────────────────────────────────────────────────────────────────────

# ❌ 기술 중심 대화:
개발자: "OrderService의 createOrder 메서드에서 OrderDTO를 받아서
        OrderEntity로 변환 후 OrderRepository에 저장합니다."

# ✅ 도메인 중심 대화:
비즈니스: "고객이 주문을 생성하면, 재고를 예약하고, 결제를 진행합니다."
개발자:   "네, Order가 생성되면 Inventory에서 Stock을 예약하고,
          Payment를 처리합니다."

# 코드에서의 유비쿼터스 언어:
public class Order {
    public void place() {           // "주문을 넣다"
        this.status = PLACED;
        registerEvent(new OrderPlaced(this));
    }
    
    public void cancel() {          // "주문을 취소하다"
        if (!canBeCancelled()) {
            throw new OrderCannotBeCancelledException();
        }
        this.status = CANCELLED;
        registerEvent(new OrderCancelled(this));
    }
}

# 2. 모델 주도 설계 (Model-Driven Design)
# ─────────────────────────────────────────────────────────────────────────

┌─────────────────────────────────────────────────────────────────────────┐
│                                                                         │
│    ┌──────────────┐         ┌──────────────┐         ┌──────────────┐  │
│    │   비즈니스    │ ◀─────▶ │    모델      │ ◀─────▶ │    코드      │  │
│    │   전문가      │  대화    │  (공유 이해)  │  구현    │  (실행 가능)  │  │
│    └──────────────┘         └──────────────┘         └──────────────┘  │
│                                    │                                    │
│                                    │                                    │
│                                    ▼                                    │
│                          ┌──────────────────┐                          │
│                          │  유비쿼터스 언어  │                          │
│                          │  (Ubiquitous     │                          │
│                          │   Language)      │                          │
│                          └──────────────────┘                          │
│                                                                         │
│    "모델과 코드는 하나다. 모델이 변경되면 코드가 변경되고,                │
│     코드가 변경되면 모델이 변경된다."                                    │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

# 3. 이커머스 도메인에서의 유비쿼터스 언어 예시
# ─────────────────────────────────────────────────────────────────────────

┌─────────────────────────────────────────────────────────────────────────┐
│ 비즈니스 용어          │ 코드 표현                │ 의미                 │
├─────────────────────────────────────────────────────────────────────────┤
│ 상품을 등록하다        │ Product.create()        │ 새 상품 생성         │
│ 가격을 변경하다        │ Product.changePrice()   │ 상품 가격 수정       │
│ 상품을 활성화하다      │ Product.activate()      │ 판매 가능 상태로     │
│ 재고를 입고하다        │ Stock.addStock()        │ 재고 수량 증가       │
│ 재고를 예약하다        │ Stock.reserve()         │ 주문용 재고 확보     │
│ 예약을 확정하다        │ Stock.confirmReservation() │ 결제 완료 후 확정 │
│ 예약을 취소하다        │ Stock.cancelReservation()  │ 주문 취소 시 복구 │
└─────────────────────────────────────────────────────────────────────────┘

반 버논의 실용적 DDD - Implementing Domain-Driven Design

2013년 반 버논은 'Implementing Domain-Driven Design(IDDD)'을 통해 에릭 에반스의 이론을 실제 구현 가능한 형태로 발전시켰습니다.

# 반 버논의 실용적 DDD (IDDD)
# ═══════════════════════════════════════════════════════════════════════════

"DDD는 전략적 설계와 전술적 설계로 나뉜다.
 전략적 설계 없이 전술적 패턴만 적용하는 것은 
 DDD가 아니라 'DDD-Lite'일 뿐이다."
                                        - Vaughn Vernon, 2013

# 전략적 설계 (Strategic Design) - 큰 그림
# ─────────────────────────────────────────────────────────────────────────

┌─────────────────────────────────────────────────────────────────────────┐
│                         이커머스 시스템                                  │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│   ┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐    │
│   │    Product      │    │   Inventory     │    │     Order       │    │
│   │    Context      │    │    Context      │    │    Context      │    │
│   │   (상품 관리)    │    │   (재고 관리)    │    │   (주문 관리)    │    │
│   │                 │    │                 │    │                 │    │
│   │  • 상품 등록    │    │  • 재고 입고    │    │  • 주문 생성    │    │
│   │  • 가격 관리    │    │  • 재고 예약    │    │  • 결제 처리    │    │
│   │  • 카테고리     │    │  • 재고 조회    │    │  • 배송 추적    │    │
│   └────────┬────────┘    └────────┬────────┘    └────────┬────────┘    │
│            │                      │                      │              │
│            │    ProductCreated    │    StockReserved     │              │
│            └──────────────────────┼──────────────────────┘              │
│                                   │                                     │
│                          Domain Events                                  │
│                      (Bounded Context 간 통신)                          │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

# Bounded Context (경계된 컨텍스트)
# ─────────────────────────────────────────────────────────────────────────

# 같은 용어도 컨텍스트에 따라 다른 의미를 가진다:

┌─────────────────────────────────────────────────────────────────────────┐
│                                                                         │
│   Product Context에서의 "Product"    │   Order Context에서의 "Product"  │
│   ─────────────────────────────────  │   ─────────────────────────────  │
│   • 상품명, 설명, 이미지              │   • 상품 ID, 이름, 가격만 필요    │
│   • 카테고리, 태그                    │   • 주문 시점의 스냅샷            │
│   • 가격 정책, 할인                   │   • 수량 정보                     │
│   • 상태 관리 (DRAFT/ACTIVE)         │   • 주문 라인 아이템으로 존재     │
│                                      │                                  │
│   class Product {                    │   class OrderLineItem {          │
│       ProductId id;                  │       ProductId productId;       │
│       ProductName name;              │       String productName;        │
│       Money price;                   │       Money unitPrice;           │
│       Category category;             │       Quantity quantity;         │
│       ProductStatus status;          │   }                              │
│   }                                  │                                  │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

# 전술적 설계 (Tactical Design) - 구현 패턴
# ─────────────────────────────────────────────────────────────────────────

┌─────────────────────────────────────────────────────────────────────────┐
│                        DDD 전술적 패턴                                   │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │                    Aggregate (집합체)                            │   │
│  │  ┌─────────────────────────────────────────────────────────┐    │   │
│  │  │              Aggregate Root (집합체 루트)                │    │   │
│  │  │                                                         │    │   │
│  │  │  • 일관성 경계의 진입점                                  │    │   │
│  │  │  • 불변식(Invariant) 보장                               │    │   │
│  │  │  • 외부에서는 Root를 통해서만 접근                       │    │   │
│  │  │                                                         │    │   │
│  │  │  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐     │    │   │
│  │  │  │   Entity    │  │   Entity    │  │ Value Object│     │    │   │
│  │  │  │  (식별자有)  │  │  (식별자有)  │  │  (값으로 비교)│     │    │   │
│  │  │  └─────────────┘  └─────────────┘  └─────────────┘     │    │   │
│  │  └─────────────────────────────────────────────────────────┘    │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                                                                         │
│  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐        │
│  │  Domain Event   │  │ Domain Service  │  │   Repository    │        │
│  │  (도메인 이벤트) │  │ (도메인 서비스) │  │   (저장소)      │        │
│  │                 │  │                 │  │                 │        │
│  │ • 과거에 발생한 │  │ • 여러 Aggregate│  │ • Aggregate     │        │
│  │   사실을 표현   │  │   에 걸친 로직  │  │   영속성 관리   │        │
│  │ • 불변 객체     │  │ • 상태 없음     │  │ • 컬렉션처럼    │        │
│  └─────────────────┘  └─────────────────┘  └─────────────────┘        │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

헥사고날 아키텍처가 DDD를 완성한다

헥사고날 아키텍처는 DDD의 도메인 모델을 기술적 세부사항으로부터 보호하는 방패 역할을 합니다. 이 둘의 결합이 진정한 도메인 중심 설계를 가능하게 합니다.

# 헥사고날 아키텍처 + DDD = 완전한 도메인 중심 설계
# ═══════════════════════════════════════════════════════════════════════════

"애플리케이션이 사용자, 프로그램, 자동화된 테스트, 배치 스크립트에 의해
 동등하게 구동될 수 있게 하고, 최종적으로 사용될 런타임 장치와 데이터베이스로부터
 격리된 상태에서 개발되고 테스트될 수 있게 하라."
                                        - Alistair Cockburn, 2005

# 왜 헥사고날 아키텍처인가?
# ─────────────────────────────────────────────────────────────────────────

# 문제: 전통적 계층 아키텍처에서 도메인 모델의 오염

┌─────────────────────────────────────────────────────────────────────────┐
│  전통적 계층 아키텍처의 문제                                             │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│   @Entity                          // JPA 의존성                        │
│   @Table(name = "products")        // 데이터베이스 스키마 의존           │
│   public class Product {                                                │
│       @Id                                                               │
│       @GeneratedValue                                                   │
│       private Long id;             // 기술적 ID (도메인 개념 아님)       │
│                                                                         │
│       @Column(name = "product_name")                                    │
│       private String name;         // 단순 String (비즈니스 규칙 없음)  │
│                                                                         │
│       @ManyToOne                   // JPA 관계 매핑                     │
│       @JoinColumn(name = "category_id")                                 │
│       private Category category;                                        │
│                                                                         │
│       // Getter, Setter만 존재                                          │
│       // 비즈니스 로직은 어디에? → Service 클래스에 흩어져 있음          │
│   }                                                                     │
│                                                                         │
│   문제점:                                                               │
│   1. 도메인 모델이 JPA에 종속됨                                         │
│   2. 데이터베이스 스키마가 도메인 모델을 결정함                          │
│   3. 비즈니스 규칙이 코드에 드러나지 않음                               │
│   4. 테스트 시 데이터베이스가 필요함                                    │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

# 해결: 헥사고날 아키텍처로 도메인 보호

┌─────────────────────────────────────────────────────────────────────────┐
│  헥사고날 아키텍처의 해결책                                              │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│   // 순수한 도메인 모델 (프레임워크 의존성 없음!)                        │
│   public class Product extends AggregateRoot<ProductId> {               │
│                                                                         │
│       private final ProductId id;      // 도메인 식별자 (Value Object)  │
│       private ProductName name;        // 비즈니스 규칙 포함            │
│       private Money price;             // 통화 + 금액 캡슐화            │
│       private ProductStatus status;    // 상태 전이 규칙 포함           │
│                                                                         │
│       // 팩토리 메서드 - 생성 규칙 캡슐화                               │
│       public static Product create(ProductName name, Money price, ...) {│
│           Product product = new Product(...);                           │
│           product.registerEvent(new ProductCreated(...));               │
│           return product;                                               │
│       }                                                                 │
│                                                                         │
│       // 비즈니스 메서드 - 도메인 로직 캡슐화                           │
│       public void changePrice(Money newPrice) {                         │
│           if (!canChangePrice()) {                                      │
│               throw new IllegalStateException("...");                   │
│           }                                                             │
│           Money oldPrice = this.price;                                  │
│           this.price = newPrice;                                        │
│           registerEvent(new ProductPriceChanged(...));                  │
│       }                                                                 │
│   }                                                                     │
│                                                                         │
│   장점:                                                                 │
│   1. 도메인 모델이 순수한 Java 객체 (POJO)                              │
│   2. 비즈니스 규칙이 도메인 모델 안에 캡슐화됨                          │
│   3. 데이터베이스 없이 단위 테스트 가능                                 │
│   4. 프레임워크 교체 시 도메인 모델 변경 불필요                         │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

# 의존성 방향의 역전
# ─────────────────────────────────────────────────────────────────────────

┌─────────────────────────────────────────────────────────────────────────┐
│                                                                         │
│   전통적 아키텍처:              헥사고날 아키텍처:                       │
│                                                                         │
│   Controller                    Adapter (REST)                          │
│       │                             │                                   │
│       ▼                             ▼                                   │
│   Service                       Port (Interface)                        │
│       │                             │                                   │
│       ▼                             ▼                                   │
│   Repository ◀── Domain         Domain ──▶ Port (Interface)            │
│       │              │              │              │                    │
│       ▼              │              │              ▼                    │
│   Database           │              │         Adapter (JPA)             │
│                      │              │              │                    │
│   (도메인이 DB에     │              │              ▼                    │
│    의존함!)          │              │         Database                  │
│                      │              │                                   │
│                      │              │   (도메인이 아무것도              │
│                      │              │    의존하지 않음!)                │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

# 이 워크샵에서 배울 것
# ─────────────────────────────────────────────────────────────────────────

1. 도메인 모델 설계
   - Aggregate Root, Entity, Value Object 구현
   - 불변식(Invariant) 보장
   - 도메인 이벤트 발행

2. 포트와 어댑터 구현
   - Driving Port (Use Case): 외부 → 애플리케이션
   - Driven Port (Repository, Event): 애플리케이션 → 외부
   - Adapter: Port의 구체적 구현

3. 이벤트 기반 통신
   - Bounded Context 간 느슨한 결합
   - Kafka를 통한 비동기 이벤트 전달

4. 테스트 전략
   - 도메인 모델: 순수 단위 테스트
   - 애플리케이션: Mock을 활용한 테스트
   - 어댑터: 통합 테스트
1
Hexagonal Architecture 이해
헥사고날 아키텍처의 핵심 개념과 DDD와의 관계를 이해합니다.

헥사고날 아키텍처란?

Alistair Cockburn이 2005년 제안한 Ports and Adapters 패턴으로, 비즈니스 로직을 외부 시스템으로부터 완전히 분리합니다.

# 헥사고날 아키텍처 (Ports and Adapters)
# ═══════════════════════════════════════════════════════════════════════════

#                    ┌─────────────────────────────────────────┐
#                    │           Driving Side (왼쪽)            │
#                    │    (Primary/Inbound Adapters)           │
#                    └─────────────────────────────────────────┘
#                                      │
#                                      ▼
#     ┌──────────────┐    ┌─────────────────────────────┐    ┌──────────────┐
#     │  REST API    │───▶│      Driving Ports          │    │   Kafka      │
#     │  Controller  │    │    (Input Interfaces)       │◀───│  Consumer    │
#     └──────────────┘    └─────────────────────────────┘    └──────────────┘
#                                      │
#                                      ▼
#                    ┌─────────────────────────────────────────┐
#                    │                                         │
#                    │         APPLICATION CORE                │
#                    │    ┌───────────────────────────┐       │
#                    │    │      Domain Model         │       │
#                    │    │   (Entities, Value Obj)   │       │
#                    │    └───────────────────────────┘       │
#                    │    ┌───────────────────────────┐       │
#                    │    │     Use Cases             │       │
#                    │    │   (Application Services)  │       │
#                    │    └───────────────────────────┘       │
#                    │                                         │
#                    └─────────────────────────────────────────┘
#                                      │
#                                      ▼
#     ┌──────────────┐    ┌─────────────────────────────┐    ┌──────────────┐
#     │   JPA        │◀───│      Driven Ports           │───▶│   Kafka      │
#     │  Repository  │    │   (Output Interfaces)       │    │  Producer    │
#     └──────────────┘    └─────────────────────────────┘    └──────────────┘
#                                      │
#                                      ▼
#                    ┌─────────────────────────────────────────┐
#                    │           Driven Side (오른쪽)           │
#                    │   (Secondary/Outbound Adapters)         │
#                    └─────────────────────────────────────────┘

# 핵심 원칙:
# 1. 의존성 방향: 외부 → 내부 (Domain은 아무것도 의존하지 않음)
# 2. Port: 인터페이스 (비즈니스 관점의 계약)
# 3. Adapter: Port의 구현체 (기술적 세부사항)

DDD와 헥사고날의 결합

Domain-Driven Design의 전술적 패턴을 헥사고날 구조에 배치합니다.

# DDD + Hexagonal Architecture 매핑
# ═══════════════════════════════════════════════════════════════════════════

# ┌─────────────────────────────────────────────────────────────────────────┐
# │                        BOUNDED CONTEXT                                  │
# │  ┌───────────────────────────────────────────────────────────────────┐ │
# │  │                    APPLICATION LAYER                              │ │
# │  │  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐   │ │
# │  │  │   Use Cases     │  │  Command/Query  │  │   Event         │   │ │
# │  │  │   (Services)    │  │   Handlers      │  │   Handlers      │   │ │
# │  │  └─────────────────┘  └─────────────────┘  └─────────────────┘   │ │
# │  └───────────────────────────────────────────────────────────────────┘ │
# │  ┌───────────────────────────────────────────────────────────────────┐ │
# │  │                      DOMAIN LAYER                                 │ │
# │  │  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐               │ │
# │  │  │  Aggregate  │  │   Entity    │  │ Value Object│               │ │
# │  │  │   Root      │  │             │  │             │               │ │
# │  │  └─────────────┘  └─────────────┘  └─────────────┘               │ │
# │  │  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐               │ │
# │  │  │  Domain     │  │  Domain     │  │ Repository  │               │ │
# │  │  │  Service    │  │  Event      │  │  Interface  │               │ │
# │  │  └─────────────┘  └─────────────┘  └─────────────┘               │ │
# │  └───────────────────────────────────────────────────────────────────┘ │
# └─────────────────────────────────────────────────────────────────────────┘

# 이커머스 도메인 예시:
# ┌─────────────────────┐     ┌─────────────────────┐
# │  Product Context    │     │  Inventory Context  │
# │  ─────────────────  │     │  ─────────────────  │
# │  • Product (AR)     │────▶│  • Stock (AR)       │
# │  • Category (VO)    │     │  • StockMovement    │
# │  • Price (VO)       │     │  • Reservation      │
# │  • ProductCreated   │     │  • StockUpdated     │
# └─────────────────────┘     └─────────────────────┘
#         │                           │
#         └───────────────────────────┘
#                    │
#            Domain Events로 통신

프로젝트 구조

이 워크샵에서 구현할 멀티 모듈 프로젝트 구조입니다.

# 프로젝트 구조 (Multi-Module Gradle)
# ═══════════════════════════════════════════════════════════════════════════

ecommerce-hexagonal/
├── build.gradle.kts                    # 루트 빌드 설정
├── settings.gradle.kts                 # 모듈 정의
├── docker-compose.yml                  # PostgreSQL, Kafka, Zookeeper
│
├── common/                             # 공통 모듈
│   └── src/main/java/
│       └── com/example/common/
│           ├── domain/
│           │   ├── AggregateRoot.java
│           │   ├── DomainEvent.java
│           │   └── ValueObject.java
│           └── application/
│               └── EventPublisher.java
│
├── product/                            # 상품 도메인 모듈
│   └── src/main/java/
│       └── com/example/product/
│           ├── domain/                 # 도메인 레이어
│           │   ├── model/
│           │   │   ├── Product.java           # Aggregate Root
│           │   │   ├── ProductId.java         # Value Object
│           │   │   ├── ProductName.java       # Value Object
│           │   │   ├── Money.java             # Value Object
│           │   │   └── Category.java          # Value Object
│           │   ├── event/
│           │   │   ├── ProductCreated.java
│           │   │   └── ProductPriceChanged.java
│           │   ├── repository/
│           │   │   └── ProductRepository.java # Port (Interface)
│           │   └── service/
│           │       └── ProductDomainService.java
│           │
│           ├── application/            # 애플리케이션 레이어
│           │   ├── port/
│           │   │   ├── in/                    # Driving Ports
│           │   │   │   ├── CreateProductUseCase.java
│           │   │   │   ├── GetProductUseCase.java
│           │   │   │   └── UpdatePriceUseCase.java
│           │   │   └── out/                   # Driven Ports
│           │   │       ├── LoadProductPort.java
│           │   │       ├── SaveProductPort.java
│           │   │       └── ProductEventPort.java
│           │   ├── service/
│           │   │   └── ProductService.java    # Use Case 구현
│           │   └── dto/
│           │       ├── CreateProductCommand.java
│           │       └── ProductResponse.java
│           │
│           └── adapter/                # 어댑터 레이어
│               ├── in/                        # Driving Adapters
│               │   ├── web/
│               │   │   └── ProductController.java
│               │   └── kafka/
│               │       └── ProductEventConsumer.java
│               └── out/                       # Driven Adapters
│                   ├── persistence/
│                   │   ├── ProductJpaEntity.java
│                   │   ├── ProductJpaRepository.java
│                   │   └── ProductPersistenceAdapter.java
│                   └── messaging/
│                       └── ProductKafkaAdapter.java
│
└── inventory/                          # 재고 도메인 모듈 (동일 구조)
    └── src/main/java/
        └── com/example/inventory/
            ├── domain/
            ├── application/
            └── adapter/
2
프로젝트 초기 설정
Spring Boot 3.x 멀티 모듈 프로젝트를 생성하고 의존성을 설정합니다.

루트 build.gradle.kts

Spring Boot 3.2+, Java 21, Kotlin DSL을 사용한 빌드 설정입니다.

// build.gradle.kts (Root)
plugins {
    java
    id("org.springframework.boot") version "3.2.5" apply false
    id("io.spring.dependency-management") version "1.1.4" apply false
}

allprojects {
    group = "com.example"
    version = "1.0.0"

    repositories {
        mavenCentral()
    }
}

subprojects {
    apply(plugin = "java")
    apply(plugin = "org.springframework.boot")
    apply(plugin = "io.spring.dependency-management")

    java {
        sourceCompatibility = JavaVersion.VERSION_21
        targetCompatibility = JavaVersion.VERSION_21
    }

    dependencies {
        // Lombok
        compileOnly("org.projectlombok:lombok")
        annotationProcessor("org.projectlombok:lombok")
        
        // Testing
        testImplementation("org.springframework.boot:spring-boot-starter-test")
        testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
    }

    tasks.withType<Test> {
        useJUnitPlatform()
    }
}

settings.gradle.kts

멀티 모듈 프로젝트 구성을 정의합니다.

// settings.gradle.kts
rootProject.name = "ecommerce-hexagonal"

include(
    "common",
    "product",
    "inventory",
    "api-gateway"
)

Common 모듈 설정

공통 도메인 기반 클래스를 정의하는 모듈입니다.

// common/build.gradle.kts
plugins {
    id("java-library")
}

// Spring Boot 플러그인 비활성화 (라이브러리 모듈)
tasks.named("bootJar") { enabled = false }
tasks.named("jar") { enabled = true }

dependencies {
    api("com.fasterxml.jackson.core:jackson-databind")
    api("jakarta.validation:jakarta.validation-api:3.0.2")
}

Product 모듈 설정

상품 도메인 모듈의 의존성을 설정합니다.

// product/build.gradle.kts
dependencies {
    implementation(project(":common"))
    
    // Spring Boot Starters
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-validation")
    
    // Kafka
    implementation("org.springframework.kafka:spring-kafka")
    
    // Database
    runtimeOnly("org.postgresql:postgresql")
    
    // MapStruct for DTO mapping
    implementation("org.mapstruct:mapstruct:1.5.5.Final")
    annotationProcessor("org.mapstruct:mapstruct-processor:1.5.5.Final")
    
    // Testing
    testImplementation("com.h2database:h2")
    testImplementation("org.springframework.kafka:spring-kafka-test")
    testImplementation("org.testcontainers:postgresql:1.19.7")
    testImplementation("org.testcontainers:kafka:1.19.7")
}

Docker Compose 설정

로컬 개발 환경을 위한 인프라 구성입니다.

# docker-compose.yml
version: '3.8'

services:
  postgres:
    image: postgres:16-alpine
    container_name: ecommerce-postgres
    environment:
      POSTGRES_USER: ecommerce
      POSTGRES_PASSWORD: ecommerce123
      POSTGRES_DB: ecommerce
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ecommerce"]
      interval: 10s
      timeout: 5s
      retries: 5

  zookeeper:
    image: confluentinc/cp-zookeeper:7.5.3
    container_name: ecommerce-zookeeper
    environment:
      ZOOKEEPER_CLIENT_PORT: 2181
      ZOOKEEPER_TICK_TIME: 2000
    ports:
      - "2181:2181"

  kafka:
    image: confluentinc/cp-kafka:7.5.3
    container_name: ecommerce-kafka
    depends_on:
      - zookeeper
    ports:
      - "9092:9092"
      - "29092:29092"
    environment:
      KAFKA_BROKER_ID: 1
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
      KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"

  kafka-ui:
    image: provectuslabs/kafka-ui:latest
    container_name: ecommerce-kafka-ui
    depends_on:
      - kafka
    ports:
      - "8090:8080"
    environment:
      KAFKA_CLUSTERS_0_NAME: local
      KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:29092

volumes:
  postgres_data:

# 실행 명령어:
# docker-compose up -d
# docker-compose logs -f kafka
3
Common 모듈 - DDD 기반 클래스
Aggregate Root, Value Object, Domain Event 등 DDD 기반 추상 클래스를 구현합니다.

DDD 빌딩 블록 이해하기 - 에릭 에반스의 전술적 패턴

코드를 작성하기 전에, DDD의 핵심 빌딩 블록이 무엇이고 왜 필요한지 이해해야 합니다. 이것들은 단순한 디자인 패턴이 아니라 도메인 지식을 코드로 표현하는 방법입니다.

# DDD 빌딩 블록 (Building Blocks) - 에릭 에반스
# ═══════════════════════════════════════════════════════════════════════════

"전술적 설계 패턴은 도메인 모델을 표현하기 위한 어휘다.
 이 어휘 없이는 복잡한 도메인을 코드로 표현할 수 없다."
                                        - Eric Evans

# 1. Entity (엔티티)
# ─────────────────────────────────────────────────────────────────────────
# 정의: 고유한 식별자를 가지며, 시간이 지나도 동일성을 유지하는 객체

# 실생활 예시:
# - 사람: 이름이 바뀌어도 주민등록번호로 같은 사람임을 알 수 있음
# - 은행 계좌: 잔액이 변해도 계좌번호로 같은 계좌임을 알 수 있음
# - 상품: 가격이 변해도 상품 ID로 같은 상품임을 알 수 있음

# 코드에서:
Person person1 = new Person("P001", "홍길동");
Person person2 = new Person("P001", "홍길동");
person1.changeName("김철수");

// person1과 person2는 같은 사람인가? → YES! (ID가 같으므로)
// person1.equals(person2) → true (ID 기반 비교)

# 2. Value Object (값 객체)
# ─────────────────────────────────────────────────────────────────────────
# 정의: 식별자 없이 속성 값으로만 동등성을 판단하는 불변 객체

# 실생활 예시:
# - 돈: 만원짜리 두 장은 구분할 필요 없음 (값이 같으면 같은 것)
# - 주소: "서울시 강남구"는 어디서 쓰든 같은 주소
# - 색상: RGB(255, 0, 0)은 어디서든 빨간색

# 코드에서:
Money money1 = Money.krw(10000);
Money money2 = Money.krw(10000);

// money1과 money2는 같은가? → YES! (값이 같으므로)
// money1.equals(money2) → true (값 기반 비교)

# Entity vs Value Object 비교
# ─────────────────────────────────────────────────────────────────────────

┌─────────────────────────────────────────────────────────────────────────┐
│              Entity                    │         Value Object           │
├─────────────────────────────────────────────────────────────────────────┤
│ 고유 식별자 있음                        │ 식별자 없음                    │
│ ID로 동등성 판단                        │ 모든 속성으로 동등성 판단      │
│ 가변 (상태 변경 가능)                   │ 불변 (새 객체 생성)            │
│ 생명주기 있음                           │ 생명주기 없음                  │
│ 예: Product, Order, Customer           │ 예: Money, Address, DateRange  │
└─────────────────────────────────────────────────────────────────────────┘

# 3. Aggregate (집합체)
# ─────────────────────────────────────────────────────────────────────────
# 정의: 데이터 변경의 단위로 취급되는 연관 객체의 묶음

# 실생활 예시:
# - 주문(Order)과 주문항목(OrderItem)
#   → 주문항목만 따로 수정할 수 없음, 반드시 주문을 통해 수정
# - 게시글(Post)과 댓글(Comment)
#   → 게시글이 삭제되면 댓글도 함께 삭제

┌─────────────────────────────────────────────────────────────────────────┐
│                         Order Aggregate                                 │
│  ┌───────────────────────────────────────────────────────────────────┐ │
│  │                    Order (Aggregate Root)                         │ │
│  │                                                                   │ │
│  │   orderId: OrderId          ← 외부에서 접근하는 유일한 진입점     │ │
│  │   customerId: CustomerId                                          │ │
│  │   status: OrderStatus                                             │ │
│  │   totalAmount: Money                                              │ │
│  │                                                                   │ │
│  │   ┌─────────────────┐  ┌─────────────────┐                       │ │
│  │   │  OrderLineItem  │  │  OrderLineItem  │  ← 내부 Entity        │ │
│  │   │  (내부 Entity)  │  │  (내부 Entity)  │    외부 직접 접근 불가│ │
│  │   └─────────────────┘  └─────────────────┘                       │ │
│  │                                                                   │ │
│  │   ┌─────────────────┐                                            │ │
│  │   │ ShippingAddress │  ← Value Object                            │ │
│  │   │ (Value Object)  │                                            │ │
│  │   └─────────────────┘                                            │ │
│  └───────────────────────────────────────────────────────────────────┘ │
│                                                                         │
│  규칙:                                                                  │
│  1. 외부에서는 Aggregate Root(Order)를 통해서만 접근                   │
│  2. 하나의 트랜잭션에서 하나의 Aggregate만 수정                        │
│  3. Aggregate 간 참조는 ID로만 (직접 객체 참조 X)                      │
└─────────────────────────────────────────────────────────────────────────┘

# 4. Domain Event (도메인 이벤트)
# ─────────────────────────────────────────────────────────────────────────
# 정의: 도메인에서 발생한 중요한 사건을 나타내는 객체

# 특징:
# - 과거형으로 명명 (ProductCreated, OrderPlaced, PaymentCompleted)
# - 불변 객체 (발생한 사실은 변경할 수 없음)
# - 다른 Bounded Context에 변경 사항 전파

# 이벤트 흐름 예시:
┌──────────────┐     ProductCreated      ┌──────────────┐
│   Product    │ ─────────────────────▶  │  Inventory   │
│   Context    │                         │   Context    │
└──────────────┘                         └──────────────┘
      │                                        │
      │ "상품이 생성되었다"                     │ "재고를 초기화한다"
      │                                        │
      ▼                                        ▼
  Product.create()                      Stock.create(productId, 0)

AggregateRoot 추상 클래스

모든 Aggregate Root가 상속받는 기반 클래스입니다. 도메인 이벤트 관리 기능을 포함합니다.

// common/src/main/java/com/example/common/domain/AggregateRoot.java
package com.example.common.domain;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

/**
 * Aggregate Root 기반 클래스
 * - 도메인 이벤트 수집 및 발행 관리
 * - 불변성 보장을 위한 이벤트 리스트 캡슐화
 */
public abstract class AggregateRoot<ID> {
    
    private final List<DomainEvent> domainEvents = new ArrayList<>();
    
    public abstract ID getId();
    
    /**
     * 도메인 이벤트 등록
     * - Aggregate 내부에서만 호출
     * - 비즈니스 로직 수행 후 이벤트 발생
     */
    protected void registerEvent(DomainEvent event) {
        this.domainEvents.add(event);
    }
    
    /**
     * 등록된 도메인 이벤트 조회
     * - 불변 리스트로 반환하여 외부 수정 방지
     */
    public List<DomainEvent> getDomainEvents() {
        return Collections.unmodifiableList(domainEvents);
    }
    
    /**
     * 도메인 이벤트 초기화
     * - 이벤트 발행 후 호출
     */
    public void clearDomainEvents() {
        this.domainEvents.clear();
    }
}

DomainEvent 인터페이스

모든 도메인 이벤트가 구현하는 마커 인터페이스입니다.

// common/src/main/java/com/example/common/domain/DomainEvent.java
package com.example.common.domain;

import java.time.Instant;
import java.util.UUID;

/**
 * 도메인 이벤트 기반 인터페이스
 * - 이벤트 식별자, 발생 시간, 집계 정보 포함
 */
public interface DomainEvent {
    
    /**
     * 이벤트 고유 식별자
     */
    UUID getEventId();
    
    /**
     * 이벤트 발생 시간 (UTC)
     */
    Instant getOccurredAt();
    
    /**
     * 이벤트 발생 Aggregate 타입
     */
    String getAggregateType();
    
    /**
     * 이벤트 발생 Aggregate ID
     */
    String getAggregateId();
}

// common/src/main/java/com/example/common/domain/BaseDomainEvent.java
package com.example.common.domain;

import java.time.Instant;
import java.util.UUID;

/**
 * 도메인 이벤트 기본 구현
 */
public abstract class BaseDomainEvent implements DomainEvent {
    
    private final UUID eventId;
    private final Instant occurredAt;
    
    protected BaseDomainEvent() {
        this.eventId = UUID.randomUUID();
        this.occurredAt = Instant.now();
    }
    
    @Override
    public UUID getEventId() {
        return eventId;
    }
    
    @Override
    public Instant getOccurredAt() {
        return occurredAt;
    }
}

ValueObject 추상 클래스

Value Object의 동등성 비교를 위한 기반 클래스입니다.

// common/src/main/java/com/example/common/domain/ValueObject.java
package com.example.common.domain;

import java.util.List;
import java.util.Objects;

/**
 * Value Object 기반 클래스
 * - 값에 의한 동등성 비교
 * - 불변성 보장 (상속 클래스에서 final 필드 사용)
 */
public abstract class ValueObject {
    
    /**
     * 동등성 비교에 사용할 속성들 반환
     * - 하위 클래스에서 구현
     */
    protected abstract List<Object> getEqualityComponents();
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        
        ValueObject that = (ValueObject) o;
        return Objects.equals(getEqualityComponents(), that.getEqualityComponents());
    }
    
    @Override
    public int hashCode() {
        return Objects.hash(getEqualityComponents().toArray());
    }
}

// 사용 예시: Money Value Object
// common/src/main/java/com/example/common/domain/Money.java
package com.example.common.domain;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Currency;
import java.util.List;

/**
 * 금액 Value Object
 * - 통화와 금액을 함께 관리
 * - 불변 객체로 연산 시 새 객체 반환
 */
public final class Money extends ValueObject {
    
    private final BigDecimal amount;
    private final Currency currency;
    
    public static final Money ZERO_KRW = Money.of(BigDecimal.ZERO, "KRW");
    
    private Money(BigDecimal amount, Currency currency) {
        this.amount = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.HALF_UP);
        this.currency = currency;
    }
    
    public static Money of(BigDecimal amount, String currencyCode) {
        if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Amount must be non-negative");
        }
        return new Money(amount, Currency.getInstance(currencyCode));
    }
    
    public static Money krw(long amount) {
        return of(BigDecimal.valueOf(amount), "KRW");
    }
    
    public Money add(Money other) {
        validateSameCurrency(other);
        return new Money(this.amount.add(other.amount), this.currency);
    }
    
    public Money subtract(Money other) {
        validateSameCurrency(other);
        BigDecimal result = this.amount.subtract(other.amount);
        if (result.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Result cannot be negative");
        }
        return new Money(result, this.currency);
    }
    
    public Money multiply(int quantity) {
        return new Money(this.amount.multiply(BigDecimal.valueOf(quantity)), this.currency);
    }
    
    private void validateSameCurrency(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException("Currency mismatch");
        }
    }
    
    public BigDecimal getAmount() { return amount; }
    public Currency getCurrency() { return currency; }
    
    @Override
    protected List<Object> getEqualityComponents() {
        return List.of(amount, currency);
    }
    
    @Override
    public String toString() {
        return currency.getSymbol() + " " + amount;
    }
}

EventPublisher 인터페이스

도메인 이벤트 발행을 위한 Port 인터페이스입니다.

// common/src/main/java/com/example/common/application/EventPublisher.java
package com.example.common.application;

import com.example.common.domain.DomainEvent;
import java.util.List;

/**
 * 도메인 이벤트 발행 Port
 * - Application Layer에서 사용
 * - Adapter에서 구현 (Kafka, RabbitMQ 등)
 */
public interface EventPublisher {
    
    /**
     * 단일 이벤트 발행
     */
    void publish(DomainEvent event);
    
    /**
     * 다중 이벤트 발행
     */
    default void publishAll(List<DomainEvent> events) {
        events.forEach(this::publish);
    }
}

// common/src/main/java/com/example/common/application/UseCase.java
package com.example.common.application;

/**
 * Use Case 마커 인터페이스
 * - 단일 책임 원칙 적용
 * - 하나의 비즈니스 기능만 정의
 */
public interface UseCase {
}

// common/src/main/java/com/example/common/domain/Identifier.java
package com.example.common.domain;

import java.util.List;
import java.util.UUID;

/**
 * 식별자 Value Object 기반 클래스
 */
public abstract class Identifier extends ValueObject {
    
    private final UUID value;
    
    protected Identifier(UUID value) {
        if (value == null) {
            throw new IllegalArgumentException("Identifier cannot be null");
        }
        this.value = value;
    }
    
    protected Identifier() {
        this(UUID.randomUUID());
    }
    
    public UUID getValue() {
        return value;
    }
    
    @Override
    protected List<Object> getEqualityComponents() {
        return List.of(value);
    }
    
    @Override
    public String toString() {
        return value.toString();
    }
}
4
Product 도메인 모델 구현
상품 도메인의 Aggregate Root, Entity, Value Object를 구현합니다.

도메인 모델링 사고 과정 - 반 버논의 접근법

코드를 작성하기 전에 도메인 전문가와 대화하며 유비쿼터스 언어를 정의합니다. 이 과정이 DDD의 핵심입니다.

# 도메인 모델링 사고 과정
# ═══════════════════════════════════════════════════════════════════════════

"좋은 도메인 모델은 코드를 작성하기 전에 화이트보드 앞에서 탄생한다.
 비즈니스 전문가와 개발자가 함께 그림을 그리고 토론하라."
                                        - Vaughn Vernon

# Step 1: 도메인 전문가와의 대화
# ─────────────────────────────────────────────────────────────────────────

비즈니스: "상품을 등록할 때 상품명, 설명, 가격, 카테고리가 필요해요."
개발자:   "상품명에 제한이 있나요?"
비즈니스: "네, 2자 이상 100자 이하여야 해요. 빈 값은 안 됩니다."
개발자:   "가격은요?"
비즈니스: "0원 이상이어야 하고, 통화 단위도 함께 관리해야 해요."
개발자:   "상품 상태는 어떻게 관리하나요?"
비즈니스: "처음엔 '초안' 상태로 만들어지고, 검토 후 '활성화'하면 판매 가능해요.
          판매 중지하려면 '비활성화'하고요."
개발자:   "초안 상태에서 바로 비활성화할 수 있나요?"
비즈니스: "아니요, 반드시 활성화를 거쳐야 해요."

# Step 2: 유비쿼터스 언어 정의
# ─────────────────────────────────────────────────────────────────────────

┌─────────────────────────────────────────────────────────────────────────┐
│                    Product Context 유비쿼터스 언어                       │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  용어              │ 정의                        │ 코드 표현            │
│  ─────────────────────────────────────────────────────────────────────  │
│  상품(Product)     │ 판매 가능한 물품            │ Product (AR)         │
│  상품명            │ 2-100자의 상품 이름         │ ProductName (VO)     │
│  가격              │ 통화와 금액의 조합          │ Money (VO)           │
│  카테고리          │ 상품 분류 (코드+이름)       │ Category (VO)        │
│  상품 상태         │ DRAFT/ACTIVE/INACTIVE       │ ProductStatus (Enum) │
│                                                                         │
│  행위              │ 정의                        │ 코드 표현            │
│  ─────────────────────────────────────────────────────────────────────  │
│  상품 등록         │ 새 상품을 초안으로 생성     │ Product.create()     │
│  가격 변경         │ 상품 가격 수정              │ product.changePrice()│
│  상품 활성화       │ 판매 가능 상태로 전환       │ product.activate()   │
│  상품 비활성화     │ 판매 중지 상태로 전환       │ product.deactivate() │
│                                                                         │
│  이벤트            │ 정의                        │ 코드 표현            │
│  ─────────────────────────────────────────────────────────────────────  │
│  상품이 생성됨     │ 새 상품 등록 완료           │ ProductCreated       │
│  가격이 변경됨     │ 상품 가격 수정 완료         │ ProductPriceChanged  │
│  상태가 변경됨     │ 상품 상태 전이 완료         │ ProductStatusChanged │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

# Step 3: 불변식(Invariant) 정의
# ─────────────────────────────────────────────────────────────────────────

# 불변식: 항상 참이어야 하는 비즈니스 규칙

Product Aggregate 불변식:
├── 상품명은 2-100자여야 한다
├── 가격은 0 이상이어야 한다
├── 상태 전이 규칙:
│   ├── DRAFT → ACTIVE (활성화)
│   ├── ACTIVE → INACTIVE (비활성화)
│   └── INACTIVE → ACTIVE (재활성화)
└── 가격 변경은 DRAFT 또는 ACTIVE 상태에서만 가능하다

# Step 4: Aggregate 경계 결정
# ─────────────────────────────────────────────────────────────────────────

질문: "Product와 Category는 같은 Aggregate인가?"

분석:
- Category가 변경되면 Product도 함께 변경되어야 하는가? → No
- Category 없이 Product가 존재할 수 있는가? → No (필수 속성)
- Category를 독립적으로 관리해야 하는가? → Yes (별도 관리 화면)

결론: Category는 Product의 Value Object로 포함
      (별도 Category Aggregate가 있다면 ID만 참조)

┌─────────────────────────────────────────────────────────────────────────┐
│                      Product Aggregate                                  │
│  ┌───────────────────────────────────────────────────────────────────┐ │
│  │                 Product (Aggregate Root)                          │ │
│  │                                                                   │ │
│  │   ┌─────────────┐  ┌─────────────┐  ┌─────────────┐              │ │
│  │   │ ProductId   │  │ ProductName │  │   Money     │              │ │
│  │   │ (VO)        │  │ (VO)        │  │   (VO)      │              │ │
│  │   └─────────────┘  └─────────────┘  └─────────────┘              │ │
│  │                                                                   │ │
│  │   ┌─────────────┐  ┌─────────────────────────────┐               │ │
│  │   │ Category    │  │ ProductStatus               │               │ │
│  │   │ (VO)        │  │ (Enum)                      │               │ │
│  │   └─────────────┘  └─────────────────────────────┘               │ │
│  └───────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘

ProductId Value Object

상품 식별자를 위한 강타입 ID입니다.

// product/src/main/java/com/example/product/domain/model/ProductId.java
package com.example.product.domain.model;

import com.example.common.domain.Identifier;
import java.util.UUID;

/**
 * 상품 식별자 Value Object
 * - UUID 기반 고유 식별자
 * - 타입 안전성 보장
 */
public final class ProductId extends Identifier {
    
    private ProductId(UUID value) {
        super(value);
    }
    
    public static ProductId of(UUID value) {
        return new ProductId(value);
    }
    
    public static ProductId of(String value) {
        return new ProductId(UUID.fromString(value));
    }
    
    public static ProductId generate() {
        return new ProductId(UUID.randomUUID());
    }
}

ProductName, Category Value Objects

상품명과 카테고리를 위한 Value Object입니다.

// product/src/main/java/com/example/product/domain/model/ProductName.java
package com.example.product.domain.model;

import com.example.common.domain.ValueObject;
import java.util.List;

/**
 * 상품명 Value Object
 * - 비즈니스 규칙: 2~100자
 */
public final class ProductName extends ValueObject {
    
    private final String value;
    
    private ProductName(String value) {
        validate(value);
        this.value = value.trim();
    }
    
    public static ProductName of(String value) {
        return new ProductName(value);
    }
    
    private void validate(String value) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("Product name cannot be empty");
        }
        if (value.trim().length() < 2 || value.trim().length() > 100) {
            throw new IllegalArgumentException("Product name must be 2-100 characters");
        }
    }
    
    public String getValue() { return value; }
    
    @Override
    protected List<Object> getEqualityComponents() {
        return List.of(value);
    }
    
    @Override
    public String toString() { return value; }
}

// product/src/main/java/com/example/product/domain/model/Category.java
package com.example.product.domain.model;

import com.example.common.domain.ValueObject;
import java.util.List;

/**
 * 카테고리 Value Object
 * - 계층 구조 지원 (상위/하위 카테고리)
 */
public final class Category extends ValueObject {
    
    private final String code;
    private final String name;
    private final String parentCode;
    
    private Category(String code, String name, String parentCode) {
        validateCode(code);
        this.code = code;
        this.name = name;
        this.parentCode = parentCode;
    }
    
    public static Category of(String code, String name) {
        return new Category(code, name, null);
    }
    
    public static Category of(String code, String name, String parentCode) {
        return new Category(code, name, parentCode);
    }
    
    private void validateCode(String code) {
        if (code == null || !code.matches("^[A-Z]{2,10}$")) {
            throw new IllegalArgumentException("Category code must be 2-10 uppercase letters");
        }
    }
    
    public boolean isRootCategory() {
        return parentCode == null;
    }
    
    public String getCode() { return code; }
    public String getName() { return name; }
    public String getParentCode() { return parentCode; }
    
    @Override
    protected List<Object> getEqualityComponents() {
        return List.of(code);
    }
}

Product Aggregate Root

상품 도메인의 핵심 Aggregate Root입니다. 모든 비즈니스 규칙을 캡슐화합니다.

// product/src/main/java/com/example/product/domain/model/Product.java
package com.example.product.domain.model;

import com.example.common.domain.AggregateRoot;
import com.example.common.domain.Money;
import com.example.product.domain.event.ProductCreated;
import com.example.product.domain.event.ProductPriceChanged;
import com.example.product.domain.event.ProductStatusChanged;

import java.time.Instant;

/**
 * 상품 Aggregate Root
 * 
 * 불변식 (Invariants):
 * - 상품명은 필수
 * - 가격은 0 이상
 * - 상태 전이 규칙 준수
 */
public class Product extends AggregateRoot<ProductId> {
    
    private final ProductId id;
    private ProductName name;
    private String description;
    private Money price;
    private Category category;
    private ProductStatus status;
    private final Instant createdAt;
    private Instant updatedAt;
    
    // Private 생성자 - 팩토리 메서드 사용 강제
    private Product(ProductId id, ProductName name, String description, 
                    Money price, Category category) {
        this.id = id;
        this.name = name;
        this.description = description;
        this.price = price;
        this.category = category;
        this.status = ProductStatus.DRAFT;
        this.createdAt = Instant.now();
        this.updatedAt = this.createdAt;
    }
    
    /**
     * 상품 생성 팩토리 메서드
     * - 도메인 이벤트 발행
     */
    public static Product create(ProductName name, String description, 
                                  Money price, Category category) {
        ProductId id = ProductId.generate();
        Product product = new Product(id, name, description, price, category);
        
        // 도메인 이벤트 등록
        product.registerEvent(new ProductCreated(
            id.getValue().toString(),
            name.getValue(),
            price.getAmount(),
            price.getCurrency().getCurrencyCode(),
            category.getCode()
        ));
        
        return product;
    }
    
    /**
     * 재구성용 팩토리 메서드 (Repository에서 사용)
     */
    public static Product reconstitute(ProductId id, ProductName name, 
                                        String description, Money price, 
                                        Category category, ProductStatus status,
                                        Instant createdAt, Instant updatedAt) {
        Product product = new Product(id, name, description, price, category);
        product.status = status;
        // createdAt, updatedAt은 final이 아니므로 직접 설정
        return product;
    }
    
    /**
     * 가격 변경
     * - 비즈니스 규칙: DRAFT, ACTIVE 상태에서만 가능
     */
    public void changePrice(Money newPrice) {
        if (!canChangePrice()) {
            throw new IllegalStateException(
                "Cannot change price in status: " + status);
        }
        
        Money oldPrice = this.price;
        this.price = newPrice;
        this.updatedAt = Instant.now();
        
        registerEvent(new ProductPriceChanged(
            id.getValue().toString(),
            oldPrice.getAmount(),
            newPrice.getAmount(),
            newPrice.getCurrency().getCurrencyCode()
        ));
    }
    
    private boolean canChangePrice() {
        return status == ProductStatus.DRAFT || status == ProductStatus.ACTIVE;
    }
    
    /**
     * 상품 활성화
     */
    public void activate() {
        if (status != ProductStatus.DRAFT) {
            throw new IllegalStateException(
                "Only DRAFT products can be activated");
        }
        
        ProductStatus oldStatus = this.status;
        this.status = ProductStatus.ACTIVE;
        this.updatedAt = Instant.now();
        
        registerEvent(new ProductStatusChanged(
            id.getValue().toString(),
            oldStatus.name(),
            status.name()
        ));
    }
    
    /**
     * 상품 비활성화
     */
    public void deactivate() {
        if (status != ProductStatus.ACTIVE) {
            throw new IllegalStateException(
                "Only ACTIVE products can be deactivated");
        }
        
        ProductStatus oldStatus = this.status;
        this.status = ProductStatus.INACTIVE;
        this.updatedAt = Instant.now();
        
        registerEvent(new ProductStatusChanged(
            id.getValue().toString(),
            oldStatus.name(),
            status.name()
        ));
    }
    
    // Getters
    @Override
    public ProductId getId() { return id; }
    public ProductName getName() { return name; }
    public String getDescription() { return description; }
    public Money getPrice() { return price; }
    public Category getCategory() { return category; }
    public ProductStatus getStatus() { return status; }
    public Instant getCreatedAt() { return createdAt; }
    public Instant getUpdatedAt() { return updatedAt; }
}

// product/src/main/java/com/example/product/domain/model/ProductStatus.java
package com.example.product.domain.model;

/**
 * 상품 상태 열거형
 * 
 * 상태 전이:
 * DRAFT → ACTIVE → INACTIVE
 *           ↑         ↓
 *           └─────────┘
 */
public enum ProductStatus {
    DRAFT,      // 초안 (판매 불가)
    ACTIVE,     // 활성 (판매 가능)
    INACTIVE,   // 비활성 (판매 중지)
    DELETED     // 삭제됨 (논리 삭제)
}

Domain Events

상품 도메인에서 발생하는 이벤트들입니다.

// product/src/main/java/com/example/product/domain/event/ProductCreated.java
package com.example.product.domain.event;

import com.example.common.domain.BaseDomainEvent;
import java.math.BigDecimal;

/**
 * 상품 생성 이벤트
 * - Inventory 도메인에서 구독하여 재고 초기화
 */
public class ProductCreated extends BaseDomainEvent {
    
    private final String productId;
    private final String productName;
    private final BigDecimal price;
    private final String currency;
    private final String categoryCode;
    
    public ProductCreated(String productId, String productName, 
                          BigDecimal price, String currency, String categoryCode) {
        super();
        this.productId = productId;
        this.productName = productName;
        this.price = price;
        this.currency = currency;
        this.categoryCode = categoryCode;
    }
    
    @Override
    public String getAggregateType() { return "Product"; }
    
    @Override
    public String getAggregateId() { return productId; }
    
    // Getters
    public String getProductId() { return productId; }
    public String getProductName() { return productName; }
    public BigDecimal getPrice() { return price; }
    public String getCurrency() { return currency; }
    public String getCategoryCode() { return categoryCode; }
}

// product/src/main/java/com/example/product/domain/event/ProductPriceChanged.java
package com.example.product.domain.event;

import com.example.common.domain.BaseDomainEvent;
import java.math.BigDecimal;

/**
 * 상품 가격 변경 이벤트
 */
public class ProductPriceChanged extends BaseDomainEvent {
    
    private final String productId;
    private final BigDecimal oldPrice;
    private final BigDecimal newPrice;
    private final String currency;
    
    public ProductPriceChanged(String productId, BigDecimal oldPrice, 
                                BigDecimal newPrice, String currency) {
        super();
        this.productId = productId;
        this.oldPrice = oldPrice;
        this.newPrice = newPrice;
        this.currency = currency;
    }
    
    @Override
    public String getAggregateType() { return "Product"; }
    
    @Override
    public String getAggregateId() { return productId; }
    
    public String getProductId() { return productId; }
    public BigDecimal getOldPrice() { return oldPrice; }
    public BigDecimal getNewPrice() { return newPrice; }
    public String getCurrency() { return currency; }
}

// product/src/main/java/com/example/product/domain/event/ProductStatusChanged.java
package com.example.product.domain.event;

import com.example.common.domain.BaseDomainEvent;

/**
 * 상품 상태 변경 이벤트
 */
public class ProductStatusChanged extends BaseDomainEvent {
    
    private final String productId;
    private final String oldStatus;
    private final String newStatus;
    
    public ProductStatusChanged(String productId, String oldStatus, String newStatus) {
        super();
        this.productId = productId;
        this.oldStatus = oldStatus;
        this.newStatus = newStatus;
    }
    
    @Override
    public String getAggregateType() { return "Product"; }
    
    @Override
    public String getAggregateId() { return productId; }
    
    public String getProductId() { return productId; }
    public String getOldStatus() { return oldStatus; }
    public String getNewStatus() { return newStatus; }
}
5
Product Application Layer - Ports & Use Cases
Driving/Driven Ports와 Use Case를 구현합니다.

Port와 Adapter 이해하기 - 왜 인터페이스가 필요한가?

Port는 애플리케이션의 경계를 정의하는 인터페이스입니다. 이를 통해 도메인 로직을 외부 기술로부터 완전히 분리할 수 있습니다.

# Port와 Adapter의 본질
# ═══════════════════════════════════════════════════════════════════════════

"Port는 애플리케이션이 외부 세계와 대화하는 방법을 정의한다.
 Adapter는 그 대화를 실제로 수행하는 구현체다."
                                        - Alistair Cockburn

# 비유: 전기 콘센트와 플러그
# ─────────────────────────────────────────────────────────────────────────

┌─────────────────────────────────────────────────────────────────────────┐
│                                                                         │
│   전기 콘센트 (Port)              │   가전제품 플러그 (Adapter)         │
│   ─────────────────────────────   │   ─────────────────────────────     │
│   • 표준 인터페이스 정의          │   • 콘센트 규격에 맞는 구현         │
│   • 220V, 60Hz 제공               │   • 다양한 제품이 연결 가능         │
│   • 어떤 제품이 연결될지 모름     │   • 콘센트만 맞으면 동작            │
│                                                                         │
│   소프트웨어에서:                                                       │
│   • Port = Interface              │   • Adapter = Implementation        │
│   • 비즈니스 관점의 계약          │   • 기술적 세부사항                 │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

# Driving Port vs Driven Port
# ─────────────────────────────────────────────────────────────────────────

┌─────────────────────────────────────────────────────────────────────────┐
│                                                                         │
│   Driving Port (Primary/Inbound)    │   Driven Port (Secondary/Outbound)│
│   ─────────────────────────────     │   ─────────────────────────────   │
│   • 외부 → 애플리케이션             │   • 애플리케이션 → 외부            │
│   • "애플리케이션을 사용하는 방법"  │   • "애플리케이션이 사용하는 것"   │
│   • Use Case 인터페이스             │   • Repository, Event Publisher   │
│                                                                         │
│   예시:                             │   예시:                           │
│   • CreateProductUseCase            │   • LoadProductPort               │
│   • GetProductUseCase               │   • SaveProductPort               │
│   • UpdateProductUseCase            │   • ProductEventPort              │
│                                                                         │
│   구현 위치:                        │   구현 위치:                       │
│   • Application Layer에서 구현      │   • Adapter Layer에서 구현         │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

# 의존성 역전 원칙 (DIP) 적용
# ─────────────────────────────────────────────────────────────────────────

# ❌ 잘못된 방향 (전통적 계층 아키텍처)

┌──────────────────┐
│   Controller     │
└────────┬─────────┘
         │ 의존
         ▼
┌──────────────────┐
│    Service       │
└────────┬─────────┘
         │ 의존
         ▼
┌──────────────────┐
│   Repository     │  ← 구체 클래스
└────────┬─────────┘
         │ 의존
         ▼
┌──────────────────┐
│    Database      │
└──────────────────┘

문제: Service가 Repository 구현체에 직접 의존
     → 데이터베이스 없이 테스트 불가
     → 데이터베이스 변경 시 Service도 변경 필요

# ✅ 올바른 방향 (헥사고날 아키텍처)

┌──────────────────┐
│  REST Adapter    │  ← Driving Adapter
└────────┬─────────┘
         │ 호출
         ▼
┌──────────────────┐
│  Driving Port    │  ← Interface (Use Case)
│  (Interface)     │
└────────┬─────────┘
         │ 구현
         ▼
┌──────────────────┐
│ Application      │  ← Use Case 구현
│ Service          │
└────────┬─────────┘
         │ 의존 (인터페이스에)
         ▼
┌──────────────────┐
│  Driven Port     │  ← Interface (Repository)
│  (Interface)     │
└────────┬─────────┘
         │ 구현
         ▼
┌──────────────────┐
│  JPA Adapter     │  ← Driven Adapter
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│    Database      │
└──────────────────┘

장점: Application Service는 인터페이스에만 의존
     → Mock으로 쉽게 테스트 가능
     → 데이터베이스 변경 시 Adapter만 교체

# Use Case 설계 원칙
# ─────────────────────────────────────────────────────────────────────────

1. 단일 책임 원칙 (SRP)
   - 하나의 Use Case = 하나의 비즈니스 기능
   - CreateProductUseCase: 상품 생성만 담당
   - GetProductUseCase: 상품 조회만 담당

2. 인터페이스 분리 원칙 (ISP)
   - 클라이언트가 필요한 메서드만 노출
   - Controller는 CreateProductUseCase만 주입받으면 됨

3. 명령-조회 분리 (CQS)
   - Command: 상태 변경, void 또는 결과 반환
   - Query: 상태 조회, 데이터 반환

Driving Ports (Input Ports)

외부에서 애플리케이션을 호출하기 위한 인터페이스입니다.

// product/src/main/java/com/example/product/application/port/in/CreateProductUseCase.java
package com.example.product.application.port.in;

import com.example.common.application.UseCase;
import com.example.product.application.dto.CreateProductCommand;
import com.example.product.application.dto.ProductResponse;

/**
 * 상품 생성 Use Case (Driving Port)
 * - 단일 책임: 상품 생성만 담당
 */
public interface CreateProductUseCase extends UseCase {
    ProductResponse createProduct(CreateProductCommand command);
}

// product/src/main/java/com/example/product/application/port/in/GetProductUseCase.java
package com.example.product.application.port.in;

import com.example.common.application.UseCase;
import com.example.product.application.dto.ProductResponse;
import java.util.List;
import java.util.Optional;

/**
 * 상품 조회 Use Case
 */
public interface GetProductUseCase extends UseCase {
    Optional<ProductResponse> getProduct(String productId);
    List<ProductResponse> getAllProducts();
    List<ProductResponse> getProductsByCategory(String categoryCode);
}

// product/src/main/java/com/example/product/application/port/in/UpdateProductUseCase.java
package com.example.product.application.port.in;

import com.example.common.application.UseCase;
import com.example.product.application.dto.ChangePriceCommand;
import com.example.product.application.dto.ProductResponse;

/**
 * 상품 수정 Use Case
 */
public interface UpdateProductUseCase extends UseCase {
    ProductResponse changePrice(ChangePriceCommand command);
    ProductResponse activateProduct(String productId);
    ProductResponse deactivateProduct(String productId);
}

Command & Response DTOs

Use Case의 입출력을 위한 DTO입니다. Record를 사용하여 불변성을 보장합니다.

// product/src/main/java/com/example/product/application/dto/CreateProductCommand.java
package com.example.product.application.dto;

import jakarta.validation.constraints.*;
import java.math.BigDecimal;

/**
 * 상품 생성 Command
 * - Java 17+ Record 사용
 * - Bean Validation 적용
 */
public record CreateProductCommand(
    @NotBlank(message = "Product name is required")
    @Size(min = 2, max = 100, message = "Name must be 2-100 characters")
    String name,
    
    @Size(max = 1000, message = "Description max 1000 characters")
    String description,
    
    @NotNull(message = "Price is required")
    @DecimalMin(value = "0", message = "Price must be non-negative")
    BigDecimal price,
    
    @NotBlank(message = "Currency is required")
    @Pattern(regexp = "^[A-Z]{3}$", message = "Currency must be 3 uppercase letters")
    String currency,
    
    @NotBlank(message = "Category code is required")
    String categoryCode,
    
    String categoryName
) {
    // Compact constructor for validation
    public CreateProductCommand {
        if (currency == null) currency = "KRW";
    }
}

// product/src/main/java/com/example/product/application/dto/ChangePriceCommand.java
package com.example.product.application.dto;

import jakarta.validation.constraints.*;
import java.math.BigDecimal;

public record ChangePriceCommand(
    @NotBlank(message = "Product ID is required")
    String productId,
    
    @NotNull(message = "New price is required")
    @DecimalMin(value = "0", message = "Price must be non-negative")
    BigDecimal newPrice,
    
    @NotBlank(message = "Currency is required")
    String currency
) {}

// product/src/main/java/com/example/product/application/dto/ProductResponse.java
package com.example.product.application.dto;

import java.math.BigDecimal;
import java.time.Instant;

/**
 * 상품 응답 DTO
 */
public record ProductResponse(
    String id,
    String name,
    String description,
    BigDecimal price,
    String currency,
    String categoryCode,
    String categoryName,
    String status,
    Instant createdAt,
    Instant updatedAt
) {
    // Builder pattern for complex construction
    public static Builder builder() {
        return new Builder();
    }
    
    public static class Builder {
        private String id;
        private String name;
        private String description;
        private BigDecimal price;
        private String currency;
        private String categoryCode;
        private String categoryName;
        private String status;
        private Instant createdAt;
        private Instant updatedAt;
        
        public Builder id(String id) { this.id = id; return this; }
        public Builder name(String name) { this.name = name; return this; }
        public Builder description(String description) { this.description = description; return this; }
        public Builder price(BigDecimal price) { this.price = price; return this; }
        public Builder currency(String currency) { this.currency = currency; return this; }
        public Builder categoryCode(String categoryCode) { this.categoryCode = categoryCode; return this; }
        public Builder categoryName(String categoryName) { this.categoryName = categoryName; return this; }
        public Builder status(String status) { this.status = status; return this; }
        public Builder createdAt(Instant createdAt) { this.createdAt = createdAt; return this; }
        public Builder updatedAt(Instant updatedAt) { this.updatedAt = updatedAt; return this; }
        
        public ProductResponse build() {
            return new ProductResponse(id, name, description, price, currency, 
                categoryCode, categoryName, status, createdAt, updatedAt);
        }
    }
}

Driven Ports (Output Ports)

애플리케이션이 외부 시스템을 호출하기 위한 인터페이스입니다.

// product/src/main/java/com/example/product/application/port/out/LoadProductPort.java
package com.example.product.application.port.out;

import com.example.product.domain.model.Product;
import com.example.product.domain.model.ProductId;
import java.util.List;
import java.util.Optional;

/**
 * 상품 조회 Port (Driven Port)
 * - 영속성 계층에서 구현
 */
public interface LoadProductPort {
    Optional<Product> findById(ProductId id);
    List<Product> findAll();
    List<Product> findByCategory(String categoryCode);
    boolean existsById(ProductId id);
}

// product/src/main/java/com/example/product/application/port/out/SaveProductPort.java
package com.example.product.application.port.out;

import com.example.product.domain.model.Product;

/**
 * 상품 저장 Port
 */
public interface SaveProductPort {
    Product save(Product product);
    void delete(Product product);
}

// product/src/main/java/com/example/product/application/port/out/ProductEventPort.java
package com.example.product.application.port.out;

import com.example.common.domain.DomainEvent;
import java.util.List;

/**
 * 상품 이벤트 발행 Port
 * - Kafka Adapter에서 구현
 */
public interface ProductEventPort {
    void publish(DomainEvent event);
    void publishAll(List<DomainEvent> events);
}

ProductService (Use Case 구현)

모든 Use Case를 구현하는 Application Service입니다.

// product/src/main/java/com/example/product/application/service/ProductService.java
package com.example.product.application.service;

import com.example.common.domain.Money;
import com.example.product.application.dto.*;
import com.example.product.application.port.in.*;
import com.example.product.application.port.out.*;
import com.example.product.domain.model.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Optional;

/**
 * 상품 Application Service
 * - 모든 Use Case 구현
 * - 트랜잭션 경계 관리
 * - 도메인 이벤트 발행 조율
 */
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ProductService implements CreateProductUseCase, 
                                       GetProductUseCase, 
                                       UpdateProductUseCase {
    
    private final LoadProductPort loadProductPort;
    private final SaveProductPort saveProductPort;
    private final ProductEventPort productEventPort;
    
    @Override
    @Transactional
    public ProductResponse createProduct(CreateProductCommand command) {
        // 1. Value Objects 생성
        ProductName name = ProductName.of(command.name());
        Money price = Money.of(command.price(), command.currency());
        Category category = Category.of(command.categoryCode(), command.categoryName());
        
        // 2. Aggregate 생성 (도메인 이벤트 자동 등록)
        Product product = Product.create(name, command.description(), price, category);
        
        // 3. 저장
        Product savedProduct = saveProductPort.save(product);
        
        // 4. 도메인 이벤트 발행
        productEventPort.publishAll(savedProduct.getDomainEvents());
        savedProduct.clearDomainEvents();
        
        // 5. 응답 반환
        return toResponse(savedProduct);
    }
    
    @Override
    public Optional<ProductResponse> getProduct(String productId) {
        return loadProductPort.findById(ProductId.of(productId))
            .map(this::toResponse);
    }
    
    @Override
    public List<ProductResponse> getAllProducts() {
        return loadProductPort.findAll().stream()
            .map(this::toResponse)
            .toList();
    }
    
    @Override
    public List<ProductResponse> getProductsByCategory(String categoryCode) {
        return loadProductPort.findByCategory(categoryCode).stream()
            .map(this::toResponse)
            .toList();
    }
    
    @Override
    @Transactional
    public ProductResponse changePrice(ChangePriceCommand command) {
        // 1. Aggregate 로드
        Product product = loadProductPort.findById(ProductId.of(command.productId()))
            .orElseThrow(() -> new ProductNotFoundException(command.productId()));
        
        // 2. 도메인 로직 실행
        Money newPrice = Money.of(command.newPrice(), command.currency());
        product.changePrice(newPrice);
        
        // 3. 저장
        Product savedProduct = saveProductPort.save(product);
        
        // 4. 이벤트 발행
        productEventPort.publishAll(savedProduct.getDomainEvents());
        savedProduct.clearDomainEvents();
        
        return toResponse(savedProduct);
    }
    
    @Override
    @Transactional
    public ProductResponse activateProduct(String productId) {
        Product product = loadProductPort.findById(ProductId.of(productId))
            .orElseThrow(() -> new ProductNotFoundException(productId));
        
        product.activate();
        Product savedProduct = saveProductPort.save(product);
        
        productEventPort.publishAll(savedProduct.getDomainEvents());
        savedProduct.clearDomainEvents();
        
        return toResponse(savedProduct);
    }
    
    @Override
    @Transactional
    public ProductResponse deactivateProduct(String productId) {
        Product product = loadProductPort.findById(ProductId.of(productId))
            .orElseThrow(() -> new ProductNotFoundException(productId));
        
        product.deactivate();
        Product savedProduct = saveProductPort.save(product);
        
        productEventPort.publishAll(savedProduct.getDomainEvents());
        savedProduct.clearDomainEvents();
        
        return toResponse(savedProduct);
    }
    
    // Domain → DTO 변환
    private ProductResponse toResponse(Product product) {
        return ProductResponse.builder()
            .id(product.getId().getValue().toString())
            .name(product.getName().getValue())
            .description(product.getDescription())
            .price(product.getPrice().getAmount())
            .currency(product.getPrice().getCurrency().getCurrencyCode())
            .categoryCode(product.getCategory().getCode())
            .categoryName(product.getCategory().getName())
            .status(product.getStatus().name())
            .createdAt(product.getCreatedAt())
            .updatedAt(product.getUpdatedAt())
            .build();
    }
}

// product/src/main/java/com/example/product/application/service/ProductNotFoundException.java
package com.example.product.application.service;

public class ProductNotFoundException extends RuntimeException {
    public ProductNotFoundException(String productId) {
        super("Product not found: " + productId);
    }
}
6
Product Adapters - REST API & JPA
Driving Adapter(REST Controller)와 Driven Adapter(JPA Repository)를 구현합니다.

REST Controller (Driving Adapter)

HTTP 요청을 받아 Use Case를 호출하는 Driving Adapter입니다.

// product/src/main/java/com/example/product/adapter/in/web/ProductController.java
package com.example.product.adapter.in.web;

import com.example.product.application.dto.*;
import com.example.product.application.port.in.*;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
 * 상품 REST Controller (Driving Adapter)
 * 
 * 책임:
 * - HTTP 요청/응답 처리
 * - 입력 검증
 * - Use Case 호출
 * - 에러 응답 변환
 */
@RestController
@RequestMapping("/api/v1/products")
@RequiredArgsConstructor
public class ProductController {
    
    private final CreateProductUseCase createProductUseCase;
    private final GetProductUseCase getProductUseCase;
    private final UpdateProductUseCase updateProductUseCase;
    
    @PostMapping
    public ResponseEntity<ProductResponse> createProduct(
            @Valid @RequestBody CreateProductCommand command) {
        ProductResponse response = createProductUseCase.createProduct(command);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }
    
    @GetMapping("/{productId}")
    public ResponseEntity<ProductResponse> getProduct(@PathVariable String productId) {
        return getProductUseCase.getProduct(productId)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }
    
    @GetMapping
    public ResponseEntity<List<ProductResponse>> getAllProducts(
            @RequestParam(required = false) String category) {
        List<ProductResponse> products = (category != null)
            ? getProductUseCase.getProductsByCategory(category)
            : getProductUseCase.getAllProducts();
        return ResponseEntity.ok(products);
    }
    
    @PatchMapping("/{productId}/price")
    public ResponseEntity<ProductResponse> changePrice(
            @PathVariable String productId,
            @Valid @RequestBody ChangePriceRequest request) {
        ChangePriceCommand command = new ChangePriceCommand(
            productId, request.price(), request.currency());
        ProductResponse response = updateProductUseCase.changePrice(command);
        return ResponseEntity.ok(response);
    }
    
    @PostMapping("/{productId}/activate")
    public ResponseEntity<ProductResponse> activateProduct(@PathVariable String productId) {
        ProductResponse response = updateProductUseCase.activateProduct(productId);
        return ResponseEntity.ok(response);
    }
    
    @PostMapping("/{productId}/deactivate")
    public ResponseEntity<ProductResponse> deactivateProduct(@PathVariable String productId) {
        ProductResponse response = updateProductUseCase.deactivateProduct(productId);
        return ResponseEntity.ok(response);
    }
}

// product/src/main/java/com/example/product/adapter/in/web/ChangePriceRequest.java
package com.example.product.adapter.in.web;

import jakarta.validation.constraints.*;
import java.math.BigDecimal;

public record ChangePriceRequest(
    @NotNull BigDecimal price,
    @NotBlank String currency
) {}

JPA Entity (Persistence Model)

데이터베이스 매핑을 위한 JPA Entity입니다. 도메인 모델과 분리됩니다.

// product/src/main/java/com/example/product/adapter/out/persistence/ProductJpaEntity.java
package com.example.product.adapter.out.persistence;

import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;

/**
 * 상품 JPA Entity
 * - 도메인 모델과 분리된 영속성 모델
 * - 데이터베이스 스키마에 최적화
 */
@Entity
@Table(name = "products", indexes = {
    @Index(name = "idx_product_category", columnList = "category_code"),
    @Index(name = "idx_product_status", columnList = "status")
})
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class ProductJpaEntity {
    
    @Id
    @Column(name = "id", columnDefinition = "uuid")
    private UUID id;
    
    @Column(name = "name", nullable = false, length = 100)
    private String name;
    
    @Column(name = "description", length = 1000)
    private String description;
    
    @Column(name = "price", nullable = false, precision = 19, scale = 4)
    private BigDecimal price;
    
    @Column(name = "currency", nullable = false, length = 3)
    private String currency;
    
    @Column(name = "category_code", nullable = false, length = 10)
    private String categoryCode;
    
    @Column(name = "category_name", length = 50)
    private String categoryName;
    
    @Enumerated(EnumType.STRING)
    @Column(name = "status", nullable = false, length = 20)
    private ProductStatusJpa status;
    
    @Column(name = "created_at", nullable = false, updatable = false)
    private Instant createdAt;
    
    @Column(name = "updated_at", nullable = false)
    private Instant updatedAt;
    
    @Version
    @Column(name = "version")
    private Long version;
    
    @PrePersist
    protected void onCreate() {
        if (createdAt == null) createdAt = Instant.now();
        if (updatedAt == null) updatedAt = Instant.now();
    }
    
    @PreUpdate
    protected void onUpdate() {
        updatedAt = Instant.now();
    }
}

// product/src/main/java/com/example/product/adapter/out/persistence/ProductStatusJpa.java
package com.example.product.adapter.out.persistence;

public enum ProductStatusJpa {
    DRAFT, ACTIVE, INACTIVE, DELETED
}

JPA Repository Interface

Spring Data JPA Repository입니다.

// product/src/main/java/com/example/product/adapter/out/persistence/ProductJpaRepository.java
package com.example.product.adapter.out.persistence;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.UUID;

/**
 * Spring Data JPA Repository
 */
public interface ProductJpaRepository extends JpaRepository<ProductJpaEntity, UUID> {
    
    List<ProductJpaEntity> findByCategoryCode(String categoryCode);
    
    List<ProductJpaEntity> findByStatus(ProductStatusJpa status);
    
    @Query("SELECT p FROM ProductJpaEntity p WHERE p.status = :status AND p.categoryCode = :category")
    List<ProductJpaEntity> findByStatusAndCategory(
        @Param("status") ProductStatusJpa status, 
        @Param("category") String categoryCode);
    
    boolean existsByNameAndCategoryCode(String name, String categoryCode);
}

Persistence Adapter (Driven Adapter)

JPA를 사용하여 Port를 구현하는 Driven Adapter입니다.

// product/src/main/java/com/example/product/adapter/out/persistence/ProductPersistenceAdapter.java
package com.example.product.adapter.out.persistence;

import com.example.common.domain.Money;
import com.example.product.application.port.out.LoadProductPort;
import com.example.product.application.port.out.SaveProductPort;
import com.example.product.domain.model.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Optional;

/**
 * 상품 영속성 Adapter
 * - LoadProductPort, SaveProductPort 구현
 * - Domain ↔ JPA Entity 변환
 */
@Component
@RequiredArgsConstructor
public class ProductPersistenceAdapter implements LoadProductPort, SaveProductPort {
    
    private final ProductJpaRepository jpaRepository;
    
    @Override
    public Optional<Product> findById(ProductId id) {
        return jpaRepository.findById(id.getValue())
            .map(this::toDomain);
    }
    
    @Override
    public List<Product> findAll() {
        return jpaRepository.findAll().stream()
            .map(this::toDomain)
            .toList();
    }
    
    @Override
    public List<Product> findByCategory(String categoryCode) {
        return jpaRepository.findByCategoryCode(categoryCode).stream()
            .map(this::toDomain)
            .toList();
    }
    
    @Override
    public boolean existsById(ProductId id) {
        return jpaRepository.existsById(id.getValue());
    }
    
    @Override
    public Product save(Product product) {
        ProductJpaEntity entity = toEntity(product);
        ProductJpaEntity savedEntity = jpaRepository.save(entity);
        return toDomain(savedEntity);
    }
    
    @Override
    public void delete(Product product) {
        jpaRepository.deleteById(product.getId().getValue());
    }
    
    // Domain → JPA Entity
    private ProductJpaEntity toEntity(Product product) {
        return ProductJpaEntity.builder()
            .id(product.getId().getValue())
            .name(product.getName().getValue())
            .description(product.getDescription())
            .price(product.getPrice().getAmount())
            .currency(product.getPrice().getCurrency().getCurrencyCode())
            .categoryCode(product.getCategory().getCode())
            .categoryName(product.getCategory().getName())
            .status(ProductStatusJpa.valueOf(product.getStatus().name()))
            .createdAt(product.getCreatedAt())
            .updatedAt(product.getUpdatedAt())
            .build();
    }
    
    // JPA Entity → Domain
    private Product toDomain(ProductJpaEntity entity) {
        return Product.reconstitute(
            ProductId.of(entity.getId()),
            ProductName.of(entity.getName()),
            entity.getDescription(),
            Money.of(entity.getPrice(), entity.getCurrency()),
            Category.of(entity.getCategoryCode(), entity.getCategoryName()),
            ProductStatus.valueOf(entity.getStatus().name()),
            entity.getCreatedAt(),
            entity.getUpdatedAt()
        );
    }
}
7
Kafka Adapter - 이벤트 발행/구독
Kafka를 사용한 도메인 이벤트 발행 및 구독 Adapter를 구현합니다.

Kafka 설정

Spring Kafka 설정과 토픽 정의입니다.

# product/src/main/resources/application.yml
spring:
  application:
    name: product-service
  
  datasource:
    url: jdbc:postgresql://localhost:5432/ecommerce
    username: ecommerce
    password: ecommerce123
    driver-class-name: org.postgresql.Driver
  
  jpa:
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        dialect: org.hibernate.dialect.PostgreSQLDialect
        format_sql: true
    show-sql: true
  
  kafka:
    bootstrap-servers: localhost:9092
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
      properties:
        spring.json.type.mapping: >
          ProductCreated:com.example.product.domain.event.ProductCreated,
          ProductPriceChanged:com.example.product.domain.event.ProductPriceChanged,
          ProductStatusChanged:com.example.product.domain.event.ProductStatusChanged
    consumer:
      group-id: product-service
      auto-offset-reset: earliest
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
      properties:
        spring.json.trusted.packages: com.example.*

server:
  port: 8081

Kafka Configuration

Kafka Producer/Consumer 및 토픽 설정입니다.

// product/src/main/java/com/example/product/adapter/out/messaging/KafkaConfig.java
package com.example.product.adapter.out.messaging;

import org.apache.kafka.clients.admin.NewTopic;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.config.TopicBuilder;

/**
 * Kafka 토픽 설정
 */
@Configuration
public class KafkaConfig {
    
    public static final String PRODUCT_EVENTS_TOPIC = "product-events";
    public static final String PRODUCT_CREATED_TOPIC = "product-created";
    public static final String PRODUCT_PRICE_CHANGED_TOPIC = "product-price-changed";
    
    @Bean
    public NewTopic productEventsTopic() {
        return TopicBuilder.name(PRODUCT_EVENTS_TOPIC)
            .partitions(3)
            .replicas(1)
            .build();
    }
    
    @Bean
    public NewTopic productCreatedTopic() {
        return TopicBuilder.name(PRODUCT_CREATED_TOPIC)
            .partitions(3)
            .replicas(1)
            .build();
    }
    
    @Bean
    public NewTopic productPriceChangedTopic() {
        return TopicBuilder.name(PRODUCT_PRICE_CHANGED_TOPIC)
            .partitions(3)
            .replicas(1)
            .build();
    }
}

Kafka Producer Adapter (Driven Adapter)

도메인 이벤트를 Kafka로 발행하는 Adapter입니다.

// product/src/main/java/com/example/product/adapter/out/messaging/ProductKafkaAdapter.java
package com.example.product.adapter.out.messaging;

import com.example.common.domain.DomainEvent;
import com.example.product.application.port.out.ProductEventPort;
import com.example.product.domain.event.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.support.SendResult;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.concurrent.CompletableFuture;

/**
 * Kafka 이벤트 발행 Adapter
 * - ProductEventPort 구현
 * - 이벤트 타입별 토픽 라우팅
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class ProductKafkaAdapter implements ProductEventPort {
    
    private final KafkaTemplate<String, Object> kafkaTemplate;
    private final ObjectMapper objectMapper;
    
    @Override
    public void publish(DomainEvent event) {
        String topic = resolveTopicName(event);
        String key = event.getAggregateId();
        
        CompletableFuture<SendResult<String, Object>> future = 
            kafkaTemplate.send(topic, key, event);
        
        future.whenComplete((result, ex) -> {
            if (ex == null) {
                log.info("Event published successfully: topic={}, key={}, offset={}", 
                    topic, key, result.getRecordMetadata().offset());
            } else {
                log.error("Failed to publish event: topic={}, key={}", topic, key, ex);
            }
        });
    }
    
    @Override
    public void publishAll(List<DomainEvent> events) {
        events.forEach(this::publish);
    }
    
    private String resolveTopicName(DomainEvent event) {
        return switch (event) {
            case ProductCreated e -> KafkaConfig.PRODUCT_CREATED_TOPIC;
            case ProductPriceChanged e -> KafkaConfig.PRODUCT_PRICE_CHANGED_TOPIC;
            default -> KafkaConfig.PRODUCT_EVENTS_TOPIC;
        };
    }
}

// 이벤트 발행 결과를 위한 Outbox 패턴 (선택적 구현)
// product/src/main/java/com/example/product/adapter/out/messaging/OutboxEvent.java
package com.example.product.adapter.out.messaging;

import jakarta.persistence.*;
import lombok.*;
import java.time.Instant;
import java.util.UUID;

/**
 * Outbox 패턴을 위한 이벤트 저장 Entity
 * - 트랜잭션 내에서 이벤트 저장
 * - 별도 프로세스에서 Kafka로 발행
 */
@Entity
@Table(name = "outbox_events")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class OutboxEvent {
    
    @Id
    private UUID id;
    
    @Column(name = "aggregate_type", nullable = false)
    private String aggregateType;
    
    @Column(name = "aggregate_id", nullable = false)
    private String aggregateId;
    
    @Column(name = "event_type", nullable = false)
    private String eventType;
    
    @Column(name = "payload", nullable = false, columnDefinition = "TEXT")
    private String payload;
    
    @Column(name = "created_at", nullable = false)
    private Instant createdAt;
    
    @Column(name = "published_at")
    private Instant publishedAt;
    
    @Enumerated(EnumType.STRING)
    @Column(name = "status", nullable = false)
    private OutboxStatus status;
}

enum OutboxStatus {
    PENDING, PUBLISHED, FAILED
}

Global Exception Handler

REST API 예외 처리를 위한 Global Handler입니다.

// product/src/main/java/com/example/product/adapter/in/web/GlobalExceptionHandler.java
package com.example.product.adapter.in.web;

import com.example.product.application.service.ProductNotFoundException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.time.Instant;
import java.util.HashMap;
import java.util.Map;

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(ProductNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleProductNotFound(ProductNotFoundException ex) {
        log.warn("Product not found: {}", ex.getMessage());
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(new ErrorResponse("NOT_FOUND", ex.getMessage(), Instant.now()));
    }
    
    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ErrorResponse> handleIllegalArgument(IllegalArgumentException ex) {
        log.warn("Invalid argument: {}", ex.getMessage());
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
            .body(new ErrorResponse("BAD_REQUEST", ex.getMessage(), Instant.now()));
    }
    
    @ExceptionHandler(IllegalStateException.class)
    public ResponseEntity<ErrorResponse> handleIllegalState(IllegalStateException ex) {
        log.warn("Invalid state: {}", ex.getMessage());
        return ResponseEntity.status(HttpStatus.CONFLICT)
            .body(new ErrorResponse("CONFLICT", ex.getMessage(), Instant.now()));
    }
    
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ValidationErrorResponse> handleValidation(
            MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getAllErrors().forEach(error -> {
            String fieldName = ((FieldError) error).getField();
            String errorMessage = error.getDefaultMessage();
            errors.put(fieldName, errorMessage);
        });
        
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
            .body(new ValidationErrorResponse("VALIDATION_ERROR", errors, Instant.now()));
    }
    
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGeneral(Exception ex) {
        log.error("Unexpected error", ex);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred", Instant.now()));
    }
}

record ErrorResponse(String code, String message, Instant timestamp) {}
record ValidationErrorResponse(String code, Map<String, String> errors, Instant timestamp) {}
8
Inventory 도메인 구현
재고 도메인의 Aggregate, Use Case, Adapter를 구현합니다.

Bounded Context 간 통신 - 도메인 이벤트의 힘

Product와 Inventory는 서로 다른 Bounded Context입니다. 이들은 직접 호출하지 않고 도메인 이벤트를 통해 느슨하게 결합됩니다.

# Bounded Context 간 통신 전략
# ═══════════════════════════════════════════════════════════════════════════

"Bounded Context 간의 통합은 DDD에서 가장 어려운 부분이다.
 도메인 이벤트는 이 문제를 우아하게 해결한다."
                                        - Vaughn Vernon

# 왜 직접 호출하면 안 되는가?
# ─────────────────────────────────────────────────────────────────────────

# ❌ 잘못된 방식: 직접 호출

┌──────────────────┐         직접 호출         ┌──────────────────┐
│  Product Service │ ─────────────────────────▶│ Inventory Service│
│                  │                           │                  │
│  createProduct() │                           │  initStock()     │
│  {               │                           │                  │
│    // 상품 저장  │                           │                  │
│    save(product);│                           │                  │
│    // 재고 초기화│                           │                  │
│    inventoryService.initStock(productId); ← 강한 결합!         │
│  }               │                           │                  │
└──────────────────┘                           └──────────────────┘

문제점:
1. Product가 Inventory에 직접 의존 → 결합도 증가
2. Inventory 서비스 장애 시 Product 생성도 실패
3. 트랜잭션 경계가 모호해짐
4. 테스트 시 Inventory도 함께 설정 필요

# ✅ 올바른 방식: 도메인 이벤트

┌──────────────────┐    ProductCreated     ┌──────────────────┐
│  Product Service │ ═══════════════════▶  │ Inventory Service│
│                  │      (Kafka)          │                  │
│  createProduct() │                       │  handleEvent()   │
│  {               │                       │  {               │
│    // 상품 저장  │                       │    // 재고 초기화│
│    save(product);│                       │    Stock.create()│
│    // 이벤트 발행│                       │  }               │
│    publish(event)│                       │                  │
│  }               │                       │                  │
└──────────────────┘                       └──────────────────┘

장점:
1. Product는 Inventory를 모름 → 느슨한 결합
2. Inventory 장애 시에도 Product 생성 성공
3. 각 서비스가 독립적인 트랜잭션
4. 새로운 구독자 추가 용이 (예: 검색 인덱싱, 알림)

# Context Map - 컨텍스트 간 관계
# ─────────────────────────────────────────────────────────────────────────

┌─────────────────────────────────────────────────────────────────────────┐
│                         E-Commerce Context Map                          │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│   ┌─────────────────┐                    ┌─────────────────┐           │
│   │    Product      │   ProductCreated   │   Inventory     │           │
│   │    Context      │ ═══════════════▶   │    Context      │           │
│   │                 │   (Published       │                 │           │
│   │   Upstream      │    Language)       │   Downstream    │           │
│   │   (발행자)      │                    │   (구독자)      │           │
│   └─────────────────┘                    └─────────────────┘           │
│          │                                       │                      │
│          │ ProductPriceChanged                   │ StockReserved        │
│          ▼                                       ▼                      │
│   ┌─────────────────┐                    ┌─────────────────┐           │
│   │    Pricing      │                    │     Order       │           │
│   │    Context      │                    │    Context      │           │
│   │                 │                    │                 │           │
│   │   Downstream    │                    │   Downstream    │           │
│   └─────────────────┘                    └─────────────────┘           │
│                                                                         │
│   관계 유형:                                                            │
│   ═══▶ Published Language: 표준화된 이벤트 스키마로 통신               │
│   ───▶ Customer-Supplier: 하류가 상류에 요구사항 전달 가능             │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

# 이벤트 스키마 설계 원칙
# ─────────────────────────────────────────────────────────────────────────

1. 과거형 명명: ProductCreated (O), CreateProduct (X)
2. 자기 완결적: 이벤트만으로 처리 가능한 정보 포함
3. 버전 관리: 스키마 변경 시 하위 호환성 유지
4. 멱등성 지원: eventId로 중복 처리 방지

# ProductCreated 이벤트 예시:
{
  "eventId": "evt-123",           // 중복 처리 방지
  "occurredAt": "2024-12-10T00:00:00Z",
  "aggregateType": "Product",
  "aggregateId": "prod-456",
  "productId": "prod-456",        // 자기 완결적 데이터
  "productName": "MacBook Pro",
  "price": 2990000,
  "currency": "KRW",
  "categoryCode": "LAPTOP"
}

Inventory 도메인 모델

재고 Aggregate Root와 Value Objects입니다.

// inventory/src/main/java/com/example/inventory/domain/model/StockId.java
package com.example.inventory.domain.model;

import com.example.common.domain.Identifier;
import java.util.UUID;

public final class StockId extends Identifier {
    private StockId(UUID value) { super(value); }
    
    public static StockId of(UUID value) { return new StockId(value); }
    public static StockId of(String value) { return new StockId(UUID.fromString(value)); }
    public static StockId generate() { return new StockId(UUID.randomUUID()); }
}

// inventory/src/main/java/com/example/inventory/domain/model/Quantity.java
package com.example.inventory.domain.model;

import com.example.common.domain.ValueObject;
import java.util.List;

/**
 * 수량 Value Object
 * - 음수 불가
 * - 불변 객체
 */
public final class Quantity extends ValueObject {
    
    private final int value;
    
    public static final Quantity ZERO = new Quantity(0);
    
    private Quantity(int value) {
        if (value < 0) {
            throw new IllegalArgumentException("Quantity cannot be negative");
        }
        this.value = value;
    }
    
    public static Quantity of(int value) {
        return new Quantity(value);
    }
    
    public Quantity add(Quantity other) {
        return new Quantity(this.value + other.value);
    }
    
    public Quantity subtract(Quantity other) {
        return new Quantity(this.value - other.value);
    }
    
    public boolean isGreaterThanOrEqual(Quantity other) {
        return this.value >= other.value;
    }
    
    public boolean isZero() {
        return this.value == 0;
    }
    
    public int getValue() { return value; }
    
    @Override
    protected List<Object> getEqualityComponents() {
        return List.of(value);
    }
}

// inventory/src/main/java/com/example/inventory/domain/model/Stock.java
package com.example.inventory.domain.model;

import com.example.common.domain.AggregateRoot;
import com.example.inventory.domain.event.*;
import java.time.Instant;

/**
 * 재고 Aggregate Root
 * 
 * 불변식:
 * - 가용 재고 >= 0
 * - 예약 재고 >= 0
 * - 총 재고 = 가용 재고 + 예약 재고
 */
public class Stock extends AggregateRoot<StockId> {
    
    private final StockId id;
    private final String productId;
    private Quantity availableQuantity;
    private Quantity reservedQuantity;
    private final Instant createdAt;
    private Instant updatedAt;
    
    private Stock(StockId id, String productId, Quantity availableQuantity) {
        this.id = id;
        this.productId = productId;
        this.availableQuantity = availableQuantity;
        this.reservedQuantity = Quantity.ZERO;
        this.createdAt = Instant.now();
        this.updatedAt = this.createdAt;
    }
    
    /**
     * 재고 생성 (상품 생성 이벤트 수신 시)
     */
    public static Stock create(String productId, int initialQuantity) {
        StockId id = StockId.generate();
        Stock stock = new Stock(id, productId, Quantity.of(initialQuantity));
        
        stock.registerEvent(new StockCreated(
            id.getValue().toString(),
            productId,
            initialQuantity
        ));
        
        return stock;
    }
    
    /**
     * 재고 입고
     */
    public void addStock(int quantity) {
        Quantity addQuantity = Quantity.of(quantity);
        Quantity oldQuantity = this.availableQuantity;
        this.availableQuantity = this.availableQuantity.add(addQuantity);
        this.updatedAt = Instant.now();
        
        registerEvent(new StockAdded(
            id.getValue().toString(),
            productId,
            quantity,
            oldQuantity.getValue(),
            availableQuantity.getValue()
        ));
    }
    
    /**
     * 재고 예약 (주문 시)
     */
    public void reserve(int quantity) {
        Quantity reserveQuantity = Quantity.of(quantity);
        
        if (!availableQuantity.isGreaterThanOrEqual(reserveQuantity)) {
            throw new InsufficientStockException(productId, 
                availableQuantity.getValue(), quantity);
        }
        
        this.availableQuantity = this.availableQuantity.subtract(reserveQuantity);
        this.reservedQuantity = this.reservedQuantity.add(reserveQuantity);
        this.updatedAt = Instant.now();
        
        registerEvent(new StockReserved(
            id.getValue().toString(),
            productId,
            quantity,
            availableQuantity.getValue(),
            reservedQuantity.getValue()
        ));
    }
    
    /**
     * 예약 확정 (결제 완료 시)
     */
    public void confirmReservation(int quantity) {
        Quantity confirmQuantity = Quantity.of(quantity);
        
        if (!reservedQuantity.isGreaterThanOrEqual(confirmQuantity)) {
            throw new IllegalStateException("Cannot confirm more than reserved");
        }
        
        this.reservedQuantity = this.reservedQuantity.subtract(confirmQuantity);
        this.updatedAt = Instant.now();
        
        registerEvent(new StockConfirmed(
            id.getValue().toString(),
            productId,
            quantity
        ));
    }
    
    /**
     * 예약 취소 (주문 취소 시)
     */
    public void cancelReservation(int quantity) {
        Quantity cancelQuantity = Quantity.of(quantity);
        
        if (!reservedQuantity.isGreaterThanOrEqual(cancelQuantity)) {
            throw new IllegalStateException("Cannot cancel more than reserved");
        }
        
        this.reservedQuantity = this.reservedQuantity.subtract(cancelQuantity);
        this.availableQuantity = this.availableQuantity.add(cancelQuantity);
        this.updatedAt = Instant.now();
        
        registerEvent(new StockReleased(
            id.getValue().toString(),
            productId,
            quantity,
            availableQuantity.getValue()
        ));
    }
    
    public int getTotalQuantity() {
        return availableQuantity.getValue() + reservedQuantity.getValue();
    }
    
    // Getters
    @Override
    public StockId getId() { return id; }
    public String getProductId() { return productId; }
    public Quantity getAvailableQuantity() { return availableQuantity; }
    public Quantity getReservedQuantity() { return reservedQuantity; }
    public Instant getCreatedAt() { return createdAt; }
    public Instant getUpdatedAt() { return updatedAt; }
}

// inventory/src/main/java/com/example/inventory/domain/model/InsufficientStockException.java
package com.example.inventory.domain.model;

public class InsufficientStockException extends RuntimeException {
    public InsufficientStockException(String productId, int available, int requested) {
        super(String.format("Insufficient stock for product %s: available=%d, requested=%d",
            productId, available, requested));
    }
}

Inventory Domain Events

재고 도메인에서 발생하는 이벤트들입니다.

// inventory/src/main/java/com/example/inventory/domain/event/StockCreated.java
package com.example.inventory.domain.event;

import com.example.common.domain.BaseDomainEvent;

public class StockCreated extends BaseDomainEvent {
    private final String stockId;
    private final String productId;
    private final int initialQuantity;
    
    public StockCreated(String stockId, String productId, int initialQuantity) {
        super();
        this.stockId = stockId;
        this.productId = productId;
        this.initialQuantity = initialQuantity;
    }
    
    @Override
    public String getAggregateType() { return "Stock"; }
    @Override
    public String getAggregateId() { return stockId; }
    
    public String getStockId() { return stockId; }
    public String getProductId() { return productId; }
    public int getInitialQuantity() { return initialQuantity; }
}

// inventory/src/main/java/com/example/inventory/domain/event/StockReserved.java
package com.example.inventory.domain.event;

import com.example.common.domain.BaseDomainEvent;

public class StockReserved extends BaseDomainEvent {
    private final String stockId;
    private final String productId;
    private final int reservedQuantity;
    private final int availableQuantity;
    private final int totalReserved;
    
    public StockReserved(String stockId, String productId, int reservedQuantity,
                         int availableQuantity, int totalReserved) {
        super();
        this.stockId = stockId;
        this.productId = productId;
        this.reservedQuantity = reservedQuantity;
        this.availableQuantity = availableQuantity;
        this.totalReserved = totalReserved;
    }
    
    @Override
    public String getAggregateType() { return "Stock"; }
    @Override
    public String getAggregateId() { return stockId; }
    
    public String getStockId() { return stockId; }
    public String getProductId() { return productId; }
    public int getReservedQuantity() { return reservedQuantity; }
    public int getAvailableQuantity() { return availableQuantity; }
    public int getTotalReserved() { return totalReserved; }
}

// 기타 이벤트들 (StockAdded, StockConfirmed, StockReleased)도 동일한 패턴으로 구현

Inventory Application Layer

재고 Use Case와 Service 구현입니다.

// inventory/src/main/java/com/example/inventory/application/port/in/ReserveStockUseCase.java
package com.example.inventory.application.port.in;

import com.example.inventory.application.dto.ReserveStockCommand;
import com.example.inventory.application.dto.StockResponse;

public interface ReserveStockUseCase {
    StockResponse reserveStock(ReserveStockCommand command);
}

// inventory/src/main/java/com/example/inventory/application/dto/ReserveStockCommand.java
package com.example.inventory.application.dto;

import jakarta.validation.constraints.*;

public record ReserveStockCommand(
    @NotBlank String productId,
    @Min(1) int quantity,
    @NotBlank String orderId
) {}

// inventory/src/main/java/com/example/inventory/application/service/InventoryService.java
package com.example.inventory.application.service;

import com.example.inventory.application.dto.*;
import com.example.inventory.application.port.in.*;
import com.example.inventory.application.port.out.*;
import com.example.inventory.domain.model.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class InventoryService implements ReserveStockUseCase, 
                                         GetStockUseCase,
                                         AddStockUseCase {
    
    private final LoadStockPort loadStockPort;
    private final SaveStockPort saveStockPort;
    private final StockEventPort stockEventPort;
    
    @Override
    @Transactional
    public StockResponse reserveStock(ReserveStockCommand command) {
        Stock stock = loadStockPort.findByProductId(command.productId())
            .orElseThrow(() -> new StockNotFoundException(command.productId()));
        
        stock.reserve(command.quantity());
        
        Stock savedStock = saveStockPort.save(stock);
        stockEventPort.publishAll(savedStock.getDomainEvents());
        savedStock.clearDomainEvents();
        
        return toResponse(savedStock);
    }
    
    @Override
    @Transactional
    public StockResponse addStock(AddStockCommand command) {
        Stock stock = loadStockPort.findByProductId(command.productId())
            .orElseThrow(() -> new StockNotFoundException(command.productId()));
        
        stock.addStock(command.quantity());
        
        Stock savedStock = saveStockPort.save(stock);
        stockEventPort.publishAll(savedStock.getDomainEvents());
        savedStock.clearDomainEvents();
        
        return toResponse(savedStock);
    }
    
    private StockResponse toResponse(Stock stock) {
        return new StockResponse(
            stock.getId().getValue().toString(),
            stock.getProductId(),
            stock.getAvailableQuantity().getValue(),
            stock.getReservedQuantity().getValue(),
            stock.getTotalQuantity()
        );
    }
}

Product Created 이벤트 Consumer

Product 도메인의 이벤트를 구독하여 재고를 초기화하는 Consumer입니다.

// inventory/src/main/java/com/example/inventory/adapter/in/kafka/ProductEventConsumer.java
package com.example.inventory.adapter.in.kafka;

import com.example.inventory.application.port.out.LoadStockPort;
import com.example.inventory.application.port.out.SaveStockPort;
import com.example.inventory.domain.model.Stock;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;

/**
 * Product 도메인 이벤트 Consumer (Driving Adapter)
 * - ProductCreated 이벤트 수신 시 재고 초기화
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class ProductEventConsumer {
    
    private final LoadStockPort loadStockPort;
    private final SaveStockPort saveStockPort;
    
    @KafkaListener(
        topics = "product-created",
        groupId = "inventory-service",
        containerFactory = "kafkaListenerContainerFactory"
    )
    public void handleProductCreated(ProductCreatedEvent event) {
        log.info("Received ProductCreated event: productId={}", event.productId());
        
        // 이미 재고가 존재하는지 확인
        if (loadStockPort.findByProductId(event.productId()).isPresent()) {
            log.warn("Stock already exists for product: {}", event.productId());
            return;
        }
        
        // 초기 재고 생성 (기본값 0)
        Stock stock = Stock.create(event.productId(), 0);
        saveStockPort.save(stock);
        
        log.info("Stock initialized for product: {}", event.productId());
    }
}

// inventory/src/main/java/com/example/inventory/adapter/in/kafka/ProductCreatedEvent.java
package com.example.inventory.adapter.in.kafka;

import java.math.BigDecimal;

/**
 * Product 도메인에서 발행한 이벤트 수신용 DTO
 */
public record ProductCreatedEvent(
    String productId,
    String productName,
    BigDecimal price,
    String currency,
    String categoryCode
) {}
9
테스트 전략
헥사고날 아키텍처의 테스트 전략과 각 레이어별 테스트를 구현합니다.

테스트 피라미드

헥사고날 아키텍처에서의 테스트 전략입니다.

# 헥사고날 아키텍처 테스트 전략
# ═══════════════════════════════════════════════════════════════════════════

#                         ┌─────────────────┐
#                         │   E2E Tests     │  ← 최소한 (느림, 비용 높음)
#                         │  (Acceptance)   │
#                         └────────┬────────┘
#                                  │
#                    ┌─────────────┴─────────────┐
#                    │    Integration Tests      │  ← Adapter 테스트
#                    │  (Adapters + External)    │     - REST Controller
#                    └─────────────┬─────────────┘     - JPA Repository
#                                  │                   - Kafka Producer/Consumer
#           ┌──────────────────────┴──────────────────────┐
#           │              Unit Tests                      │  ← 가장 많이
#           │  (Domain + Application without Adapters)     │     - Domain Model
#           └──────────────────────────────────────────────┘     - Use Cases

# 테스트 범위:
# ┌─────────────────────────────────────────────────────────────────────────┐
# │ Layer          │ Test Type        │ Dependencies                       │
# ├─────────────────────────────────────────────────────────────────────────┤
# │ Domain         │ Unit Test        │ None (Pure Java)                   │
# │ Application    │ Unit Test        │ Mocked Ports                       │
# │ Adapter (In)   │ Integration Test │ MockMvc, Real Service              │
# │ Adapter (Out)  │ Integration Test │ Testcontainers (DB, Kafka)         │
# │ Full Stack     │ E2E Test         │ All components running             │
# └─────────────────────────────────────────────────────────────────────────┘

Domain Layer 단위 테스트

도메인 모델의 비즈니스 규칙을 검증하는 순수 단위 테스트입니다.

// product/src/test/java/com/example/product/domain/model/ProductTest.java
package com.example.product.domain.model;

import com.example.common.domain.Money;
import com.example.product.domain.event.ProductCreated;
import com.example.product.domain.event.ProductPriceChanged;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.*;

/**
 * Product Aggregate Root 단위 테스트
 * - 외부 의존성 없음
 * - 비즈니스 규칙 검증
 */
class ProductTest {
    
    @Nested
    @DisplayName("상품 생성")
    class CreateProduct {
        
        @Test
        @DisplayName("유효한 정보로 상품을 생성하면 DRAFT 상태로 생성된다")
        void createProduct_WithValidData_ShouldCreateDraftProduct() {
            // Given
            ProductName name = ProductName.of("테스트 상품");
            Money price = Money.krw(10000);
            Category category = Category.of("ELEC", "전자제품");
            
            // When
            Product product = Product.create(name, "설명", price, category);
            
            // Then
            assertThat(product.getId()).isNotNull();
            assertThat(product.getName().getValue()).isEqualTo("테스트 상품");
            assertThat(product.getPrice().getAmount()).isEqualByComparingTo("10000");
            assertThat(product.getStatus()).isEqualTo(ProductStatus.DRAFT);
        }
        
        @Test
        @DisplayName("상품 생성 시 ProductCreated 이벤트가 발생한다")
        void createProduct_ShouldRaiseProductCreatedEvent() {
            // Given
            ProductName name = ProductName.of("테스트 상품");
            Money price = Money.krw(10000);
            Category category = Category.of("ELEC", "전자제품");
            
            // When
            Product product = Product.create(name, "설명", price, category);
            
            // Then
            assertThat(product.getDomainEvents()).hasSize(1);
            assertThat(product.getDomainEvents().get(0))
                .isInstanceOf(ProductCreated.class);
            
            ProductCreated event = (ProductCreated) product.getDomainEvents().get(0);
            assertThat(event.getProductName()).isEqualTo("테스트 상품");
        }
    }
    
    @Nested
    @DisplayName("가격 변경")
    class ChangePrice {
        
        @Test
        @DisplayName("DRAFT 상태에서 가격을 변경할 수 있다")
        void changePrice_WhenDraft_ShouldSucceed() {
            // Given
            Product product = createDraftProduct();
            Money newPrice = Money.krw(15000);
            
            // When
            product.changePrice(newPrice);
            
            // Then
            assertThat(product.getPrice().getAmount()).isEqualByComparingTo("15000");
        }
        
        @Test
        @DisplayName("가격 변경 시 ProductPriceChanged 이벤트가 발생한다")
        void changePrice_ShouldRaisePriceChangedEvent() {
            // Given
            Product product = createDraftProduct();
            product.clearDomainEvents(); // 생성 이벤트 제거
            Money newPrice = Money.krw(15000);
            
            // When
            product.changePrice(newPrice);
            
            // Then
            assertThat(product.getDomainEvents()).hasSize(1);
            assertThat(product.getDomainEvents().get(0))
                .isInstanceOf(ProductPriceChanged.class);
        }
    }
    
    @Nested
    @DisplayName("상태 전이")
    class StatusTransition {
        
        @Test
        @DisplayName("DRAFT 상품을 활성화할 수 있다")
        void activate_WhenDraft_ShouldBecomeActive() {
            // Given
            Product product = createDraftProduct();
            
            // When
            product.activate();
            
            // Then
            assertThat(product.getStatus()).isEqualTo(ProductStatus.ACTIVE);
        }
        
        @Test
        @DisplayName("ACTIVE 상품만 비활성화할 수 있다")
        void deactivate_WhenNotActive_ShouldThrowException() {
            // Given
            Product product = createDraftProduct();
            
            // When & Then
            assertThatThrownBy(() -> product.deactivate())
                .isInstanceOf(IllegalStateException.class)
                .hasMessageContaining("Only ACTIVE products");
        }
    }
    
    private Product createDraftProduct() {
        return Product.create(
            ProductName.of("테스트 상품"),
            "설명",
            Money.krw(10000),
            Category.of("ELEC", "전자제품")
        );
    }
}

// product/src/test/java/com/example/product/domain/model/MoneyTest.java
package com.example.product.domain.model;

import com.example.common.domain.Money;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class MoneyTest {
    
    @Test
    void add_ShouldReturnNewMoneyWithSum() {
        Money money1 = Money.krw(1000);
        Money money2 = Money.krw(500);
        
        Money result = money1.add(money2);
        
        assertThat(result.getAmount()).isEqualByComparingTo("1500");
    }
    
    @Test
    void subtract_WhenResultNegative_ShouldThrowException() {
        Money money1 = Money.krw(500);
        Money money2 = Money.krw(1000);
        
        assertThatThrownBy(() -> money1.subtract(money2))
            .isInstanceOf(IllegalArgumentException.class);
    }
}

Application Layer 단위 테스트

Use Case 테스트로, Port를 Mock하여 비즈니스 로직을 검증합니다.

// product/src/test/java/com/example/product/application/service/ProductServiceTest.java
package com.example.product.application.service;

import com.example.common.domain.Money;
import com.example.product.application.dto.*;
import com.example.product.application.port.out.*;
import com.example.product.domain.model.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.math.BigDecimal;
import java.util.Optional;

import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.*;

/**
 * ProductService 단위 테스트
 * - Port를 Mock하여 Application Layer만 테스트
 */
@ExtendWith(MockitoExtension.class)
class ProductServiceTest {
    
    @Mock
    private LoadProductPort loadProductPort;
    
    @Mock
    private SaveProductPort saveProductPort;
    
    @Mock
    private ProductEventPort productEventPort;
    
    private ProductService productService;
    
    @BeforeEach
    void setUp() {
        productService = new ProductService(
            loadProductPort, saveProductPort, productEventPort);
    }
    
    @Test
    @DisplayName("상품 생성 시 저장하고 이벤트를 발행한다")
    void createProduct_ShouldSaveAndPublishEvent() {
        // Given
        CreateProductCommand command = new CreateProductCommand(
            "테스트 상품", "설명", BigDecimal.valueOf(10000), "KRW", "ELEC", "전자제품");
        
        given(saveProductPort.save(any(Product.class)))
            .willAnswer(invocation -> invocation.getArgument(0));
        
        // When
        ProductResponse response = productService.createProduct(command);
        
        // Then
        assertThat(response.name()).isEqualTo("테스트 상품");
        assertThat(response.status()).isEqualTo("DRAFT");
        
        // 저장 검증
        ArgumentCaptor<Product> productCaptor = ArgumentCaptor.forClass(Product.class);
        then(saveProductPort).should().save(productCaptor.capture());
        assertThat(productCaptor.getValue().getName().getValue()).isEqualTo("테스트 상품");
        
        // 이벤트 발행 검증
        then(productEventPort).should().publishAll(anyList());
    }
    
    @Test
    @DisplayName("존재하지 않는 상품 조회 시 빈 Optional 반환")
    void getProduct_WhenNotFound_ShouldReturnEmpty() {
        // Given
        given(loadProductPort.findById(any(ProductId.class)))
            .willReturn(Optional.empty());
        
        // When
        Optional<ProductResponse> result = productService.getProduct("non-existent-id");
        
        // Then
        assertThat(result).isEmpty();
    }
    
    @Test
    @DisplayName("가격 변경 시 도메인 로직 실행 후 저장")
    void changePrice_ShouldUpdateAndSave() {
        // Given
        Product existingProduct = Product.create(
            ProductName.of("테스트 상품"), "설명",
            Money.krw(10000), Category.of("ELEC", "전자제품"));
        
        given(loadProductPort.findById(any(ProductId.class)))
            .willReturn(Optional.of(existingProduct));
        given(saveProductPort.save(any(Product.class)))
            .willAnswer(invocation -> invocation.getArgument(0));
        
        ChangePriceCommand command = new ChangePriceCommand(
            existingProduct.getId().getValue().toString(),
            BigDecimal.valueOf(15000), "KRW");
        
        // When
        ProductResponse response = productService.changePrice(command);
        
        // Then
        assertThat(response.price()).isEqualByComparingTo("15000");
    }
}

Adapter 통합 테스트

Testcontainers를 사용한 실제 인프라 연동 테스트입니다.

// product/src/test/java/com/example/product/adapter/out/persistence/ProductPersistenceAdapterTest.java
package com.example.product.adapter.out.persistence;

import com.example.common.domain.Money;
import com.example.product.domain.model.*;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import java.util.Optional;

import static org.assertj.core.api.Assertions.*;

/**
 * Persistence Adapter 통합 테스트
 * - Testcontainers로 실제 PostgreSQL 사용
 */
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import(ProductPersistenceAdapter.class)
class ProductPersistenceAdapterTest {
    
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test");
    
    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }
    
    @Autowired
    private ProductPersistenceAdapter adapter;
    
    @Test
    void save_ShouldPersistProduct() {
        // Given
        Product product = Product.create(
            ProductName.of("테스트 상품"), "설명",
            Money.krw(10000), Category.of("ELEC", "전자제품"));
        
        // When
        Product savedProduct = adapter.save(product);
        
        // Then
        Optional<Product> found = adapter.findById(savedProduct.getId());
        assertThat(found).isPresent();
        assertThat(found.get().getName().getValue()).isEqualTo("테스트 상품");
    }
    
    @Test
    void findByCategory_ShouldReturnMatchingProducts() {
        // Given
        Product product1 = Product.create(
            ProductName.of("상품1"), "설명",
            Money.krw(10000), Category.of("ELEC", "전자제품"));
        Product product2 = Product.create(
            ProductName.of("상품2"), "설명",
            Money.krw(20000), Category.of("ELEC", "전자제품"));
        
        adapter.save(product1);
        adapter.save(product2);
        
        // When
        var products = adapter.findByCategory("ELEC");
        
        // Then
        assertThat(products).hasSize(2);
    }
}

// REST Controller 통합 테스트
// product/src/test/java/com/example/product/adapter/in/web/ProductControllerTest.java
package com.example.product.adapter.in.web;

import com.example.product.application.dto.*;
import com.example.product.application.port.in.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import java.math.BigDecimal;
import java.time.Instant;
import java.util.Optional;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(ProductController.class)
class ProductControllerTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @Autowired
    private ObjectMapper objectMapper;
    
    @MockBean
    private CreateProductUseCase createProductUseCase;
    
    @MockBean
    private GetProductUseCase getProductUseCase;
    
    @MockBean
    private UpdateProductUseCase updateProductUseCase;
    
    @Test
    void createProduct_ShouldReturn201() throws Exception {
        // Given
        CreateProductCommand command = new CreateProductCommand(
            "테스트 상품", "설명", BigDecimal.valueOf(10000), "KRW", "ELEC", "전자제품");
        
        ProductResponse response = new ProductResponse(
            "uuid", "테스트 상품", "설명", BigDecimal.valueOf(10000), "KRW",
            "ELEC", "전자제품", "DRAFT", Instant.now(), Instant.now());
        
        given(createProductUseCase.createProduct(any())).willReturn(response);
        
        // When & Then
        mockMvc.perform(post("/api/v1/products")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(command)))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.name").value("테스트 상품"))
            .andExpect(jsonPath("$.status").value("DRAFT"));
    }
    
    @Test
    void getProduct_WhenNotFound_ShouldReturn404() throws Exception {
        // Given
        given(getProductUseCase.getProduct(any())).willReturn(Optional.empty());
        
        // When & Then
        mockMvc.perform(get("/api/v1/products/non-existent"))
            .andExpect(status().isNotFound());
    }
}
10
실행 및 검증
전체 시스템을 실행하고 API를 테스트합니다.

애플리케이션 실행

Docker Compose로 인프라를 시작하고 애플리케이션을 실행합니다.

# 1. 인프라 시작
docker-compose up -d

# 컨테이너 상태 확인
docker-compose ps

# 로그 확인
docker-compose logs -f kafka

# 2. 애플리케이션 빌드 및 실행
./gradlew clean build

# Product 서비스 실행
./gradlew :product:bootRun

# 별도 터미널에서 Inventory 서비스 실행
./gradlew :inventory:bootRun

# 3. 서비스 상태 확인
curl http://localhost:8081/actuator/health
curl http://localhost:8082/actuator/health

API 테스트 - 상품 생성

상품을 생성하고 Kafka 이벤트 발행을 확인합니다.

# 상품 생성
curl -X POST http://localhost:8081/api/v1/products \
  -H "Content-Type: application/json" \
  -d '{
    "name": "MacBook Pro 14",
    "description": "Apple M3 Pro 칩 탑재 노트북",
    "price": 2990000,
    "currency": "KRW",
    "categoryCode": "LAPTOP",
    "categoryName": "노트북"
  }' | jq

# 응답 예시:
# {
#   "id": "550e8400-e29b-41d4-a716-446655440000",
#   "name": "MacBook Pro 14",
#   "description": "Apple M3 Pro 칩 탑재 노트북",
#   "price": 2990000,
#   "currency": "KRW",
#   "categoryCode": "LAPTOP",
#   "categoryName": "노트북",
#   "status": "DRAFT",
#   "createdAt": "2024-12-10T00:00:00Z",
#   "updatedAt": "2024-12-10T00:00:00Z"
# }

# Kafka UI에서 이벤트 확인
# http://localhost:8090 접속
# product-created 토픽에서 메시지 확인

API 테스트 - 상품 조회 및 수정

상품 조회, 가격 변경, 상태 변경을 테스트합니다.

# 상품 조회
curl http://localhost:8081/api/v1/products/{productId} | jq

# 전체 상품 목록
curl http://localhost:8081/api/v1/products | jq

# 카테고리별 조회
curl "http://localhost:8081/api/v1/products?category=LAPTOP" | jq

# 가격 변경
curl -X PATCH http://localhost:8081/api/v1/products/{productId}/price \
  -H "Content-Type: application/json" \
  -d '{
    "price": 2790000,
    "currency": "KRW"
  }' | jq

# 상품 활성화
curl -X POST http://localhost:8081/api/v1/products/{productId}/activate | jq

# 상품 비활성화
curl -X POST http://localhost:8081/api/v1/products/{productId}/deactivate | jq

API 테스트 - 재고 관리

재고 조회, 입고, 예약을 테스트합니다.

# 재고 조회 (ProductCreated 이벤트로 자동 생성됨)
curl http://localhost:8082/api/v1/stocks/{productId} | jq

# 재고 입고
curl -X POST http://localhost:8082/api/v1/stocks/{productId}/add \
  -H "Content-Type: application/json" \
  -d '{
    "quantity": 100
  }' | jq

# 응답 예시:
# {
#   "stockId": "...",
#   "productId": "...",
#   "availableQuantity": 100,
#   "reservedQuantity": 0,
#   "totalQuantity": 100
# }

# 재고 예약 (주문 시뮬레이션)
curl -X POST http://localhost:8082/api/v1/stocks/{productId}/reserve \
  -H "Content-Type: application/json" \
  -d '{
    "quantity": 5,
    "orderId": "order-001"
  }' | jq

# 응답 예시:
# {
#   "stockId": "...",
#   "productId": "...",
#   "availableQuantity": 95,
#   "reservedQuantity": 5,
#   "totalQuantity": 100
# }

# 재고 부족 시 에러
curl -X POST http://localhost:8082/api/v1/stocks/{productId}/reserve \
  -H "Content-Type: application/json" \
  -d '{
    "quantity": 1000,
    "orderId": "order-002"
  }' | jq

# 응답:
# {
#   "code": "CONFLICT",
#   "message": "Insufficient stock for product ...: available=95, requested=1000"
# }

이벤트 흐름 검증

Kafka UI에서 도메인 이벤트 흐름을 확인합니다.

# Kafka UI 접속: http://localhost:8090

# 확인할 토픽들:
# 1. product-created
#    - 상품 생성 시 발행
#    - Inventory 서비스에서 구독하여 재고 초기화
#
# 2. product-price-changed
#    - 가격 변경 시 발행
#    - 다른 서비스에서 가격 동기화에 활용
#
# 3. product-events
#    - 기타 상품 관련 이벤트
#
# 4. stock-events
#    - 재고 관련 이벤트 (입고, 예약, 확정, 취소)

# 이벤트 메시지 예시 (product-created):
# {
#   "eventId": "550e8400-e29b-41d4-a716-446655440001",
#   "occurredAt": "2024-12-10T00:00:00Z",
#   "aggregateType": "Product",
#   "aggregateId": "550e8400-e29b-41d4-a716-446655440000",
#   "productId": "550e8400-e29b-41d4-a716-446655440000",
#   "productName": "MacBook Pro 14",
#   "price": 2990000,
#   "currency": "KRW",
#   "categoryCode": "LAPTOP"
# }

# PostgreSQL에서 데이터 확인
docker exec -it ecommerce-postgres psql -U ecommerce -d ecommerce

# 상품 테이블 조회
SELECT id, name, price, status FROM products;

# 재고 테이블 조회
SELECT id, product_id, available_quantity, reserved_quantity FROM stocks;
11
아키텍처 패턴 심화
CQRS, Outbox 패턴 등 고급 패턴을 적용합니다.

CQRS 패턴 적용

Command와 Query를 분리하여 읽기/쓰기 최적화를 적용합니다.

# CQRS (Command Query Responsibility Segregation)
# ═══════════════════════════════════════════════════════════════════════════

#                    ┌─────────────────────────────────────────┐
#                    │              Client                      │
#                    └─────────────────┬───────────────────────┘
#                                      │
#              ┌───────────────────────┴───────────────────────┐
#              │                                               │
#              ▼                                               ▼
#     ┌─────────────────┐                           ┌─────────────────┐
#     │   Command API   │                           │   Query API     │
#     │  (Write Model)  │                           │  (Read Model)   │
#     └────────┬────────┘                           └────────┬────────┘
#              │                                             │
#              ▼                                             ▼
#     ┌─────────────────┐                           ┌─────────────────┐
#     │  Command Handler│                           │  Query Handler  │
#     │  (Use Cases)    │                           │  (Projections)  │
#     └────────┬────────┘                           └────────┬────────┘
#              │                                             │
#              ▼                                             ▼
#     ┌─────────────────┐     Domain Events         ┌─────────────────┐
#     │   Write DB      │─────────────────────────▶│   Read DB       │
#     │  (PostgreSQL)   │                           │  (Elasticsearch)│
#     └─────────────────┘                           └─────────────────┘

// Query 전용 Port 분리
// product/src/main/java/com/example/product/application/port/in/query/ProductQueryUseCase.java
package com.example.product.application.port.in.query;

import com.example.product.application.dto.ProductSummaryDto;
import com.example.product.application.dto.ProductDetailDto;
import java.util.List;

/**
 * 상품 조회 전용 Use Case
 * - 읽기 최적화된 DTO 반환
 * - 캐싱 적용 가능
 */
public interface ProductQueryUseCase {
    ProductDetailDto getProductDetail(String productId);
    List<ProductSummaryDto> searchProducts(ProductSearchCriteria criteria);
    List<ProductSummaryDto> getProductsByCategory(String categoryCode, int page, int size);
}

// 검색 조건 DTO
public record ProductSearchCriteria(
    String keyword,
    String categoryCode,
    Long minPrice,
    Long maxPrice,
    String status,
    int page,
    int size,
    String sortBy,
    String sortDirection
) {}

// 읽기 최적화 DTO
public record ProductSummaryDto(
    String id,
    String name,
    Long price,
    String currency,
    String categoryName,
    String status,
    String thumbnailUrl
) {}

Outbox 패턴 구현

트랜잭션 일관성을 보장하는 Outbox 패턴입니다.

// Outbox 패턴: 이벤트 발행의 신뢰성 보장
// ═══════════════════════════════════════════════════════════════════════════

// 1. 트랜잭션 내에서 Outbox 테이블에 이벤트 저장
// 2. 별도 스케줄러가 Outbox 테이블을 폴링하여 Kafka로 발행
// 3. 발행 성공 시 상태 업데이트

// product/src/main/java/com/example/product/adapter/out/messaging/OutboxRepository.java
package com.example.product.adapter.out.messaging;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import java.time.Instant;
import java.util.List;
import java.util.UUID;

public interface OutboxRepository extends JpaRepository<OutboxEvent, UUID> {
    
    List<OutboxEvent> findByStatusOrderByCreatedAtAsc(OutboxStatus status);
    
    @Modifying
    @Query("UPDATE OutboxEvent e SET e.status = :status, e.publishedAt = :publishedAt WHERE e.id = :id")
    void updateStatus(UUID id, OutboxStatus status, Instant publishedAt);
}

// product/src/main/java/com/example/product/adapter/out/messaging/OutboxEventPublisher.java
package com.example.product.adapter.out.messaging;

import com.example.common.domain.DomainEvent;
import com.example.product.application.port.out.ProductEventPort;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.util.List;
import java.util.UUID;

/**
 * Outbox 기반 이벤트 발행
 * - 트랜잭션 내에서 Outbox 테이블에 저장
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class OutboxEventPublisher implements ProductEventPort {
    
    private final OutboxRepository outboxRepository;
    private final ObjectMapper objectMapper;
    
    @Override
    @Transactional
    public void publish(DomainEvent event) {
        try {
            OutboxEvent outboxEvent = OutboxEvent.builder()
                .id(event.getEventId())
                .aggregateType(event.getAggregateType())
                .aggregateId(event.getAggregateId())
                .eventType(event.getClass().getSimpleName())
                .payload(objectMapper.writeValueAsString(event))
                .createdAt(Instant.now())
                .status(OutboxStatus.PENDING)
                .build();
            
            outboxRepository.save(outboxEvent);
            log.debug("Event saved to outbox: {}", event.getEventId());
        } catch (Exception e) {
            log.error("Failed to save event to outbox", e);
            throw new RuntimeException("Failed to save event", e);
        }
    }
    
    @Override
    public void publishAll(List<DomainEvent> events) {
        events.forEach(this::publish);
    }
}

// product/src/main/java/com/example/product/adapter/out/messaging/OutboxPoller.java
package com.example.product.adapter.out.messaging;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.util.List;

/**
 * Outbox 폴러
 * - 주기적으로 PENDING 이벤트를 Kafka로 발행
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class OutboxPoller {
    
    private final OutboxRepository outboxRepository;
    private final KafkaTemplate<String, String> kafkaTemplate;
    
    @Scheduled(fixedDelay = 1000) // 1초마다 실행
    @Transactional
    public void pollAndPublish() {
        List<OutboxEvent> pendingEvents = 
            outboxRepository.findByStatusOrderByCreatedAtAsc(OutboxStatus.PENDING);
        
        for (OutboxEvent event : pendingEvents) {
            try {
                String topic = resolveTopicName(event.getEventType());
                kafkaTemplate.send(topic, event.getAggregateId(), event.getPayload())
                    .whenComplete((result, ex) -> {
                        if (ex == null) {
                            outboxRepository.updateStatus(
                                event.getId(), OutboxStatus.PUBLISHED, Instant.now());
                            log.info("Event published: {}", event.getId());
                        } else {
                            log.error("Failed to publish event: {}", event.getId(), ex);
                        }
                    });
            } catch (Exception e) {
                log.error("Error processing outbox event: {}", event.getId(), e);
                outboxRepository.updateStatus(
                    event.getId(), OutboxStatus.FAILED, Instant.now());
            }
        }
    }
    
    private String resolveTopicName(String eventType) {
        return switch (eventType) {
            case "ProductCreated" -> "product-created";
            case "ProductPriceChanged" -> "product-price-changed";
            default -> "product-events";
        };
    }
}

도메인 서비스 패턴

여러 Aggregate에 걸친 비즈니스 로직을 처리하는 도메인 서비스입니다.

// product/src/main/java/com/example/product/domain/service/ProductPricingService.java
package com.example.product.domain.service;

import com.example.common.domain.Money;
import com.example.product.domain.model.Product;
import com.example.product.domain.model.Category;

/**
 * 상품 가격 정책 도메인 서비스
 * - 여러 Aggregate에 걸친 비즈니스 로직
 * - 상태를 가지지 않음 (Stateless)
 */
public class ProductPricingService {
    
    private static final double ELECTRONICS_TAX_RATE = 0.1;
    private static final double FOOD_TAX_RATE = 0.0;
    private static final double DEFAULT_TAX_RATE = 0.05;
    
    /**
     * 카테고리별 세금 적용 가격 계산
     */
    public Money calculatePriceWithTax(Product product) {
        double taxRate = getTaxRate(product.getCategory());
        long taxAmount = (long) (product.getPrice().getAmount().longValue() * taxRate);
        return product.getPrice().add(Money.krw(taxAmount));
    }
    
    /**
     * 할인 가격 계산
     */
    public Money calculateDiscountedPrice(Product product, int discountPercent) {
        if (discountPercent < 0 || discountPercent > 100) {
            throw new IllegalArgumentException("Discount must be 0-100%");
        }
        
        long originalPrice = product.getPrice().getAmount().longValue();
        long discountAmount = originalPrice * discountPercent / 100;
        return Money.krw(originalPrice - discountAmount);
    }
    
    private double getTaxRate(Category category) {
        return switch (category.getCode()) {
            case "ELEC", "LAPTOP", "PHONE" -> ELECTRONICS_TAX_RATE;
            case "FOOD", "GROCERY" -> FOOD_TAX_RATE;
            default -> DEFAULT_TAX_RATE;
        };
    }
}

// 도메인 서비스를 Application Service에서 사용
// product/src/main/java/com/example/product/application/service/ProductPriceQueryService.java
package com.example.product.application.service;

import com.example.common.domain.Money;
import com.example.product.application.port.out.LoadProductPort;
import com.example.product.domain.model.Product;
import com.example.product.domain.model.ProductId;
import com.example.product.domain.service.ProductPricingService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class ProductPriceQueryService {
    
    private final LoadProductPort loadProductPort;
    private final ProductPricingService pricingService;
    
    public PriceInfoDto getPriceInfo(String productId, int discountPercent) {
        Product product = loadProductPort.findById(ProductId.of(productId))
            .orElseThrow(() -> new ProductNotFoundException(productId));
        
        Money originalPrice = product.getPrice();
        Money priceWithTax = pricingService.calculatePriceWithTax(product);
        Money discountedPrice = pricingService.calculateDiscountedPrice(product, discountPercent);
        
        return new PriceInfoDto(
            originalPrice.getAmount(),
            priceWithTax.getAmount(),
            discountedPrice.getAmount(),
            originalPrice.getCurrency().getCurrencyCode()
        );
    }
}

record PriceInfoDto(
    java.math.BigDecimal originalPrice,
    java.math.BigDecimal priceWithTax,
    java.math.BigDecimal discountedPrice,
    String currency
) {}
12
정리 및 Best Practices
헥사고날 아키텍처 적용 시 주의사항과 모범 사례를 정리합니다.

의존성 규칙 요약

헥사고날 아키텍처의 핵심 의존성 규칙입니다.

# 의존성 규칙 (Dependency Rule)
# ═══════════════════════════════════════════════════════════════════════════

# 1. 의존성 방향: 외부 → 내부
#    Adapter → Application → Domain
#    (Domain은 아무것도 의존하지 않음)

# 2. 레이어별 허용 의존성
# ┌─────────────────────────────────────────────────────────────────────────┐
# │ Layer          │ Can Depend On                                         │
# ├─────────────────────────────────────────────────────────────────────────┤
# │ Domain         │ Nothing (Pure Java, no frameworks)                    │
# │ Application    │ Domain, Port interfaces                               │
# │ Adapter        │ Application, Domain, External libraries               │
# └─────────────────────────────────────────────────────────────────────────┘

# 3. 패키지 구조 예시
com.example.product
├── domain/                 # 프레임워크 의존성 없음
│   ├── model/             # Aggregate, Entity, Value Object
│   ├── event/             # Domain Events
│   ├── service/           # Domain Services
│   └── repository/        # Repository Interface (Port)
│
├── application/           # Spring 의존성 최소화
│   ├── port/
│   │   ├── in/           # Driving Ports (Use Cases)
│   │   └── out/          # Driven Ports
│   ├── service/          # Use Case 구현
│   └── dto/              # Command, Response DTOs
│
└── adapter/              # 프레임워크 의존성 집중
    ├── in/
    │   ├── web/          # REST Controllers
    │   └── kafka/        # Kafka Consumers
    └── out/
        ├── persistence/  # JPA Repositories
        └── messaging/    # Kafka Producers

테스트 전략 Best Practices

효과적인 테스트 전략과 커버리지 목표입니다.

# 테스트 전략 Best Practices
# ═══════════════════════════════════════════════════════════════════════════

# 1. 테스트 피라미드 비율
#    - Unit Tests: 70% (Domain + Application)
#    - Integration Tests: 20% (Adapters)
#    - E2E Tests: 10% (Critical paths only)

# 2. 레이어별 테스트 도구
# ┌─────────────────────────────────────────────────────────────────────────┐
# │ Layer          │ Test Type        │ Tools                              │
# ├─────────────────────────────────────────────────────────────────────────┤
# │ Domain         │ Unit             │ JUnit 5, AssertJ                   │
# │ Application    │ Unit             │ JUnit 5, Mockito                   │
# │ Adapter (Web)  │ Integration      │ MockMvc, WebTestClient             │
# │ Adapter (DB)   │ Integration      │ Testcontainers, @DataJpaTest       │
# │ Adapter (Kafka)│ Integration      │ EmbeddedKafka, Testcontainers      │
# │ Full Stack     │ E2E              │ RestAssured, Testcontainers        │
# └─────────────────────────────────────────────────────────────────────────┘

# 3. 테스트 명명 규칙
# - methodName_StateUnderTest_ExpectedBehavior
# - 예: createProduct_WithValidData_ShouldReturnCreatedProduct

# 4. Given-When-Then 패턴 사용
@Test
void reserveStock_WhenSufficientStock_ShouldDecreaseAvailable() {
    // Given - 테스트 전제 조건
    Stock stock = Stock.create("product-1", 100);
    
    // When - 테스트 대상 행위
    stock.reserve(10);
    
    // Then - 예상 결과 검증
    assertThat(stock.getAvailableQuantity().getValue()).isEqualTo(90);
    assertThat(stock.getReservedQuantity().getValue()).isEqualTo(10);
}

흔한 실수와 해결책

헥사고날 아키텍처 적용 시 흔히 발생하는 실수들입니다.

# 흔한 실수와 해결책
# ═══════════════════════════════════════════════════════════════════════════

# ❌ 실수 1: Domain에 JPA 어노테이션 사용
@Entity  // Domain 모델에 JPA 어노테이션 금지!
public class Product {
    @Id
    private UUID id;
}

# ✅ 해결: 별도의 JPA Entity 사용
// Domain Model (순수 Java)
public class Product extends AggregateRoot<ProductId> { ... }

// JPA Entity (Adapter)
@Entity
public class ProductJpaEntity { ... }

# ─────────────────────────────────────────────────────────────────────────

# ❌ 실수 2: Use Case에서 직접 Repository 호출
public class ProductService {
    private final ProductJpaRepository repository; // JPA 직접 의존!
}

# ✅ 해결: Port 인터페이스 사용
public class ProductService {
    private final LoadProductPort loadProductPort;  // Port 의존
    private final SaveProductPort saveProductPort;
}

# ─────────────────────────────────────────────────────────────────────────

# ❌ 실수 3: Controller에서 비즈니스 로직 처리
@PostMapping
public ResponseEntity<?> createProduct(@RequestBody CreateProductRequest request) {
    // 비즈니스 로직이 Controller에!
    if (request.getPrice() < 0) throw new BadRequestException();
    Product product = new Product();
    product.setName(request.getName());
    // ...
}

# ✅ 해결: Use Case로 위임
@PostMapping
public ResponseEntity<ProductResponse> createProduct(
        @Valid @RequestBody CreateProductCommand command) {
    // Controller는 요청 변환과 응답만 담당
    ProductResponse response = createProductUseCase.createProduct(command);
    return ResponseEntity.status(HttpStatus.CREATED).body(response);
}

# ─────────────────────────────────────────────────────────────────────────

# ❌ 실수 4: 도메인 이벤트 직접 발행
public class ProductService {
    private final KafkaTemplate<String, Object> kafkaTemplate; // Kafka 직접 의존!
    
    public void createProduct(...) {
        kafkaTemplate.send("topic", event); // 직접 발행
    }
}

# ✅ 해결: Event Port 사용
public class ProductService {
    private final ProductEventPort eventPort; // Port 의존
    
    public void createProduct(...) {
        Product product = Product.create(...);
        saveProductPort.save(product);
        eventPort.publishAll(product.getDomainEvents()); // Port 통해 발행
    }
}

프로젝트 체크리스트

헥사고날 아키텍처 프로젝트 완성도 체크리스트입니다.

# 헥사고날 아키텍처 체크리스트
# ═══════════════════════════════════════════════════════════════════════════

## Domain Layer
□ Aggregate Root가 불변식(Invariants)을 보장하는가?
□ Value Object가 불변(Immutable)인가?
□ 도메인 이벤트가 과거형으로 명명되었는가? (ProductCreated, not CreateProduct)
□ 도메인 모델에 프레임워크 의존성이 없는가?
□ 비즈니스 규칙이 도메인 모델 내에 캡슐화되었는가?

## Application Layer
□ Use Case가 단일 책임을 가지는가?
□ Port 인터페이스가 비즈니스 관점으로 명명되었는가?
□ 트랜잭션 경계가 Application Service에서 관리되는가?
□ DTO가 도메인 모델과 분리되었는가?

## Adapter Layer
□ Adapter가 Port 인터페이스만 구현하는가?
□ 외부 시스템 의존성이 Adapter에만 존재하는가?
□ JPA Entity와 Domain Model이 분리되었는가?
□ 매핑 로직이 Adapter 내에 있는가?

## Testing
□ Domain 테스트가 외부 의존성 없이 실행되는가?
□ Application 테스트가 Port를 Mock하여 실행되는가?
□ Adapter 테스트가 실제 인프라(Testcontainers)를 사용하는가?
□ 테스트 커버리지가 80% 이상인가?

## Event-Driven
□ 도메인 이벤트가 Aggregate 내에서 등록되는가?
□ 이벤트 발행이 트랜잭션과 분리되었는가? (Outbox 패턴)
□ 이벤트 Consumer가 멱등성을 보장하는가?
□ 이벤트 스키마 버전 관리가 되어 있는가?

# 완료! 🎉
# 이 워크샵을 통해 헥사고날 아키텍처와 DDD의 핵심 개념을 
# 이커머스 도메인에 적용하는 방법을 학습했습니다.
워크샵 완료!

축하합니다! 이 워크샵을 통해 다음을 학습했습니다:

  • 헥사고날 아키텍처의 Ports & Adapters 패턴 구현
  • DDD 전술적 패턴 (Aggregate Root, Value Object, Domain Event)
  • Spring Boot 3.x + JPA를 활용한 영속성 Adapter 구현
  • Kafka를 활용한 도메인 이벤트 발행/구독
  • 레이어별 테스트 전략 (Unit, Integration, E2E)
다음 단계:
  • • Order 도메인 추가하여 Saga 패턴 구현
  • • CQRS 패턴으로 읽기/쓰기 분리
  • • Event Sourcing 적용
  • • Kubernetes 배포 및 서비스 메시 구성

참고 자료: Baeldung - Hexagonal Architecture with DDD | Hexagonal Architecture