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

Chapter 10. 이벤트

by 오오오니 2025. 3. 17.

Chapter 10. 이벤트

2024년 3월 7일 오후 12:03
비어 있음

시스템 간 강결합 문제

쇼핑몰에서 구매를 취소하면 환불을 처리해야 한다.
주문(Order) 도메인 엔티티에서 환불 기능을 실행한다면?
보통 결제 시스템이 외부에 있으므로 두 가지 문제가 발생할 수 있다.
외부 서비스가 정상이 아닐 경우 트랜잭션 터리를 어떻게 해야 할지 애매하다.
환불 기능을 실행할 때 익셉션이 발생하면 롤백 해야 할까? 커밋해야 할까?
외부 서비스 성능에 직접적인 영향을 받는다.
환불을 처리하는 외부 시스템의 응답 시간이 길어지면 대기 시간도 같이 길어진다.
설계상 문제
주문 로직과 결제 로직이 섞인다.
환불 기능이 바뀌면 Order도 영향을 받는다.
기능을 추가할 때 트랜잭션 처리가 복잡해지고 영향을 주는 외부 서비스가 증가한다.
문제가 발생한 이유 : 주문 바운디드 컨텍스트와 결제 바운디드 컨텍스트간의 강결합 때문에
강결합을 업앨 수 있는 방법 ⇒ 이벤트

이벤트 개요

이벤트 : 과거에 벌어진 어떤 것
이벤트가 발생한다는 것은 상태가 변경됐다는 것을 의미한다.
암호 변경됨 이벤트 : 회원이 암호를 변경함
이벤트는 발생하면 그 이벤트에 반응하여 원하는 동작을 수행하는 기능을 구현한다.

이벤트 관련 구성 요소

도메인 모델에 이벤트를 도입하려면 이벤ㅌ, 이벤트 생성 주체, 이벤트 디스패처, 이벤트 핸들러를 구현해야한다.
도메인 객체가 도메인 로직을 실행해서 상태가 바뀌면 이벤트를 발생 시키면 이벤트 생성 주체이벤트를 만들고 이벤트 디스패처에 전달한다.
이벤트 디스패처는 해당 이벤트를 처리할 수 있는 핸들러에 이벤트를 전달한다.
이벤트 핸들러는 이벤트를 전달받아 이벤트에 담긴 데이터를 이용해서 원하는 기능을 실행한다.

이벤트의 구성

이벤트 종류
발생 시간
추가 데이터
이벤트 핸들러가 다른 데이터를 읽어올 필요가 없게 핸들러에 필요한 데이터를 모두 담아야한다.
복사
public class ShippingInfoChangedEvent { // 클래스 이름으로 이벤트 종류를 표현 private String orderNumber; //추가 데이터 private long timestamp; //이벤트 발생 시간 private ShippingInfo newShippingInfo;//추가 데이터 // 생성자, Getter }

이벤트는 언제 사용할까?

트리거 : 도메인의 상태가 바뀔 때 후처리를 위해
다른 시스템 간의 데이터 동기화 : 배송지정보를 외부 배송 서비스 배송지 정보랑 동기화

이벤트의 장점

다른 도메인 로직이 섞이는 것을 방지
환불로직, 환불 서비스 파라미터 제거 -> 주문 도메인에서 결제(환불) 도메인 의존 제거
기능 확장이 용이

이벤트, 핸들러, 디스패처 구현

이벤트 클래스

이벤트를 표현한다.
복사
// Event 상속 public class OrderCanceledEvent extends Event { //과거시제 private String orderNumber; // 이벤트 처리에 필요한 데이터 public OrderCanceledEvent(String number) { super(); this.orderNumber = number; } }

Events

이벤트를 발행한다. 이벤트 발행을 위해 스프링이 제공하는 ApplicationEventPublicher를 사용한다.
복사
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);//이벤트 발생 } } }
setPublisher에 이벤트 퍼블리셔를 전달하기 위한 스프링 설정 클래스
복사
@Configuration public class EventsConfiguration { @Autowired private ApplicationContext applicationContext; @Bean public InitializingBean eventsInitializer(ApplicationEventPublisher eventPublisher) { return () -> Events.setPublisher(eventPublisher); } }

이벤트 발생

Events.raise()를 이용하여 관련 이벤트를 발생
복사
public class Order { public void cancel() { verifyNotYetShipped(); this.state = OrderState.CANCELED; Events.raise(new OrderCanceledEvent(number.getNumber())); } }

이벤트 핸들러

스프링이 제공하는 @EventListener 애너테이션을 사용하여 구현
복사
public class OrderCanceledEventHandler { private RefundService refundService; public OrderCancelOrderService(RefundService refundService) { this.refundService = refundService; } //관련 이벤트가 발생하면 ApplicationEventPublisher가 이 메서드를 실행함 @EventListener(OrderCanceledEvent.class) public void handle(OrderCanceledEvent orderCanceledEvent) { refundService.refund(event.getOrderNumber()); } }
흐름
응용 서비스와 동일한 트랜잭션 범위에서 이벤트 핸들러가 실행됨.

동기 이벤트 처리 문제

강결합 문제는 해소했지만 외부 서비스 영향을 받는 문제트랜잭션 문제가 남아있음.
→ 외부 환불 서비스가 느려지만 내 시스템의 성능 저하로 연결됨.
트랜잭션 문제는 외부 환불 서비스 실행에 실패했을 때 구매 취소만 처리하고 환불은 재처리, 수동처리하는 방식으로 처리할 수 도 있다. ⇒ 이벤트 비동기, 이벤트와 트랜잭션을 연계 하는 방식으로 해소

비동기 이벤트 처리

‘A 하면 이어서 B 하라’ 는
‘A 하면 최대 언제까지 B 하라’로 생각할 수 있는 경우는
→ 이벤트를 비동기로 처리하는 방식으로 구현
→ 별도 스레드로 B를 수행하는 핸들러를 실행
이벤트 처리를 구현하는 네 가지 방법
로컬 핸들러를 비동기로 실행하기
메시지 큐를 사용하기
이벤트 저장소와 이벤트 포워더 사용하기
이벤트 저장소와 이벤트 제공 API 사용하기

로컬 핸들러 비동기 실행

이벤트 핸들러를 별도 스레드로 실행.
handle()메서드가 별도 스레드로 비동기로 실행
복사
@SpringBootApplication @EnableAsync // 비동기 기능 활성화 public class ShopApplication{ public static void main(){ SpringApplication.run(ShipApplications.class, args); } }
복사
@Service public class OrderCanceledEventHandler { //이벤트 핸들러 메서드에 @Async 애너테이션 @Async @EventListener(OrderCanceledEvent.class) public void handle(OrderCanceledEvent event) { refundService.refund(event.getOrderNumber()); } }

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

Kafka나 RabbitMQ 같은 메시징 시스템 사용
이벤트를 메시지 큐에 보내고, 큐는 메시지 리스너에 이벤트 전달, 메시지 레스너는 이벤트 핸들러로 이벤트 처리
글로벌 트랜잭션으로 하나의 트랜잭션으로 묶을 수도 있지만 성능이 떨어진다.
래빗MQ 지원

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

이벤트를 저장소(DB)에 저장한 뒤 포워더가 주기적으로 이벤트 저장소에서 가져와 이벤트 핸들러를 실행.
포워드는 별도 스레드 이용. → 비동기
이벤트 처리에 실패할 경우 포워더가 다시 이벤트 저장소에서 이벤트를 읽어와 핸들러를 실행
이벤트 처리 추적 역할포워더에게 있음

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

외부 핸들러가 API 서버를 통해 이벤트 목록을 가져간다.
이벤트 처리 추적 역할외부 핸들러에게 있음

구현

이벤트 저장소
포워더 방식과 API방식 모두 이벤트 저장소를 사용
Event Entry : 이벤트 저장소에 보관할 데이터
EventEntry 클래스
Event Store : 이벤트를 저장, 조회하는 인터페이스
EventStore 인터페이스
JdbcEventStore : 인터페이스 구현 클래스 (JDBC 이용)
JdbcEventStore 구현 클래스
Event Handler : 이벤트를 저장소에 추가
EventHandler 구현 클래스
EventApi : EventStore 실행하고 결과를 JSON으로 리턴
EventApi 클래스
포워더 : 일정 주기로 EventStore에서 이벤트를 읽어와 이벤트 핸들러에 전달
EventForwarder 클래스

이벤트 적용 시 추가 고려 사항

이벤트 적용할 때 고려할 점
이벤트 발생 주체에 대한 정보를 EventEntry에 추가할지 여부
특정 주체(Order)가 발생시킨 이벤트만 조회하는 기능을 구현하려면 추가 해야함.
전송 실패를 얼마나 허용할 것인지
다른 이벤트들도 있어서 무한정 재시도 X
이벤트 손실
저장소를 이용하는 방식 : 이벤트 저장
로컬 핸들러 : 이벤트 실패시 이벤트 유실
이벤트 순서
순서대로 외부에 전달해야 할 경우, 이벤트 저장소를 사용하기
이벤트 재처리
이벤트 순번을 기억하고 동일한 이벤트를 다시 처리해야 할 때 해당 이벤트를 처리하지 않고 무시.
DB 트랜잭션 관점에서 고려할 점
주문취소를 성공하고
동기 - 트랜잭션 실패
db를 업데이트 할 때 실패한다면, DB에는 주문 취소 상태로 남아있다.
비동기 - 이벤트 처리 실패
환불을 위해 외부 api 호출을 취소한다면, 결제는 취소되지 않았다.
→ 이벤트 처리 실패와 트랜잭션 실패를 함께 고려해야 한다.
트랜잭션이 성공할 때만 이벤트 핸들러를 실행해서 해결
복사
@Async @TransactionalEventListener( classes = OrderCanceledEvent.class, phase = TransactionPhase.AFTER_COMMIT ) public void handle(OrderCanceledEvent event) { refundService.refund(event.getOrderNumber()); }
→ 이벤트 처리 실패만 고려하면 된다.
리스트 보기