시스템 간 강결합은 어떻게 없앨 수 있을까?
쇼핑몰에서 구매 취소
를 하면 환불
처리가 되어야 한다.
이때 환불
기능을 실행하는 주체는 주문 도메인이 될 수 있으며, 환불 기능을 실행하기 위해서 환불 도메인 서비스
를 파라미터로 전달받아 실행할 수 있다.
class Order {
// 외부 서비스 실행을 위해 도메인 서비스를 파라미터로 전달 받음
fun cancel(refundService: RefundService) {
verifyNotYetShipped()
this.state = OrderState.CANCELED
this.refundStatus = State.REFUND_STARTED
try {
refundService.refund(getPaymentId())
this.refundStatus = State.REFUND_COMPLETED
} catch(e: Exception)
???
}
}
}
혹은 아래와 같이 응용 서비스에서 환불 기능을 실행할 수도 있다.
class CancelOrderService {
private refundService: RefundService
@Transactional
fun cancel(orderNo: OrderNo) {
val order = findOrder(orderNo)
order.cancel()
order.refundStarted()
try {
refunsService.refund(order.getPaymentId())
order.refundCompleted()
} catch(e: Exception) {
??
}
}
}
보통 결제 시스템은 외부에 존재하므로 RefundService는 외부에 있는 결제 시스템이 제공하는 환불 서비스를 호출한다.
이 때 두가지 문제가 발생할 수 있다.
1. 외부 서비스가 정상이 아닌 경우 트랜잭션을 어떻게 해야할지 애매하다는 것
- 환불 기능을 실행하는 과정에서 익셉션이 발생하면 롤백/커밋 무엇을 실행해야 할까?
- 환불에 실패했으므로 취소 트랜잭션을 롤백 하는 것이 맞아 보이나,, 주문은 취소 상태로 변경하고 환불만 나중에 다시 시도하는 방식으로 처리하는 식도 가능하다.
2. 외부 서비스 성능에 직접적인 영향을 받게 되는 것
- 환불을 처리하는 외부 시스템의 응답 시간이 길어지면 주문 시스템의 대기시간도 그만큼 길어진다.
- 환불 처리 30초 걸리면, 주문 취소 처리도 30초 대기
위의 두 가지 문제 외에 도메인 객체에 서비스를 전달하면 추가로 설계상 문제가 나타나게 되는데 주문 로직
과 결제 로직
이 섞이는 문제다.
class Order {
fun cancel(refundService: RefundService) {
// 주문 로직
verifyNotYetShipped()
this.state = OrderState.CANCELED
// 결제 로직
this.refundStatus = State.REFUND_STARTED
try {
refundService.refund(getPaymentId())
this.refundStatus = State.REFUND_COMPLETED
} catch(e: Exception)
???
}
}
}
- Order는
주문
을 표현하는 도메인 객체인데 결제 도메인의환불 관련 로직
이 뒤섞이게 된다.- 환불 기능이 바뀌면 Order도 영향을 받게 된다는 것을 의미
- 기능 추가시 문제가 발생할 수 있다.
- 만약 주문을 취소한 뒤 환불뿐만 아니라 통지도 필요하다면 통지 로직도 섞이게 된다.
- 트랜잭션 처리가 더 복잡해지며 영향을 주는 외부 서비스가 두 개로 증가한다.
class Order {
fun cancel(refundService: RefundService, notiService: NotiService) {
// 주문 로직
verifyNotYetShipped()
this.state = OrderState.CANCELED
// 결제 로직 + 통지 로직 섞임
// refund 성공하고, noti 실패한다면 어떻게 처리 할지?
// refunt, noti 중 무엇을 먼저 처리해야할지?
}
}
이 문제가 발생하는 이유는 주문 바운디드 컨텍스트와 결제 바운디드 컨텍스트간의 강결합(high coupling)
때문이다.
- 주문이 결제와 강하게 결합되어 있어 주문 바운디드 컨텍스트가 결제 바운디드 컨텍스트에 영향을 받게 되는 것이다.
- 이벤트를 사용하면 이 강결합을 없앨 수 있는데, 이벤트에 관해 알아보자.
이벤트(Event)를 적용해보자
이벤트 구성
이벤트 생성 주체
- 도메인 객체(엔티티, 밸류, 도메인 서비스), 도메인 로직을 실행해서 상태가 바뀌면 이벤트를 발생시킨다.
이벤트 핸들러
- 이벤트 생성 주체가 발생한 이벤트에 반응
- 생성 주체가 발생한 이벤트를 전달받아 이벤트에 담긴 데이터를 이용해 원하는 기능을 실행 (ex. SMS 통지)
이벤트 디스패처
- 이벤트 생성 주체와 이벤트 핸들러를 연결시켜 줌
이벤트는 발생한 이벤트에 대한 정보를 담는다.
- 이벤트 종류 : 클래스 이름으로 이벤트 종류를 표현
- 이벤트 발생 시간
- 추가 데이터 : 주문번호, 신규 배송지 정보 등 이벤트와 관련된 정보
배송지를 변경할 때 발생하는 이벤트를 구현해보자.
구현1. 배송지 변경 이벤트 ShippingInfoChangedEvent
class ShippingInfoChangedEvent {
val orderNumber: String
val timestamp: Long
val newShippingInfo: ShippingInfo
}
- 클래스 이름은 과거 시제(Changed)를 사용하자. 이벤트는 현재 기준으로 과거에 벌어진 것을 표현하기 때문
구현2. 이벤트를 발생하는 주체인 Order 애그리거트
class Order {
fun changeShippingInfo(newShippingInfo: ShippingInfo) {
verifyNotYetShipped()
setShippingInfo(newShippingInfo)
// 이벤트를 발생시킨다.
// Events.raise()는 디스패처를 통해 이벤트를 전파하는 기능을 제공
Evernts.raise(ShippingInfoChangedEvent(number, newShippingInfo))
}
}
구현3. ShippingInfoChangedEvent를 처리하는 핸들러 구현
- 디스패처로부터 이벤트를 전달받아 필요한 작업을 수행
class ShippingInfoChangeHanlder {
@EventListener(ShippingInfoChangedEvent::class)
fun handle(evt: ShippingInfoChangedEvent) {
val order = orderRepository.findById(evt.orderNo)
shippingInfoSynhronizer.sync(
order.getNumber().getValue()
order.getShippingInfo()
)
}
}
이벤트 용도 및 장점
이 벤트는 두 가지 용도로 쓰인다.
용도1. 트리거(Trigger)
- 도메인의 상태가 바뀔때 다른 후처리가 필요하면 후처리를 실행하기 위한 트리거로 이벤트를 사용할 수 있음
- 주문 취소 이벤트(트리거) 발생 -> 이벤트 핸들러에서 환불 처리 구현
- 예매 완료 이벤트(트리거) 발생 -> 이벤트 핸들러에서 SMS 발송 방식 구현
용도2. 서로 다른 시스템 간의 데이터 동기화
- 배송지를 변경하면 외부 배송 서비스에 바뀐 배송지 정보를 전송해야 한다.
- 주문 도메인은 배송지 변경 이벤트를 발생시키고 이벤트 핸들러는 외부 배송 서비스와 배송지 정보를 동기화 한다.
이벤트 적용시 장점
- 서로 다른 도메인 로직이 섞이는 것을 방지해준다.
before
class Order {
fun cancel(refundService: RefundService) {
verifyNotYetShipped()
this.state = OrderState.CANCELED
this.refundStatus = State.REFUND_STARTED
try {
refundService.refund(getPaymentId())
this.refundStatus = State.REFUND_COMPLETED
} catch(e: Exception)
???
}
}
}
After
class Order {
fun changeShippingInfo(newShippingInfo: ShippingInfo) {
verifyNotYetShipped()
setShippingInfo(newShippingInfo)
Evernts.raise(ShippingInfoChangedEvent(number, newShippingInfo))
}
}
- 구매 취소에 더 이상 환불 로직이 존재하지 않음
- 환불 서비스를 실행하기 위한 파라미터도 없어짐
- 환불 실행 로직은 주문 취소 이벤트를 받는 이벤트 핸들러로 이동됨
- 확장도 용이해짐
- 이메일로 취소 내용을 보내고 싶다면, 이메일 발송을 처리하는 핸들러만 구현하면 됨
이벤트, 핸들러, 디스패처 구현
- 이벤트 클래스 : 이벤트 표현
- 디스패처 : 스프링에 제공하는 ApplicationEventPublisher 이용
- Events : 이벤트 발행. 이벤트 발행을 위해 ApplicationEventPublisher 사용
- 이벤트 핸들러 : 이벤트를 수신해서 처리 (스프링이 제공하는 기능 사용)
이벤트 클래스
- 원하는 클래스를 이벤트로 사용(별도 상위 타입이 존재하지 않는다.)
- 과거 시제 사용
abstract class Event {
// 공통으로 갖는 프로퍼티 구현
val timestamp = System.currentTimeMillis()
}
class OrderCanceledEvent(
val orderNumber: String
): Event()
Events 클래스와 ApplicationEventPublisher
- 이벤트 발생과 제공을 위해 스프링이 제공하는 ApplicationEventPublisher를 사용한다.
Events 클래스
class Events {
companion object {
private lateinit var publisher: ApplicationEventPublisher
// Events 클래스가 사용할 ApplicationEventPublisher 셋팅
fun setPublisher(publisher: ApplicationEventPublisher) {
this.publisher = publisher
}
// ApplicationEventPublisher 이용하여 이벤트 발행
fun raise(event: Any) {
this.publisher.publishEvent(event)
}
}
}
EventsConfiguration
@Configuration
class EventsConfiguration(
private val applicationContext: ApplicationContext
) {
@Bean
fun eventsInitializer(): InitializingBean {
return InitializingBean {
Events.setPublisher(this.applicationContext)
}
}
}
- InitializingBean 타입 객체를 빈으로 설정하여 Events 클래스를 초기화한다.
- 스프링 컨테이너는 ApplicationEventPublisher도 된다. ApplicationContext가 ApplicationEventPublisher를 구현하기 때문
public interface ApplicationContext extends EnvironmentCapable, ListableBeanFactory, HierarchicalBeanFactory,
MessageSource, ApplicationEventPublisher, ResourcePatternResolver {
}
이벤트 발생과 이벤트 핸들러
이벤트 발생
class Order {
fun cancel() {
verifyNotYetShipped()
this.state = OrderState.CANCELED
Events.raise(OrderCanceledEvent(number.getNumber())
}
}
이벤트 핸들러(처리)
@Service
class OrderCanceledEventHandler(
private val refundService: RefundService
){
@EventListener(OrderCanceledEvent::class)
fun handle(event: OrderCanceledEvent) {
refundService.refund(event.getOrderNumber())
}
}
- ApplicationEventPublisher#publishEvent() 메서드에 OrderCanceledEvent 타입을 전달하면, OrderCanceledEvent::class값을 갖는 @EventListener 애너테이션을 붙인 메서드를 찾아 실행하게 됨
- 같은 트랜잭션 범위에서 실행됨
이벤트 동기 vs 비동기 처리
이벤트 처리를 통하여 서비스간 강결합(high coupling)문제는 해결했지만 외부 서비스에 영향
을 받는 문제는 아직 존재한다.
// 응용 서비스 코드
@Transactional
fun cancel(orderNo: OrderNo) {
// 외부 연동 과정에서 익셉션이 발생하면 트랜잭션 처리는?
val order = findOrder(orderNo)
order.cancel() // order.cancel()에서 Exception 발생
}
// 이벤트를 처리하는 코드
@Service
class OrderCanceledEventHandler(
private val refundService: RefundService
){
@EventListener(OrderCanceledEvent::class)
fun handle(event: OrderCanceledEvent) {
// refundService.refund()가 느려지거나 Exception이 발생하면?
refundService.refund(event.getOrderNumber())
}
}
- refundService.refund()가 외부 환불 서비스와 연동
- 성능
- 외부 환불 기능이 느려지면 cancel()메소드도 함께 느려짐
- 외부 서비스 성능 저하 -> 시스템 성능 저하 초래
- 트랜잭션 처리
- refundService.refund()에서 Exception 발생
- cancel() 롤백? 커밋?
- 만약 롤백을 한다면 구매 취소 기능도 실패한다.
- 외부 환불 서비스 실행이 실패하였다고 반드시 트랜잭션 롤백을 진행해야할까?
- 구매 취소 자체는 처리하고 환불만 재처리하거나 수동으로 처리할 수 있진 않을까?
- 성능
위의 문제는 이벤트를 비동기로 처리
하거나 이벤트와 트랜잭션을 연계
하여 해결할 수 있다. 우리는 비동기 이벤트 처리에 대해 알아보자
비동기 이벤트 처리
우리가 구현해야하는 A하면 이어서 B를 해라
라는 요구사항은 실제로 A하면 최대 언제까지 B하라
인 경우가 많다. (모두 그렇지는 않지만..)
이 말은 즉슨, B를 하다가 실패하여도 일정 간격으로 재시도
혹은 수동 처리
가 가능하다는 말이다.
대표적인 예가 회원 가입 신청시 이메일 검증을 하는 것이다. 회원 가입 신청 시점에 이메일 발송이 실패하여도 사용자는 이메일 재전송 요청을 이용하여 수동으로 인증 이메일을 다시 받아볼 수 있다.
- 요구사항 : 회원가입신청을 하면 인증 이메일을 보내라
- 이벤트 : 회원 가입 신청
- 이벤트 핸들러 : 인증 이메일을 보냄
우리는 A하면 최대 언제까지 B하라
라는 요구사항이 있을 때 비동기 처리(별도 스레드)로 핸들러를 구현할 수 있다.
비동기 이벤트 처리는 아래 네 가지 방식으로 구현할 수 있다.
- 로컬 핸들러를 비동기로 실행
- 메시지 큐 이용
- 이벤트 저장소와 이벤트 포워더 사용
- 이벤트 저장소와 이벤트 제공 API 사용
1. 로컬 핸들러를 비동기로 실행
- 이벤트 핸들러를 별도 쓰레드로 실행하는 것
- @Async 어노테이션을 활용하거나, 코루틴을 활용할 수 있겠다. (하지만 코루틴에 대해 잘 모른다..ㅜ)
- config에 @EnableAsync 추가
- hanlder에 @Async 추가
@Service
class OrderCanceledEventHandler(
private val refundService: RefundService
){
@Async
@EventListener(OrderCanceledEvent::class)
fun handle(event: OrderCanceledEvent) {
refundService.refund(event.getOrderNumber())
}
}
2. 메시지 큐 이용
- 카프카(kafka)나 래빗MQ(RabiitMQ) 이용
- 이벤트를 메시지 큐에 저장하는 과정과 메시지 큐에서 이벤트를 읽어와 처리하는 과정은 별도 스레드나 프로세스로 처리된다.
3. 이벤트 저장소를 이용
- 이벤트를 DB에 저장한 뒤 별도 프로그램을 이용하여 핸들러에 전달하는
3-1. 이벤트 포워드 사용
- 핸들러가 스토리지에 이벤트 저장(도메인 상태 변환
- 포워드는 주기적으로 이벤트 저장소에서 이벤트를 가져와 이벤트 핸들러 실행(별도 스레드)
- 스프링 @Scheduled로 구현이 가능하다. (offset을 저장해야 함)
- 서버가 여러대라면 동기화 관련해서 생각해보긴 해야할 것 같다.
- 이벤트 핸들러가 처리에 실패할 경우 포워더는 다시 DB에서 이벤트를 읽어와 핸들러를 실행할 수 있다.
/api/events?offset=0&limit=5
- 5개 이벤트 제공
/api/events?offset=5&limit=5
- 3개 이벤트 제공
/api/events?offset=8&limit=5
- 0개 이벤트 제공
/api/events?offset=8&limit=5
3-2. 이벤트 제공 API 사용
- 이벤트 포워드와 동일하나, 포워더가 이벤트를 외부에 전달하지만 API 방식은 외부 핸들러가 API를 이용하여 이벤트 목록을 가져간다.
- 이벤트 추적은 포워더가 아니라 외부 핸들러에서 이루어진다.
이벤트를 적용할 때 추가적으로 고려할 사항에는 무엇이 있을까
이벤트 처리와 DB 트랜잭션 고려
이벤트 처리를 동기 혹은 비동기로 처리하여도, 이벤트 처리 실패와 트랜잭션 실패를 함께 고려할 수 밖에 없다.
이벤트 실패, 트랜잭션 실패를 모두 고려하면 복잡해지므로 가장 좋은 방법은 경우의 수를 줄이는 것이다.
트랜잭션이 성공할 때만 이벤트 핸들러를 실행하자.
스프링에서는 @TransactionEventListener 애너테이션을 지원하는데 이 애너테이션을 사용하면 트랜잭션 상태에 따라 핸들러 실행을 할 수 있게 한다.
@TransactionEventListener(
classes = OrderCanceledEvent::class,
phase = TransactionPhase.AFTER_COMMIT
)
fun hanlde(evt: OrderCanceledEvent) {
refundService.refund(evt.getOrderNumber())
}
- 트랜잭션 커밋이 성공한 뒤 핸들러 메서드를 실행시킨다.
- 중간에 에러가 발생해서 트랜잭션 롤백 되면 핸들러 메서드를 실행하지 않는다.
- 이벤트 핸들러를 실행했는데 트랜잭션이 롤백 되는 상황은 발생하지 않는다.
- 이렇게되면 이벤트 처리 실패만 고려하면 된다. 이벤트 특성에 따라 재처리 방식을 결정하거나 하자
데이터 불일치 케이스
case1. 동기 처리 방식
- 주문 취소
- 환불 요청 외부 API (성공)
- 주문 취소 DB UPDATE (실패)
- 환불 요청은 완료 / 주문 취소는 실패로 데이터 불일치
case2. 비동기 처리 방식
- 주문 취소
- 주문 취소 DB UPDATE (성공)
- 별도 쓰레드로 환불 요청 외부 API (실패)
- 환불 요청은 실패 / 주문 취소는 성공으로 데이터 불일치
이벤트 처리시 고려할 점
1. 포워더에서 전송 실패를 얼마나 허용할 것인지
- 포워더에서 이벤트 전송에 실패하면 실패 이벤트부터 다시 읽어와 전송을 시도
- 특정 이벤트에서 계속 전송 실패가 발생한다면? 남머지 이벤트도 전송이 불가한 상황이 옴
- 최대 전송 횟수를 정하고 다음 이벤트로 넘어가는 정책 필요
- 실패한 경우 DB나 메시지 큐에 저장한다면 분석 혹은 후처리에 도움이 된다.
2.이벤트 손실을 어떻게 처리할 것인지
- 이벤트 저장소를 사용하면 이벤트 발생과 이벤트 저장을 한 트랜잭션으로 묶이기 때문에 문제가 없음
- 로컬에서 이벤트를 비동기로 처리할 경우 이벤트 처리에 실패하면 이벤트가 유실됨(유실되도 문제 없을 때만 이용)
3. 이벤트 순서는 상관이 없는지
- 이벤트 발생 순서대로 외부 시스템에 전달해야하는 경우, 이벤트 저장소를 이용하자
- 이벤트 저장소에서 이벤트 발생 순서를 저장하고 목록을 제공하면 순차적으로 처리가 가능하다.
- 메시징 시스템은 이벤트 발생 순서와 메시지 전달 순서가 보장되지 않는다.
4. 이벤트 재처리는 어떻게 할 것인지
- 동일 이벤트를 재처리할 경우 어떻게 할지?
- 이벤트 순번을 기억해두었다가 이미 처리한 순번이 도착하면 무시하는 방식을 사용할 수 있음
- 혹은 멱등으로 처리
- 동일 이벤트를 한 번 적용하나 여러 번 적용하나 시스템이 같은 상태가 되도록 구현하면 동일 이벤트 중복처리를 방지할 수 있음
- 중복 처리에 대한 부담담에서 벗어나자
출처) 도메인 주도 개발 시작하기 - 최범균
'프로그래밍 노트 > 도메인 주도 설계(DDD)' 카테고리의 다른 글
[도메인 주도 설계] CQRS 적용하여 구현복잡도 낮추기 (0) | 2023.08.28 |
---|---|
[도메인 주도 설계] 도메인 모델과 바운디드 컨텍스트 (0) | 2023.08.21 |
[도메인 주도 설계] 도메인 서비스(Domain Service) (0) | 2023.08.16 |
[도메인 주도 설계] 표현 영역과 응용 영역(Presentation Layer, Application Layer) (2) | 2023.08.14 |
[도메인 주도 설계] 애그리거트에 대해 (3) | 2023.08.07 |