본문 바로가기
스터디/도메인 주도 개발 시작하기

[도메인 주도 개발 시작하기] 07. 이벤트와 CQRS

by 디토20 2022. 7. 23.
반응형

 

 

 

[도메인 주도 개발 시작하기] 07. 이벤트와 CQRS

 

 

 

1. 이벤트

1.1 시스템 간 강결합 문제

쇼핑몰에서 구매를 취소하면 환불 처리를 해야하는데, 이 때 환불 기능을 실행하는 주체는 주문 도메인 엔티티가 될 수 있다. 도메인 객체에서 환불 기능을 실행하려면 환불 기능을 제공하는 도메인 서비스를 파라미터로 받아 실행 할 수 있다. 또는 응용 서비스에서 실행할 수도 있는데, 보통 결제 시스템은 외부에 존재하므로 응용서비스는 외부에 있는 결제 시스템이 제공하는 환불 서비스를 호출한다.

 

이 때 두가지 문제가 발생할 수 있다. 첫번째로 외부 서비스가 정상이 아닐 경우 트랜잭션 처리에 대한 부분이다. 익셉션이 발생하면 트랜잭션을 롤백 해야 할까? 아니면 일단 커밋해야 할까?

 

두번째 문제는 성능에 관한 것이다. 환불을 처리하는 외부 시스템의 응답 시간이 길어지면 그만큼 대기 시간도 길어진다. 즉, 외부 서비스 성능에 직접적인 영향을 받게 된다.

 

위와같은 문제가 발생하는 이유는 주문 바운디드 컨텍스트와 결제 바운디드 컨텍스트간의 강결합 때문이다. 이벤트를 사용하면 이런 강결합을 없앨 수 있는데, 특히 비동기 이벤트를 사용하면 두 시스템간의 결합을 크게 낮출 수 있다.

 

 

 

1.2 이벤트 개요

여기서 이벤트라는 용어는 '과거에 벌어진 어떤 것'을 의미한다. 이벤트가 발생한다는 것은 상태가 변경됐다는 것을 의미한다.

 

1.2.1 이벤트 관련 구성요소

도메인 모델에 이벤트를 도입하려면 아래와 같은 네개의 구성요소인 이벤트, 이벤트 생성 주체, 이벤트 디스패처, 이벤트 핸들러를 구현해야 한다.

 

 

이벤트 생성 주체
  • 엔티티, 밸류, 도메인 서비스와 같은 도메인 객체
  • 도메인 객체는 도메인 로직을 실행해서 상태가 바뀌면 관련 이벤트를 발생시킨다.

 

이벤트 핸들러
  • 이벤트 생성 주체가 발생한 이벤트에 반응한다.
  • 생성 주체가 발생한 이벤트를 전달받아 이벤트에 담김 데이터를 이용해서 원하는 기능을 실행한다.
  • ex) '주문 취소됨 이벤트'를 받는 이벤트 핸들러는 해당 주문의 주문자에게 SMS로 주문 취소 사실을 통보한다.

 

이벤트 디스패처
  • 이벤트 생성 주체와 이벤트 핸들러를 연결해 주는 것
  • 이벤트 생성 주체는 이벤트를 생성해서 디스패처에 이벤트를 전달한다.
  • 이벤트를 전달받은 디스패처는 해당 이벤트를 처리할 수 있는 핸들러에 이벤트를 전파한다.
  • 이벤트 디스패처의 구현 방식에 따라 이벤트 생성과 처리를 동기나 비동기로 실행하게 된다.

 

1.2.2 이벤트의 구성

이벤트는 발생한 이벤트에 대한 정보를 담는다, 이 정보는 다음을 포함한다.

  • 이벤트 종류: 클래스 이름으로 이벤트 종류를 표현
  • 이벤트 발생 시간
  • 추가 데이터: 주문번호, 신규 배송지 정보 등 이벤트와 관련된 정보

배송지 변경할 때 발생하는 이벤트는 아래와 같이 작성할 수 있다.

 

public class ShippingInfoChangedEvent{

	private String orderNumber;
    private long timestamp;
    private ShippingInfo newShippingInfo;
    
    // 생성자, getter
}

 

클래스 이름을 보면 'Changed'라는 과거 시제를 사용했다. 이벤트는 현재 기준으로 과거에 벌어진 것을 표현하기 때문에 이벤트 이름에는 과거 시제를 사용한다.

 

public class Order {

	public void changeShippingInfo(ShippingInfo newShippingInfo) {
    	verifyNotShipped();
        setShippingInfo(newShippingInfo);
        Events.raise(new ShippingInfoChangedEvent(number, newShippingInfo)); // 이벤트 발행
    }

}


public class ShippingInfoChangedHandler {
	
    @EventListener(ShippingInfoChangedEvent.class) // 이벤트 구독
    public void handle(ShippingInfoChangeEvent evt) {
    	shippingInfoSynchronizer.sync(
        	evt.getOrderNumber(),
            evet.getNewShippingInfo();
        }
    }

}

 

이벤트는 이벤트 핸들러가 작업을 수행하는데 필요한 데이터를 담아야 한다.

 

 

 

1.2.3 이벤트 용도

이벤트는 크게 두가지 용도로 쓰인다. 첫 번째 용도는 트리거다. 도메인의 상태가 바뀔 때 다른 후처리가 필요하면 후처리를 실행하기 위한 트리거로 이벤트를 사용할 수 있다.

 

이벤트의 두 번째 용도는 서로 다른 시스템 간의 데이터 동기화이다. 배송지를 변경하면 외부 배송 서비스에 바뀐 배송지 정보를 전송해야 한다. 주문 도메인은 배송지 변경 이벤트를 발생 시키고 이벤트 핸들러는 외부 배송 서비스와 배송지 정보를 동기화 할 수 있다.

 

 

 

1.2.4 이벤트 장점

이벤트를 사용하면 서로 다른 도메인 로직이 섞이는 것을 방지할 수 있다.

 

또한 이벤트 핸들러를 사용하면 기능 확장도 용이하다. 구매 취소 시 환불과 함께 이메일로 취소내용을 보내고 싶다면 이메일 발송을 처리하는 핸들러를 구현하면 된다. 기능 확장해도 구매 취소 로직을 수정할 필요가 없다.

 

 

 

 

1.3 동기 이벤트 처리 문제

이벤트를 사용해서 강결합 문제는 해소했지만 아직 남아 있는 문제가 하나 있다. 바로 외부 서비스에 영향을 받는 문제이다. 외부 서비스의 성능 저하는 내 시스템의 성능 저하로 연결된다. 성능 저하뿐만 아니라 트랜잭션도 문제가 된다. 이런 문제를 해소하는 방법은 이벤트를 비동기로 처리하거나 이벤트와 트랜잭션을 연계하는 것이다. 두 방법 중 먼저 비동기 이벤트 처리에 대해 알아보자.

 

 

1.4 비동기 이벤트 처리

'A하면 이어서 B하라'라는 내용을 담고있는 요구사항은 실제로 'A하면 최대 언제까지 B 하라'인 경우가 많다. 이럴 경우 요구사항은 이벤트를 비동기로 처리하는 방식으로 구현할 수 있다. 다시 말해 A 이벤트가 발생하면 별도 스레드로 B를 수행하는 핸들러를 실행하는 방식으로 요구사항을 구현할 수 있다.

 

이벤트를 비동기로 구현 할 수 있는 여러 방법들
  • 로컬 핸들러를 비동기로 실행하기
  • 메시지 큐를 사용하기
  • 이벤트 저장소와 이벤트 포워더 사용하기
  • 이벤트 저장소와 이벤트 제공 API 사용하기

 

1.4.1 로컬 핸들러 비동기 실행

이벤트 핸들러를 비동기로 실행하는 방법은 이벤트 핸들러를 별도 스레드로 실행하는 것이다. 스프링의 경우 스프링이 제공하는 @Async 애너테이션을 사용하면 손쉽게 비동기로 이벤트 핸들러를 실행할 수 있다.

 

 

1.4.2 메시징 시스템을 이용한 비동기 구현

카프카래빗MQ와 같은 메시징 시스템을 사용한다. 이벤트가 발생하면 이벤트 디스패처는 이벤트를 메시지 큐에 보낸다. 메시지 큐는 이벤트를 메시지 리스너에 전달하고, 메시지 리스너는 알맞은 이벤트 핸들러를 이용해서 이벤트를 처리한다. 이때 이벤트를 메시지 큐에 저장하는 과정과 읽어와 처리하는 과정은 별도 스레드나 프로세스로 처리된다.

 

 

 

 

1.4.3 이벤트 저장소와 이벤트 포워더 사용하기

이벤트를 비동기로 처리하는 또 다른 방법은 이벤트를 일단 DB에 저장한 뒤 별도 프로그램을 이용해서 이벤트 핸들러에 전달하는 것이다.

이벤트가 발생하면 핸들러는 스토리지에 이벤트를 저장한다. 포워더는 주기적으로 이벤트 저장소에서 이벤트를 가져와 이벤트 핸들러를 실행한다. 포워더는 별도 스레드를 이용하기 때무에 이벤트 발행과 처리가 비동기로 처리된다. 이 방식은 도메인 상태와 이벤트 저장소로 동일한 DB를 사용한다. 즉, 도메인 상태 변화와 이벤트 저장이 로컬 트랜잭션으로 처리된다. 이벤트를 물리적 저장소에 보관하기 때문에 핸들러가 이벤트 처리에 실패할 경우 포워더는 다시 이벤트 저장소에서 이벤트를 읽어와 핸들러를 실행하면 된다.

 

 

 

1.4.4 이벤트 저장소와 이벤트 제공 API 사용하기

API 방식과 포워더 방식의 차이점은 이벤트를 전달하는 방식에 있다. 포워더 방식이 포워더를 이용해서 이벤트를 외부에 전달한다면, API 방식은 외부 핸들러가 API 서버를 통해 이벤트 목록을 가져간다.

 

 

 

 

2. CQRS

2.1 단일 모델의 단점

주문 내역 조회 기능을 구현하려면 여러 애그리거트에서 데이터를 가져와야 한다.

조회 화면 특성상 조회 속도가 빠를수록 좋은데 여러 애그리거트의 데이터가 필요하면 구현 방법을 고민해야 한다. 단일 모델의 여러 애그리거트에서 데이터를 가져와 출력하는 기능을 구현하기에는 고려할 게 많아서 구현을 복잡하게 만드는 원인이 된다. 이런 구현 복잡도를 낮추는 간단한 방법이 있는데 그것은 바로 상태 변경을 위한 모델과 조회를 위한 모델을 분리하는 것이다.

 

 

2.1 CQRS

시스템이 제공하는 기능은 크게 두가지로 나눌 수 있다. 하나는 상태를 변경하는 기능이고 또 다른 하나는 사용자 입장에서 상태 정보를 조회하는 기능이다. 상태를 변경하는 범위와 상태를 조회하는 범위가 정확하게 일치하지 않기 때문에 단일 모델로 두 종류의 기능을 구현하면 모델이 불필요하게 복잡해진다.

 

단일 모델을 사용할 때 발생하는 복잡도를 해결하기 위해 CQRS(Command Query Responsibility Segregation)을 사용한다. 상태를 변경하는 명령(Command)을 위한 모델과 상태를 제공하는 조회(Query)를 위한 모델을 분리하는 패턴이다.

 

CQRS는 복잡한 도메인에 적합하다. 도메인이 복잡해질수록 명령 기능과 조회 기능이 다루는 데이터 범위에 차이가 난다. CQRS를 사용하면 각 모델에 맞는 구현 기술을 선택할 수 있다. 예를 들어 명령 모델은 객체 지향에 기반해서 도메인 모델을 구현하기에 적당한 JPA를 사용해서 구현하고, 조회 모델은 DB 테이블에서 SQL로 데이터를 조회할 때 좋은 마이바티스를 사용해서 구현하면 된다.

 

 

 

명령 모델과 조회 모델이 서로 다른 데이터 저장소를 사용할 수도 있다. 명령 모델은 트랜잭션을 지원하는 RDBMS를 사용하고, 조회 모델은 조회 성능이 좋은 메모리 기반 NoSQL을 사용할 수 있다.

 

 

두 데이터 저장소 간 데이터 동기화는 이벤트를 활용해서 처리한다. 명령 모델에서 상태를 변경하면 이에 해당하는 이벤트가 발생하고, 그 이벤트를 조회 모델에 전달해서 변경 내역을 반영하면 된다.

 

 

 

2.1.1 웹과 CQRS

일반적으로 웹 서비스는 상태를 변경하는 요청보다 상태를 조회하는 요청이 많다. 대형 서비스 개발팀에서는 조회 성능을 높이기 위해 다양한 기법을 사용한다. 기본적으로 쿼리를 최적화해서 쿼리 실행 속도 자체를 높이고, 메모리에 조회 데이터를 캐싱 해서 응답 속도를 높이기도 한다. 조회 전용 저장소를 따로 사용하기도 한다. 이렇게 조회 성능을 높이기 위해 다양한 기법을 사용하는 것은 결과적으로 CQRS를 적용하는 것과 같은 효과를 만든다.

 

 

 

2.1.2 CQRS 장단점

CQRS 패턴을 적용할 때 얻을수 있는 장점은 명령 모델을 구현할 때 도메인 자체에 집증할 수 있다는 점이다. 복잡한 도메인은 주로 상태 변경 로직이 복잡한데 명령 모델과 조회 모델을 구분하면 조회 성능을 위한 코드가 명령 모델에 없으므로 도메인 로직을 구현하는데 집중할 수 있다. 또한 명령 모델에서 조회 관련 로직이 사라져 복잡도가 낮아진다. 또 다른 장점은 조회 성능을 향상시키는데 유리하다는 점이다.

 

단점은 구현할 코드가 더 많고 더 많은 구현 기술이 필요하다는 점이다. 이런 장단점을 고려해서 CQRS 패턴을 도입할지 여부를 결정하도록 하자.

728x90
반응형

댓글