이커머스 상품/재고 도메인으로 배우는 헥사고날 아키텍처와 DDD
┌─────────────────────────────────────────────────────────────────────────────┐
│ 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 │ │
│ └───────────────────────┘ │
└─────────────────────────────┘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의 핵심 철학
# ═══════════════════════════════════════════════════════════════════════════
"도메인 모델은 특정 다이어그램이 아니다.
다이어그램이 전달하고자 하는 아이디어다.
코드가 표현하는 것이고, 팀원들의 머릿속에 있는 것이다."
- 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() │ 주문 취소 시 복구 │
└─────────────────────────────────────────────────────────────────────────┘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 = 완전한 도메인 중심 설계
# ═══════════════════════════════════════════════════════════════════════════
"애플리케이션이 사용자, 프로그램, 자동화된 테스트, 배치 스크립트에 의해
동등하게 구동될 수 있게 하고, 최종적으로 사용될 런타임 장치와 데이터베이스로부터
격리된 상태에서 개발되고 테스트될 수 있게 하라."
- 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을 활용한 테스트
- 어댑터: 통합 테스트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의 구현체 (기술적 세부사항)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/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
rootProject.name = "ecommerce-hexagonal"
include(
"common",
"product",
"inventory",
"api-gateway"
)공통 도메인 기반 클래스를 정의하는 모듈입니다.
// 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/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.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코드를 작성하기 전에, 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)모든 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();
}
}모든 도메인 이벤트가 구현하는 마커 인터페이스입니다.
// 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;
}
}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;
}
}도메인 이벤트 발행을 위한 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();
}
}코드를 작성하기 전에 도메인 전문가와 대화하며 유비쿼터스 언어를 정의합니다. 이 과정이 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) │ │ │
│ │ └─────────────┘ └─────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘상품 식별자를 위한 강타입 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());
}
}상품명과 카테고리를 위한 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);
}
}상품 도메인의 핵심 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 // 삭제됨 (논리 삭제)
}상품 도메인에서 발생하는 이벤트들입니다.
// 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; }
}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: 상태 조회, 데이터 반환외부에서 애플리케이션을 호출하기 위한 인터페이스입니다.
// 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);
}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);
}
}
}애플리케이션이 외부 시스템을 호출하기 위한 인터페이스입니다.
// 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);
}모든 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);
}
}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입니다. 도메인 모델과 분리됩니다.
// 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
}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);
}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()
);
}
}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: 8081Kafka 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로 발행하는 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
}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) {}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"
}재고 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/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)도 동일한 패턴으로 구현재고 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 도메인의 이벤트를 구독하여 재고를 초기화하는 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
) {}헥사고날 아키텍처에서의 테스트 전략입니다.
# 헥사고날 아키텍처 테스트 전략
# ═══════════════════════════════════════════════════════════════════════════
# ┌─────────────────┐
# │ 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 │
# └─────────────────────────────────────────────────────────────────────────┘도메인 모델의 비즈니스 규칙을 검증하는 순수 단위 테스트입니다.
// 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);
}
}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");
}
}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());
}
}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상품을 생성하고 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 토픽에서 메시지 확인상품 조회, 가격 변경, 상태 변경을 테스트합니다.
# 상품 조회
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재고 조회, 입고, 예약을 테스트합니다.
# 재고 조회 (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;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 패턴: 이벤트 발행의 신뢰성 보장
// ═══════════════════════════════════════════════════════════════════════════
// 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
) {}헥사고날 아키텍처의 핵심 의존성 규칙입니다.
# 의존성 규칙 (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
# ═══════════════════════════════════════════════════════════════════════════
# 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의 핵심 개념을
# 이커머스 도메인에 적용하는 방법을 학습했습니다.축하합니다! 이 워크샵을 통해 다음을 학습했습니다: