도메인 주도 개발 시작하기 - 10. 이벤트
10.1 시스템 간 강결합 문제
10.2 이벤트 개요
10.3 이벤트, 핸들러, 디스패처 구현
10.4 동기 이벤트 처리 문제
10.5 비동기 이벤트 처리
10.6 이벤트 적용 시 추가 고려 사항
10.1 시스템 간 강결합 문제
- 트랜잭션 처리 문제
- 환불 기능을 실행하는 과정에서 익셉션이 발생하는 경우
① 주문 취소 트랜잭션을 롤백
② 주문을 취소 상태로 변경한 뒤 환불만 나중에 시도
- 환불 기능을 실행하는 과정에서 익셉션이 발생하는 경우
- 성능 문제
- 외부 시스템의 응답 시간이 길어지면 대기 시간도 길어진다. → 즉, 외부 서비스 성능에 직접적인 영향을 받게 된다.
- 설계상 문제
- 다른 서비스의 로직이 섞이는 문제가 발생한다.
- 기능을 추가할 때마다 로직이 섞이는 문제가 더 커지고, 트랜잭션 처리가 더 복잡해진다.
public class Order { // 기능을 추가할 때마다 파라미터가 함께 추가되어야 하고, 로직이 더 많이 섞이게 되어 트랜잭션 처리가 더 복잡해진다. public void cancel(RefundService refundService) { // 주문 로직 verifyNotYetShipped(); this.state = OrderState.CANCELED; // 결제 로직 this.refundStatus = State.STARTED; try { // 응답 시간이 길어지면 서비스 성능에 영향을 받는다. refundService.refund(getPaymentId()); this.refundStatus = State.REFUND_COMPLETED; } catch (Exception ex) { // 두 로직의 트랜잭션 처리를 해야 한다. ... } } }
➡️ 바운디드 컨텍스트 간의 강결합을 없앨 수 있는 방법은 이벤트를 사용하는 것이다.
10.2 이벤트 개요
- 이벤트라는 용어는 ‘과거에 벌어진 어떤 것’을 의미한다.
- 예를 들어, 사용자가 암호를 변경한 것은 ‘암호를 변경 이벤트’가 발생한 것이다.
- 이벤트가 발생했다는 것은 상태가 변경됐다는 것을 의미한다.
-
이벤트는 발생하는 것에서 끝나지 않고, 그 이벤트에 반응하여 원하는 동작을 수행하는 기능을 구현한다.
$("#myBtn").click(function(event)) { alert("이벤트 발생!"); }
- 도메인 모델에서도 UI 컴포넌트와 유사하게 도메인의 상태 변경을 이벤트로 표현할 수 있다.
~할 때
,~가 발생하면
,만약 ~하면
과 같은 요구사항은 도메인의 상태 변경과 관련된 경우가 많고 이런 요구사항을 이벤트를 이용해서 구현할 수 있다.
10.2.1) 이벤트 관련 구성요소
- 도메인 모델에 이벤트를 도입하려면 네 개의 구성요소를 구현해야 한다.
- 이벤트
- 이벤트 생성 주체
- 이벤트 디스패처(퍼블리셔)
- 이벤트 핸들러(구독자)
-
도메인 모델에서 이벤트 생성 주체는 엔티티, 밸류, 도메인 서비스와 같은 도메인 객체이다.
→ 도메인 객체는 도메인 로직을 실행해서 상태가 바귀면 관련 이벤트를 발생시킨다.
- 이벤트 핸들러는 이벤트를 전달받아 이벤트에 담긴 데이터를 이용해서 원하는 기능을 실행한다.
- 이벤트 생성 주체와 이벤트 핸들러를 연결해 주는 것이 이벤트 디스패처이다.
- 이벤트 생성 주체는 이벤트를 생성해서 디스패처에 이벤트를 전달한다.
- 이벤트를 전달받은 디스패처는 해당 이벤트를 처리할 수 있는 핸들러에 이벤트를 전파한다.
- 이벤트 디스패처의 구현 방식에 따라 이벤트 생성과 처리를 동기나 비동기로 실행하게 된다.
10.2.2) 이벤트의 구성
- 이벤트는 발생한 이벤트에 대한 정보를 담는다.
- 이벤트 종류: 클래스 이름으로 이벤트 종류를 표현
- 이벤트 발생 시간
- 추가 데이터: 주문번호, 신규 배송지 정보 등 이벤트와 관련된 정보
-
이벤트는 현재 기준으로 과거에 벌어진 것을 표현하기 때문에 이벤트 이름에는 과거 시제를 사용한다.
public class ShippingInfoChangedEvent { private String orderNumber; private long timestamp; private ShippingInfo newShippingInfo; ... }
- 이벤트를 발생하는 주체는
Order
애그리거트다. Events.raise()
는 디스패처를 통해 이벤ㄴ트를 전파하는 기능을 제공한다.
public Order { // ShippingInfoChangedEvent 이벤트의 주체 public void changeShippinginfo(ShippingInfo newShippingInfo) { verifyNotYetShipped(); setShippingInfo(newShippingInfo); **Events.raise**(new ShippingInfoChangedEvent(number, newShippingInfo)); } ... }
- 이벤트를 처리하는 핸들러는 디스패처로부터 이벤트를 전달받아 필요한 작업을 수행한다.
public class ShippingInfoChangedHandler { @EventListener(ShippingInfoChangedEvent.class) public void handle(ShippingInfoChangedEvent event) { // 필요한 데이터를 읽기 위해 API를 호출하거나 DB에서 데이터를 읽어온다. Order order = orderRepository.findById(event.getOrderNo()); shippingInfoSynchronizer.sync( order.getNumber().getValue(), order.getShippingInfo()); } }
- 이벤트는 데이터를 담아야 하지만 그렇다고 이벤트 자체와 관련 없는 데이터를 포함할 필요는 없다.
- 이벤트를 발생하는 주체는
10.2.3) 이벤트 용도
-
이벤트는 크게 두 가지 용도로 쓰인다.
① 후처리 실행을 위한 트리거
예매 결과를 SMS로 통지하는 이벤트를 트리거로 사용할 수 있다.② 시스템 간의 동기화
배송지를 변경하면 외부 배송 서비스에 바뀐 배송지 정보를 전송해야 한다.
10.2.4) 이벤트 장점
- 이벤트를 사용하면 서로 다른 도메인 로직이 섞이는 것을 방지할 수 있다.
10.3 이벤트, 핸들러, 디스패처 구현
- 이벤트 클래스: 이벤트를 표현한다.
- 디스패처: 스프링이 제공하는 ApplicationEventPublisher를 이용한다.
- Events: 이벤트를 발행한다. 이벤트 발행을 위해 ApplicationEventPublisher를 사용한다.
- 이벤트 핸들러: 이벤트를 수신해서 처리한다. 스프링이 제공하는 기능을 사용한다.
10.3.1) 이벤트 클래스
- 이벤트 자체를 위한 상위 타입은 존재하지 않으므로, 원하는 클래스를 이벤트로 사용하면 된다.
- 이벤트 클래스의 이름은 과거 시제를 사용해야 한다.
- 이벤트 클래스는 이벤트를 처리하기 위해 필요한 최소한의 데이터를 포함해야 한다.
-
모든 이벤트가 공통으로 갖는 프로퍼티가 존재한다면 관련된 상위 클래스를 만들 수도 있다.
@Getter public abstract class Event { private long timestamp; public Event() { this.timestamp = System.currentTimeMillis(); } }
10.3.2) Events 클래스와 ApplicationEventPublisher
-
이벤트 발생과 출판을 위해 스프링이 제공하는
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); } } }
-
Events#setPublisher()
메서드에 이벤트 퍼블리셔를 전달하기 위해 스프링 설정 클래스를 작성한다.@Configuration public class EventsConfiguration { @Autowired private ApplicationContext applicationContext; @Bean public InitializingBean eventsInitializer() { return () -> Events.setPublisher(applicationContext); } }
InitializingBean
타입으 스프링 빈 객체를 초기화 할 때 사용하는 인터페이스로, Events 클래스를 초기화 한다.ApplicationContext
는ApplicationEventPublisher
를 상속하고 있으므로Events
를 초기화할 때ApplicationContext
를 전달한다.
10.3.3) 이벤트 발생과 이벤트 핸들러
- 이벤트를 발생시키는 코드는
Events.raise()
메서드를 사용한다. -
이벤트를 처리할 핸들러는 스프링이 제공하는
@EventListener
애너테이션을 사용해서 구현한다.@Service public class OrderCanceledEventHandler { private RefundService refundService; public OrderCanceledEventHandler(RefundService refundService) { this.refundService = refundService; } @EventListener(OrderCanceledEvent.class) public void handle(OrderCanceledEvent event) { refundService.refund(event.getOrderNumber()); } }
10.3.4) 흐름 정리
- 도메인 기능을 실행한다.
- 도메인 기능은
Events.raise()
를 이용해서 이벤트를 발생시킨다. Events.raise()
는 스프링이 제공하는ApplicationEventPublisher
를 이용해서 이벤트를 출판한다.ApplicationEventPublisher
는@EventListener
애너테이션이 붙은 메서드를 찾아 실행한다.
→ 도메인 상태 변경과 이벤트 핸들러는 같은 트랜잭션 범위에서 실행된다.
10.4 동기 이벤트 처리 문제
- 이벤트를 사용해서 강결합 문제를 해결할 수 있지만, 외부 서비스에 영향을 받는 문제가 남아있다.
- 아래의 소스에서
refundService.refund()
가 외부 환불 서비스와 연동한다고 가정해보자.
// 1.응용 서비스 코드 @Transactional // 외부 연동 과정에서 예외가 발생하는 경우 트랜잭션 처리? public void cancel(OrderNo orderNo) { Order order = findOrder(orderNo); order.cancel(); // order.cancel()에서 OrderCanceledEvent 발생 } // 2.이벤트를 처리하는 코드 @Service public class OrderCanceledEventHandler { ... @EventListener(OrderCanceledEvent.class) public void handle(OrderCanceledEvent event) { // refundService.refund()가 느려지거나 예외가 발생하면? refundService.refund(event.getOrderNumber()); } }
- 만약 외부 환불 기능이 느려지면
cancel()
메서드도 같이 영향을 받는다. - 트랜잭션 처리에 대해서도 롤백을 할지 부분적으로 처리할지 생각해봐야 한다.
→ 외부 시스템과의 연동을 동기로 처리할 때 발생하는 성능과 트랜잭션 범위 문제를 해소하는 방법은 이벤트를 비동기로 처리하거나 이벤트와 트랜잭션을 연계하는 것이다.
- 아래의 소스에서
10.5 비동기 이벤트 처리
- 우리가 구현해야 할 것 중에서 ‘A 하면 이어서 B 하라’는 내용을 담고 있는 요구사항은 실제로 ‘A 하면 최대 언제까지 B 하라’인 경우가 많다.
- 회원 가입 신청 후 이메일 검증을 위해 이메일이 몇 초에서 몇 분 뒤에 도착해도 된다.
- 주문을 취소하자마자 바로 결제가 취소되지 않더라도, 수십 초 혹은 며칠 내에 결제가 취소되면 된다.
→ 일정 시간 안에만 후속 조치를 처리하면 되는 경우가 많다.
- ‘A 하면 일정 시간 안에 B 하라’는 요구사항에서 ‘A 하면’ 부분을 이벤트로 볼 수도 있다.
- ‘회원 가입 신청을 하면’은 회원 가입 신청 이벤트로볼 수 있고, ‘인증 이메일을 보내라’ 기능은 이벤트를 처리하는 핸들러에서 보낼 수 있다.
- 이벤트를 비동기로 구현하는 방법
- 로컬 핸들러를 비동기로 실행하기
- 메시지 큐를 사용하기
- 이벤트 저장소와 이벤트 포워더 사용하기
- 이벤트 저장소와 이벤트 제공 API 사용하기
10.5.1) 로컬 핸들러 비동기 실행
- 이벤트 핸들러를 비동기로 실행하는 방법은 이벤트 핸들러를 별도 스레드로 실행하는 것이다.
@Async
애너테이션 사용-
@EnableAsync
애너테이션을 사용해서 비동기 기능을 활성화 한다.@SpringBootApplication @EnableAsync public class ShopApplication { public static void main (String[] args) { SpringApplication.run(ShopApplication.class, args); } }
-
이벤트 핸들러 메서드에
@Async
애너테이션을 붙인다.@Service public class OrderCanceledEventHandler { @Async @EventListener(OrderCanceledEvent.class) public void handle(OrderCanceledEvent event) { refundService.refund(event.getOrderNumber()); } }
→ OrderCanceledEvent가 발생하면 handle() 메서드를 별도 스레드를 이용해서 비동기로 실행한다.
-
10.5.2) 메시징 시스템을 이용한 비동기 구현
- 비동기로 이벤트를 처리해야 할 때 카프카나 래빗MQ와 같은 메시징 시스템을 사용하는 것이다.
- 이벤트가 발생하면 이벤트 디스패처는 이벤트를 메시지 큐에 보낸다.
- 메시지 큐는 이벤트를 메시지 리스너에 전달하고, 메시지 리스너는 알맞은 이벤트 핸들러를 이용해서 이벤트를 처리한다.
- 메시지 큐에 저장하는 과정과 메시지 큐에서 이벤트를 읽어와 처리하는 과정은 별도 스레드나 프로세스로 처리된다.
- 필요하다면 이벤트를 발생시키는 도메인 기능과 메시지 큐에 이벤트를 저장하는 절차를 한 트랜잭션으로 묶어야 할 수도 있다.
- 글로벌 트랜잭션을 사용하면 안전하게 이벤트를 메시지 큐에 전달할 수 있는 장점이 있지만, 글로벌 트랜잭션으로 인해 전체 성능이 떨어지는 단점도 있다.
- 또한, 글로벌 트랜잭션을 지원하지 않는 메시징 시스템도 있다.
- 메시지 큐를 사용하면 보통 이벤트를 발생시키는 주체와 이벤트 핸들러가 별도 프로세스에서 동작한다.
10.5.3) 이벤트 저장소를 이용한 비동기 처리
두 가지 처리 방식
- 포워더 방식
- 이벤트를 DB에 저장한 뒤에 별도 프로그램을 이용해서 이벤트 핸들러에 전달한다.
- 이벤트 발생 → 핸들러 → 스토리지에 이벤트 저장 → 포워더 → 이벤트 핸들러 실행
- 포워더는 별도 스레드를 이용하기 때문에 이벤트 발행과 처리가 비동기로 처리된다.
- 이벤트를 물리적 저장소에 보관하기 때문에 핸들러가 이벤트 처리에 실패할 경우 포워더는 다시 이벤트 저장소에서 이벤트를 읽어봐 핸들러를 실행하면 된다.
→ 이 방식은 도메인 상태와 이벤트 저장소로 동일한 DB를 사용하기 때문에, 도메인의 상태 변화와 이벤트 저장이 로컬 트랜잭션으로 처리된다.
- API 방식
- 이벤트를 외부에 제공하는 API를 사용한다.
- API 방식과 포워더 방식의 차이점은 이벤트를 전달하는 방식이다.
- API방식은 외부 핸들러가 API 서버를 통해 이벤트 목록을 가져간다. (포워더 방식은 포워더를 이용해서 이벤트를 전달한다.)
- API 방식에서는 이벤트 목록을 요구하는 외부 핸들러가 이벤트를 어디까지 처리했는지 기억해야 한다. (포워더 방식은 이벤트를 어디까지 처리했는지 추적한다.)
이벤트 저장소 구현
- 포워더 방식과 API 방식 모두 이벤트 저장소를 사용하므로 이벤트를 저장할 저장소가 필요하다.
EventEntry
: 이벤트 저장소에 보관할 데이터id
: 식별자type
: 이벤트 타입contnetType
: 직렬화한 데이터 형식payload
: 이벤트 데이터timestamp
: 이벤트 시간
EventStore
: 이벤트를 저장하고 조회하는 인터페이스를 제공- 이벤트 객체를 직렬화해서 payload에 저장한다.
- 이벤트는 과거에 벌어진 사건이므로 데이터가 변경되지 않는다. 이런 이유로 EventStore 인터페이스는 새로운 이벤트를 추가하는 기능과 조회하는 기능만 제공하고, 수정하는 기능은 제공하지 않는다.
JdcbEventStore
: JDBC를 이용한 EventStore 구현 클래스EventApi
: REST API를 이용해서 이벤트 목록을 제공하는 컨트롤러
이벤트 저장을 위한 핸들러 구현
- 발생한 이벤트를 이벤트 저장소에 추가하는 이벤트 핸들러를 구현한다.
- Event 타입을 상속받은 이벤트 타입만 이벤트 저장소에 보관하기 위해
@EventListener
애너테이션 값으로 Event.class를 갖는다.
REST API 구현
- 이벤트를 수정하는 기능이 없으므로 REST API는 단순 조회 기능만 존재한다.
- API를 사용하는 클라이언트는 일정 간격으로 다음 과정을 실행한다.
- 가장 마지막에 처리한 데이터의 offset인 lastOffset을 구한다. 저장한 lastOffset이 없으면 0을 사용한다.
- 마지막에 처리한 lastOffset을 offset으로 사용해서 API를 실행한다.
- API 결과로 받은 데이터를 처리한다.
- offset + 데이터 개수를 lastOffset으로 저장한다.
→ 마지막에 처리한 lastOffset을 저장하는 이유는 같은 이벤트를 중복해서 처리하지 않기 위해서이다.
- 클라이언트 API를 이용해서 이벤트를 가져올 수 있기 때문에 이벤트 처리에 실패하면 다시 실패한 이벤트부터 읽어와 이벤트를 재처리할 수 있다.
포워더 구현
- 포워더는 API 방식의 클라이언트 구현과 유사하다.
- 포워더는 일정 주기로 EventStore에서 이벤트를 읽어와 이벤트 핸들러에 전달하면 된다.
- API 방식 클라이언트와 마찬가지로 마지막으로 전달한 이벤트의 offset을 기억해 두었다가 다음 조회 시점에 마지막으로 처리한 offset 부터 이벤트를 가져오면 된다.
10.6 이벤트 적용 시 추가 고려 사항
- 이벤트 소스를 EventEntry에 추가할지 여부
- ‘Order가 발생시킨 이벤트만 조회하기’ 처럼 특정 주체가 발생시킨 이벤트만 조회하는 기능이 필요하다면, 이벤트에 발생 주체 정보를 추가해야 한다.
- 포워더에서 전송 실패를 얼마나 허용할지
- 포워더는 이벤트 전송에 실패한 이벤트부터 다시 읽어와 전송을 시도한다.
- 하지만, 특정 이벤트에서 계속 전송에 실패하면 나머지 이벤트를 전송할 수 없게 된다.
- 따라서 포워더를 구현할 때는 실패한 이벤트의 재전송 횟수 제한을 두어야 한다.
- 처리에 실패한 이벤트를 생략하지 않고 별도 실패용 DB나 메시지 큐에 저장하기도 한다. 처리에 실패한 이벤트를 물러적인 저장소에 남겨두면 이후 실패 이유 분석이나 후처리에 도움이 된다.
- 이벤트 손실
- 이벤트 저장소를 사용하는 방식은 이벤트 발생과 이벤트 저장을 한 트랜잭션으로 처리하기 때문에 트랜잭션에 성공하면 이벤트가 저장소에 보관된다는 것을 보장할 수 있다.
- 반면에 로컬 핸들러를 이용해서 이벤트를 비동기로 처리할 경우 이벤트 처리에 실패하면 이벤트를 유실하게 된다.
- 이벤트 순서
- 이벤트 발생 순서대로 외부 시스템에 전달해야 할 경우, 이벤트 저장소를 사용하는 것이 좋다.
- 반면에 메시징 시스템은 사용 기술에 따라 이벤트 발생 순서와 메시지 전달 순서가 다를 수도 있다.
- 이벤트 재처리
- 동일한 이벤트를 다시 처리해야 할 때 어떻게 할지 결정해야 한다.
- 가장 쉬운 방법은 마지막으로 처리한 이벤트의 순번을 기억해 두었다가 이미 처리한 순번의 이벤트는 처리하지 않고 무시하는 것이다.
- 이벤트를 멱등으로 처리하는 방법도 있다.
10.6.1) 이벤트 처리와 DB 트랜잭션 고려
- 이벤트를 처리할 때는 DB 트랜잭션을 함께 고려해야 한다.
-
이벤트 처리를 동기로 하든 비동기로 하든 이벤트 처리 실패와 트랜잭션 실패를 함께 고려해야 한다.
→ 경우의 수를 줄이기 위해 트랜잭션이 성공할 때만 이벤트 핸들러를 실행하는 것이 방법이다.
-
@TransactionalEventListener
애너테이션은 스프링 트랜잭션 상태에 따라 이벤트 핸들러를 실행할 수 있게 한다.@TransactionalEventListener( class = OrderCancledEvent.class, phase = TransactionPhase.AFTER_COMMIT ) public void handle(OrderCanceledEvent event) { refundService.refund(event.getOrderNumber()); }
→ AFTER_COMMIT으로 커밋에 성공한 뒤 핸들러 메서드를 실행하면, 이벤트 핸들러를 실행했는데 트랜잭션이 롤백 되는 상황은 발행하지 않는다.
- 트랜잭션이 성공할 대만 이벤트 핸들러를 실행하게 되면 트랜잭션 실패에 대한 경우의 수가 줄어 이제 이벤트 처리 실패만 고민하면 된다.