Microservice Pattern: Chap.5 비즈니스 로직 설계

bluehope·2024년 10월 16일
post-thumbnail

마이크로서비스 아키텍처는 비즈니스 로직이 분산되어 있어 도메인 모델을 명확히 나누기 어렵다. 모놀리식에서라면 문제가 되지 않을 부분들이 마이크로서비스 아키텍처에서는 서비스의 경계를 넘어서는 문제가 발생되기 때문에 상호 연관성을 최소화하기 위해 서비스간 경계를 넘나드는 객체 레퍼런스를 제거해야 한다. 또한 트랜잭선의 관리 역시 saga 패턴 등을 사용해야 하기 때문에 발생되는 추가적인 문제도 포함하여 해결해야 한다. 이러한 문제들은 주로 DDD 애그리거트(aggregate) 패턴을 도입하여 해결한다.

Aggregate
formed by the collection of units or particles into a body, mass, or amount
Cambridge Dictonaty (link)
1. 합계, 총액
2. (건설 자재용) 골재
네이버 영어사전(link)

  • 애그리거트를 사용하면 객체 참조 대신 기본키(PK, Primary Key)를 이용하여 애그리거트가 서로 참조하기 때문에 객체 레퍼런스가 서비스 경계를 넘어서지 않는다.
  • 한 트랜잭션으로 하나의 애그리거트만 생성/수정할 수 있기 때문에 트랜잭션 모델의 제약 조건(서비스별 local DB만 반영)과 동일하게 처리 가능하다.

동등성

이번 챕터에는 Entity, Value, Aggregate 등의 용어가 많이 나온다. 이러한 용어를 명확하게 이해하기 위해서는 우선 동등성 이라는 개념을 먼저 파악할 필요가 있다. 동등성에 대해 잘 정리된 내용(Entity vs Value Object 차이점)이 있어 발췌하였다.

Reference equality(참조 동등성)

만약 2개의 객체가 메모리에 같은 주소를 참조하면 동등하다는 의미입니다

Identifier equality(식별자 동등성)

식별자 동등성은 id 필드를 가진 클래스들이 같은 참조자를 가진다면 동등하다는 의미입니다.

structural equality(구조 동등성)

구조 동등성은 모든 멤버 변수가 같다면 동등하다는 의미입니다.

도메인(Domain)이란?

도메인은 소프트웨어로 해결하고자 하는 문제 영역을 나타낸다. 예를 들어 주문, 회원관리, 정산, 배송 등 하나의 문제를 다루는 영역을 도메인이라고 할 수 있다. 애플리케이션은 도메인간 상호 연동을 통해 비즈니스를 제공하게 된다. 도메인에 대하여 잘 설명한 내용(도메인 모델 시작하기)이 있어 일부 발췌하였다.

도메인 계층

도메인 계층은 도메인의 핵심 규칙을 구현한다. 다음 코드를 살펴보자. 이 코드는 주문 도메인의 일부 기능을 도메인 모델 패턴으로 구현한 것이다.

  • isShippingChangable() : 배송지를 변경할 수 있는지 검사하는 메서드
  • OrderState 는 주문 대기중이거나 상품 준비 중에는 배송지를 변경할 수 있다는 도메인 규칙을 isShippingChangable() 메서드를 이용해 구현하고 있다.
public class Order {
	private OrderState state;
    private ShippingInfo shippingInfo;
    public void changeShippingInfo(ShippingInfo newShippingInfo) {
    	if (!state.isShippingChangable()) {
        	throw ew illegalStateExecption("can't change shipping in " + state);
        }
        this.shippingInfo = newShippingInfo;
    }
    ...
} 
public enum OrderState {
	PAYMENT_WAITING {
    	public boolean isShippingChangable() { return true; }
    },
    PREPARING {
    	public boolean isShippingChangable() { return true; }
    },
    SHIPPED, DELIVERING, DELIVERY_COMPLETED;
    public boolean isShippingChangable() {
    	return false;
    }
}    	

큰 틀에서 보면 OrderState 는 Order 에 속한 데이터이므로 배송지 정보 변경 가능 여부를 판단하는 코드를 이동시킬 수 있다.

public class Order {
	private OrderState state;
    private ShippingInfo shippingInfo;
    public void changeShippingInfo(ShippingInfo newShippingInfo) {
    	if (!state.isShippingChangable()) {
        	throw ew illegalStateExecption("can't change shipping in " + state);
        }
        this.shippingInfo = newShippingInfo;
    }
    private boolean isShippingChangable() {
    	return state == OrderState.PAYMENT_WAITING || state == OrderState.PREPARING;
    }
    ...
}
public enum OrderState {
	PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED;  
}   

배송지 변경 판단 규칙은 OrderStateOrder 중 어디에 위치하는 것이 좋을까? 만약 배송지 변경의 판단 규칙이 단순히 주문 상태만이 아니라, 다른 정보를 함께 사용한다면 어떨까? OrerState 만으로는 배송지 변경 가능 여부를 판단할 수 없기 때문에 Order 에서 구현하는 것이 적합하다.
여기서 중요한 점은 배송지 변경 가능 여부가 Order 에 있느냐 OrerState 에 있느냐가 아니다. 주문과 관련된 중요 업무 규칙 (= 배송지 변경 판단 규칙) 을 주문 도메인 모델에서 구현한다는 점이다. 핵심 규칙을 구현한 코드가 도메인 모델에만 위치하기 때문에 규칙이 바뀌거나 확장될 때 다른 코드에 영향을 덜 주면서 변경 내역을 모델에 반영할 수 있다.

도메인의 주요 구성요소

도메인 모델을 하는데 주요 구성요소로는 Entity, Value, Aggregate, Repository, Domain Service 등이 있다. 각각의 개념에 대하여 잘 정리된 내용(아키텍처 개요)이 있어 발췌하였다.

요소설명
Entity고유의 식별자를 갖는 객체로, 자신의 라이프 사이클을 갖는다. 도메인의 고유한 개념을 표현한다. 도메인 모델의 데이터를 포함하며 해당 데잍와 관련된 기능을 함께 제공한다.
Value고유의 식별자를 갖지 않는 객체로, 주로 개념적으로 하나인 값을 표현할 때 사용한다. Entity의 속성 뿐만 아니라 다른 Value 타입의 속성으로도 사용할 수 있다.
Aggregate연관된 Entity 와 Value 객체를 개념적으로 하나로 묶은 것을 말한다.
Repository도메인 모델의 영속성을 처리한다. DBMS 테이블에서 Entity 객체를 로딩하거나 저장하는 기능을 제공한다.
Domain Service특정 Entity 에 속하지 않은 도메인 로직을 제공한다. 즉, 도메인 로직이 여러 Entity 와 Value 를 필요로 하는 경우 도메인 서비스에서 로직을 구현한다.

Entity

Entity는 각 객체마다 서로 다른 식별자를 갖는 객체로, 도메인의 고유한 개념을 표현하며, 데이터와 기능을 같이 가지면서 자신만의 라이프 사이클을 가진다. 예를 들어, 주문 도메인에서의 각 주문 Entity는 서로 다른 유일한 주문 번호를 식별자로 가지고, 엔티티의 식별자는 엔티티를 생성하고 속성을 바꾸고 삭제할 때까지 유지된다. Entity의 식별자는 고유하기 때문에 두 엔티티 객체의 식별자가 같으면 두 엔티티를 갖다고 판단할 수 있고, 이를 이용해 equals()hashCode() 등을 를 구현할 수 있다. 엔티티의 식별자를 생성하는 시점은 도메인의 특징과 사용 기술에 따라 달라질 수 있다.

참고로 도메인 모델에서 말하는 Entity는 데이터 뿐만 아니라 도메인의 기능을 제공하는 점에서 데이터베이스 모델에서의 Entity와 차이가 있다. 즉, 도메인 관점에서 기능을 구현하고, 도메인 내부의 세부 구현 사항을 캡슐화해서 데이터가 임의로 변경되는 것을 막는다. 또한 도메인 모델의 엔티티는 여러 데이터가 하나의 개념적 의미를 가질 때 Value 타입을 이용해서 표현할 수 있다. 반면에 관계형 데이터베이스는 Value 타입을 제대로 표현하기 힘들다.

public class Order {
  // 주문 도메인 모델의 데이터
  private OrderNo number;
  private User user;
  private DeliveryInfo deliveryInfo;
  ...
  // setters
  public void changeDeliveryInfo(DeliveryInfo deliveryInfo) {
  	...
  }
  // getters
}  

Value

같은 개념을 갖는 서로 다른 필드들을 실제로 하나의 개념으로 표현할 수 있는 객체이다. 예를 들어, 주문한 사람에 대한 정보를 관리하는 Consumer 클래스는 Value 타입으로 도메인 개념을 다음처럼 나타낼 수 있다.

public class Consumer {
	private String name;
    private String phoneNumber;
    public Consumer(String name, String phoneNumber) {
    	this.name = name;
        this.phoneNumber = phoneNumber;
    }
    // getters
}   

Value 타입은 주로 불변(immutable)한 값으로 구현되며, 기존 데이터를 변경하는 setter보다 변경한 데이터를 새로 갖는 constructor 방식을 선호한다. 만약 setter를 사용하면 도메인의 핵심 개념이나 의도가 코드에서 사라지게 되고, 온전하지 않은 상태의 도메인 객체가 생성될 수 있는 문제가 있다.

public class Order {
	public Order(Consumer consumer, OrderState orderState, List<OrderLineItem> orderLineItems, DeliveryInfo deliveryInfo) {
    	// 생성자에서 필요한 정보를 모두 받아 비즈니스 로직을 수행하여 도메인 객체가 불완전한 상태로 생성 및 사용되는 것을 원천적으로 방지함
    	setConsumer(consumer);
        setOrderLineItems(orderLineItems);
        ...
    }  
    // setter 메소드를 private로 구현하여 외부 객체에서 내부 데이터를 변경하는 것을 차단
    private void setConsumer(Consumer consumer) {
    	if (consumer == null) throw new IllegalArgumetException("Consumer can not be Null Object");
        this.consumer = consumer;
    }
    private void setOrderLineItems(List<OrderLineItem> orderLineItems) {
    	// 생성자 호출 시점에 도메인 제약 규칙 검증 가능
    	verifyAtLeastOneOrMoreOrderLineItems(orderLineItems);
        this.orderLineItems = orderLineItems;
        calculateTotalAmounts();
    }
    private void calculateTotalAmounts() {
    	this.totalAmounts = orderLineItems.stream().mapToInt(x -> x.getAmounts()).sum();
    }
}   

불변 Value 타입을 사용하면 자연스럽게 Value 타입에 setter를 구현하지 않고, setter를 구현해야 할 특별한 사유가 없다면 Value 타입은 불변으로 구현해야 한다.

  • setter를 구현해야 할 특별한 이유가 없다면 불변하게 구현할 것
  • setter를 사용해야 한다면, private을 통해 외부에서 데이터 변경을 방지할 것
  • constructor를 통해 생성 시점에 필요한 데이터를 전달함으로써 불완전한 도메인 사용을 방지할 것
  • 생성자 호출 시점에 해당 도메인의 제약 규칙, NPE를 체크하고 검증할 것

Entitiy와 value object의 차이점은 객체를 비교하는 방식으로, Entity는 Identifier equality(식별자 동등성)를 통해 동일 객체 여부를 판단하고, Value object는 structural equality(구조 동등성)를 통해 동일 객체 여부르 판단할 수 있다. 즉, Entity 객체는 고유한 식별자가 있고, lifecycle이 관리되지만 value object는 식별자나, lifecycle 관리가 불필요하다. 2개의 객체의 필드 값이 모두 동일하다면 2개를 동등하다고 판단할 수 있으면 Value 객체이고, 2개의 entity가 필드 값이 모두 같더라도 식별자가 다르면 동등하지 않다고 판단해야 한다면 Entity 객체이다.
이를 다시 해석해 보면, 하나의 객체를 같은 특성 값을 가진 다른 객체로 대체할 수 있다면(즉, structual equality) Value 객체로 지정하는 것이 좋다. 일반적으로 'Money'로 구현되는 객체는 오늘 내가 가진 1달러와 다른 사람이 가지고 있는 1달러가 동일하므로 상호 교환 가능하므로 Value 객체가 적합하다. 즉, 도메인에서도 Money나 Integer처럼 structural equality로 동등성을 다룰 수 있다면 Value object가 적합하다.

애그리거트 (Aggregate)

애플리케이션이 발전함에 따라 도메인 모델도 확대되게 되고, 그에 따라 Entity나 Value가 지속 확대되고 모델의 복잡성은 증가하게 된다. 그러다보면 큰 수준에서의 도메인 모델 자체를 이해하지 못하면서 세부적인 Entity에 매몰되는 문제가 발생될 수도 있다. 따라서 도메인 모델의 전체 구조를 이해하고, 설계를 설명하는데는 Aggregate가 효율적이다.
예를 들어 '주문'이라는 도메인은 주문, 배송지 정보, 주문자, 주문 목록, 총 결제 금액 등의 하위 모델을 가진다. 이러한 하위 개념을 표현한 모델을 하나로 묶어서 주문 이라는 상위 개념으로 표현하여 개별 객체가 아닌 관련 객체를 묶어 객체 군집 단위로 모델을 만들 수 있다.

애그리거트는 군집에 속한 객체를 관리하는 root 엔티티를 갖으며, root는 애그리거트에 속해 있는 Entity와 Value 객체를 이용해서 구현해야 할 기능을 제공한다. 애그리거트를 사용하면 다른 애그리거트에서는 루트를 이용하여 기능을 호출하고, 루트를 이용하여 애그리거트 내의 Entity나 Value에 접근할 수 있게 한다. 즉, 애그리거트 단위로 구현을 캡슐화 하는 것이다.

public class Order {
    public void changeDeliveryInfo(DeliveryInfo newDeliveryInfo) {
    	checkDeliveryInfoChangable(); // 현재 주문 상태가 배송지 주소를 변경할 수 있는 상태인지 확인
        this.DeliveryInfo = newDeliveryInfo;
    }
    private void checkDeliveryInfoChangable() {
    	... // 배송지 주소를 변경할 수 있는지 상태를 확인하는 도메인 규칙 코드
    }
}    

주문 애그리거트는 반드시 Order를 통해서만 DeliveryInfo를 변경하며, 배송지를 변경하려면 루트 엔티티를 사용하기 때문에, Order에서 구현된 도메인 규칙(checkDeliveryInforChangable())을 항상 보장할 수 있게 된다.

Repository

도메인 객체를 지속적으로 사용하려면 RDBMS와 같은 물리적인 저장소에 도메인 객체를 보관해야 하며, 이러한 지속적인 보관을 위해 Repository로 도메인 모델을 구성한다. Entity 와 Value 가 요구사항에서 도출되는 도메인 모델이라면 Repository는 데이터이 지속성을 구현하기 위한 도메인 모델이다.

Repository
애그리거트 단위로 도메인 객체를 저장하고 조회하는 기능을 정의한다.

public interface OrderRepository {
	Order findByOrderId(OrderId orderId);
    void save(Order order);
    void delete(Order order);
}    

OrderRepository 메서드는 애그리거트 루트인 Order를 단위로 데이터를 관리한다. Order는 애그리거트에 속한 모든 객체를 포함하고 있으므로 애그리거트 단위로 처리하는 것이며, Repository를 통해서 도메인 객체에 필요한 정보를 확보하여 도메인 객체의 기능을 실행한다. OrderRepository는 도메인 객체를 영속화하는 데 필요한 기능을 추상화한 것이고, 실제 OrderRepository를 구현한 클래스는 저수준 모듈(Infrastructure 레이어)이 된다.

public class CancelOrderService {
	private OrderRepository orderRepository;
    public void cancel(OrderId orderId) {
    	Order order = orderRepository.findByOrderId(orderId);
        if (order == null) throw new NoOrderException(orderId);
        else order.cancel();
    }
}   

도메인 언어

이러한 도메인을 사용때는 도메인 규칙이 잘 드러날 수 있도록 표현하는 것이 필요하여 언에 대한 고려가 필요하다. 도메인에 대한 용어를 잘 표현하면 다음과 같은 장점을 얻을 수 있다.

  • 코드의 가독성을 높여 코드를 분석하고 이해하는 시간 축소(코드를 도메인 용어로 해석하거나, 혹은 반대로 도메인 용어를 코드로 해석하는 과정이 축소되 수 있음)
  • 도메인 규칙을 코드로 작성하기 위해 의미를 변환 과정의 오류 발생 가능성 축소

예를 들어 오더를 처리하는데 총 6단계가 진행된다고 가정하면, OrderState를 단순 상태의 순번을 나열하는 형태로 구현할 수도 있다.

public OrderState {
	STEP1, STEP2, STEP3, STEP4, STEP5, STEP6
}

그러나 위 구현 방식은 오더 처리 상태에 대한 비즈니스 정보가 부족하여 가독성도 떨어지며, 배송지 변경이 어떤 상태에서만 가능하다는 비즈니스 로직을 식별하는 것도 어렵게 된다. 따라서 다음 처럼 언어를 변경하는 것이 유리하다.

public OrderState {
	PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED;
}

비즈니스 로직 구성 패턴

주문 서비스는 비즈니스 로직과 어댑터로 구성되어 있는 육각형 구조이다. 인바운드 어댑터는 외부의 요청을 받아 비즈니스 로직을 호출하고, 아웃바운드 어댑터는 비즈니스 로직에서 필요한 외부 연동(DB 연동 등)을 위해 호출한다.

The Order Service has a hexagonal architecture. It consists of the business logic and one or more adapters that interface with external applications and other services.

주문 서비스는 다음의 어댑터로 구성된다.

  • REST API 어댑터: 비즈니스 로직을 호출하는 REST API가 구현된 인바운드 어댑터
  • OrderCommandHandlers: 메시지 채널에서 들어온 커맨드 메시지를 받아 비즈니스 로직을 호출하는 인바운드 어댑터
  • DB 어댑터: 비즈니스 로직이 DB 접근을 위해 호출하는 아웃바운드 어댑터
    도메인 이벤트 발행 어댑터: 이벤트를 메시지 브로커에 발행하는 아웃바운드 어댑터

트랜잭션 스크립트 패턴

트랜잭션 스크립트 팬언은 표현계층에서 들어온 요청을 처리하는 방식으로, 객체지향이 아닌 절차적 코드 형태로 동작(behavior)이 구현된 클래스와 상태(state)를 보관하는 클래스를 분리하여 구성하여 비즈니스 로직을 요청 타입별로 하나씩 매핑된 절차적 트랜잭션 스크립트 뭉치로 구성하는 것이다.

Organizing business logic as transaction scripts. In a typical transaction script–based design, one set of classes implements behavior and another set stores state. The transaction scripts are organized into classes that typically have no state. The scripts use data classes, which typically have no behavior.

  • 동작을 하는 클래스: OrderService, OrderDao
  • 상태가 있는 클래스: Order

도메인 모델 패턴

도메인 모델 패턴이란 비즈니스 로직을 상태와 동작을 가진 클래스로 구성된 객체 모델로 구성한 것으로 객체 지향적으로 설계한 비즈니스 로직으로 클래스간 관계를 갖는 형태가 된다. 각 클래스는 문제 영역(problem domain)에 맞춰 설계되며, 각 클래스가 상태, 동작을 같이 가지고 있을 수 있다. 도메인 모델 패턴을 이용하면 객체 지향 설계에 적합하여 다음 장점이 있다.

  • 설계를 이해하고 관리가 용이함
  • 객체지향 설계는 테스트가 용이함
  • 객체지향 설계는 잘 알려진 설계 패턴을 응용 가능하여 확장이 용이함

Organizing business logic as a domain model. The majority of the business logic consists of classes that have state and behavior.

  • 동작만 있는 클래스: OrderService, OrderRepository
  • 상태만 있는 클래스: DeliveryInformation,
  • 도메인 모델: Order

트랜잭션 스크립트 모델 vs 도메인 모델

소프트웨어 아키텍처에서 트랜잭션 스크립트 패턴과 도메인 모델 패턴은 각각 데이터의 처리와 비즈니스 로직을 관리하는 방식에 차이가 있다. 두 패턴의 개념과 트랜잭션 스크립트 패턴에서 도메인 모델 패턴으로의 전환을 예제 코드를 통해 살펴보자.

트랜잭션 스크립트 패턴 (Transaction Script Pattern)

트랜잭션 스크립트 패턴은 비즈니스 로직을 절차적으로 처리하는 패턴으로, 데이터베이스 트랜잭션 단위로 동작하는 스크립트를 만들어, 각각의 스크립트가 특정 기능을 수행하게 한다. 비즈니스 로직은 서비스 계층에 위치하며, 데이터는 주로 데이터베이스와 직접 상호작용하는 형태이다.

  • 절차적 프로그래밍: 비즈니스 로직을 단계별로 나열하여 순차적으로 처리
  • 단순한 코드 구조: 간단한 애플리케이션에 적합하지만, 복잡한 비즈니스 규칙을 관리하기에는 중복코드 등 어려움
  • 테스트 용이성: 특정 비즈니스 기능을 테스트하기는 쉽지만, 중복된 로직이 여러 스크립트에 걸쳐 발생할 수 있음

예제 코드(python)

class OrderService:
    def create_order(self, order_data):
        # 1. 재고 확인
        product = ProductRepository.get_product(order_data['product_id'])
        if product.stock < order_data['quantity']:
            raise ValueError("재고 부족")

        # 2. 주문 생성 및 저장
        order = {
            'id': generate_order_id(),
            'product_id': order_data['product_id'],
            'quantity': order_data['quantity'],
            'status': 'CREATED'
        }
        OrderRepository.save_order(order)

        # 3. 재고 업데이트
        product.stock -= order_data['quantity']
        ProductRepository.update_product(product)

        # 4. 결제 처리
        payment_result = PaymentGateway.process_payment(order_data['payment_info'])
        if not payment_result['success']:
            raise ValueError("결제 실패")
        
        # 5. 주문 상태 업데이트
        order['status'] = 'COMPLETED'
        OrderRepository.update_order(order)

        return order
  • 재고 확인:

    • ProductRepository에서 제품 정보를 가져온 후, 주문하려는 수량과 재고를 비교한다. 재고가 부족할 경우, 예외를 발생시켜 주문을 중단한다.
    • if product.stock < order_data['quantity']는 제품의 재고 상태를 검증하는 조건이다.
  • 주문 생성 및 저장:

    • 주문 데이터를 기반으로 주문 객체를 생성하고, 상태를 CREATED로 설정한 후, OrderRepository를 사용해 주문을 저장한다.
    • OrderRepository.save_order(order)는 생성된 주문 객체를 데이터베이스에 저장하는 역할을 수행한다.
  • 재고 업데이트:

    • 주문이 생성된 후에는 제품의 재고를 줄여야 하므로, 제품의 stock 값을 감소시키고 이를 업데이트한다.
    • product.stock -= order_data['quantity']는 주문 수량만큼 재고를 줄인다.
  • 결제 처리:

    • PaymentGateway.process_payment를 통해 결제를 처리한다.
    • 결제가 실패하면 예외를 발생시켜 트랜잭션을 종료하고, 결제가 성공했을 때만 다음 단계로 진행한다.
  • 주문 상태 업데이트:

    • 결제가 완료된 후에는 주문 상태를 COMPLETED로 변경하고 이를 저장소에 업데이트한다.
    • OrderRepository.update_order(order)는 상태가 변경된 주문 객체를 데이터베이스에 반영한다.

트랜잭션 스크립트 패턴에서는 모든 로직이 OrderService 클래스 내의 create_order 메서드에 집중되어 있으므로 절차적으로 작성된 특성 상 코드의 흐름을 이해하기 쉽게 해 주지만, 로직이 길어질수록 복잡성이 증가하고 중복 코드가 발생할 가능성이 높다. 특히, 다른 서비스에서 동일한 로직을 사용하고자 할 때, 코드 재사용성이 떨어질 수 있다.

도메인 모델 패턴 (Domain Model Pattern)

도메인 모델 패턴은 객체 지향 설계를 바탕으로 도메인 개념을 코드로 모델링하는 패턴으로 애플리케이션의 비즈니스 로직과 규칙을 객체로 표현하며, 각 객체는 상태와 동작을 함께 가지고 있는 형태이다.

  • 객체 지향 프로그래밍: 비즈니스 개념을 클래스와 객체로 모델링하여 재사용성을 높임
  • 응집도 높은 코드 구조: 복잡한 비즈니스 로직을 클래스 내에서 캡슐화하여 관리
  • 유연한 유지보수: 도메인 객체의 상태와 동작을 명확하게 정의함으로써, 시스템 확장 및 변경이 용이함

예제 코드(python)

class Order:
    def __init__(self, product, quantity):
        if product.stock < quantity:
            raise ValueError("재고 부족")
        
        self.id = generate_order_id()
        self.product = product
        self.quantity = quantity
        self.status = 'CREATED'
    
    def complete(self, payment_info):
        payment_result = PaymentGateway.process_payment(payment_info)
        if not payment_result['success']:
            raise ValueError("결제 실패")
        
        self.status = 'COMPLETED'
        self.product.reduce_stock(self.quantity)


class Product:
    def __init__(self, id, name, stock):
        self.id = id
        self.name = name
        self.stock = stock
    
    def reduce_stock(self, quantity):
        if self.stock < quantity:
            raise ValueError("재고 부족")
        
        self.stock -= quantity


class OrderRepository:
    @staticmethod
    def save(order):
        # 주문 데이터를 저장하는 로직
        pass

    @staticmethod
    def update(order):
        # 주문 데이터를 업데이트하는 로직
        pass


# 서비스 계층에서의 사용 예시
class OrderService:
    def create_order(self, product_id, quantity, payment_info):
        product = ProductRepository.get_product(product_id)
        order = Order(product, quantity)
        order.complete(payment_info)
        
        OrderRepository.save(order)
        ProductRepository.update_product(order.product)

        return order

56- Order 클래스:

  • Order 클래스는 주문에 대한 상태와 동작을 정의하고, 생성자(__init__)에서 제품과 수량을 받아 주문 객체를 초기화하고, 생성 시점에 재고를 확인하게 된다.

  • complete 메서드는 결제를 처리하고, 결제가 성공하면 주문 상태를 COMPLETED로 변경하며, 제품의 재고를 줄이는 역할을 수행한다.

  • Product 클래스:

    • Product 클래스는 제품의 상태와 행동을 나타내며, 재고를 줄이는 reduce_stock 메서드가 포함되어 있어서, 만약 재고가 부족할 경우에는 예외를 발생시킨다.
    • reduce_stock 메서드는 제품 객체가 스스로의 재고를 관리하게 하여, 재고 관련 로직을 한 곳에 모아 두게 된다.
  • OrderService 클래스:

    • OrderService 클래스는 OrderProduct 객체를 조합하여 주문 생성 및 처리를 수행한다. OrderService는 객체 간의 상호작용을 관리하며, 도메인 로직 자체는 OrderProduct 객체 내부에 위치하는 형태이다.
    • create_order 메서드는 주문을 생성하고, 결제를 처리하며, 데이터베이스에 저장하는 역할을 수행한다.
  • OrderRepository 클래스:

    • OrderRepository는 주문 객체를 데이터베이스에 저장하거나 업데이트하는 역할을 담당하며, 저장소와 도메인 로직을 분리하여 데이터 접근 로직과 비즈니스 로직을 명확히 구분하게 한다.

도메인 모델 패턴에서는 OrderProduct와 같은 도메인 객체가 비즈니스 로직을 책임지도록 설계하며, Order 객체는 결제 처리와 상태 변경을 스스로 처리하며, Product 객체는 재고 감소를 담당하여 처리한다. 이 구조는 코드의 응집도를 높여주고, 객체 간의 역할과 책임이 명확하게 구분될 수 있게 하며, 새로운 기능을 객체의 메서드로 추가하기 쉬워 확장성이 높아질 수 있다.

트랜잭션 스크립트와 도메인 모델 비교

트랜잭션 스크립트 패턴에서 도메인 모델 패턴으로의 전환하면 다음의 장점을 얻을 수 있다.

  • 재사용성 개선: 트랜잭션 스크립트 패턴에서는 재고 감소 로직이 서비스 계층에 존재하지만, 도메인 모델 패턴으로 전환하면 Product 객체의 메서드로 이동하게 되어, 재고 관리 로직의 재사용성을 높이고, 제품과 관련된 모든 로직이 한 곳에 집중되게 된다.
  • 유지보수성 향상: 도메인 모델 패턴에서는 객체 자체가 비즈니스 로직을 소유하므로, 기능 추가 시 객체 내부에서 변경이 이루어지므로 기존 객체와 서비스 계층의 코드를 수정할 필요 없이 새로운 기능을 추가할 수 있다.
  • 객체 지향 설계의 장점 활용: 도메인 모델 패턴은 객체 간의 메시지 전달과 메서드 호출을 통해 비즈니스 로직을 구성하므로, 객체 지향 설계의 장점을 최대한 활용할 수 있다.
항목트랜잭션 스크립트 패턴도메인 모델 패턴
코드 구조절차적인 스크립트로 구성객체와 클래스에 비즈니스 로직을 캡슐화
적용 시기단순한 비즈니스 로직이나 적은 도메인 규칙에 적합복잡한 비즈니스 로직을 처리하고 확장이 필요한 경우에 적합
유지보수성로직 중복이 발생하기 쉬워 유지보수에 불리코드 재사용과 확장이 용이하여 유지보수에 유리
테스트 용이성특정 기능 단위의 테스트가 용이도메인 객체를 통한 상태 및 동작 테스트가 가능
비즈니스 로직 위치서비스 계층에 위치도메인 객체 내부에 위치
  • 트랜잭션 스크립트 사용이 유리한 경우: 시스템이 비교적 단순하고, 비즈니스 로직이 복잡하지 않으며, 명령문 기반의 프로그래밍으로 빠르게 개발해야 할 때 적합하다.
  • 도메인 모델 사용이 유리한 경우: 시스템의 도메인 규칙이 복잡하고, 다양한 객체들이 서로 상호작용하며, 코드의 유지보수와 확장성이 중요할 때 적합하다.

트랜잭션 스크립트에서 도메인 모델로의 전환 시 고려 사항

  • 객체의 역할 및 책임 정의: 도메인 모델을 설계할 때, 객체 간의 책임을 명확히 정의하는 것이 중요하다. 예를 들어, Order 객체는 주문 생성 및 상태 변경과 같은 역할을 담당하고, Product 객체는 재고 관리 역할만 수행하는 것 처럼 명확한 구분이 필요하다.
  • 중복 코드 제거: 트랜잭션 스크립트 패턴에서는 여러 서비스에 동일한 비즈니스 로직이 반복될 수 있지만, 도메인 모델로 전환하면 중복 코드를 객체 내 메서드로 통합할 수 있다.
  • 테스트 시나리오 작성: 도메인 모델 전환 후, 객체의 메서드 단위로 테스트 시나리오를 작성하여 각 객체가 예상대로 동작하는지 검증해야 하므로 변경이 필요하다.

Objects and Data Structures
출처: Chap. 6 in Clean Code, Robert C. Martin 외, Prentice Hall, 2009

객체는 행동(behavior)을 노출하고 데이터를 감춘다. 이는 새로운 종류의 객체를 추가할 때, 기존의 행동을 변경하지 않고도 쉽게 확장할 수 있게 한다. 그러나 기존 객체에 새로운 행동을 추가하는 것은 어렵게 만든다.
반면, 데이터 구조(data structures)는 데이터를 노출하고 의미 있는 행동을 포함하지 않는다. 이는 기존 데이터 구조에 새로운 행동을 쉽게 추가할 수 있게 해주지만, 기존 함수에 새로운 데이터 구조를 추가하는 것은 어렵게 만든다.
시스템 설계 시, 경우에 따라 새로운 데이터 타입(data types)을 추가할 유연성이 필요하면 객체 지향 접근 방식을 유리하다. 그러나 새로운 행동(behavior)을 추가할 유연성이 필요할 때는 데이터 타입과 절차적 프로그래밍(procedures)을 사용하는 것이 더 적합하다.
숙련된 소프트웨어 개발자는 이러한 문제들을 편견 없이 이해하고, 작업의 요구 사항에 따라 최적의 접근 방식을 선택합니다.

Objects expose behavior and hide data. This makes it easy to add new kinds of objects without changing existing behaviors. It also makes it hard to add new behaviors to existing objects. Data structures expose data and have no significant behavior. This makes it easy to add new behaviors to existing data structures but makes it hard to add new data structures to existing functions.
In any given system we will sometimes want the flexibility to add new data types, and so we prefer objects for that part of the system. Other times we will want the flexibility to add new behaviors, and so in that part of the system we prefer data types and procedures. Good software developers understand these issues without prejudice and choose the approach that is best for the job at hand.

도메인 주도 설계: DDD 애그리거트 패턴

도메인 주도 설계(Domain-Driven Design, 이후 DDD)는 복잡한 비즈니스 로직을 개발하기 위한 접근 방식으로, 아래와 같은 빌딩 블록들을 사용하여 도메인 모델을 구축한다.

  • 엔티티(Entity): 영속성 신원을 가진 객체. 두 엔티티가 속성 값이 동일해도 엄연히 다른 객체. 자바에서는 JPA에 @Entity를 붙여 DDD 엔티티를 나타냄
  • 값 객체(Value Object): 여러 값을 모아 놓은 객체. 속성 값이 동일한 두 값 객체는 서로 바꾸어 사용할 수 있다. (예: 통화와 금액으로 구성된 Money 클래스)
  • 팩토리(Factory): 일반 생성자로 직접 만들기에 복잡한 객체 생성 로직이 구현된 객체 또는 메서드. 인스턴스로 생성할 구상 클래스를 감출 수 있으며, 클래스의 정적 메서드로 구현할 수 있음.
  • 리포지터리(Repository): 엔티티를 저장하는 DB 접근 로직을 캡슐화한 객체
  • 서비스(Service): 엔티티, 밸류 객체에 속하지 않은 비즈니스 로직 구현 객체

전통적인 객체 지향 설계에 기반한 도메인 모델은 클래스 간 관계를 모아노은 것으로 패키지 형태로 구성되며 서로 연관된 클래스가 그물망처럼 얽혀있게 된다. 위의 예제에는 Consumer, Order, Restaurant, Courier 등 비즈니스 객체에 대응되는 클래스가 있지만, 비즈니스 객체들의 경계가 불분명하여 마이크로서비스 아키텍처로 전환시 문제가 생길 가능성이 높다. 아래 예제에서는 특히 어느 클래스가 Order 라는 비즈니스 객체의 일부인지 명확하지 않다.

A traditional domain model is a web of interconnected classes. It doesn’t explicitly specify the boundaries of business objects, such as Consumer and Order.

불분명한 경계 문제

Order 라는 비즈니스 객체는 정확히 무슨 작업을 하는 것이고, 그 범위는 어디까지인지 식별해야 한다. 기본적으로 Order 객체를 조회하거나 어떤 변경을 일으키는 일이 Order 객체의 범위이겠지만, Order 에는 주문 품목, 지불 정보 등 다른 연관된 데이터가 다양하게 존재한다. 따라서 도메인 객체의 경계를 확실하게 파악하는데 이러한 구성은 충분하지 않다.

예를 들어 하나의 오더(주문번호 1건)에 대하여 두 사람(Sam & Mary) 가 동시에 처리하는 상황을 가정(회사에서 직원 두명이 거래처 한곳의 오더를 동시에 수정 처리하는 경우는 흔히 발생된다)하자. 애플리케이션에서는 두 소비자가 각각 DB에서 주문 내역 및 품목을 조회하고, 두 사람은 각각 최소 주문 금액을 유지하면서 최종 주문량을 맞추는 일을 수행할 수 있다. 동시에 두 사람의 트랜잭션이 처리가 된다면 두 사람의 각각 최소 수량을 넘도록 처리하지만 실제로는 최소 주문량보다 적은 주문으로 오더가 변경될 수 있다.

위 트랜잭션의 흐름에서는 두 소비자(Sam 과 Mary)가 동시에 품목 변경을 시도하고 있다. 첫번째 트랜잭션은 주문 및 품목을 불러와서 두번쨰 트랜잭션을 수행하기 이전에 최소 주문 금액 조건이 만족되는 것을 확인했다. 이후 두번째 트랜잭션을 처리할때 Sam은 $X 만큼, Mary는 $Y 만큼 총액을 축소하는 오더 변경을 시도하게 되어 둘 다 처리가 완료된다면 최소 주문 금액 조건을 위배하는 경우가 발생하게 된다. 그러나 트랜잭션 로직 상 최소 주문금액 조건을 이미 통과하였기 때문에 비즈니스 규칙을 위배한 형태로 조건이 업데이트 되게 되는 문제가 발생하게 될 수 있다.

애그리거트 패턴

도메인의 경계를 명확하게 구분하기 위한 방법으로 애그리거트를 사용한다. 애그리거트는 한 단위로 취급 가능한 경계 내부의 도메인 객체로 하나의 루트 엔터티와 하나 이상의 엔터니 + 밸류 객체로 구성된다. 애그리거트는 도메인 모델을 개별적으로 이해하기 쉬운 덩어리(chunk)로 분해하여 로드, 수정, 삭제 같은 작업 범위를 분명하게 설정한다. 애그리거트는 보통 DB에서 통채로 가져오기 때문에 지연 로딩 문제도 자연적으로 해소 가능하게 된다.

패턴: 애그리거트
도메인 모델을 여러 애그리거트로 구성한다. 각 애그리거트는 한 단위로 취급 가능한 객체망이다.

References between aggregates are by primary key rather than by object reference. The Order aggregate has the IDs of the Consumer and Restaurant aggregates. Within an aggregate, objects have references to one another.

하나의 애그리거트에 속한 객체들은 유사하거나 동일한 라이프 사이클을 갖도록 구성된다. 즉, 주문 애그리거트를 생성하게 되면 주문과 관련된 다른 객체들도 함께 생성되고, 함께 제거되는 형태로 적용된다. 하나의 애그리거트에 속한 객체는 다른 애그리거트에 속할 수 없으며, 각 애그리거트는 자기 자신만 관리해야 하며 다른 애그리거트를 관리해서는 안된다. 예를 들어 주문 애그리거트는 '배송지 변경' 혹은 '주문 상품의 수 변경' 등 자기 자신의 정보는 생성, 변경 하지만 회원의 비밀번호를 변경하지는 않아야 한다.

애그리거트 규칙

일관성 문제를 해소하기 위한 애그리거트는 그 경계 그리고 루트를 식별하는 것이 핵심이다. 애그리거트는 정해진 규칙을 준수하여 설계한다.

  • 규칙 #1: 애그리거트 루트만 참조해해야 한다. 외부 클래스는 반드시 에그리거트 루트 엔티티만 참조할수 있게 제한하도록 하여 자신의 불변 값을 강제할 수 있게 한다.
  • 규칙 #2: 애그리거트 간 참조는 반드시 기본 키를 사용해야 한다. 객체 참조 대신 기본 를 사용하게 되면 애그리거트 간에는 느슨하게 결합되고 경계가 분명해지기 때문에, 다른 애그리거트를 업데이트 하는 일이 발생되지 않는다. 또한 애그리거트는 그 자체가 저장 단위가 되므로 저장 로직도 간단해지고, NoSQL DB에 애그리거트를 저장하는 것이 더욱 용이해 지며, partitioning/shading을 처리할때도 기본키로 처리가 가능하여 손쉽게 확장이 가능해진다.
  • 규칙 #3: 하나의 트랜잭션은 반드시 하나의 애그리거트만 생성/수정/삭제해야 한다. 이는 특히 NoSQL DB을 사용하는데 매우 적합한 구조가 된다. 이 규칙을 준수하려면 여러 애그리거트를 생성/수정하는 작업을 구현하기가 조금 복잡해지지만, 트랜잭션은 saga 패턴 등으로 구현이 가능할 수 있다. 즉, saga의 각 단계는 정확히 하나의 애그리거트를 생성하거나 수정한다.

A transaction can only create or update a single aggregate, so an application uses a saga to update multiple aggregates. Each step of the saga creates or updates one aggregate.

위 그림은 3개의 트랜잭션으로 이루어진 saga 패턴 예제이다. 1번 트랜잭션은 서비스 A의 애그리거트 X를 생성 또는 업데이트 하고, 2~3번 트랜잭션은 서비스 B의 애그리거트를 생성/업데이트 하지만, 2번 트랜잭션이 애그리거트 Y를, 3번 트랜잭션이 애그리거트 Z를 각각 업데이트 하는 구조이다.
서비스 하나에서 여러 애그리거트에 걸쳐 일관성을 유지하는 다른 방법은 여러 애그리거트를 하나의 트랜잭션으로 업데이트 하는 형태로 서비스 B가 애그리거트 Y, Z를 하나의 트랜잭션으로 업데이트 하는 형태이다. 만약 서비스 B가 local DB를 RDBMS로 구성한다면 애그리거트 Y와 Z를 하나의 트랜잭션으로 업데이트가 가능하겠지만, NoSQL DB를 사용하는 경우에는 현실적으로 어려운 문제이다. 따라서 saga 패턴을 적용하는 것이 합리적이다.

애그리거트의 크기(granularity)

각 애그리거트의 업데이트가 발생될때 직렬화되어 처리하게 되므로, 가능하면 잘게 나뉘어야 애플리케이션의 동시 처리 용량 및 확장성에 유리하다. 그러나 애그리거트 단위로 트랜잭션을 처리하기 떄문에 데이터의 갱신을 원자적으로 처리하기 위해서는 애그리거트의 크기가 더 커져야 할 수도 있다.

An alternative design defines a Customer aggregate that contains the Customer and Order classes. This design enables an application to atomically update a Consumer and one or more of its Orders.

앞에서는 Order 애그리거트와 Consumer 애그리거트를 분리해서 살펴보았으나, 위 그림처럼 Order 애그리거트를 Consumer 애그리거트의 일부로 설계할 수도 있다. 이처럼 Consumer 애그리거트를 크게 잡으면 ConsumerOrder를 원자적으로 업데이트 할 수 있는 장점이 있고, 동일한 고객이 서로 다른 주문을 업데이트하게 되면 트랜잭션이 직렬화되어 처리가 된다. 만약 두 사용자가 동일한 고객의 상이한 주문을 고치려고 한다면 충돌이 발생한다.

예를 들어 상품 상세 페이지에는 상품 상세 정보와 함께 리뷰 내용을 보여주는 경우를 생각해 보자. 언뜻 보기에는 Product 와 Review 가 하나의 애그리거트에 속하는 것 처럼 보일 수 있지만, 이들은 함께 생성되지도, 함께 변경되지도 않으며, 심지어 이들을 변경하는 주체도 각각 다른 것을 확인할 수 있다. 그러므로 이 경우 각 엔티티의 변경이 서로에게 영향을 주지 않기 때문에 서로 다른 애그리거트로 만드는 것이 합리적이다. 이러한 도메인 규칙과 일관성에 대하여 잘 정리된 내용(Aggregate, 애그리거트)이 있어 발췌하여 정리하였다.

출처: Aggregate, 애그리거트

루트 엔티티의 역할은 단순히 애그리거트에 속한 객체를 포함하는 것만이 아니다. 핵심은 일관성이 깨지지 않도록 하는 것이다. 이를 위해 루트 엔티티는 애그리거트가 제공해야 할 도메인 기능을 구현한다. 루트 엔티티가 제공하는 메서드는 도메인 규칙에 따라 애그리거트에 속한 객체의 일관성이 깨지지 않도록 구현해야 한다.

애그리거트 외부에서 애그리거트에 속한 객체를 직접 변경해서는 안 된다. 이는 애그리거트 루트가 강제하는 규칙을 적용할 수 없어 모델의 일관성을 깨는 원인이 된다.

ShippingInfo shippingInfo = order.getShippingInfo();
shippingInfo.setAddress(newAddress);

다음 코드는 루트에서 ShippingInfo 를 가져와 직접 정보를 변경하고 있다. 주문 상태에 상관없이 배송지 정보를 변경하는데, 이는 도메인 규칙을 무시하고 직접 DB 테이블의 데이터를 수정하는 것과 같은 결과를 만든다. 다시 말해, 논리적인 데이터 일관성이 깨지게 된다.

ShippingInfo shippingInfo = order.getShippingInfo();
if (state != OrderState.PAYMENT_WAITING && state != OrderState.PREPARING) {
	throw new IllegalArgumentException();
}
shippingInfo.setAddress(newAddress);

그렇다면 다음과 같이 상태 확인 로직을 Service 에 구현하면 되지 않을까? 이렇게 되면 동일한 검사 로직이 여러 서비스에서 중복으로 구현될 가능성이 높아 유지보수에 좋지 않다. 불필요한 중복을 피하고 루트를 통해서만 도메인 로직을 구현하게 만들려면 도메인 모델에 대해 다음과 같은 요소를 습관적으로 적용해야 한다.

  • 단순히 필드를 변경하는 setter 를 공개 범위로 만들지 않는다.
  • 밸류 타입은 Immutable 하게 구현한다.

공개적인 setter 는 도메인의 의미나 의도를 표현하지 못하고 도메인 로직을 도메인 객체가 아닌 응용이나 표현 영역으로 분산시킨다.
도메인 로직이 한 곳에 응집되지 않으므로 유지 보수에 많은 시간과 노력이 필요하다.

도메인 모델의 엔티티나 밸류에 공개적인 setter 를 넣지 않는 것만으로도 일관성이 깨질 가능성이 줄어든다.
공개적인 setter 를 사용하지 않으면 의미가 드러나는 이름의 메서드를 사용해서 구현할 가능성이 높아진다.

public class Order {
	private ShippingInfo shippingInfo;    
    public void changeShippingInfo(ShippingInfo newShippingInfo) {
    	verifyNotYetShipped();
        setShippingInfo(newShippingInfo);
    }    
    // setter의 접근 허용 범위는 private!
    private void setShippingInfo(ShippingInfo newShippingInfo) {
    	// 밸류가 immutable이면 새로운 객체를 할당해서 값을 변경해야 한다.
        this.shippingInfo = newShippingInfo;
    }
}   

밸류 타입의 내부 상태 변경은 오직 루트를 통해서만 가능해야 한다. 애그리거트 루트가 도메인 규칙을 올바르게만 구현한다면 애그리거트 전체의 일관성을 올바르게 유지할 수 있다.

루트의 기능 구현

root 엔티티는 애그리거트 내부의 다른 객체들과 조합해서 기능을 완성한다. 루트는 기능 구현을 위해 구성요소의 상태를 참조하기도 하지만, 기능 실행을 위임하기도 한다. 이 과정에서, 애그리거트 외부에서 내부 상태를 변경하는 기능을 실행하게 되면 일관성을 무너뜨리는 버그를 만들 수 있다. 이러한 버그가 생기지 않도록 Immutable 하게 구현하는 것이 좋다.

팀 표준이나 구현 기술의 제약으로 인해 불변 객체를 구현할 수 없다면 변경 기능을 패키지나 protected 범위로 한정해서 외부에서 실행할 수 없도록 제한하는 방법도 있다. 보통 한 애그리거트에 속하는 모델은 한 패키지에 속하므로 애그리거트 외부에서 상태 변경을 방지할 수 있다.

트랜잭션 범위

트랜잭션 범위는 작을수록 좋다. 한 트랜잭션이 하나의 테이블을 수정하는 것과 세 개의 테이블을 수정하는 것을 비교하면 성능에서 차이가 발생한다. 여러 개의 테이블을 수정하는 것은 잠금 대상이 더 많아짐을 의미하고, 그만큼 동시에 처리할 수 있는 트랜잭션 개수가 줄어드는 것을 의미한다. 이것은 당연하게도 처리량, 즉 전체적인 성능을 떨어뜨린다.
한 트랜잭션에서는 한 개의 애그리거트만 수정해야 한다. 한 트랜잭션에서 두 개 이상의 애그리거트를 수정하면 트랜잭션 충돌이 발생할 가능성이 높아지므로 전체 처리량이 떨어지게 된다.
이는 곧, 애그리거트에서 다른 애그리거트를 변경하지 않는다는 것을 의미한다. 한 애그리거트에서 다른 애그리거트를 수정하면 결과적으로 두 개의 애그리거트를 한 트랜잭션에서 수정하게 되므로, 애그리거트 내부에서 다른 애그리거트의 상태 변경 기능을 실행해서는 안 된다.

여러 번 강조해서 얘기하지만,

  • 한 트랜잭션에서는 하나의 애그리거트만 수정해야 하며, 한 애그리거트는 자신이 아닌, 다른 애그리거트를 변경해서는 안된다.
  • 자기 자신이 아닌 다른 애그리거트의 상태 변경은 결과적으로 한 트랜잭션에서 여러 애그리거트를 수정하는 것을 의미한다.
  • 애그리거트 내부에서 다른 애그리거트의 상태를 변경하는 기능을 실행하면 안 된다.

자신이 속한 애그리거트가 아닌, 다른 애그리거트의 상태를 변경하는 것은 다음과 같은 이유로 사용을 금지한다.

  • 자신의 책임 범위를 넘어 다른 애그리거트의 상태까지 관리하는 꼴!
  • 하나의 트랜잭션에서 여러 애그리거트를 수정하기 때문에 성능 저하를 유발한다.
  • 애그리거트는 최대한 독립적이어야 하는데, 다른 애그리거트의 기능에 의존하면 애그리거트 간의 결합도가 높아진다.
  • 결합도가 높아질수록 향후 수정 비용이 증가한다.
    부득이하게 한 트랜잭션으로 두 개 이상의 애그리거트를 수정해야 한다면 어떻게 해야 할까?
    애그리거트에서 직접 수정하지 말고, Service 에서 두 애그리거트를 수정하도록 구현하면 된다.
public class ChangeOrderService {  
  	// 두 개 이상의 애그리거트를 변경해야 할 경우, service에서 각 애그리거트의 상태를 변경하자.
  	@Transactional
  	public void changeShippingInfo(OrderId id, ShippingInfo newShippingInfo, boolean useNewShippingAddressAsMemberAddr) {
  		Order order = orderRepository.findbyId(id);
  		if (order == null) throw new OrderNotFoundException();
  		order.shipTo(newShippingInfo);
  		if (useNewShippingAddressAsMemberAddr) {
  			Member member = findMember(order.getOrder());
  			member.changeAddress(newShippingInfo.getAddress());
  		}
  	}
}  

도메인 이벤트를 사용하면 한 트랜잭션에서 하나의 애그리거트를 수정하면서 동기 · 비동기로 다른 애그리거트의 상태를 변경할 수 있다. 이에 대한 내용은 뒤에서 다루도록 하겠다.
한 트랜잭션에서 한 개의 애그리거트만을 변경할 것을 권장하지만, 다음 경우에는 둘 이상의 애그리거트 변경을 고려할 수 있다.

  • 팀 표준: 팀이나 조직의 표준에 따라 사용자 유스케이스와 관련된 서비스의 기능을 한 트랜잭션에서 실행해야 하는 경우
  • 기술 제약: 기술적으로 이벤트 방식을 도입할 수 없는 경우
  • UI 구현의 편리: 운영자의 편리함을 위해 주문 목록 화면에서 여러 주문의 상태를 한 번에 변경하고 싶은 경우

Repository와 Aggregate

Aggregate 는 개념상 완전한 한 개의 도메인 모델을 표현하므로 객체의 영속성을 처리하는 Repository 는 애그리거트 단위로 존재한다.
새로운 애그리거트를 만들면 저장소에 영속화해야 하고, 사용하려면 저장소에 읽어와야 한다.
때문에 리포지토리는 보통 다음의 두 메서드를 기본으로 제공한다.

  • save
  • findById

두 메서드 외에 필요에 따라 다양한 조건으로 애그리거트 검색 메서드나 삭제 메서드를 추가할 수 있다.
어떤 기술을 이용해서 리포지토리를 구현하느냐에 따라서 애그리거트의 구현도 영향을 받는다. JPA를 사용하면 데이터베이스 관계형 모델에 객체 도메인 모델을 맞춰야 할 때도 있다. 특히, 레거시 DB, 팀 내 DB 설계 표준을 따라야 하는 경우에는 DB 테이블 구조에 맞게 모델을 변경해야 한다.
애그리거트는 개념적으로 하나이므로 Repository 는 애그리거트 전체를 저장소에 영속화해야 한다.

// repository에 aggregate를 저장하면 aggregate 전체를 영속화해야 한다.
orderRepository.save(order);

애그리거트를 구하는 메서드는 완전한 애그리거트를 제공해야 한다. 완전한 애그리거트를 제공하지 않으면 필드가 값이 올바르지 않아 기능 실행 도중에 NPE 같은 문제가 발생할 수 있다.

// repository는 완전한 order를 제공해야 한다.
Order order = orderRepository.findById(orderId);
// order가 온전한 애그리거트가 아니면, 실행 도중에 NPE같은 문제가 발생한다.
order.cancel();

애그리거트를 영속화할 저장소로 어떤 기술을 사용하든지 간에 애그리거트 상태가 변경되면 모든 변경을 원자적으로 반영해야 한다. 원자적으로 반영되지 않으면 데이터의 일관성이 깨지는 문제가 발생한다.

  • RDBMS를 이용한 구현은 트랜잭션을 이용해 변경의 원자적 변경을 보장할 수 있다.
  • mongoDB를 이용한 구현은 하나의 애그리거트를 하나의 문서에 저장함으로써 변경의 손실을 방지할 수 있다.

비즈니스 로직 설계: 애그리거트

마이크로서비스의 비즈니스 로직은 대부분 애그리거트로 구성되고 나머지는 도메인 서비스와 saga에서 구현된다. Saga는 로컬 트랜잭션을 오케스트레이션하여 데이터 일관성을 맞추고, 인바운드 어댑터는 비즈니스 로직의 진입점인 서비스를 호출한다. 서비스는 리포지토리로 DB에서 애그리거트를 조회하거나 저장는 역할을 담당하며, 리포지터리는 각각 DB에 접근하는 아웃바운드 어댑터로 구현된다.

An aggregate-based design for the Order Service business logic

비즈니스 로직은 Order 애그리거트, OrderService, OrderRepository와 하나 이상의 saga로 구성되며, OrderServiceOrderRepository를 이용하여 Order를 조회/저장한다. 주문 서비스에 국한된 간단한 요청은 Order 애그리거트를 직접 업데이트하고, 여러 서비스에 걸친 업데이트 요청은 saga를 생성해서 처리한다.

도메인 이벤트

DDD 맥락에서의 도메인 이벤트는 애그리거트에 발생한 사건이다. 도메인 이벤트는 클래스로 표현되며, 어떤 상태의 변경을 나타낸다. 예를 들어 Order 애그리거트에서 주문이 CREATED, CANCELED, DELIVERED 등으로 상태가 바뀌는 이벤트가 발생한다. 즉, 애그리거트의 상태가 전이될때마다 이벤트를 발행하고, 해당 이벤트를 구독하는 다른 애그리거트에서 해당 이벤트에 맞는 처리를 진행하게 된다.

패턴: 도메인 이벤트
애그리거트는 뭔가 생성되거나, 중요한 변경이 발생했을 때 도메인 이벤트를 발행한다.

이벤트는 다른 사용자, 애플리케이션 또는 다른 컴포넌트 등 해당 애그리거트의 상태 변경을 파악하여 그에 맞는 행동을 취하기 위해 이벤트를 모니터링하고 있으며, 해당 모니터링하는 구성원에 상태 변경을 통보하게 된다.

  • 코레오그래피 사가를 이용하여 여러 서비스에 걸쳐 데이터 일관성을 유지
  • 레플리카를 둔 서비스에 소스 데이터가 변경되었음을 알림
  • 미리 등록된 웹훅이나 메시지 브로커를 통해 비즈니스 프로세스의 다음 단계를 진행하도록 다른 애플리케이션에 알림
  • 사용자 브라우저에 웹 소켓 메시지를 보내거나, 엘라스틱 서치 같은 텍스트 DB를 업데이트하기 위해 같은 애플리케이션의 다른 컴포넌트에 알림
  • 텍스트나 이메일로 사용자에게 알림. (주문한 상품이 배달되었다. 처방전이 준비되었다 등)
  • 애플리케이션이 제대로 작동되고 있는지 도메인 이벤트를 모니터링하면서 확인
  • 사용자 행동을 모델링하기 위해 이벤트를 분석

도메인 이벤트란?

도메인 이벤트는 과거 분사형 동사로 명명한 클래스로, 이벤트에 의미를 부여하는 프로퍼티가 있는데, 원시 값 (primitive value) 또는 값 객체 (value object)이며, 예를 들어 OrderCreated 이벤트 클래스는 orderId라는 프로퍼티를 가지고 있으며, eventId, timestamp 등과 같은 메타 데이터도 포함하며, 변경을 일으킨 사용자의 신원 정보 등을 추가하여 감사(audit)에 활용하기도 한다.

    // OrderCreatedEvent 클래스와 DomainEventEnvelope 인터페이스
	interface DomainEvent();    
    interface OrderDomainEvent extends DomainEvent();    
    class OrderCreatedEvent implements OrderDomainEvent();    
    interface DomainEventEnvelope <T extends DomainEvent> {
    	String getAggregatedId();
        Message getMessage();
        String getAggregateType();
        String getEventId();        
        T getEvent();
    }
  • DomainEvent: 자신을 구현한 클래스가 도메인 이벤트임을 알리는 마커 인터페이스
  • OrderDomainEvent는 DomainEvent 인터페이스를 상속한 Order 애그리거트가 발행한 OrderCreatedEvent의 마커 인터페이스
  • DomainEventEnvelope: 이벤트 객체 및 메타데이터를 조회하는 메서드가 있으며, DomainEvent를 상속한 매개변수화 객체를 받음

Marker Interface에 대한 설명이 잘 나온 내용([Java]✏️마커 인터페이스에 대하여.) 가 있어 발췌하였다.

1) 마커 인터페이스(Marker Interface) 란?

A marker interface is an interface that has no methods or constants inside it.

 마커 인터페이스란 위에서 살펴본 바와 같이 인터페이스 내부에 상수도, 메소드도 없는 인터페이스를 말합니다. 아무 내용도 없어서 쓸모가 없어 보이지만 마커 인터페이스는 객체의 타입과 관련된 정보를 제공해줍니다. 따라서 컴파일러와 JVM은 이 마커 인터페이스를 통해 객체에 대한 추가적인 정보를 얻을 수 있게 되죠.

 마커 인터페이스의 대표적 예시로는 Cloneable, Serializable 인터페이스가 있습니다. 

 위 정보만으로는 마커 인터페이스가 무엇인지에 대한 감이 전혀 오지 않으니 실제 활용 사례를 Serializable 인터페이스를 통해서 알아보도록 하겠습니다.

2) 마커 인터페이스의 활용 예시

아래의 코드를 살펴봅시다! 주의 깊게 봐야 하는 곳은 9번 라인과 22번 라인입니다.

9번 라인의 경우 객체를 파일로 저장하려는 곳입니다.
22번 라인의 경우 Serializable 인터페이스를 구현하지 않은 임의의 오브젝트입니다.

import java.io.*;
public class MarkerInterfaceTest {
    public void serializableTest() throws IOException {
        File f= new File("test.txt");
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream(f));
        // 이 부분이 객체를 파일로 저장하는 부분!
        objectOutputStream.writeObject(new SomeObject("kjhoon", "kjhoon0330@snu.ac.kr"));
    }
    public static void main(String[] args) {
        MarkerInterfaceTest t = new MarkerInterfaceTest();
        try {
            t.serializableTest();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
class SomeObject {
    private String name;
    private String email;
    public SomeObject(String name, String email) {
        this.name = name;
        this.email = email;
    }
}

위 코드를 실행시킬 시 아래와 같이 NotSerializableException 에러 메시지가 뜨게 되죠.
직렬화가 불가능하다는 것인데 위 코드의 일부분을 아래와 같이 고치면 에러를 해결할 수 있습니다.

class SomeObject implements Serializable { // Serializable 인터페이스 구현
        // 생략
}

즉, SomeObject 객체가 Serializable 인터페이스를 구현하면 되는 것이죠. 또한 Serializable 인터페이스는 마커 인터페이스이기 때문에 아무 메소드도 구현해줄 필요가 없습니다.

 하지만.. 단지 이것만으로 에러가 어떻게 해결된 것인지 이해하기가 어렵습니다.. 이에 대해 좀 더 알아보기 위해 writeObject의 코드 중 일부를 확인해보죠!

ObjectOutputStream의 writeObject 함수 내부

 빨간 상자 안을 살펴보면 obj가 Serializable의 인스턴스인지에 대한 여부를 체크하고 있고, 그렇지 않으면 아래 코드에서 에러를 발생시키는 것을 알 수 있습니다. 즉, Serializable 이라는 마커 인터페이스를 활용하여 코드를 진행시켰음을 알 수 있다. 이러한 방식을 통해 아무 내용도 없는 마커 인터페이스가 객체에 추가적인 정보를 주입시킨다는 것을 알 수 있죠.
여기까지 마커 인터페이스가 어떻게 활용되었는지를 살펴보았습니다!

이벤트 강화

주문 이벤트를 처리하는 컨슈머를 작성한다고 가정해보자. 발생한 일은 OrderCreatedEvent 클래스에 고스란히 담겨 있지만, 이벤트 컨슈머가 이 이벤트를 받아 처리하려면 주문 내역이 필요하게 된다. 이러한 정보를 OrderService에서 직접 조회하는 것도 가능하지만, 이벤트 컨슈머가 서비스를 추가적으로 쿼리해서 애그리거트를 조회하는 것 보다는 필요한 주요 정보를 이벤트에 포함해서 제공하여 서비스를 조회하는 오버헤드를 줄일 수 있다.

class OrderCreatedEvent implements OrderEvent{
	private List<OrderLineItem> lineItems;
    private DeliveryInformation deliveryInformation; // 컨슈머가 필요로 하는 데이터
    private PaymentInformation paymentInformation;
    private long restaurantId;
    private String restaurantName;
}

주문한 내역이 이미 OrderCreatedEvent 있기 때문에 주문 이력 서비스 같은 이벤트 컨슈머는 따로 데이터를 조회할 필요 없이 비즈니스 로직을 처리할 수 있게 된다. 그러나 이벤트 강화 기법은 컨슈머의 오버헤드를 줄이고 처리를 단순화하는 장점이 있지만, 컨슈머의 요건이 바뀌면 이벤트 클래스도 바뀌어야 하므로 이벤트 클래스의 안정성이 떨어지는 문제가 있다.

도메인 이벤트 식별

도메인 이벤트 식별은 Pivotal의 DDD 관련 강의를 통해 소개한다. 세부 사항은 마이크로서비스 개발을 위한 Domain Driven Design 강의나, 박재성님이 KCD2020에서 발표한 도메인 지식 탐구를 위한 이벤트 스토밍(Event Storming) 자료 및 강의, 그리고 Junha Baek 님의 EventStorming 시리즈 를 참고하면 좋겠다.

이벤트 브레인스토밍(Event brainstroming)

Event Brainstorming을 위한 준비물 - 출처:https://leanpub.com/introducing_eventstorming/read_sample

  • 주황색 노트: Domain event로 Item Added to Cart 등의 이벤트임
    • 과거형으로 표시하며, 비즈니스 적으로 발생된 것들을 표기
    • Domain experts 와 IT developers가 동통으로 이해할 수 있는 용어 사용
    • 유형: Notification, State transfer
  • 파란색 노트: Command로 Add Item to Cart 등을 나타냄
    • 이벤트를 생성하게 되는 명령
    • 이벤트로 인해 발생된 명령을 처리
  • 노란색 노트: Aggregate로 Item, Product 등을 구성되는 트랜잭션 단위
    • 트랜잭션의 단위를 구성하며, Item의 ID, 수량, 품목 등 정보를 포함
    • entity 와 value 들을 포함
    • Aggregate 간에는 eventually consitency를 보장해야 함
    • Aggregate 간의 참조는 object reference가 아닌 Id를 통해 접근해야 함(loosely coupled로 구성)
  • 자주색 노트: 외부 시스템(SMTP, SMS 알람 등)

Event Brainstorming을 결과 - 출처:도메인 지식 탐구를 위한 이벤트 스토밍(Event Storming)

애그리거트 식별

이벤트를 식별한 결과를 활용하여 각 이벤트를 grouping 한다. Grouping에는 data ownership(어떤 팀이 어떤 데이터를 처리해야 하는가?), 배포 관련(lifecycle, 배포 주기 등), 독립적 확장성(서비스가 정의되면 해당 단위로 확장, 축소가 진행됨) 등을 고려해서 grouping이 필요하다. Grouping을 처리한 결과로는 애그리거트가 도출될 수 있게 된다.

Aggregate 식별 결과 - 출처:도메인 지식 탐구를 위한 이벤트 스토밍(Event Storming)

위 그림에서는 강의, 결제, 결제시도 라는 애그리거트가 도출되었다. 애그리거트가 도출되면 bounded context 기준으로 분류한다. 하나의 bounded context는 하나 이상의 애그리거트를 가지고 있으며, 여러 애그리거트가 통합된 bounded context가 만들어질 수 있다. 예를 들어 주식 주문이라는 bounded context는 매도와 매수를 통합하여 처리하는 bounded context로 관리할 수도 있고, 혹은 매도와 매수를 각각 관리하도록 만들수도 있다.

도메인 이벤트 생성 및 발행

도메인 이벤트는 애그리거트가 발행한다. 애그리거트는 자신의 상태가 변경되는 시점과, 발행해야 할 이벤트를 알고 있다. 그러나 애그리거트가 직접 상태 변경 메시지 API를 호출하면 인프라와 비즈니스 로직이 혼재되는 문제가 생길 수 있다. 따라서 애그리거트와 호출하는 서비스를 분리하여 책임 분리를 하는 것이 좋다. 이렇게 책임을 분리하는 방법으로 두가지 방법을 도입할 수 있다.

애그리거트 반환값에 이벤트 생성

애그리거트는 자신의 상태가 변경될때(상태 전이) 필요한 이벤트를 생성하고, 생성된 이벤트를 응답으로 반환하여 애그리거트 루트에서 이벤트를 발행하는 형태이다. accept() 메소드는 기본적으로 응답값이 없어도 무관한 내용이었으나(내부 상태만 변경), 이벤트를 발행하기 위해 List<Event>를 응답값으로 반환하는 로직의 변경이 발생되었다.

public class Ticket {
    public List<TicketDomainEvent> accept(LocalDateTime readyBy) {
		...
       this.acceptTime = LocalDateTime.now(); // Ticket의 데이터 업데이트
       this.readyBy = readyBy; 
       return singletonList(new TicketAcceptedEvent(readyBy)); // 이벤트 반환
    }
}

위 코드는 Ticketaccept() 함수에서 응답값으로 TicketAcceptedEvent instance를 새로 생성해서 반환한다. 주방 서비스는 Ticket.accept()를 호출하고 이벤트를 발행하게 된다.

@Transactional
public class KitchenService {
  @Autowired
  private TicketRepository ticketRepository;
  @Autowired
  private TicketDomainEventPublisher domainEventPublisher;
  public void accept(long ticketId, LocalDateTime readyBy) {
  	// accept 메소드는 ticketId로 repository를 조회 후 ticket이 없으면 exception 생성
    Ticket ticket = ticketRepository.findById(ticketId)
            .orElseThrow(() -> new TicketNotFoundException(ticketId));
    // ticket이 있으면 해당 ticket의 accept() 메소드를 호출
    List<TicketDomainEvent> events = ticket.accept(readyBy); // accept 메소드에서 응답으로 event 객체를 반환 받음
    domainEventPublisher.publish(ticket, events); // accept 메소드에서 응답한 도메인 이벤트를 publisher를 통해 발행
  }
}

애그리거트 루트에 이벤트 등록

이벤트가 발생할때 애그리거트 루트에 이벤트를 등록하고, 서비스가 등록된 이벤트를 조회하여 발행하는 방식이 있다.

public class Ticket extends AbstractAggregateRoot {
    public void accept(LocalDateTime readyBy) {
		...
       this.acceptTime = LocalDateTime.now(); // Ticket 업데이트
       this.readyBy = readyBy;
       registerEvent(new TicketAcceptedEvent(readBy)); // Ticket을 생성하고 상위 클래스인 AbstrctAggregateRoot에 정의된 registerEvent를 호출하여 이벤트 등록 
    }
}

상위 클래스인 AbstractAggregateRootregisterEvent() 라는 메소드를 구현하고 있으며, 서비스는 AbstractAggregateRoot에 구현되어 있는 domainEvents() 메소드를 호출해서 이벤트를 가져와서 발행한다. 두번째 방식은 AbstractAggregateRoot 클래스를 서비스가 상속 받아야 하지만, Java는 하나의 클래스만 상속이 가능하므로 다른 클래스를 상속받아야 하는 요건이 있다면 적합하지 않을 수 있다.

도메인 이벤트의 확실한 발행

메시지를 로컬 DB 트랜잭션의 일부로 확실하게 전달하는 방법으로 트랜잭셔널 메시징을 이용하는 것이 필요하다. 서비스는 DB에서 애그리거트를 업데이트하는 트랜잭션의 일부로 이벤트를 발행하기 위해 트랜잭셔널 메시징을 사용하며, DB 업데이트 트랜잭션의 일부로 이벤트를 DB테이블에 삽입하고, 트랜잭션이 커밋되면 이 테이블에 삽입된 이벤트를 메시지 브로커에서 발행하는 형태로 구현이 가능하다.

도메인 이벤트의 소비

도메인 이벤트는 결국 메시지로 변환되며 메시지는 broker를 통하든 혹으 직접이든 컨슈머에 전달하게 된다. 따라서 서비스는 이벤트를 유형에 맞춰 수신하는 이벤트 핸들러 등을 구현해야 한다.

주방 서비스 설계

주방서비스

주방 서비스는 음식점이 주문을 관리할 수 있게 하는 서비스로 Restaurant 애그리거트와 Ticket 애그리거트를 메인으로 사용한다.

  • Restaurant Aggregate: 음식점 매뉴 및 운영 시간을 알고 있는 상태에서 주문을 검증
  • Ticket Aggregate: 배달원이 픽업할 수 있게 음식점이 미리 준비해야 할 주문을 표시
    주방 서비스(KitchenService)는 2개의 애그리거트(Restaurant, Ticket)와 TicketRestaurant 애그리거트를 생성/수정하는 메소드 및 TicketRepository, RestaurantRepository를 통해 데이터를 저장하는 메소드를 구현해야 한다.

인바운드 어댑터

  • REST API: 음식점 점원이 사용하는 UI가 호출하는 REST API로 KitchenService를 호출하여 Ticket을 생성 또는 수정을 수행
  • KitchenServiceCommandHandler: Saga가 호출하는 비동기 요청/응답 API로 KitchenService를 호출하여 Ticket을 생성 또는 수정
  • KitchenServiceEventConsumer: RestaurantService가 발행한 이벤트를 구독하면서 KitchenService를 호출하여 Restaurant을 생성 또는 수정

아웃바운드 어댑터

  • DB 어댑터: TicketRepository, RestaurantRepository 인터페이스를 구현하여 DB에 접근
  • DomainEventPublishingAdapter: DomainEventPublisher 인터페이스를 구현하여 Ticket 도메인 이벤트를 발행
    The design of Kitchen Service

Ticket 애그리거트

Ticket 애그리거트는 음식점 주방 관점의 주문을 나타낸다. 주방에서는 소비자나 배달 등 음식을 만드는데 불필요한 정보는 불필요하며, 주문된 음식을 만들어 픽업까지 수행하는 역할에만 충실하게 구현한다. Ticket 클래스는 다음처럼 구현된다. RestaurantId 값은 객체 참조가 아니라 repository의 key 값으로 구현한다.

@Entity(table = "tickets")
public class Ticket {

    @Id
    private Long id;

    @Enumerated(EnumType.STRING)
    private TicketState state;
    
    private TicketState previousState;
    
    private Long restaurantId; // 객체 참조(주소)가 아닌 repository에 저장된 key

    @ElementCollection
    @CollectionTable(name = "ticket_line_items")
    private List<TicketLineItem> lineItems;

    private LocalDateTime readyBy;
    private LocalDateTime acceptTime;
    private LocalDateTime preparingTime;
    private LocalDateTime pickedUpTime;
    private LocalDateTime readyForPickupTime;

    public static ResultWithAggregateEvents<Ticket, TicketDomainEvent>     
		create(Long id, TicketDetails details) {
			return new ResultWithAggregateEvents<>(new Ticket(id, details), new  TicketCreatedEvent(id, details));
    }
    
	public List<TicketDomainEvent> accept(LocalDateTime readyBy) {
		switch (state) {
            case AWAITING_ACCEPTANCE: 
                this.acceptTime = LocalDateTime.now();
                if (!acceptTime.isBefore(readyBy))
                    throw new IllegalArgumentException("readyBy is not in the future");
                this.readyBy = readyBy;
                return singletonList(new TicketAcceptedEvent(readyBy));
            default:
                throw new UnsupportedStateTransitionException(state);
        }
    }
	public List<TicketDomainEvent> pickedUp() {
		switch (state) {
			case READY_FOR_PICKUP:
 				this.state = TicketState.PICKED_UP;
				this.pickedUpTime = LocalDateTime.now();
				return singletonList(new TicketPickedUpEvent());
			default:
		throw new UnsupportedStateTransitionException(state);
		}
	}
  
	public List<TicketPreparationStartedEvent> preparing() { 
  		switch (state) {
			case ACCEPTED:
				this.state = TicketState.PREPARING;
				this.preparingTime = ZonedDateTime.now();
				return singletonList(new TicketPreparationStartedEvent());
			default:
				throw new UnsupportedStateTransitionException(state);
		} 
    }
	public List<TicketDomainEvent> cancel() { 
    	switch (state) {
			case CREATED: 
            case ACCEPTED:
				this.state = TicketState.CANCELLED;
				return singletonList(new TicketCancelled()); 
            case READY_FOR_PICKUP:
				throw new TicketCannotBeCancelledException();
			default:
				throw new UnsupportedStateTransitionException(state);
		}
	}

KitchenService 서비스는 인바운드 어댑터가 호출하며, 주문의 상태를 변경하는 accept(), reject(), preparing() 등이 메소드가 각각의 애그리거트를 가져와서 애그리거트 루트에 있는 메소드를 호출하여 도메인 이벤트를 발행한다. accept() 메소드는 새 주문을 받을때 orderIdreadyBy 값을 인자로 받으며, 이 메소드가 다시 Ticket 애그리거트의 accept()를 호출하고 이벤트를 발행하게 된다.

public class KitchenService {
  @Autowired
  private TicketRepository ticketRepository;
  
  @Autowired
  private TicketDomainEventPublisher domainEventPublisher;
  public void accept(long ticketId, ZonedDateTime readyBy) {
    Ticket ticket =
          ticketRepository.findById(ticketId)
            .orElseThrow(() ->
                      new TicketNotFoundException(ticketId));
    List<TicketDomainEvent> events = ticket.accept(readyBy);
    domainEventPublisher.publish(ticket, events); // 도메인 이벤트를 실제로 발행한다.
  }
}

KitchenServiceCommandHander 클래스는 주문 서비스에 구현된 saga가 전송한 커맨드 메시지를 처리하는 어댑터로, KitchenService를 호출하여 Ticket을 생성 또는 수정하는 핸들러 메소드를 커맨드별로 정의한다.

  public class KitchenServiceCommandHandler {
      @Autowired
      private KitchenService kitchenService;
      public CommandHandlers commandHandlers() { // 커맨드 메시지를 핸들러에 매핑
          return CommandHandlersBuilder
              .fromChannel("orderService")
              .onMessage(CreateTicket.class, this::createTicket)
              .onMessage(ConfirmCreateTicket.class, this::confirmCreateTicket)
              .onMessage(CancelCreateTicket.class, this::cancelCreateTicket)
              .build();
  private Message createTicket(CommandMessage<CreateTicket> cm) {
      CreateTicket command = cm.getCommand();
      long restaurantId = command.getRestaurantId();
      Long ticketId = command.getOrderId();
      TicketDetails ticketDetails = command.getTicketDetails();
      try {
          // KitchenService를 호출하여 Ticket 생성
          Ticket ticket = kitchenService.createTicket(restaurantId, ticketId, ticketDetails); 
          CreateTicketReply reply = new CreateTicketReply(ticket.getId());
          return withSuccess(reply); // 정상 처리 응답 발송
      } catch (RestaurantDetailsVerificationException e) {
          return withFailure(); // 처리 실패 응답 발송
      } 
  } 
  private Message confirmCreateTicket(CommandMessage<ConfirmCreateTicket> cm) { // 주문 확정
      Long ticketId = cm.getCommand().getTicketId();
      kitchenService.confirmCreateTicket(ticketId);
      return withSuccess();
  }
...

주문 서비스 설계

주문 서비스는 주문을 생성, 수정, 취소하는 API를 제공하는 서비스로 고객으로부터의 주문을 처리하게 된다. OrderService, OrderRepository, RestaurantRepository, CreateOrderSaga 등으로 구성되며, OrderService는 비즈니스 로직의 진입점이며, RestAPI, DB어댑터, Saga 처리 어댑터 등으로 구성된다.

인바운드 어댑터

  • REST API: 컨슈머가 사용하는 UI가 호출하는 REST API, OrderService를 이용하여 Order 생성/수정
  • OrderEventConsumer : 음식점 서비스가 발행한 이벤트를 구독하며, OrderService를 호출하여 Restarant 레플리카를 생성/수정
  • OrderCommandHandler: saga가 호출하는 비동기 요청/응답 기반의 API로 OrderService를 호출하여 Order를 수정
  • SagaReplyAdapter: saga 응답 채널을 구독하고 saga를 호출

아웃바운드 어댑터

  • DB 어댑터: OrderRepository 인터페이스를 구현하여 주문 서비스 DB에 접근
  • DomainEventPublishingAdapter: DomainEventPublisher 인터페이스를 구현하여 Order 도메인 이벤트를 발행
  • OutboundCommandMessageAdapter: CommamdPublisher 인터페이스를 구현한 클래스로, 커맨드 메시지를 saga 참여자들에게 발송

The design of Order Service. It has a REST API for managing orders. It exchanges messages and events with other services via several message channels.

Order 애그리거트

Order 애그리거트는 소비자가 한 주문을 나타낸다. Order 클래스가 애그리거트 루트가 되며, OrderLineItem(주문 상세 품목), DeliveryInfo, PaymentInfo 등 밸류 객체를 포함한다. ConsumerRestaurant는 다른 애그리거트이므로 기본 키 값을 이용하여 상호 참조해야 한다. DeliveryInfoPaymentInfo는 밸류가 아닌 클래스이다.

The design of the Order aggregate, which consists of the Order aggregate root and various value objects.

@Entity
@Table(name = "orders")
@Access(AccessType.FIELD)
public class Order {

  @Id
  @GeneratedValue
  private Long id;

  @Version
  private Long version;

  @Enumerated(EnumType.STRING)
  private OrderState state;

  private Long consumerId;
  private Long restaurantId;

  @Embedded
  private OrderLineItems orderLineItems;

  @Embedded
  private DeliveryInformation deliveryInformation;

  @Embedded
  private PaymentInformation paymentInformation;

  @Embedded
  private Money orderMinimum = new Money(Integer.MAX_VALUE);
}

이 클래스는 id 필드를 기본키로 ORDERS 테이블에 매핑되고, version 필드는 낙관적 잠금(optimistic locking)을 할때 사용되며, Order의 상태는 OderState라는 enum으로 표시한다.

Order 애그리거트 상태기계

OrderService는 다른 서비스들과 saga로 협동을 수행한다. 기보적으로 pending 상태를 활용하며, pending 상태는 symantic lock에 활용할 수 있다. 오더가 생성, 취소, 변경 작업 중에도 pending 상태를 활용하여 해당 작업이 처리 가능한지 확인하고, 처리가 완료되면 상태를 그 다음에 처리해야 할 비즈니스 로직에 따른 상태로 전이시킨다.

Part of the state machine model of the Order aggregate

Order 애그리거트의 주문 생성 관련 메소드

@Entity
@Table(name = "orders")
@Access(AccessType.FIELD)
public class Order {

  public static ResultWithDomainEvents<Order, OrderDomainEvent>
  createOrder(long consumerId, Restaurant restaurant, List<OrderLineItem> orderLineItems) {
    Order order = new Order(consumerId, restaurant.getId(), orderLineItems);
    List<OrderDomainEvent> events = singletonList(new OrderCreatedEvent(
            new OrderDetails(consumerId, restaurant.getId(), orderLineItems,
                    order.getOrderTotal()),
            restaurant.getName()));
    return new ResultWithDomainEvents<>(order, events);
  }

  public Order(long consumerId, long restaurantId, List<OrderLineItem> orderLineItems) {
    this.consumerId = consumerId;
    this.restaurantId = restaurantId;
    this.orderLineItems = new OrderLineItems(orderLineItems);
    this.state = APPROVAL_PENDING;
  }
  
  public List<OrderDomainEvent> noteApproved() {
    switch (state) {
      case APPROVAL_PENDING:
        this.state = APPROVED;
        return singletonList(new OrderAuthorized());
      default:
        throw new UnsupportedStateTransitionException(state);
    }

  }

  public List<OrderDomainEvent> noteRejected() {
    switch (state) {
      case APPROVAL_PENDING:
        this.state = REJECTED;
        return singletonList(new OrderRejected());

      default:
        throw new UnsupportedStateTransitionException(state);
    }
  }
}
  • createOrder(): 주문을 생성하고 OrderCreatedEvent를 발행하는 정적(static) 팩토리 메소드
  • OrderCreatedEvent: 주문 품목, 총액, 음식점 ID, 음식점명 등 주문 내역이 포함된 강화된 이벤트
    • Order: 처음에 APPROVAL_PENDING 상태로 만들어지고, CreateOrderSaga 완료 시 고객의 신용카드 승인까지 성공하면 noteApproved(), 서비스 중 하나라도 주문을 거부하거나 신용카드 승인이 실패하면 noteRejected()가 호출된다. 이렇듯 Order 애그리거트에 있는 메소드는 대부분 애그리거트 상태에 따라 동작이 결정됨

Order 클래스의 주문 변경 관련 메소드

@Entity
@Table(name = "orders")
@Access(AccessType.FIELD)
public class Order {

  public ResultWithDomainEvents<LineItemQuantityChange, OrderDomainEvent> revise(OrderRevision orderRevision) {
    switch (state) {

      case APPROVED:
        LineItemQuantityChange change = orderLineItems.lineItemQuantityChange(orderRevision);
        if (change.newOrderTotal.isGreaterThanOrEqual(orderMinimum)) {
          throw new OrderMinimumNotMetException();
        }
        this.state = REVISION_PENDING;
        return new ResultWithDomainEvents<>(change, singletonList(new OrderRevisionProposed(orderRevision, change.currentOrderTotal, change.newOrderTotal)));

      default:
        throw new UnsupportedStateTransitionException(state);
    }
  }
  
  public List<OrderDomainEvent> confirmRevision(OrderRevision orderRevision) {
    switch (state) {
      case REVISION_PENDING:
        LineItemQuantityChange licd = orderLineItems.lineItemQuantityChange(orderRevision);

        orderRevision.getDeliveryInformation().ifPresent(newDi -> this.deliveryInformation = newDi);

        if (!orderRevision.getRevisedLineItemQuantities().isEmpty()) {
          orderLineItems.updateLineItems(orderRevision);
        }

        this.state = APPROVED;
        return singletonList(new OrderRevised(orderRevision, licd.currentOrderTotal, licd.newOrderTotal));
      default:
        throw new UnsupportedStateTransitionException(state);
    }
  }
}
  • revise(): 변경된 주문량이 최소 주문량 이상인지 확인하고 문제가 없으면 주문 상태를 REVISION_PENDING으로 변경
  • confirmRevision(): 주방 서비스, 회계 서비스 업데이트가 성공하면 주문 변경 사가가 호출하여 주문 변경을 마무리

OrderService 클래스

@Transactional
public class OrderService {
  
  @Autowired
  private OrderRepository orderRepository;
  
  @Autowired
  private SagaManager<CreateOrderSagaState> createOrderSagaManager;
  
  @Autowired
  private SagaManager<ReviseOrderSagaData> reviseOrderSagaManager;
 
  @Autowired
  private OrderDomainEventPublisher orderAggregateEventPublisher;

  public Order createOrder(long consumerId, long restaurantId,
                           List<MenuItemIdAndQuantity> lineItems) {
    Restaurant restaurant = restaurantRepository.findById(restaurantId)
            .orElseThrow(() -> new RestaurantNotFoundException(restaurantId));

    List<OrderLineItem> orderLineItems = makeOrderLineItems(lineItems, restaurant); // Order 애그리거트 (주문 상품) 생성

    ResultWithDomainEvents<Order, OrderDomainEvent> orderAndEvents =
            Order.createOrder(consumerId, restaurant, orderLineItems); // 주문 생성

    Order order = orderAndEvents.result;
    orderRepository.save(order); // Order를 DB에 저장

    orderAggregateEventPublisher.publish(order, orderAndEvents.events); // 도메인 이벤트 발행
    OrderDetails orderDetails = new OrderDetails(consumerId, restaurantId, orderLineItems, order.getOrderTotal());
    CreateOrderSagaState data = new CreateOrderSagaState(order.getId(), orderDetails);
    createOrderSagaManager.create(data, Order.class, order.getId()); // CreateOrderSaga 생성
    meterRegistry.ifPresent(mr -> mr.counter("placed_orders").increment());
    return order;
  }

  public Order reviseOrder(long orderId, OrderRevision orderRevision) {
    Order order = orderRepository.findById(orderId).orElseThrow(() -> new  OrderNotFoundException(orderId)); // Order 조회
    ReviseOrderSagaData sagaData = new ReviseOrderSagaData(order.getConsumerId(), orderId, null, orderRevision);
    reviseOrderSagaManager.create(sagaData); // ReviseOrderSaga 생성
    return order;
  }
}

OrderService 클래스는 비즈니스 로직의 진입점으로, 주문의 생성, 수정 메소드가 모드 구현된 클래스이다. 이 클래스의 메소드들은 saga를 만들어 Order 애그리거트 생성/수정을 오케스트레이션해야 한다. 또한 OrderRespository, OrderDomainEventPublisher, SagaManager 등 주입되는 의존성이 많아 보다 복잡한 구조이다.
createOrder()는 먼저 Order 애그리거트를 생성/저장 한 이후에 애그리거트가 발생시킨 도메인 이벤트를 발행하고, CreateOrderSaga를 생성하여 이후 트랜잭션이 처리될 수 있도록 한다. reviseOrder()Order를 조회한 후 ReviseOrderSaga 생성역할을 수행한다.

이번 챕터에서 DDD를 기반으로 애그리거트를 도메인 모델을 구성하고, 애그리거트 간에는 객체 참조가 아닌 PK로 참조하는 것을 살펴 보았고, 실제 코드 레벨에서 KitchenService는 saga에 참여할 뿐 saga를 직접 시작하지는 않는다. 반면에 OrderService는 주문을 생성하고 수정하는 등 트랜잭션에 변경이 발생했을때 saga에 의지하여 다른 서비스가 있는 데이터가 트랜잭션 관점에서 일관성이 보장하는 것을 살펴 보았으며, OrderService 메서드는 대부분 직접 Order를 업데이트 하지 않고 saga를 만드는 것을 확인할 수 있다.

profile
다시 시작해보자!

0개의 댓글