[DDD, 도메인 주도 개발] 10.이벤트
이벤트의 용도와 장점
구매를 취소할 때 다음과 같이 주문 도메인 클래스에서 주문 상태를 변경하고, 환불 서비스를 주입받아 환불 처리를 한다.
(결제 시스템은 외부에 존재하므로 환불 서비스는 외부 결제 시스템이 제공하는 환불 서비스를 호출한다.)
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 e) {
...
}
}
}
이때 시스템 간 강결합 문제 가 발생한다.
문제점1. 외부 시스템 비정상일 때 트랜잭션
환불 과정에서 익셈션이 발생한다면? 롤백? 커밋?
주문 상태를 취소로 변경하고 환불만 나중에 시도할 수도 있다.
문제점2. 성능
환불 외부 시스템의 응답 시간이 길어지면 그만큼 대기 시간도 길어진다.
문제점3. 설계
도메인 객체에 서비스를 전달하게 되면 주문 관련 도메인 객체인데 결제, 환불 관련 로직이 뒤섞이게 될 수 있다.
이벤트 사용으로 시스템간 강한 결합을 제거 할 수 있다.
[이벤트 란]
이벤트가 발생하면 그 이벤트에 반응하여 원하는 동작을 수행하는 기능을 구현한다. 이 때 이벤트는 "과거에 벌어진 어떤 것"을 의미한다.
'~할 때', '~가 발생하면', '만약 ~하면' 과 같은 요구사항이 도메인의 상태변경과 관련된 경우가 많고 이 때 이벤트를 사용한다.
예) 주문을 취소할 때 미메일을 보낸다.
이벤트 구성요소
- 이벤트 생성 주체: 엔티티, 밸류, 도메인 서비스등 도메인 객체
- 이벤트 디스패처(이벤트 퍼블리셔): 이벤트 생성 주체로부터 이벤트를 전달 받아 이벤트를 처리할 수 있는 핸들러에 이벤트를 전파한다.
- 이벤트 핸들러(이벤트 구독자): 이벤트를 전달 받아 이벤트에 담긴 데이터를 이용해 원하는 기능을 실행한다.
[배송지 변경 관련 이벤트 예제 코드]
public class ShippingInfoChangedEvent {
private String orderNumber;
private long timestamp;
private ShippingInfo newShippingInfo;
// 생성자, Getter
}
public class Order {
public void changeShippingInfo(ShippingInfo newShippingInfo) {
verifyNotYetShipped();
setShippingInfo(newShippingInfo);
Events.raise(new ShippingInfoChangedEvent(number, newShippingInfo));
}
...
}
public class ShippingInfoChangedHandler {
@EventListener(ShippingInfoChangedEvent.class)
public void handle(ShppingInfoChangedEvent evt) {
shippingInfoSynchronizer.sync(
evt.getOrderNumber(),
evt.getNewShippingInfo());
}
}
public class ShippingInfoChangedHandler {
@EventListener(ShippingInfoChangedEvent.class)
public void handle(ShippingInfoChangedEvent evt) {
// 이벤트가 필요한 데이터를 담고 있지 않으면,
// 이벤트 핸들러는 리포지터리, 조회 API, 직접 DB 접근 등의
// 방식을 통해 필요한 데이터를 조회해야함
Order order = orderRepository.findById(evt.getOrderNo());
shippingInfoSynchronizer.sync(
order.getNumber().getValuer(),
order.getShippingInfo());
}
}
- 이벤트는 이벤트 종류 (클래스명), 이벤트 발생 시간, 추가 데이터 등의 이벤트와 관련된 정보를 담고 있다.
- 과거 시제 사용
- Event.raise()를 통해 이벤트 전파
- 핸들러는 @EventListener(ShippingINfoChangedEvent.class)를 이용해 구현
- 이벤트에 관련 정보를 담고 있지 않으면 핸들러에서 필요한데이터를 조회하는 구현이 추가되어야 하기 때문에 데이터를 담고 있는 것이 좋다.
[이벤트 용도]
1. 트리거
도메인의 상태가 바뀔 때 후처리가 필요한 경우 후처리 실행을 위한 트리거로 사용
2. 서로다른 시스템 간의 데이터 동기화
배송지 변경시 외부 배송 서비스에 바뀐 배송지 정보를 전송 해야 한다.
[이벤트 장점]
- 환불로직, 환불 서비스 파라미터 제거 -> 주문 도메인에서 결제(환불) 도메인 의존 제거
- 서로 다른 도메인 로직이 섞이는 것을 방지 할 수 있다.
- 기능 확장 용이: 기능 확장시 핸들러 구현 추가
핸들러 디스패처와 핸들러 구현
- 이벤트 클래스: 이벤트 표현, 과거 시제 사용, 이벤트 처리를 위해 필요한 최소한의 데이터 포
- 디스패처: 스프링이 제공하는 ApplicationEventPublisher를 이용한다.
- Events: 이벤트를 발행한다. 이벤트 발행을 위해 ApplicationEventPublisher 를 사용한다.
- 이벤트 핸들러: 이벤트를 수신해서 처리한다. 스프링 제공 기능 사용, 응용 서비스와 동일한 트랜잭션 범위에서 실행
public class OrderCanceledEvent {
// 이벤트는 핸들러에서 이벤트를 처리하는 데 필요한 데이터를 포함한다.
private String orderNumber;
public OrderCanceledEvent(String number) {
this.orderNumber = number;
}
public String getOrderNumber() {
return orderNumber;
}
}
// 모든 이벤트 공통 프로퍼티가 있다면 상위 클래스를 만든다.
public abstract class Event {
private long timestamp;
public Event() {
this.timestamp = System.currentTimeMillis();
}
public long getTimestamp() {
return timestamp;
}
}
// Event 상속
public class OrderCanceledEvent extends Event {
private String orderNumber;
public OrderCanceledEvent(String number) {
super();
this.orderNumber = number;
}
}
/**
* ApplicationEventPublisher 를 이용해 이벤트 발생
*/
public class Events {
private static ApplicationEventPublisher publisher;
static void setPublisher(ApplicationEventPublisher publisher) {
Events.publisher = publisher;
}
public static void raise(Object event) {
if (publisher != null) {
publisher.publishEvent(event);
}
}
}
// InitializingBean를 사용해 Events 클래스를 초기화
@Configuration
public class EventsConfiguration {
@Autowired
private ApplicationContext applicationContext;
@Bean
public InitializingBean eventsInitializer(ApplicationEventPublisher eventPublisher) {
return () -> Events.setPublisher(eventPublisher);
}
}
// 이벤트 발생
public class Order {
public void cancel() {
verifyNotYetShipped();
this.state = OrderState.CANCELED;
Events.raise(new OrderCanceledEvent(number.getNumber()));
}
}
// 이벤트 핸들러
// OrderCanceledEvent 이벤트가 발생하면 handle 메소드가 실행된다.
public class OrderCanceledEventHandler {
private RefundService refundService;
public OrderCancelOrderService(RefundService refundService) {
this.refundService = refundService;
}
@EventListener(OrderCanceledEvent.class)
public void handle(OrderCanceledEvent orderCanceledEvent) {
refundService.refund(event.getOrderNumber());
}
}
[고민 포인트]
외부 연동 과정에서 익셉션이 발생할 때 트랜잭션 처리는 ?
외부 연동 시스템이 느려지면 곧 내 시스템의 성능 저하로 연결된다.
=> 비동기로 처리하거나 이벤트와 트랜잭션을 연계하여 해결 할 수 있다.
비동기 이벤트 처리
' A 하면 B 하라 '는 요구사항에서 B 처리가 즉각적으로 이루어질 필요가 없는 경우 (보통은 수초~수분내인 경우가 많음)에
이벤트를 비동기로 처리 할 수 있다.
[비동기 이벤트 처리 구현 방법들]
방법1. 로컬 핸들러를 비동기로 실행하기
- @EnableAsync애너테이션을 사용해서 비동기 기능을 활성화한다.
- 이벤트 핸들러 메서드에 @Async애너테이션을 붙인다.
방법2. 메시징 시스템을 이용한 비동기 구현
- 이벤트를 메시지 큐에 저장하는 과정과 메시지 큐에서 이벤트를 읽어와 처리하는 과정은 별도 스레드나 프로세스로 처리
- 래빗MQ(글로벌 트랜잭션 지원), 카프카
이벤트가 발생하면 이벤트 디스패처는 이벤트를 메세지 큐에 보낸다.
-> 메세지 큐는 이벤트를 메세지 리스너에 전달
-> 메시지 리스너는 알맞은 이벤트 핸들러를 이용해서 이벤트를 처리
방법3. 이벤트 저장소와 이벤트 포워더 사용하기
- 이벤트를 db에 저장한 뒤에 별도 프로그램을 이용해 이벤트 핸들러에 전달하는 방법
- 도메인의 상태와 이벤트 저장소로 동일한 db를 사용한다.
- 포워더는 주기적으로 이벤트 저장소에서 이벤트를 가져와 핸들러를 실행한다, 별도의 스레드를 이용하므로 이벤트 발행과 비동기로 처리된다.
- 이벤트 처리 실패시 포워더는 다시 이벤트 저장소에서 이벤트를 읽어와 핸들러를 실행한다.
방법4. 이벤트 저장소와 이벤트 제공 API 사용하기
- 이벤트 핸들러가 api 서버를 통해 이벤트 목록을 가져간다.
- 핸들러가 어디까지 이벤트를 처리했는지 알고 있어야 한다.
이벤트 저장소 구현
- EventEntry: 이벤트 저장소에 보관할 데이터다. 이벤트 식별을 위한 id, 이벤트 타입, 직렬화 데이터 타입, 이벤트 데이터, 이벤트 시간을 갖는다.
- EventStore: 이벤트를 저장하고 조회하는 인터페이스를 제공 -> 이벤트 객체를 직렬화 해서 payload에 저장, 이벤트는 과거형이므로 추가, 조회기능만 제공 (수정 없음)
- JdbcEventStore: JDBC를 이용한 EventStore 구현 클래스
- EventApi: REST API를 이용해서 이벤트 목록을 제공하는 컨트롤러
클라이언트는 위에서 구현된 api를 호출하여 이벤트를 처리한다.
마지막에 처리된 이벤트를 lastoffset과 같은 값에 저장해 중복 처리를 피한다.
포워더 구현
- 포워더는 @Scheulred등을 사용해 일정 주기로 EventStore에서 이벤트를 읽어와 이벤트 핸들러에 전달한다.
- api방식과 마찬가지로 마지막 전달 이벤트 offset을 기억해 두어 중복 처리를 방지한다.
💡 자동 증가 컬럼 주의사항
자동 증가 컬럼은 insert 쿼리를 실행하는 시점에 값이 증가하고 트랜잭션 커밋 시점에서야. DB에 반영된다.
마지막 컬럼이 10인데, A트랜잭션이 시작되고, B트랜잭션이 시작된 후 B 가 먼저 커밋되면 A트랜잭션이 커밋되기 전에 11은 조회 되지 않는다.
이런 문제는 ID를 기준으로 데이터를 지연조회 하는 방식으로 해결한다. (참고: https://javacan.tistory.com/entry/MYSQL-auto-inc-col-gotcha)
[이벤트 적용 시 추가 고려 사항]
- 이벤트 소스를 EventEntry에 추가할지 여부
- 특정 주체 정보 추가
- 포워더에서 전송 실패를 얼마나 허용할 것인가
- 특정 이벤트에서 계속 실패하면 그 실패하는 이벤트 때문에 다른 이벤트 실행이 안됌
- 재전송 횟수 제한을 두거나 실패 이벤트를 따로 물리 저장소에 남기기
- 이벤트 손실
- 이벤트 순서
- 이벤트 재처리
- 순번을 기억하고 있다가 처리한 순번의 이벤트일 경우 무시
- 이벤트 핸들러를 멱등성으로 처리
- DB 트랜잭션 처리
- 동기든 비동기든 이벤트 처리 실패와 트랜잭션 실패를 함께 고려해야 한다.
- @TransactionalEventListener : 스프링 트랜잭션 상태에 따라 이벤트 핸들러를 실행할 수 있게 한다.
Reference