본문은 [도메인 주도 개발 시작하기: 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을 지정함으로써 트랜잭션 커밋에 성공한 뒤에 핸들러 메서드를 실행하게 했다