on
DDD와 MSA 기초(2)
DDD와 MSA에 대해 여러 시리즈로 정리합니다. 이번 편에서는요,
- 이벤트
- 비동기 이벤트 처리
- CQRS
에 대해 다룹니다.
이벤트
이벤트(event)는 과거에 벌어진 어떤 것을 의미합니다. 이벤트가 발생한다는 것은 상태가 변경됐다는 것을 의미합니다. 이벤트는 발생하는 것에서 끝나지 않고 그 이벤트에 반응하여 원하는 동작을 수행하는 기능을 구현합니다.
- 도메인 모델에서 이벤트 주체는
엔티티
,밸류
,도메인 서비스
와 같은 도메인 객체입니다. 도메인 객체는 도메인 로직을 실행해서 상태가 바뀌면 관련 이벤트를 발생합니다. - 이벤트 핸들러는 이벤트 생성 주체가 발생한 이벤트를 전달 받아 이벤트에 담긴 데이터를 이용해서 원하는 기능을 실행합니다.
- 이벤트 생성 주체와 이벤트 핸들러는 이벤트 디스패처를 통해 연결되는데 생성 주체에서 생성된 이벤트는 디스패처에 전달되고 이를 처리할 수 있는 핸들러에 디스패처가 이벤트를 전파합니다. 이런 생성과 처리 과정은 디스패처의 구현 방식에 따라 비동기/동기 방식으로 실행됩니다.
예시 - 이벤트
public class ShippingInfoChangedEvent {
private String orderNumber;
private long timestamp;
private ShippingInfo newShippingInfo;
}
- 이벤트 종류: 클래스 이름으로 표현
- 이벤트는 현재 기준으로 과거 벌어진 것을 표현하기 때문에 이벤트 이름에는 과거 시제를 사용합니다. ex.
changed
- 이벤트는 현재 기준으로 과거 벌어진 것을 표현하기 때문에 이벤트 이름에는 과거 시제를 사용합니다. ex.
- 이벤트 발생 시간
- 추가 데이터
예시 - 이벤트 핸들러
public class ShippingInfoChangedHandler implements EventHandler<ShippingInfoChangedEvent> {
@Override
public void handle(ShippingInfoChangedEvent event) {
shippingInfoSynchronized.sync(event.getOrderNumber(), event.getNewShippingInfo());
}
...
}
- 이벤트는 도메인의 상태가 바뀔 때 다른 후처리를 해야 할 경우 후처리를 실행하기 위한 트리거로 사용할 수 있습니다.
- ex. 주문 취소 이벤트 트리거 -> 주문을 취소하면 환불 처리를 위한 트리거로 사용 가능
- ex. 예매 완료 이벤트 트리거 -> 예매를 완료하면 예매 완료 이벤트를 발생, 이 이벤트 핸들러에서 SMS를 발송
- 이벤트를 통해 서로 다른 시스템 간의 데이터를 동기화시킬 수 있습니다.
- ex. 배송지 변경 이벤트 -> 이벤트 핸들러가 외부 배송 서비스와 배송지 정보를 동기화
- 이벤트를 사용하면 서로 다른 도메인 로직이 섞이는 것을 방지할 수 있습니다.
// Before: 주문 도메인의 구매 취소 로직에 환불 로직이 섞여있을 때 public class Order { public void cancel(RefundService refundService) { verifyNotYetShipped(); this.state = OrderState.CANCELED; this.refundStatus = State.REFUND_STARTED; try { refundService.refund(getPaymentId()); this.refundStatus = State.REFUND_COMPLETED; } catch (Exception ex) { ... } } }
// After: 이벤트를 적용해 주문 도메인과 환불 도메인을 분리했을 때 public class Order { public void cancel() { verifyNotYetShipped(); this.state = OrderState.CANCELED; this.refundStatus = State.REFUND_STARTED; // 환불 실행 로직은 주문 취소 이벤트를 받는 이벤트 핸들러로 이동 -> 주문 도메인에서 결제(환불) 도메인으로의 의존 제거 Events.raise(new OrderCanceledEvent(number.getNumber())); } }
- 이벤트 핸들러를 사용하면 도메인 로직에 영향 없이 기능을 확장할 수 있습니다.
이벤트 처리 흐름
참고: 응용 서비스와 동일한 트랜잭션 범위에서 핸들러의
handle()
이 실행되는 것을 알 수 있는데 도메인의 상태 변경과 이벤트 핸들러는 같은 트랜잭션 범위에서 실행되는 것을 알 수 있습니다.
비동기 이벤트 처리
외부 시스템과의 연동을 동기로 처리할 때 발생하는 성능과 트랜잭션 범위 문제를 해결하는 방법 중 하나가 이벤트를 비동기로 처리하는 것입니다.
“A하면 이어서 B하라 => A하면 최대 언제까지 B하라”
- ex. 회원 가입 신청 후 검증 이메일 도착까지 10 ~ 20초 정도 소요
- ex. 주문 취소 후 수분, 수일 내 결제 취소
A 이벤트가 발생하면 별도 스레드로 B를 수행하는 핸들러를 실행하는 방식으로 요구사항을 구현할 수 있습니다.
비동기 이벤트 처리 - 1) 로컬 핸들러의 비동기 실행
동기와 비동기로 실행할 이벤트를 처리할 때의 차이점은 동기는 실행할 이벤트 핸들러를 바로 실행하지만, 비동기로 실행할 이벤트 핸들러는 executor.submit()
을 이용해서 스레드 풀에 핸들러 실행 작업을 등록한다는 점입니다.
별도 스레드로 이벤트 핸들러를 사용한다는 것은 raise()
메서드와 관련된 트랜잭션 범위에 이벤트 핸들러 실행이 묶이지 않는다는 것을 의미합니다. 별도 스레드를 이용해서 이벤트 핸들러를 실행하면 이벤트 발생 코드와 같은 트랜잭션 범위에 묶을 수 없기 떄문에
한 트랜잭션으로 실행해야 하는 이벤트 핸들러는 비동기로 처리해서는 안됩니다.
비동기 이벤트 처리 - 2) 메시징 시스템을 이용한 비동기 구현
메시징 시스템은 글로벌 트랜잭션을 지원하면서 클러스터와 고가용성도 지원하기 때문에 안정적으로 메시지를 전달할 수 있는 장점이 있습니다. (Kafka는 글로벌 트랜잭션을 지원하지는 않지만 성능이 높음)
이벤트
가 발생하면 이벤트 디스패처
가 이벤트를 -> 메시지 큐
에 보내고 메시지 큐는 이벤트를 -> 메시지 리스너
에 전달합니다. 메시지 리스너는 알맞은 이벤트 핸들러
를 이용해서 이벤트를 처리합니다.
이때 이벤트를 메시지 큐에 저장하는 과정
과 메시지 큐에서 이벤트를 읽어와 처리하는 과정
은 별도 스레드나 프로세스로 처리됩니다.
- 이벤트를 발생하는 도메인 기능과 메시지 큐에 이벤트를 저장하는 절차를 한 트랜잭션으로 묶어야 하는데 도메인 기능을 실행한 결과를 DB에 반영하고 이 과정에서 발생한 이벤트를 메시지 큐에 저장하는 것을
같은 트랜잭션 범위에서 실행하려면
글로벌 트랜잭션
이 필요합니다. 글로벌 트랜잭션으로 안전하게 이벤트를 메시지 큐에 저장할 수 있지만 이로 인해 전체 성능이 떨어질 수도 있습니다. - 이벤트를 발생하는 주체와 이벤트 핸들러가 별도 프로세스에서 동작하는데 이는 자바에서 이벤트 발생 JVM과 이벤트 처리 JVM이 다르다는 것을 의미합니다.
비동기 이벤트 처리 - 3) 이벤트 저장소를 이용한 비동기 처리(포워더, API)
이벤트를 일단 DB에 저장한 뒤에 포워더와 같은 별도 프로그램을 이용해서 이벤트 핸들러에 저장할 수도 있습니다. 포워더는 주기적으로 이벤트 저장소에서 이벤트를 읽어와 이벤트 핸들러를 실행합니다. 포워더는 별도 스레드를 이용하기 때문에
이벤트 발행과 처리가 비동기로 처리됩니다. 도메인의 상태와 이벤트 저장소로 동일한 DB를 사용하며 도메인의 상태 변화와 이벤트 저장이 로컬 트랜잭션으로 처리됩니다. 이벤트를 물리적 저장소에 보관하기 때문에 핸들러가 이벤트 처리에 실패할 때
포워더가 다시 읽어와서 실행할 수 있습니다.
이벤트를 외부에 제공하는 API 방식도 있는데 외부 핸들러가 API 서버를 통해 이벤트 목록을 가져오는 방식입니다. 이벤트 목록을 요구하는 외부 핸들러가 자신이 이벤트를 어디까지 처리했는지 기억해야 합니다.
CQRS (Command Query Responsibility Segregation)
여러 애그리거트에서 데이터를 가져와 출력하는 기능을 구현할 때에는 고려할 것들이 많아서 구현이 복잡해질 수 있는데 이러한 복잡도를 낮추는 방법이 상태 변경을 위한 모델
과 조회를 위한 모델
을 분리하는 것입니다.
- 상태를 변경하는 기능 - 현재 저장하고 있는 데이터를 변경하는 방식으로 구현합니다. 주로 한 애그리거트의 상태를 변경합니다.
- 새로운 주문 생성
- 배송지 정보 변경
- 회원의 암호 변경
- 사용자 입장에서 상태 정보를 조회하는 기능 - 필요한 데이터를 읽어와 UI를 통해 보여주는 방식으로 구현합니다. 한 애그리거트의 데이터를 조회할 수도 있지만 두 개 이상의 애그리거트에서 데이터를 조회할 수 있습니다.
- 주문 상세 내역 보기
- 게시글 목록 보기
- 회원 정보 보기
- 판매 통계 보기
CQRS는 복잡한 도메인에 적합하며 CQRS를 통해 각 모델에 맞는 구현 기술을 선택할 수 있습니다.
명령 모델과 조회 모델을 서로 다른 기술을 이용해서 구현 | 명령 모델과 조회 모델이 서로 다른 DB를 사용(두 데이터 저장소 간 동기화는 이벤트 활용) |
- 명령 모델과 조회 모델이 서로 다른 DB를 사용할 때 데이터 동기화 시점을 동기/비동기 방식에 따라 다르게 구현할 수 있는데 동기 이벤트와 글로벌 트랜잭션을 사용하면 전반적인 성능이 떨어지는 단점이 있습니다.
- 조회 성능을 높이기 위해서 하는 다양한 기법(쿼리 최적화, 메모리에 조회 데이터 캐시 등)을 사용하는 것은 결과적으로 CQRS를 적용하는 것과 비슷한 효과를 낼 수 있습니다. 조회 속도를 높이기 위해 별도 처리를 하고 있다면 명시적으로 명령 모델과 조회 모델을 구분하는 것이 좋습니다.
- ✅ CQRS 패턴을 적용하면 명령 모델을 구현할 떄 도메인 자체에 집중할 수 있다는 장점이 있습니다. 또한 명령 모델에서 조회 관련 로직이 사라져 복잡도를 낮춰줍니다. 조회 성능 역시 조회에 특화된 쿼리를 사용해 처리량을 늘려 성능을 향상시킬 수 있습니다.
- ⚠️ 반면 구현해야 할 코드가 더 많아질 수 있어 도메인이 단순하거나 트래픽이 많지 않은 서비스라면 조회 전용 모델을 만들 이유가 없을 수도 있습니다. 또한 더 많은 구현 기술이 필요하고 유지 보수 비용이 높아질 수 있습니다.