본문은 [도메인 주도 개발 시작하기: DDD 핵심 개념 정리부터 구현까지]를 읽고 간단하게 정리한 글입니다. 필요에 따라 생략/수정된 부분이 있을 수 있으며, 내용이 추후 변경될 수 있습니다.
10.1 시스템 간 강결합 문제
- 여러 시스템이 섞여서 하나의 기능을 수행하게 되면 다음과 같은 문제들이 발생한다
- 1) 트랜잭션 처리가 애매해지는 문제
- 2) 외부 서비스의 성능에 영향을 받는 문제
- 3) 도메인 객체에 서로 다른 도메인 로직이 섞이는 문제
- 4) 다른 기능을 추가할 때 위 문제들이 더욱 복잡해진다
- 위 문제들이 발생하는 이유는 바운디드 컨텍스트 간의 강결합(high coupling) 때문이다
- 이벤트를 사용함으로써 강결합을 없앨 수 있다
- 특히 비동기 이벤트를 사용하면 두 시스템 간의 결합을 크게 낮출 수 있다
10.2 이벤트 개요
- 이벤트란 '과거에 벌어진 어떤 것'을 의미한다
- 이벤트가 발생한다는 것은 상태가 변경됐다는 것을 의미한다
- 도메인 모델에서 도메인의 상태 변경을 이벤트로 표현할 수 있다
1) 이벤트 관련 구성요소
- 도메인 모델에 이벤트를 도입하려면 이벤트, 이벤트 생성 주체, 이벤트 디스패처 (퍼블리셔), 이벤트 핸들러(구독자)라는 네 가지 구성요소를 구현해야 한다
- 1) 이벤트 생성 주체
- 엔티티, 밸류, 도메인 서비스와 같은 도메인 객체
- 이들 도메인 객체는 도메인 로직을 실행해서 상태가 바뀌면 관련 이벤트를 발생시킨다
- 2) 이벤트 핸들러
- 이벤트 생성 주체가 발생한 이벤트에 반응한다
- 생성 주체가 발생한 이벤트를 전달받아 이벤트에 담긴 데이터를 이용해서 원하는 기능을 실행한다
- 3) 이벤트 디스패처
- 이벤트 생성 주체와 이벤트 핸들러를 연결해주는 것
- 이벤트 생성 주체가 이벤트를 생성하여 디스패처에 이를 전달하면, 디스패처는 다시 핸들러에 이벤트를 전달한다
- 이벤트 디스패처의 구현 방식에 따라 이벤트 생성과 처리를 동기나 비동기로 실행한다
2) 이벤트의 구성
- 이벤트는 발생한 이벤트에 대한 정보를 담는다
- 이벤트 종류: 클래스 이름으로 이벤트 종류를 표현
- 이벤트 발생 시간
- 추가 데이터: 주문번호, 신규 배송지 정보 등 이벤트와 관련된 정보
- 이벤트는 현재 기준으로 과거에 벌어진 것을 표현하므로 이벤트 이름에는 과거 시제를 사용한다
- 이벤트는 이벤트 핸들러가 작업을 수행하는 데 필요한 데이터를 담아야 하는데, 이 데이터가 부족하면 핸들러는 관련 API를 호출하거나 DB에서 데이터를 직접 읽어온다
3) 이벤트 용도
- 이벤트는 크게 두 가지 용도로 쓰인다
- 1) 트리거
- 도메인의 상태가 바뀔 때 다른 후처리가 필요하면 후처리를 실행하기 위한 트리거로 이벤트를 사용할 수 있다
- 2) 서로 다른 시스템 간의 데이터 동기화
4) 이벤트 장점
- 1) 이벤트를 사용하면 서로 다른 도메인 로직이 섞이는 것을 방지할 수 있다
- 2) 이벤트 핸들러를 사용하면 기능 확장도 용이하다
- 기존 로직에 기능을 추가하고 싶으면 이벤트 핸들러를 추가해서 해당 기능을 수행하면 된다
10.3 이벤트, 핸들러, 디스패처 구현
1) 이벤트 클래스
- 이벤트 자체를 위한 상위 타입은 존재하지 않으며, 원하는 클래스를 이벤트로 사용하면 된다
- 모든 이벤트가 공통으로 갖는 프로퍼티가 존재한다면 상위 클래스를 만들어 이벤트들이 해당 상위 클래스를 상속받게끔 구현할 수 있다
- 이벤트는 과거에 벌어진 상태 변화나 사건을 의미하므로 이벤트 클래스 이름은 과거 시제를 사용한다
- 이벤트 클래스는 이벤트를 처리하는 데 필요한 최소한의 데이터를 포함해야 한다
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() 메서드에 이벤트 퍼블리셔를 전달하기 위해 스프링 설정 클래스를 작성한다
- ApplicationContext는 ApplicationEventPublisher를 상속하고 있으므로 Events 클래스를 초기화할 때 ApplicationContext를 전달한다
@Configuration
public class EventsConfiguration {
@Autowired
private ApplicationContext applicationContext;
@Bean
public InitializingBean eventsInitializer() {
return () -> Events.setPublisher(applicationContext);
}
}
3) 이벤트 발생과 이벤트 핸들러
- 이벤트를 발생시킬 코드는 Event.raise() 메서드를 사용한다
public class Order {
public void cancel() {
verifyNotYetShipped();
this.state = OrderState.CANCELED;
Events.raise(new OrderCanceledEvent(number.getNumber()));
}
...
- 이벤트를 처리할 핸들러는 스프링이 제공하는 @EventListener 애너테이션을 사용해서 구현한다
@Service
public class OrderCanceledEventHandler {
private RefundService refundService;
public OrderCanceledEventHandler(RefundService refundService) {
this.refundService = refundService;
}
@EnventListener(OrderCanceledEvent.class)
public void handle(OrderCanceledEvent event) {
refundService.refund(event.getOrderNumber());
}
}
4) 흐름 정리
- 이벤트 처리 흐름
- 1) 도메인 기능 실행
- 2) 도메인 기능이 Event.raise()를 이용해서 이벤트를 발생
- 3) Event.raise()는 스프링의 ApplicationEventPublisher를 이용해서 이벤트를 출판
- 4) ApplicationEventPublisher는 @EventListener 애너테이션이 붙은 메서드를 찾아 실행
10.4 동기 이벤트 처리 문제
// 1. 응용 서비스 코드
@Transactional // 외부 연동 과정에서 익셉션이 발생하면 트랜잭션 처리는?
public void cancel(OrderNo orderNo) {
Order order = findOrder(orderNo);
order.cancel(); // 이벤트 발생
}
// 2. 이벤트를 처리하는 코드
@Service
public class OrderCanceledEventHandler {
...
@EnventListener(OrderCanceledEvent.class)
public void handle(OrderCanceledEvent event) {
// refundService.refund()가 느려지거나 익셉션이 발생하면?
refundService.refund(event.getOrderNumber());
}
}
- 이벤트를 사용해서 강결합 문제를 해결하더라도 외부 서비스에 영향을 받는 문제(성능 및 트랜잭션 범위 문제)는 해소되지 않는다
- 이벤트를 비동기로 처리하거나 이벤트와 트랜잭션을 연계함으로써 관련 문제를 해소할 수 있다
10.5 비동기 이벤트 처리
- 'A하면 이어서 B하라'는 내용을 담고 있는 요구사항은 실제로 'A 하면 최대 언제까지 B 하라'인 경우가 많다
- 이 경우엔 이벤트를 비동기로 처리하는 방식으로 구현할 수 있다
- A 이벤트가 발생하면 별도 스레드로 B를 수행하는 핸들러를 실행하는 방식으로 요구사항을 구현할 수 있다
- 이벤트를 비동기로 구현할 수 있는 방법은 다양한데, 여기서는 다음 네 가지 방식을 알아본다
- 로컬 핸들러로 비동기 실행하기
- 메시지 큐를 사용하기
- 이벤트 저장소와 이벤트 포워더 사용하기
- 이벤트 저장소와 이벤트 제공 API 사용하기
1) 로컬 핸들러 비동기 실행
- 이벤트 핸들러를 별도 스레드로 실행
- @EnableAsync 애너테이션을 스프링 설정 클래스에 붙여서 비동기 기능을 활성화
- 이벤트 핸들러 메서드에 @Async 애니터이션을 붙인다
2) 메시징 시스템을 이용한 비동기 구현
- 카프카나 래빗MQ와 같은 메시징 시스템을 사용하여 비동기로 이벤트를 처리할 수 있다
- 이벤트가 발생하면 이벤트 디스패처는 이벤트를 메시지 큐에 보낸다
- 메시지 큐는 이벤트를 메시지 리스너에 전달한다
- 리스너는 알맞은 이벤트 핸들러를 이용해서 이벤트를 처리한다
- 이때 이벤트를 메시지 큐에 저장하는 과정과 메시지 큐에서 이벤트를 읽어와 처리하는 과정은 별도 스레드나 프로세스로 처리된다
- 필요하다면 이벤트를 발생시키는 도메인 기능과 메시지 큐에 이벤트를 저장하는 절차를 한 트랜잭션으로 묶어야 한다
- 이 경우엔 글로벌 트랜잭션이 필요하다
- 글로벌 트랜잭션은 안전하게 이벤트를 메시지 큐에 전달할 수 있지만, 반대로 전체 성능이 떨어진다는 단점이 있다
- 글로벌 트랜잭션을 지원하지 않는 메시징 시스템도 있다
- 메시지 큐를 사용하면 보통 이벤트를 발생시키는 주체와 이벤트 핸들러가 별도 프로세스에서 동작한다
- 이는 이벤트 발생 JVM과 이벤트 처리 JVM이 다르다는 것을 의미한다
- 한 JVM에서 메시지 큐를 이용해서 이벤트를 주고받을 수 있지만, 이는 시스템을 복잡하게 만들 뿐이다
3) 이벤트 저장소를 이용한 비동기 처리
- 이벤트 저장소를 이용한 첫 번째 방식은 포워더를 사용하는 것이다
- 이벤트를 일단 DB에 저장한 뒤에 별도 프로그램을 이용해서 이벤트 핸들러에 전달할 수 있다
- 이벤트가 발생하면 핸들러는 스토리지에 이벤트를 저장한다
- 포워더는 주기적으로 이벤트 저장소에서 이벤트를 가져와 이벤트 핸들러에 실행한다
- 포워더는 별도 스레드를 이용하기 때문에 이벤트 발행과 처리가 비동기로 처리된다
- 이 방식은 도메인의 상태와 이벤트 저장소로 동일한 DB를 사용한다
- 즉, 도메인의 상태 변화와 이벤트 저장이 로컬 트랜잭션으로 처리된다
- 이벤트를 물리적 저장소에 보관하므로 핸들러가 이벤트 처리에 실패하면 포워더는 다시 이벤트 저장소에서 이벤트를 읽어와 핸들러를 실행하면 된다
- 이벤트 저장소를 이용한 두 번째 방법은 이벤트를 외부에 제공하는 API를 사용하는 것이다
- API 방식과 포워더 방식의 차이점은 이벤트를 전달하는 방식에 있다
- 포워더 방식은 포워더를 이용해서 이벤트를 외부에 전달한다
- API 방식은 외부 핸들러가 API 서버를 통해 이벤트 목록을 가져간다
- 따라서 각기 다른 주체가 이벤트를 어디까지 처리했는지 추적한다 (포워더 vs 외부 핸들러)
- 포워더 방식과 API 방식 모두 이벤트 저장소를 사용하므로 이벤트를 저장할 저장소가 필요하다
- 이벤트 저장소를 구현한 코드 구조는 위와 같다
10.6 이벤트 적용 시 추가 고려 사항
- 이벤트를 구현할때 추가로 고려할 점이 있다
- 1) 이벤트 소스를 EventEntry에 추가할지 여부
- 이벤트를 발생시킨 주체에 따라 분기 처리할 수 있다
- 2) 포워더에서 전송 실패를 얼마나 허용할 것인가
- 포워더는 이벤트 전송에 실패하면 실페한 이벤트부터 다시 읽어와 전송을 시도한다
- 특정 이벤트에서 계속 전송이 실패하면 나머지 이벤트를 전송할 수 없다
- 포워더를 구현할 때는 실패한 이벤트의 재전송 횟수 제한을 두어야 한다
- 3) 이벤트 손실
- 이벤트 저장소를 사용하는 방식은 트랜잭션에 성공하면 이벤트가 저장소에 보관된다는 것을 보장할 수 있다
- 로컬 핸들러를 이용해서 이벤트를 비동기로 처리할 경우 이벤트 처리에 실패하면 이벤트를 유실하게 된다
- 4) 이벤트 순서
- 이벤트 저장소를 사용하면 이벤트 발생 순서와 메시지 전달 순서가 일치함이 보장된다
- 메시징 시스템은 사용 기술에 따라 이벤트 발생 순서와 메시지 전달 순서가 다를 수도 있다
- 5) 이벤트 재처리
- 동일한 이벤트를 다시 처리해야 할 때 이벤트를 어떻게 할 지 결정해야 한다
- 가장 쉬운 방법은 마지막으로 처리한 이벤트의 순번을 기억해두고, 이미 처리했던 순번의 이벤트는 무시하는 것이다
- 이벤트를 멱등으로 처리하는 방법도 있다
- 멱등성이란 연산을 여러 번 적용해도 결과가 달라지지 않는 성질을 의미한다
- 비슷하게 이벤트 처리도 동일 이벤트를 한 번 적용하나 여러 번 적용하나 시스템이 같은 상태가 되도록 핸들러를 구현할 수 있다
- 이벤트 핸들러가 멱등성을 가지면 시스템 장애로 인해 같은 이벤트가 중복해서 발생해도 결과적으로 동일한 상태가 된다
- 이는 이벤트 중복 발생이나 중복 처리에 대한 부담을 줄여준다
1) 이벤트 처리와 DB 트랜잭션 고려
- 이벤트를 처리할 때는 DB 트랜잭션을 함께 고려해야 한다
- 주문 취소와 환불 기능을 다음과 같이 이벤트를 이용해서 구현하면 다음과 같다
- 주문 취소 기능은 주문 취소 이벤트를 발생시킨다
- 주문 취소 이벤트 핸들러는 환불 서비스에 환불 처리를 요청한다
- 환불 서비스는 외부 API를 호출해서 결제를 취소한다
- 이벤트 발생과 처리를 모두 동기로 처리하면 실행 흐름은 다음과 같다
- 12번 과정까지 다 성공하고 13번 과정에서 DB를 업데이트하는 데 실패하면 결제는 취소됐는데 DB에는 주문이 취소되지 않는 상태로 남게된다
- 비동기로 처리할 때도 마찬가지로 DB 트랜잭션을 고려해야 한다
- 이벤트 핸들러를 호출하는 5번 과정은 비동기로 실행한다
- DB 업데이트와 트랜잭션을 다 커밋한 뒤에 환불 로직인 11~13번 과정을 실행했을 때, 12번 과정에서 외부 API 호출에 실패하면 DB에는 주문이 취소된 상태로 데이터가 바뀌었는데 결제는 취소되지 않는 상태로 남게 된다
- 이벤트 처리를 어떤 방식으로 하든 간에 이벤트 처리 실패와 트랜잭션 실패를 함께 고려해야 한다
- 트랜잭션 실패와 이벤트 처리 실패를 모두 고려하면 복잡해지므로 경우의 수를 줄이는 게 좋다
- 트랜잭션이 성공할 때만 이벤트 핸들러를 실행시키면 경우의 수를 줄일 수 있다
- 스프링은 @TransactionalEventListener 애너테이션을 지원하는데, 이는 스프링 트랜잭션 상태에 따라 이벤트 핸들러를 실행할 수 있게 한다
- phase 속성 값에 TransactionPhase.AFTER_COMMIT을 지정함으로써 트랜잭션 커밋에 성공한 뒤에 핸들러 메서드를 실행하게 했다
- 이 기능을 사용하면 이벤트를 실행했는데 트랜잭션이 롤백 되는 상황이 발생하지 않는다
@TransactionalEventListener(
classes = OrderCanceledEvent.class,
phase = TransactionPhase.AFTER_COMMIT
)
public void handle(OrderCanceledEvent event) {
refundService.refund(event.getOrderNumber());
}
- 이벤트 저장소로 DB를 사용해도 동일한 효과를 볼 수 있다
- 이벤트 발생 코드와 이벤트 저장 처리를 한 트랜잭션으로 처리하면 된다
- 트랜잭션이 성공할 때만 이벤트 핸들러를 실행하게 되면 트랜잭션 실패에 대한 경우의 수가 줄어 이벤트 처리 실패만 고민하면 된다
'책 > 도메인 주도 개발 시작하기: DDD 핵심 개념 정리부터 구현까지' 카테고리의 다른 글
[도메인 주도 개발 시작하기] 11장: CQRS (0) | 2022.11.05 |
---|---|
[도메인 주도 개발 시작하기] 9장: 도메인 모델과 바운디드 컨텍스트 (1) | 2022.10.28 |
[도메인 주도 개발 시작하기] 8장: 애그리거트 트랜잭션 관리 (0) | 2022.10.24 |
[도메인 주도 개발 시작하기] 7장: 도메인 서비스 (0) | 2022.10.21 |
[도메인 주도 개발 시작하기] 6장: 응용 서비스와 표현 영역 (0) | 2022.10.14 |