티스토리 뷰

DDD

[DDD] 5. 도메인 이벤트와 CQRS

mandykr 2022. 5. 31. 14:45

목차

1. 도메인 이벤트

2. 스프링 ApplicationEvent

3. 이벤트 소싱(Event Sourcing)

4. CQRS(명령 및 쿼리 책임 분리)

 

 

 

 

1. 도메인 이벤트

느슨한 결합과 강한 결합

  • 외부 서비스가 정상이 아닐 경우 트랜잭션 처리를 어떻게 해야 할지 애매
  • 외부 서비스 성능에 직접적인 영향을 받는 문제가 있다.
  • 도메인 객체에 서비스를 전달하면 추가로 설계상 문제가 나타날 수 있다.
  • 도메인 객체에 서비스를 전달할 떄 또 다른 문제는 기능을 추가할 때 발생한다.
  • 비동기 이벤트를 사용하면 두 시스템 간의 결합을 크게 낮출 수 있다.

 

이벤트

  • 이벤트가 발생한다는 것은 상태가 변경됐다는 것을 의미한다.
  • 도메인 모델에서도 UI 컴포넌트와 유사하게 도메인의 상태 변경을 이벤트로 표현할 수 있다.
  • 보통 '~할 때', '~가 발생하면', '만약 ~하면'과 같은 요구사항은 도메인의 상태 변경과 관련된 경우가 많고 이런 요구사항을 이벤트를 이용해서 구현할 수 있다.
  • 이벤트는 이미 과거에 일어난 사건이기 때문에 수정되거나 삭제되지 않는다.

 

이벤트 관련 구성요소

  • 도메인 모델에서 이벤트 주체는 엔티티, 밸류, 도메인 서비스와 같은 도메인 객체이다.
  • 도메인 객체는 도메인 로직을 실행해서 상태가 바뀌면 관련 이벤트를 발생한다.
  • 이벤트 핸들러(handler)는 이벤트 생성 주체가 발생한 이벤트에 반응한다.
  • 이벤트 핸들러는 생성 주체가 발생한 이벤트를 전달받아 이벤트에 담긴 데이터를 이용해서 원하는 기능을 실행한다.
  • 이벤트 생성 주체와 이벤트 핸들러를 연결해 주는 것이 이벤트 디스패처(dispatcher)이다.
  • 이벤트를 전달받은 디스패처는 해당 이벤트를 처리할 수 있는 핸들러에 이벤트를 전파한다.

 

이벤트의 구성

  • 이벤트는 현재 기준으로 (바로 직전이라도) 과거에 벌어진 것을 표현하기 때문에 이벤트 이름에는 과거 시제를 사용한다.
  • 이벤트는 이벤트 핸들러가 작업을 수행하는 데 필요한 최소한의 데이터를 담아야 한다.

 

이벤트 용도

  • 도메인의 상태가 바뀔 때 다른 후처리를 해야 할 경우 후처리를 실행하기 위한 트리거로 이벤트를 사용할 수 있다.
  • 이벤트의 두 번째 용도는 서로 다른 시스템 간의 데이터 동기화이다.

 

이벤트 장점

  • 서로 다른 도메인 로직이 섞이는 것을 방지할 수 있다.
  • 이벤트 핸들러를 사용하면 기능 확장도 용이하다.

 

비동기 이벤트 처리

  • 로컬 핸들러를 비동기로 실행하기
  • 메시지 큐를 사용하기
  • 이벤트 저장소와 이벤트 포워더 사용하기
  • 이벤트 저장소와 이벤트 제공 API 사용하기

2. 스프링 ApplicationEvent

1) Event Publisher 사용

[ Event Publisher ]

@Transactional
@Service
public class ProductService {
    private final ApplicationEventPublisher publisher;

    public ProductService(ApplicationEventPublisher publisher) {
        this.publisher = publisher;
    }

    public void create(String name, int price) {
        Product product = new Product(name, BigDecimal.valueOf(price));
        publisher.publishEvent(new ProductCreatedEvent(name));
    }
}

public class ProductCreatedEvent {
    private UUID id;
    private final String name;
    private final int price;

    public ProductCreatedEvent(UUID id, String name, int price) {
        this.id = id;
        this.name = name;
        this.price = price;
    }

    public UUID getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public int getPrice() {
        return price;
    }
}

 

[ Event Listener ]

@Component
public class EmailSender implements Sender {
    @EventListener
    public void send(ProductCreatedEvent event) {
        System.out.println("email sender = " + event.getName());
    }

    @Override
    public void send(String text) {
        System.out.println("email sender = " + text);
    }
}

@Component
public class SmsSender implements Sender {
    @EventListener
    public void send(ProductCreatedEvent event) {
        System.out.println("sms sender = " + event.getName());
    }

    @Override
    public void send(String text) {
        System.out.println("sms sender = " + text);
    }
}

 

@EventListener는 이벤트를 발행하는 트랜잭션에 묶여있기 때문에 send() 에서 실행이 실패하면 이벤트를 발행하는 로직이 롤백된다.

이 문제는 @TransactionalEventListener를 사용해 트랜잭션 범위 밖에서 실행되도록 할 수 있다.

@TransactionalEventListener
public void send(ProductCreatedEvent event) {
    System.out.println("email sender = " + event.getName());
}

특정 조건에 의해 이벤트 로직이 수행되어야 할 때 이벤트 발행은 조건을 체크하는 로직이 들어가지 않도록 작성하고

이벤트를 수신하는 쪽에서 조건에 따라 로직이 처리되도록 작성할 수 있다.

@TransactionalEventListener(condition = "#event.price != 0")
public void send(ProductCreatedEvent event) {
    System.out.println("email sender = " + event.getName());
}

 

[ Test ]

@RecordApplicationEvents 를 사용해 이벤트 목록 주입받을 수 있다.

다음과 같은 테스트는 비용이 크기 때문에 단위 테스트 보다는 인수 테스트에서 작성해볼 수 있다.

@RecordApplicationEvents
@SpringBootTest
class ProductServiceTest {
    @Autowired
    ProductService productService;

    @Autowired
    private ApplicationEvents events;

    @Test
    void create() {
        assertThat(events.stream(ProductCreatedEvent.class).count()).isEqualTo(0);
        productService.create("치킨", 10_000);
        assertThat(events.stream(ProductCreatedEvent.class).count()).isEqualTo(1);
    }
}

 

2) Abstract Aggregate Root

명시적으로 AbstractAggregateRoot 클래스를 상속하도록 작성한다.

엔티티에서 이벤트를 register 하고 응용 서비스에서 명시적으로 Spring Data Jpa 의 save()를 호출해 이벤트를 발행한다.

@Transactional
@Service
public class ProductService {
    private final ProductRepository productRepository;
    private final ApplicationEventPublisher publisher;

    public void changePrice(UUID id, int price) {
        Product product = productRepository.findById(id).orElseThrow(EntityNotFoundException::new);
        product.changePrice(BigDecimal.valueOf(price));
        // 이벤트 발행을 위해 save()를 명시적으로 호출해줘야 한다.
        productRepository.save(product);
    }
}

@Entity
public class Product extends AbstractAggregateRoot<Product> {
    @Column(name = "id", columnDefinition = "varbinary(16)")
    @Id
    private UUID id;

    // ...

    public void changePrice(BigDecimal price) {
        this.price = new ProductPrice(price);
        registerEvent(new ProductPriceChangedEvent(id, price));
    }
}

public class ProductPriceChangedEvent {
    private final UUID id;
    private final BigDecimal price;

    public ProductPriceChangedEvent(UUID id, BigDecimal price) {
        this.id = id;
        this.price = price;
    }
}

 

엔티티의 ID를 @GeneratedValue를 이용해 자동 생성하는 경우 엔티티를 DB에 저장하기 전에는 ID를 알 수 없는 문제가 발생한다.

이런 경우 @PostPersist를 사용해 엔티티가 영속화된 경우 이벤트를 생성하도록 할 수 있다.

// service
public Product create(String name, int price) {
    Product product = productRepository.save(new Product(name, BigDecimal.valueOf(price)));
    return product;
}

// entity
@PostPersist
private void created() {
    registerEvent(new ProductCreatedEvent(id, name, price));
}

3. 이벤트 소싱(Event Sourcing)

개념

  • 도메인 모델에서 발생하는 모든 이벤트를 기록하는 데이터 저장 기법
  • 반응형 시스템에 적합하고 규모 확장 용이
  • 로깅은 예외 상황이나 프로파일링까지 고려하지만 Event Sourcing은 비즈니스 이벤트에 대해서만 다룬다.
  • Update나 Delete 연산은 수행되지 않는다.

 

입출금 예시

이벤트: 사용자 A가 통장 a에 1000원을 입금(+)한다.
현재 상태: 1000원

이벤트: 사용자 A가 통장 a에 500원을 인출(-)한다.
현재 상태: 500원

이벤트: 사용자 A가 통장 a에 1000원을 입금(+)한다.
현재 상태: 1500원

4. CQRS(명령 및 쿼리 책임 분리)

CQRS

  • 상태를 변경하는 명령(Command)을 위한 모델과 상태를 제공하는 조회(Query)를 위한 모델을 분리하는 패턴이다.
  • CQRS는 복잡한 도메인에 적합하다.

 

웹과 CQRS

  • 메모리에 캐시하는 데이터는 DB에 보관된 데이터를 그대로 저장하기보다는 화면에 맞는 모양으로 변환한 데이터를 캐시할 때 성능에 더 유리하다.
  • 조회 속도를 높이기 위해 별도 처리를 하고 있다면 명시적으로 명령 모델과 조회 모델을 구분하자.
  • 조회 기능 때문에 명령 모델이 복잡해지는 것을 방지할 수 있고 명령 모델에 관계없이 조회 기능에 특화된 구현 기법을 보다 쉽게 적용할 수 있다.

 

CQRS 장단점

  • 명령 모델을 구현할 때 도메인 자체에 집중할 수 있다.
  • 조회 성능을 향상시키는 데 유리하다.
  • 구현해야 할 코드가 더 많다.
  • 더 많은 구현 기술이 필요하다.

 

 

 

 

 

출처

NEXTSTEP: DDD 세레나데 3기

728x90