[마이크로서비스 패턴] 5. 비즈니스 로직 설계

DaeHoon·2023년 11월 5일
2

5.1 비즈니스 로직 구성 패턴

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

5.1.1 비즈니스 로직 설계: 트랜잭션 스크립트 패턴

  • 비즈니스 로직을 요청 타입별로 하나씩 매핑된 절차적 트랜잭션 스크립트 뭉치로 구성한다.
    • 일반적으로 동작을 하는 클래스: OrderService, OrderDao
    • 상태가 있는 클래스: Order

5.1.2 비즈니스 로직 설계: 도메인 모델 패턴 (객체지향 설계)

  • 비즈니스 로직을 상태와 동작을 가진 클래스로 구성된 객체 모델로 구성한다.
    • 동작만 있는 클래스: OrderService, OrderRepository
    • 상태만 있는 클래스: DeliveryInformation,
    • 도메인 모델:Order
  • 대부분의 클래스는 상태와 동작을 가진다.

트랜잭션 스크립트 패턴을 적용하면 OrderService 클래스는 각 요청 및 시스템 작업마다 하나의 메서드를 갖게 두지만, 도메인 모델 패턴을 적용하면 서비스 메서드가 단순해진다

객체 지향 설계의 장점

  • 설계를 이해/관리하기 쉽다. -> 모두를 관리하는 하나의 거대한 클래스 대신 소수의 책임만 맡은 여러 클래스들로 구성되기 때문
  • 테스트가 쉬움. -> 각 클래스를 독립적으로 테스트할 수 있다.
  • 잘 알려진 설계 패턴을 응용할 수 있기 때문에 확장에 용이 -> 전략 패턴, 템플릿 메서드 패턴을 적용하여 코드를 변경하지 않아도 컴포넌트 확장 가능

5.1.3 도메인 주도 설계 개요

  • DDD는 복잡한 비즈니스 로직을 개발하기 위해 OOD를 개선한 접근 방식
    • 각 서비스는 자체 도메인 모델을 가지며, 애플리케이션 전체 도메인 모델의 문제점을 방지할 수 있다.
    • 하위 도메인과 이와 연관된 경계 컨텍스트(Bounded Context) 개념은 DDD 패턴의 메인 전략들
  • 아래는 DDD에서 도메인 모델을 구축하는 데 흔히 쓰이는 빌딩 블록
    • 엔티티 (Entity): 영속성 신원을 가진 객체. 두 엔티티가 속성 값이 동일해도 엄연히 다른 객체. 자바에서는 JPA에 @Entity를 붙여 DDD 엔티티를 나타냄
    • 값 객체 (Value Object): 여러 값을 모아 놓은 객체. 속성 값이 동일한 두 값 객체는 서로 바꾸어 사용할 수 있다. (예: 통화와 금액으로 구성된 Money 클래스)
    • 팩토리 (Factory): 일반 생성자로 직접 만들기에 복잡한 객체 생성 로직이 구현된 객체 또는 메서드. 인스턴스로 생성할 구상 클래스를 감출 수 있으며, 클래스의 정적 메서드로 구현할 수 있음.
    • 리포지터리 (Repository): 엔티티를 저장하는 DB 접근 로직을 캡슐화한 객체
    • 서비스 (Service): 엔티티, 밸류 객체에 속하지 않은 비즈니스 로직 구현 객체

5.2 도메인 모델 설계: DDD 에그리거트 패턴

  • 전통적인 객체 지향 설계에 기반한 도메인 모델은 클래스 간 관계를 모아노은 것
  • 위의 예제에는 Consumer, Order, Restaurant, Courier 등 비즈니스 객체에 대응되는 클래스가 존재하지만, 비즈니스 객체들의 경계가 불분명하다.
    • 어느 클래스가 Order 라는 비즈니스 객체의 일부인지 분명하지 않음.

5.2.1 불분명한 경계 문제

  • Order 라는 비즈니스 객체에 어떤 작업을 수행한다고 가정하자. 정확히 무슨 작업을 하는 것이고, 그 범위는 어디까지일까? Order 객체를 조회하거느 어떤 변경을 일으키는 일이겠지만, 실제로 이 객체뿐만 아니라 주문 품목, 지불 정보 등 다른 연관된 데이터 베이스도 많다. 즉 5-4 그림만 보아서는 개발자가 도메인 객체의 경계를 확실하게 파악할 수 없다.

  • 여러 소비자가 주문하는 상황에서 최소 주문량의 총족 여부를 어떻게 보장할 수 있을까?
    • 샘과 메리라는 두 소비자가 주문을 하는 동시에 주문이 본인의 예산을 초과했는지 결정한다고 가정해보자.
    • 애플리케이션 관점에서 두 고객은 DB에서 주문 및 품목을 조회한다. 두 사람은 재고를 낮추기 위해 품목을 수정한고 각자 입장에서 보면 재고는 충족된다.

5.2.2 애그리거트는 경계가 분명하다.

  • 에그리거트는 한 단위로 취급 가능한 경계 내부의 도메인 객체. 하나의 루트 엔티티 하나 이상의 기타 엔티티 + 값 객체로 구성된다.
  • 2장에서의 요건 정의서에 적힌 명사를 분석해서 도메인 모델을 생성해 봤을 때, 여기서 주문, 소비자, 음식점 같은 명사가 바로 에그리거트.

애그리거트는 일관된 경계

  • 업데이트 작업은 에그리거트 루트에서 호출 -> 불변 값이 강제됨.
  • 동시성 -> 에그리거트 루트를 (버전, DB 락)을 잠군다음 처리

애그리거트를 식별하는 일이 관건

  • DDD 도메인 모델 설계의 핵심은 애그리거트와 그 경 계, 그리고 루트를 식별하는 것

5.2.3 애그리거트 규칙

규칙 #1: 애그리거트 루트만 참조해라

  • 위에서 orderLineItem을 직접 업데이트하면 왜 위험한지 설명했다. 이를 방지하기 위해서 외부 클래스는 반드시 에그리거트 루트 엔티티만 참조할수 있게 제한해야 한다.

규칙 #2: 애그리거트 간 참조는 반드시 기본 키를 사용해라

  • 객체 참조 대신 레퍼런스 참조를 사용하면, 에그리거트는 느슨하게 결합되고 경계가 분명해지기 때문에, 혹여 실수로 다른 애그리거트를 업데이트할 일이 일어나지 않음.
  • 애그리거트는 그 자체가 저장 단위이므로 저장 로직도 간단해진다. 그래서 MongoDB 같은 NoSQL DB에 애그리거트를 저장하기가 한결 쉽다.

규칙 #3: 하나의 트랜잭션으로 하나의 애그리거트를 수정해라

  • 하나의 트랜잭션으로 오직 하나의 애그리거트만 생성/수정 해야한다.
  • 단 이 규칙을 준수하려면 여러 애그리거트를 생성/수정하는 작업을 구현하기가 조금 복잡해진다. 하지만 사가로 해결 가능한 문제. 사가의 각 단계는 정확히 애그리거트 하나를 생성/수정한다.

  • 1번 트랜잭션은 서비스 A의 애그리거트 X를 업데이트 한다.
  • 2~3번 트랜잭션은 모두 서비스 B에 있고, 2번 트랜잭션이 애그리거트 Y를, 3번 트랜잭션이 애그리거트 Z를 각각 업데이트 한다.
  • 서비스 하나에서 여러 애그리거트에 걸쳐 일관성을 유지하는 또 다른 방법은 여러 애그리거트를 한 트랜잭션으로 업데이트 하는 방법이다. 가령 서비스 B에서 애그리거트 Y,Z를 한 트랜잭션으로 업데이트 하면 된다. 물론 트랜잭션이 잘 지원되는 RDBMS에서 가능하고 NoSQL DB는 사가 외에 별 다른 수단이 존재하지 않는다. (몽고도 트랜잭션 지원이 되는걸로 알긴 하는데)

5.2.4 애그리거트 입도

  • 애그리거트는 작으면 작을 수록 좋음. -> 각 애그리거트의 업데이트는 직렬화되므로 잘게 나뉘어져 있으면 그만큼 애플리케이션이 동시 처리 가능한 요청 개수가 늘고 확장성이 좋아진다.

  • 위 그림처럼 Order 애그리거트를 Consumer 애그리거트의 일부로 설계하는 방법도 있음.
  • Consumer 애그리거트를 크게 잡으면 Consumer 및 하나 이상의 Order를 원자적으로 업데이트 할 수 있으나 확장성이 떨어진다.
    • 동일한 고객이 상이한 주문을 업데이트하는 트랜잭션이 직렬화된다.
    • 마찬가지로 두 사용자가 동일한 고객의 상이한 주문을 고치려고 하면 충돌이 난다.
  • 이러한 이유떄문에 애그리거트는 가급적이면 잘게 나누는 것이 좋다.

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

  • 사가: 로컬 트랜잭션을 오케스트레이션하여 데이터 일관성을 맞춤
  • 인바운드 어댑터: 비즈니스 로직의 진입점인 서비스를 호출함.
  • 서비스: 리포지토리로 DB에서 애그리거트를 조회하거나 저장함.
  • 리포지터리: 각각 DB에 접근하는 아웃바운드 어댑터를 구현함.
  • 비즈니스 로직: Order 애그리거트 OrderService, OrderRepository, 하나 이상의 사가
    • OrderServiceOrderRepository를 이용하여 Order를 조회/저장한다.
    • 주문 서비스에 국한된 간단한 요청은 Order 애그리거트를 직접 업데이트하고, 여러 서비스에 걸친 업데이트 요청은 사가를 생성해서 처리한다.

5.3 도메인 이벤트 발행

  • 이벤트(Event): DDD 맥락에서의 도메인 이벤트는 애그리거트에서 발생한 사건
  • 도메인 이벤트는 도메인 모델에서는 클래스로 표현되며, 대부분 어떤 상태의 변경을 나타냄. 예를 들어 Order 애그리거트라면 주문 생성됨, 주문 취소됨, 주문 배달됨 등 상태가 바뀌는 이벤트가 발생함.

5.3.1 변경 이벤트를 발행하는 이유

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

애플리케이션 DB에서의 애그리거트 상태 전이가 이 모든 상황에서 알림을 트리거하는 장본인

5.3.2 도메인 이벤트란 무엇인가?

  • 도메인 이벤트는 과거 분사형 동사로 명명한 클래스
    • 이벤트에 의미를 부여하는 프로퍼티가 있는데, 원시 값 (primitive value) 또는 값 객체 (value object)
    • OrderCreate 이벤트 클래스에는 orderId 프로퍼티가 존재함
  • DomainEvent: 자신을 구현한 클래스가 도메인 이벤트임을 알리는 마커 인터페이스
    • 이 인터페이스를 상속한 OrderDomainEventOrder 애그리거트가 발행한 OrderCreatedEvent의 마커 인터페이스
  • DomainEventEnvelope: 이벤트 객체 및 메타데이터를 조회하는 메서드가 존재. 이 인터페이스는 DomainEvent를 상속한 매개변수화 객체를 받음
// 예제 5-1 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();
    }

5.3.3 이벤트 강화

  • 주문 이벤트를 처리하는 컨슈머를 작성한다고 가정해보자. 발생한 일은 OrderCreatedEvent 클래스에 고스란히 담겨 있지만, 이벤트 컨슈머가 이 이벤트를 받아 처리하려면 주문 내역이 필요함.
  • 필요한 정보를OrderService에서 직접 가져와도 되지만, 이벤트 컨슈머가 서비스를 쿼리해서 애그리거트를 조회하는 것은 오버헤드를 유발함.
class OrderCreatedEvent implements OrderEvent{
	private List<OrderLineItem> lineItems;
    private DeliveryInformation deliveryInformation; // 컨슈머가 필요로 하는 데이터
    private PaymentInformation paymentInformation;
    private long restaurantId;
    private String restaurantName;
}
  • 주문한 내역이 이미 OrderCreatedEvent 있기 때문에 주문 이력 서비스 같은 이벤트 컨슈머는 따로 데이터를 조회할 필요가 없음. (7장에 더 자세한 설명)
  • 이벤트 강화 기법은 컨슈머를 단순화하는 이점이 있지만, 컨슈머 요건이 바뀌면 이벤트 클래스도 바뀌어야 하므로 이벤트 클래스의 안정성은 떨어짐

5.3.4 도메인 이벤트 식별

  • 이벤트 스토밍 (Event Storming): 복잡한 도메인을 이해하기 위해 이벤트 중심으로 워크숍을 하는 것
    • 각계 도메인 전문가들이 한자리에 모여 큼지막한 화이트 보드나 긴 종이 두루마리에 수많은 점착식 메모지를 붙이고, 몇 시간 동안 이벤트 스토밍을 하면 애그리거트와 이벤트로 구성된 이벤트 중심적인 도메인 모델이 완성됨.
  • 이벤트 스토밍은 다음 3단계를 거친다.
      1. 이벤트 브레인스토밍 (Event Brainstorming): 도메인 이벤트를 머릿속에서 쥐어 짜낸다. 오렌지색 점착식 메모지로 구분된 도메인 이벤트를 모델링 화면에 대략 그려놓은 타임라인에 배치한다.
      1. 이벤트 트리거 (event trigger) 식별: 각각의 이벤트를 일으키는 트리거를 식별한다.
      • 사용자 액션: 파란색 점차식 메모지로 커맨드를 표시
        • 외부 시스템: 자주색 점착식 메모지로 표시
        • 기타 도메인 이벤트
        • 시간 경과
      1. 애그리거트 식별: 각 커맨드를 소비 후 적절한 이벤트를 발생시키는 애그리거트를 식별해서 노란색 점착식 메모지로 표시한다.


SK C&C의 이벤트 스토밍 예시

5.3.5 도메인 이벤트 생성 및 발행

도메인 이벤트 생성

  • 도메인 이벤트를 이용한 통신은 비동기 메시징 형태를 취하지만, 비즈니스 로직이 도메인 이벤트를 메시지 브로커에 발행하려면 먼저 도메인 이벤트를 생성해야 한다.
// 예제 5-3 Ticket 애그리거트의 accept() 메서드
public class Ticket {
    public List<TicketDomainEvent> accept(LocalDateTime readyBy) {
		...
       this.acceptTime = LocalDateTime.now(); // Ticket 업데이트
       this.readyBy = readyBy;
       
       
       return singletonList(new TicketAcceptedEvent(readyBy)); // 이벤트 반환
    }
}
  • 서비스는 애그리거트 루트 메서드를 호출 후 이벤트를 발행한다.
// 예제 5-4 KitchenServive는 Ticket.accept()를 호출한다.

@Transactional
public class KitchenService {

  @Autowired
  private TicketRepository ticketRepository;

  @Autowired
  private TicketDomainEventPublisher domainEventPublisher;

  public void accept(long ticketId, LocalDateTime readyBy) {
    Ticket ticket = ticketRepository.findById(ticketId)
            .orElseThrow(() -> new TicketNotFoundException(ticketId));
    List<TicketDomainEvent> events = ticket.accept(readyBy); // ticket.accept 호출
    
    domainEventPublisher.publish(ticket, events); // 도메인 이벤트 발행
  }

}
  • accept()는 DB에서 TicketRepository로 Ticket을 가져온 후, 다시 Ticket.accpet()로 Ticket을 업데이트 한다.
  • 그 다음 TicketDomainEventPublisher.publish()를 호출하여 Ticket이 반환한 이벤트를 발행한다.
// 예제 5-5 Ticket은 도메인 이벤트를 기록하는 상위 클래스 AbstractAggregateRoot를 상속한다.
public class Ticket extemds AbstractAggregateRoot {
    public void accept(LocalDateTime readyBy) {
		...
       this.acceptTime = LocalDateTime.now(); // Ticket 업데이트
       this.readyBy = readyBy;
       
       
      registerEvent(new TicketAcceptedEvent(readBy));
    }
}
  • Ticket 클래스를 애그리거트 루트의 특정 필드에 이벤트를 쌓아두고 서비스가 이벤트를 가져다 발행하는 방법도 존재함.
  • registerEvent(): 상위 클래스 AbstractAggregateRoot에 정의된 메서드.
  • 서비스는 AbstractAggregateRoot.domainEvents()를 호출해서 이벤트를 가져온다.

도메인 이벤트를 확실하게 발행하는 방법

  • 메시지를 로컬 DB 트랜잭션의 일부로 확실하게 전달하는 방법은 트랜잭셔널 메시징을 이용하는 방법이 있었고, 도메인 이벤트도 다를 바 없다.
  • 서비스는 DB에서 애그리거트를 업데이트하는 트랜잭션의 일부로 이벤트를 발행하기 위해 트랜잭셔널 메시징을 사용한다. DB 업데이트 트랜잭션의 일부로 이벤트를 OUTBOX 테이블에 삽입하고, 트랜잭션이 커밋되면 이 테이블에 삽입된 이벤트를 메시지 브로커에 발행한다.
// 예제 5-6 이벤추에이트 트램 프레임워크의 DomainEventPublisher 인터페이스
public interface DomainEventPublisher{
	void publish(String aggregateType, Object aggregateId, List<DomainEvent> domainEvents);
}
  • 애그리거트 타입/ID와 도메인 이벤트 목록을 매개변수로 받음.
  • publish()는 이 프레임워크에 탑재된 MessageProducer 인터페이스를 통해 트랜잭션을 걸어 이벤트를 발행한다.
  • DomainEventPublisher 이벤트 발행기를 서비스가 직접 호출할 수도 있지만, 그러면 서비스가 유효한 이벤트만 발행하리라는 보장이 없음.
    • 가령 KithchenServiceTicket 애그리거트의 이벤트 마커 인터페이스를 구현한 TicketDomainEvent를 구현한 이벤트
  • 더 좋은 방법은 AbstractAggregateDomainEventPublisher의 하위 클래스를 구현하는 것
// 예제 5-7 타입-안전한 도메인 이벤트 발행기의 추상 상위 클래스
public abstract class AbstractAggregateDomainEventPublisher<A, E extends DomainEvent> {
  private Function<A, Object> idSupplier;
  private DomainEventPublisher eventPublisher;
  private Class<A> aggregateType;

  protected AbstractAggregateDomainEventPublisher(DomainEventPublisher eventPublisher,
                                                  Class<A> aggregateType,
                                                  Function<A, Object> idSupplier) {
    this.eventPublisher = eventPublisher;
    this.aggregateType = aggregateType;
    this.idSupplier = idSupplier;
  }

  public Class<A> getAggregateType() {
    return aggregateType;
  }

  public void publish(A aggregate, List<E> events) {
    eventPublisher.publish(aggregateType, idSupplier.apply(aggregate), (List<DomainEvent>) events);
  }

}
  • publisher()는 애그리거트 ID를 조회 후 DomainEventPublisher.publish()를 호출
// 예제 5-8 Ticket 애그리거트의 도메인 이벤트를 발행하는 타입-안전한 인터페이스
package net.chrisrichardson.ftgo.kitchenservice.domain;

import io.eventuate.tram.events.aggregates.AbstractAggregateDomainEventPublisher;
import io.eventuate.tram.events.publisher.DomainEventPublisher;

public class TicketDomainEventPublisher extends AbstractAggregateDomainEventPublisher<Ticket, TicketDomainEvent> {

  public TicketDomainEventPublisher(DomainEventPublisher eventPublisher) {
    super(eventPublisher, Ticket.class, Ticket::getId);
  }

}
  • 이 클래스는 정의에 따라 TicketDomainEvent의 하위 클래스에 해당하는 이벤트만 발행한다.

5.3.6 도메인 이벤트 소비

  • 도메인 이벤트는 결국 메시지로 바뀌어 카프카 같은 메시지 브로커에 발행된다.
예제 5-9 이벤트를 핸들러 메서드로 디스패치한다.
public class KitchenServiceEventConsumer {

  @Autowired
  private KitchenService kitchenService;

  public DomainEventHandlers domainEventHandlers() { // 이벤트와 이벤트 핸들러를 매핑
    return DomainEventHandlersBuilder
            .forAggregateType("net.chrisrichardson.ftgo.restaurantservice.domain.Restaurant")
            .onEvent(RestaurantCreated.class, this::createMenu)
            .onEvent(RestaurantMenuRevised.class, this::reviseMenu)
            .build();
  }

  public void reviseMenu(DomainEventEnvelope<RestaurantMenuRevised> de) { // RestaurantMenuRevised 이벤트 핸들러
    long id = Long.parseLong(de.getAggregateId());
    RestaurantMenu revisedMenu = de.getEvent().getRevisedMenu();
    kitchenService.reviseMenu(id, revisedMenu);
  }

}
  • reviseMenu(): RestaurantMenuRevised 이벤트를 처리함. kitchenService.reviseMenu()를 호출하여 음식점 메뉴를 업데이트 한 후, 이벤트 핸들러가 발행한 도메인 이벤트 목록을 반환한다.

5.4 주방 서비스 비즈니스 로직

  • 주방 서비스의 애그리거트
    • Restaurant Aggregate: 음식점 매뉴 및 운영 시간을 알고 있는 상태에서 주문을 검증
    • Ticket Aggregate: 배달원이 픽업할 수 있게 음식점이 미리 준비해야 할 주문을 나타냄

주방 서비스에는 3개의 인바운드 어댑터가 존재한다.

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

아웃바운드 어댑터는 2개가 존자핸다

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

5.4.1 Ticket 애그리거트

Ticket 클래스 구조

// 예제 5-10, 11 Ticket 엔티티와 도메인 메서드
@Entity
@Table(name = "tickets")
@Access(AccessType.FIELD)
public class Ticket {

  @Id
  private Long id;

  @Enumerated(EnumType.STRING)
  private TicketState state;

  private TicketState previousState;

  private Long restaurantId;

  @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 ResultWithDomainEvents<Ticket, TicketDomainEvent> create(long restaurantId, Long id, TicketDetails details) {
    return new ResultWithDomainEvents<>(new Ticket(restaurantId, id, details));
  }

  private Ticket() {
  }

  public Ticket(long restaurantId, Long id, TicketDetails details) {
    this.restaurantId = restaurantId;
    this.id = id;
    this.state = TicketState.CREATE_PENDING;
    this.lineItems = details.getLineItems();
  }


  public List<TicketDomainEvent> accept(LocalDateTime readyBy) {
    switch (state) {
      case AWAITING_ACCEPTANCE:
        // Verify that readyBy is in the futurestate = TicketState.ACCEPTED;
        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);
    }
  }



  // TODO cancel()

  public List<TicketDomainEvent> preparing() {
    switch (state) {
      case ACCEPTED:
        this.state = TicketState.PREPARING;
        this.preparingTime = LocalDateTime.now();
        return singletonList(new TicketPreparationStartedEvent());
      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<TicketDomainEvent> cancel() {
    switch (state) {
      case AWAITING_ACCEPTANCE:
      case ACCEPTED:
        this.previousState = state;
        this.state = TicketState.CANCEL_PENDING;
        return emptyList();
      default:
        throw new UnsupportedStateTransitionException(state);
    }
  }

}
  • JPA로 저장하는 이 클래스는 TICKET 테이블에 매핑. 중요한거는 Restaurant 객체를 객체 잠조가 아닌 PK 참조를 하고 있다.

Ticket 애그리거트 동작

  • accept(): 음식점이 주문을 접수함
  • preparing(): 음식점이 주문을 준비하기 시작함. 주문은 더 이상 변경/취소가 불가능
  • readyForPickUP(): 주문 픽업 준비가 끝남.

create()는 Ticket을 생성하고 preparing()은 음식점에서 주문을 준비하기 시작할 때 호출됨. preparing()은 주문 상태를 PREPARING으로 변경하고, 그 시간을 기록한 후 이벤트를 발행한다.
cancel()은 사용자가 주문을 취소할 때 호출된다. 취소가 가능한 상태면 주문 상태 변경 후 이벤트 반환, 불가능한 경우는 예외를 던진다.

KitchenService 도메인 서비스

// 예제 5-12 KitchenService 도메인 서비스

@Transactional
public class KitchenService {

  @Autowired
  private TicketRepository ticketRepository;

  @Autowired
  private TicketDomainEventPublisher domainEventPublisher;

  @Autowired
  private RestaurantRepository restaurantRepository;

  public void createMenu(long id, RestaurantMenu menu) {

  public void accept(long ticketId, LocalDateTime readyBy) {
    Ticket ticket = ticketRepository.findById(ticketId)
            .orElseThrow(() -> new TicketNotFoundException(ticketId));
    List<TicketDomainEvent> events = ticket.accept(readyBy);
    domainEventPublisher.publish(ticket, events);
  }


  // ...
}
  • accept()는 음식점에서 새 주문을 접수할 때 다음 두 매개변수를 전달받아 호출된다.
    • orderId: 접수한 주문 ID
    • readyBy: 주문 픽업 준비가 끝날 것으로 예상되는 시간
  • 이 메서드는 Ticket 애그리거트를 가져와 accept()를 호출한다. 그리고 생성된 이벤트를 무조건 발행한다.

KitchenServiceCommandHandler 클래스

  • 주문 서비스에 구현된 사가가 전송한 커맨드 메시지를 처리하는 어댑터.
// 예제 5-13 사가가 전송한 커맨드 메시지를 처리한다.
public class KitchenServiceCommandHandler {

  @Autowired
  private KitchenService kitchenService;

  public CommandHandlers commandHandlers() { // 커맨드 메시지를 메시지 핸들러에 매핑
    return SagaCommandHandlersBuilder
            .fromChannel(KitchenServiceChannels.kitchenServiceChannel)
            .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 {
      Ticket ticket = kitchenService.createTicket(restaurantId, ticketId, ticketDetails); // KitchenService를 호출하여 Ticket 생성
      CreateTicketReply reply = new CreateTicketReply(ticket.getId());
      return withLock(Ticket.class, ticket.getId()).withSuccess(reply); // 성공 응답 반환
    } catch (RestaurantDetailsVerificationException e) {
      return withFailure(); // 실패 응답 반환
    }
  }

  private Message confirmCreateTicket // 주문 확정
          (CommandMessage<ConfirmCreateTicket> cm) {
    Long ticketId = cm.getCommand().getTicketId();
    kitchenService.confirmCreateTicket(ticketId);
    return withSuccess();
  }

}

5.5 주문 서비스 비즈니스 로직

주문 서비스에는 4개의 인바운드 어댑터가 존재한다.

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

아웃바운드 어댑터는 3개가 존자핸다

  • DB 어댑터: OrderRepository 인터페이스를 구현하여 주문 서비스 DB에 접근한다.
  • DomainEventPublishingAdapter: DomainEventPublisher 인터페이스를 구현하여 Order 도메인 이벤트를 발행한다.
  • OutboundCommandMessageAdapter: CommamdPublisher 인터페이스를 구현한 클래ㅡㅅ. 커맨드 메시지를 사가 참여자들에게 보낸다.

5.5.1 Order 애그리거트

Order 애그리거트 구조

// 예제  5-14 Order 클래스와 필드
@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 필드를 PK로 ORDERS 테이블에 매핑
  • version 필드는 낙관적 잠금 (optimistic locking)을 할 때 사용됨.
  • Order의 상태는 enum으로 나타냄

Order 애그리거트 State Machine

  • OrderService 또는 사가 첫 번째 단계, 둘 중 하나는 Order 메서드를 호출해서 수행 가능한 작업인지 확인한 후 해당 주문을 APPROVAL_PENDING 상태로 변경. 중간에 pending 상태를 두면서 시맨틱 락을 적용함.

Order 애그리거트 메서드

// 예제 5-15 주문 생성 도중 호출되는 메서드들
@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를 발행하는 정적 팩토리 메서드
  • OrderCreatedEvent: 주문 품목, 총액, 음식점 ID, 음식점명 등 주문 내역이 포함된 이벤트
  • Order는 처음에 APPROVAL_PENDING 상태로 출발한다. CreateOrderSaga 완료 시 고객의 신용카드 승인까지 성공하면 noteApproved(), 서비스 중 하나라도 주문을 거부하거나 신용카드 승인이 실패하면 noteRejected()가 호출된다. 이렇듯 Order 애그리거트에 있는 메서드는 대부분 애그리거트 상태에 따라 동작이 결정됨.
// 예제 5-16 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()을 호출하여 주문 변경을 마무리 한다.

5.5.2 OrderService 클래스

  • 이 클래스의 메서드들은 대부분 사가를 만들어 Order 애그리거트 생성/수정을 오케스트레이션하므로 복잡함.
  • OrderRespository, OrderDomainEventPublisher, SagaManager 등 주입되는 의존성이 많음.
package net.chrisrichardson.ftgo.orderservice.domain;

import io.eventuate.tram.events.aggregates.ResultWithDomainEvents;
import io.eventuate.tram.events.publisher.DomainEventPublisher;
import io.eventuate.tram.sagas.orchestration.SagaManager;
import io.micrometer.core.instrument.MeterRegistry;
import net.chrisrichardson.ftgo.orderservice.api.events.OrderDetails;
import net.chrisrichardson.ftgo.orderservice.api.events.OrderDomainEvent;
import net.chrisrichardson.ftgo.orderservice.api.events.OrderLineItem;
import net.chrisrichardson.ftgo.orderservice.sagas.cancelorder.CancelOrderSagaData;
import net.chrisrichardson.ftgo.orderservice.sagas.createorder.CreateOrderSagaState;
import net.chrisrichardson.ftgo.orderservice.sagas.reviseorder.ReviseOrderSagaData;
import net.chrisrichardson.ftgo.orderservice.web.MenuItemIdAndQuantity;
import net.chrisrichardson.ftgo.restaurantservice.events.MenuItem;
import net.chrisrichardson.ftgo.restaurantservice.events.RestaurantMenu;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Optional;
import java.util.function.Function;

import static java.util.stream.Collectors.toList;

@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;
  }


}
  • createOrder(): 먼저 Order 애그리거트를 생성/저장한 후 애그리거트가 발생시킨 도메인 이벤트를 발행하고, 제일 마지막에 CreateOrderSaga를 생성함.
  • reviseOrder(): Order를 조회한 후 ReviseOrderSaga 생성

모놀리틱과 차이점

  • 모놀리틱과 다르게 다양한 설계 제약 조건이 부과된 DDD 애그리거트로 도메인 모델을 구성하고, 상이한 애그리거트는 객채 참조가 아닌 PK 참조한다.
  • 사가를 이용하여 여러 서비스에 걸쳐 데이터 일관성을 유지한다는 중요한 차이점이 존재한다.
    • KitchenService는 사가에 참여할 뿐 사가를 시작하지는 않지만, OrderService는 주문을 생성하고 수정할 때 사가에 전적으로 의지한다. 다른 서비스가 있는 데이터가 트랜잭션 관점에서 일관성이 보장되어야 하기 때문이다.
    • 그래서 OrderService 메서드는 대부분 직접 Order를 업데이트 하지 않고 사가를 만든다.

스터디 하면서 논의한 내용들

  • 애그리거트의 크기가 작으면 좋은 이유.
    • 여러 트랜잭션이 동시에 데이터베이스에 접근하고 수정하는 경우 애그리거트의 크기가 커지면 트랜잭션 자체가 길어져 락을 오래 쥐고 있어 간섭과 충돌이 발생할 확률이 높다. 애그리거트를 작게 쪼개면 트랜잭션 자체가 짧아져 해당 컬럼에 락을 잡고 있는 시간도 짧아져 디비가 동시 처리 가능한 요청이 빨라지고, 서비스가 나눠져 트랜잭션이 충돌할 확률이 적어 확장성이 증가한다.
    • 예를 들어 Order 애그리거트를 Consumer 애그리거트의 일부로 설계하는 두 사용자가 동일한 고객의 상이한 주문을 고치려고 하면 충돌이 난다. 이를 Order와 Consumer 애그리거트로 쪼개면 트랜잭션 시간 자체가 짧아져 락을 쥐고 있는 시간이 짧아지고, 동시성 제어를 처리하는 속도가 짧아져 트랜잭션을 더 빠르게 처리할 수 있는 것을 확장성이 증가한다고 표현할 것 같다.
profile
평범한 백엔드 개발자

0개의 댓글