효습
10장 이벤트 본문
10.1 시스템 간 강결합 문제
쇼핑몰에서 구매를 취소해서 환불해야하는 상황
- 환불 기능을 실행하는 주체는 주문 도메인 엔티티
- 도메인 객체나 응용 서비스에서 환불 기능을 구현할 수 있음
- 보통 결제 시스템은 외부에 존재하므로 외부의 결제 시스템을 호출한다. 이때 발생할 수 있는 두 가지 문제
- 외부 서비스(결제 시스템)이 정상이 아닐 경우 트랜잭션 처리는 어떻게 할지 애매하다.
- 환불 기능을 실행하는 과정에서 익셉션이 발생하면 트랜잭션을 롤백? 아니면 일단 커밋?
- 외부 서비스에서 익셉션일 발생하면 환불에 실패했으므로 주문 취소가 맞아보이나 일단 주문의 상태를 변경하고 나중에 환불만 다시 시도할 수도 있음
- 환불을 처리하는 외부 시스템의 응답 시간이 길어지면 그만큼 대기 시간이 길어짐 → 외부 시스템의 영향을 받음
- +) 이 외에 도메인 객체에 서비스를 전달하면 도메인 객체 내에 주문 로직과 결제 로직 , 둘이 섞일 수 있음
- 외부 서비스(결제 시스템)이 정상이 아닐 경우 트랜잭션 처리는 어떻게 할지 애매하다.
- 위와 같은 문제가 발생하는 이유는 주문 바운디드 컨텍스트와 결제 바운디드 컨텍스트가 강결합(high coupling)되어있기 때문이다.
- 강결합을 없앨 수 있는 방법 → 바로 이벤트
10.2 이벤트 개요
이벤트(event)라는 용어는 ‘과거에 벌어진 어떤 것’이라는 의미
10.2.1 이벤트 관련 구성 요소
- 도메인 모델에 이벤트를 도입하려면 네 개의 구성요소인 이벤트 , 이벤트 생성 주체 , 이벤트 디스패처(퍼블리셔) , 이벤트 핸들러(구독자)를 구현해야함
- 이벤트 생성 주체는 엔티티 ,밸류 , 도메인 서비스와 같은 도메인 객체
- 이벤트 핸들러(handler)는 이벤트 생성 주체가 발생한 이벤트를 전달받아 이벤트에 담긴 데이터를 이용해서 원하는 기능을 실행한다
- 이벤트 생성 주체와 이벤트 핸들러를 연결해 주는 것이 이벤트 디스패처(dispatcher)
10.2.2 이벤트의 구성
이벤트는 발생한 이벤트에 대한 정보를 담는다.
- 이벤트 종류 : 클래스 이름으로 이벤트 종류를 표현
- 주문변경 이벤트 클래스 이름의 예 : ShippingInfoChandgedEvent
- 이벤트 발생 시간
- 추가 데이터 : 주문번호 , 신규 배송지 정보 등 이벤트와 관련된 정보
public class Order {
public void changeShippingInfo(ShippingInfo newShippingInfo){
verifyNotYetShippped();
setShippingInfo(newShippingInfo);
Events.raise(new ShippingInfoChangedEvent(number , newShiipingInfo));
}
....
- 이벤트를 발생시키는 주체 : Order 애그리거트
- 배송지 정보를 변경 → 이벤트 발생
Events.raise()
는 디스패처를 통해 이벤트를 전파하는 기능을 제공
public Class ShippingInfoChangedHandler{
@EventListener(ShippingInfoChangedEvent.class)
public void handle(ShippingInfoChangedEvent evt){
//이벤트에 필요한 데이터를 담고 있지 않으면 ,
//이벤트 핸들러는 리포지터리, 조회 api , 직접 db 접근 등의
// 방식을 통해 필요한 데이터를 조회해야한다.
Order order = orderRepository.findById(evt.getOrderNo());
shippingInfoSynchronizer.sync(
order.getNumber().getValue();
order.getShippingInfo());
}
....
- 이벤트는 이벤트 핸들러가 작업을 수행하는 데 필요한 데이터를 담아야 한다.
- 이 데이터가 부족한 경우 핸들러는 필요한 데이터를 읽기 위해 관련 API를 호출하거나 DB에서 데이터를 직접 읽어와야 한다.
- 이벤트 자체와 관련 없는 데이터를 포함할 필요는 없다.
10.2.3 이벤트 용도
이벤트는 크게 두 가지 용도로 쓰인다
- 트리거(trigger) : 도메인 상태가 바뀔 때 다른 후처리가 필요하면 후처리를 실행하기 위한 트리거로 이벤트를 사용할 수 있다.
- 서로 다른 시스템 간의 데이터 동기화 : 배송지를 변경하면 외부 배송 서비스에 바뀐 배송지 정보를 전송해야 한다 ← 이때 이벤트 핸들러를 사용
10.2.4 이벤트 장점
// 이벤트 적용 전
public class Order {
public void cancel (RefundService refundService) {
this.state = OrderState.CANCELED;
this.refundStatus = State.REFUND_STATED;
try {
refundService.refund(getPaymentId());
this.refundStatus = State.REFUND_COMPLETED;
} catch (Exception e) {
...
}
}
}
// 이벤트 적용
public class Order {
public void cancel() {
this.state = OrderState.CANCELED;
Events.raise(new OrderCanceledEvent(number.getNumber()));
}
}
- 환불 로직이 없어짐
- cancel() 메서드에서 환불 서비스를 실행하기 위해 사용한 파라미터도 없어짐
- 환불 실행 로직은 주문 취소 이벤트를 받는 이벤트 핸들러로 이동하게 됨
- 이벤트를 사용하여 주문 도메인에서 결제(환불) 도메인으로의 의존을 제거했다.
이벤트 핸들러를 사용하면 기능 확장이 용이하다.
10.3 이벤트 , 핸드러 , 디스패처 구현
이벤트와 관련된 코드는 다음과 같다.
- 이벤트 클래스 : 이벤트를 표현한다.
- 디스패처 : 스프링이 제공하는 ApplicationEventPublisher를 이용한다.
- Events : 이벤트를 발행한다. 이벤트 발행을 위해 ApplicationEventPublisher를 사용한다.
- 이벤트 핸들러: 이벤트를 수신해서 처리한다. 스프링이 제공하는 기능을 사용한다.
10.3.1 이벤트 클래스
- 이벤트 자체를 위한 상위 타입은 존재하지 않는다. 원하는 클래스를 이벤트로 사용하면 OK
- 클래스 이름은 꼭 과거시제로
- 이벤트 클래스는 이벤트를 처리하는 데 필요한 최소한의 데이터를 포함해야 한다.
- 모든 이벤트가 공통으로 갖는 프로퍼티가 존재한다면 관련 상위 클래스를 만들 수도 있다.
10.3.2 Event 클래스와 ApplicationEventPublisher
- 이벤트 발생과 출판을 위해 스프링이 제공하는 ApplicationEventPublisher를 사용한다.
- 스프링 컨테이너는 ApplicationEventPublisher도 된다.
- Event 클래스는 ApplicationEventPublisher를 사용해서 이벤트를 발생시키도록 구현한다.
package com.myshop.common.event;
import org.springframework.context.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);
}
}
}
raise()
: ApplicationEventPublisher가 제공하는 publishEvent() 메서드를 이용해서 이벤트를 발생시킨다.- setPublisher() 메서드를 통해 Application Event Publisher 객체를 전달받음
10.3.3 이벤트 발생과 이벤트 핸들러
- 이벤트를 발생시킬 코드는 Event.raise() 메서드를 사용한다.
- 이벤트를 처리할 핸들러는 스프링이 제공하는
@EventListener
애너테이션을 사용해서 구현한다.
@EventListener(Event.class)
public void handle(Event event) {
eventStore.save(event);
}
- Order 클래스에서 raise() 메서드를 통해 OrderCanceledEvent 타입 객체를 넘겨 받으면
@EventListener
애너테이션을 붙인 메서드를 찾아 실행함
10.3.4 흐름 정리
- 도메인 기능을 실행한다.
- 도메인 기능은 Events.raise()를 이용해서 이벤트를 발생시킨다.
- Events.raise()는 스프링이 제공하는 ApplicationEventPublisher를 이용해서 이벤트를 출판한다.
- ApplicationEventPublisher는 @EventListener 애너테이션이 붙은 메서드를 찾아 실행
10.4 동기 이벤트 처리 문제
- 외부 서비스를 사용할 경우, 외부 서비스의 성능 저하 → 내 시스템의 성능 저하
- 앞서 언급한 트랜잭션도 문제
→ 이벤트를 비동기로 처리하거나 이벤트와 트랜잭션을 연계
10.5 비동기 이벤트 처리
‘A하면 이어서 B하라’는 내용을 담고 있는 요구사항은 실제로 ‘A하면 최대 언제까지 B 하라’인 경우가 많음
- ‘A하면’은 이벤트로 볼 수 있다
- A 이벤트가 발생하면 별도 스레드로 B를 수행하는 핸들러를 실행하는 방식으로 요구사항을 구현할 수 있음
이벤트를 비동기로 구현하는 방법
- 로컬 핸들러를 비동기로 실행하기
- 메세지 큐를 사용하기
- 이벤트 저장소와 이벤트 포워더 사용하기
- 이벤트 저장소와 이벤트 제공 API 사용하기
10.5.1 로컬 핸들러 비동기 실행
- 스프링이 제공하는
@Async
애너테이션을 사용하면 비동기로 이벤트 핸들러를 실행할 수 있음 @EnableAsync
애너테이션을 사용해서 비동기 기능을 활성화시킨다.- 이벤트 핸들러 메서드에
@Async
애너테이션을 붙인다.
10.5.2 메세징 시스템을 이용한 비동기 구현
- 카프카(Kafka)나 래빗MQ(RabbitMQ)와 같은 메시징 시스템을 사용한다.
- 이벤트가 발생하면 이벤트 디스패처는 이벤트를 메세지 큐에 보낸다
- 메세지 큐는 메세지 리스너에게 이벤트를 전달한다.
- 이벤트를 메세지 큐에 저장하는 과정과 메세지 큐에서 이벤트를 읽어와 처리하는 과정은 별도 스레드나 프로세스로 처리한다.
- 메세지 리스너는 알맞는 이벤트 핸들러를 이용해서 이벤트를 처리한다.
- 필요하다면 이벤트를 발생시키는 도메인 기능과 메세지 큐에 이벤트를 저장하는 절차를 한 트랜잭션으로 묶어야한다.
- 이를 위해서는 글로벌 트랜잭션이 필요함
- 글로벌 트랜잭션을 사용하면 안전하게 이벤트를 메세지 큐에 전달할 수 있는 장점이 있지만
- 반대로 글로벌 트랜잭션으로 인해 전체 성능이 떨어지는 단점이 있다.
- 글로벌 트랜잭션을 지원하지 않는 메시징 시스템도 있음
- 메시지 큐를 사용하면 보통 이벤트를 발생시키는 주체와 이벤트 핸들러가 별도 프로세스에서 동작한다.
- 이벤트 발생 JVM과 이벤트 처리 JVM이 다르다는 것을 의미
- 한 JVM에서 이벤트를 주고받을 수 있긴 하지만 동일 JVM에서 비동기 처리를 위해 시스템을 복잡하게 만들어야함
- 래빗MQ는 글로벌 트랜잭션 지원과 함께 클러스터와 고가용성을 지원하기 때문에 안정적으로 메시지를 전달할 수 있고 다양한 개발 언어와 통신 프로토콜을 지원한다.
- 카프카는 글로벌 트랜잭션을 지원하지는 않지만 다른 메시징 시스템에 비해 높은 성능을 보여준다.
10.5.3 이벤트 저장소를 이용한 비동기 처리
DB에 저장한 뒤에 별도 프로그램을 이용해서 이벤트 핸들러에 전달하는 방법
- 이벤트가 발생하면 핸들러는 스토리지에 이벤트를 저장한다.
- 포워더는 주기적으로 이벤트 저장소에서 이벤트를 가져와 이벤트 핸들러를 실행한다.
- 포워더는 별도 스레드를 이용하기 때문에 이벤트 발행과 처리가 비동기로 처리된다.
- 이 방식은 도메인의 상태와 이벤트 저장소로 동일한 DB를 사용한다.
- 도메인의 상태 변화와 이벤트 저장이 로컬 트랜잭션으로 처리된다.
- 이벤트를 물리적 저장소에 보관하기 때문에 핸들러가 이벤트 처리에 실패할 경우 포워더는 다시 이벤트 저장소에서 이벤트를 읽어와 핸들러를 실행하면 된다.
이벤트를 외부에 제공하는 API를 사용하는 방법
- API 방식과 포워더 방식의 차이점은 이벤트를 전달하는 방식이다.
- 포워더 방식은 포워더를 이용해서 이벤트를 외부에 전달한다.
- API 방식은 외부 핸들러가 API 서버를 통해 이벤트 목록을 가져간다.
- 포워더 방식은 이벤트를 어디까지 처리했는지 추적하는 역할이 포워더에 있고
- API 방식에서는 이벤트 목록을 요구하는 외부 핸들러가 자신이 어디까지 이벤트를 처리했는지 기억해야한다.
이벤트 저장소 구현
이벤트를 저장할 저장소가 필요하다.
- EventEntry : 이벤트 저장소에 보관할 데이터이다. EventEntry는 이벤트 식별하기 위한 id , 이벤트 타입인 type, 직렬화한 데이터 형식인 contentType , 이벤트 데이터 자체인 payload , 이벤트 시간인 timestamp를 가지고 있다.
- EventStore : 이벤트를 저장하고 조회하는 인터페이스를 제공한다.
- JdbcEventStore : JDBC를 이용한 EventStore 구현 클래스이다.
- EventApi : REST API를 이용해서 이벤트 목록을 제공하는 컨트롤러이다.
이벤트는 과거에 벌어진 사건이므로 데이터가 변경되지 않는다.
10.6 이벤트 적용 시 추가 고려 사항
- 이벤트 소스를 EventEntry에 추가할지 여부
- 포워더에서 전송 실패를 얼마나 허용할지
- 실패한 이벤트의 재전송 횟수 제한을 두어야 한다.
- 이벤트 손실
- 이벤트 저장소를 사용하는 방식은 이벤트 발생과 이벤트 저장을 한 트랜잭션으로 처리하기 때문에 트랜잭션에 성공하면 이벤트가 저장소에 보관된다는 것을 보장할 수 있다.
- 로컬 핸들러를 이용해서 이벤트를 비동기로 처리하는 경우 이벤트 처리에 실패하면 이벤트를 유실하게 된다.
- 이벤트 순서
- 이벤트 저장소는 저장소에 이벤트를 발생 순서대로 저장하고 그 순서대로 이벤트 목록을 제공한다.
- 메시징 시스템은 사용 기술에 따라 이벤트 발생 순서와 메시지 전달 순서가 다를 수 있다.
- 이벤트 재처리에 대한 것
- 동일한 이벤트를 다시 처리해야 할 때 마지막으로 처리한 이벤트의 순번을 기억해두었다가 이미 처리한 순번의 이벤트가 도착하면 해당 이벤트를 처리하지 않고 무시하는 것이 가장 쉬운 방법이다.
10.6.1 이벤트 처리와 DB 트랜잭션 고려
이벤트를 처리할 때는 DB 트랜잭션을 함께 고려해야한다.
- 이벤트 처리를 동기로 하든 비동기로 하든 이벤트 처리 실패와 트랜잭션 실패를 함께 고려해야한다.
- 경우의 수를 줄이는 방법은 트랜잭션이 성공할 때만 이벤트 핸들러를 실행하는 것이다.
- 스프링은
@TransactionalEventListener
애너테이션을 지원한다.- 스프링 트랜잭션 상태에 따라 이벤트 핸들러를 실행할 수 있게 한다.
- 이벤트 핸들러를 실행했는데 트랜잭션이 롤백 되는 상황은 발생하지 않는다.
- 이벤트 저장소로 DB를 사용해도 동일한 효과를 볼 수 있다.
- 이벤트 발생 코드와 이벤트 저장 처리를 한 트랜잭션으로 처리하면 된다.
'책 > 도메인 주도 개발 시작하기' 카테고리의 다른 글
11장 CQRS (0) | 2024.05.07 |
---|---|
9장 도메인 모델과 바운디드 컨텍스트 (2) | 2024.04.08 |
8장 애그리거트 트랜잭션 관리 (1) | 2024.04.01 |
7장 도메인 서비스 (0) | 2024.04.01 |
6장 응용 서비스와 표현 영역 (0) | 2024.03.25 |