CH10 이벤트
Last updated
Last updated
결제 취소가 발생했을때 외부 서비스를 호출해서 환불을 해줘야 하는 경우를 가정해보자. 이 경우 아래 두 가지(A와 B) 처리가 되어야 한다.
A) 주문의 상태가 '결제 취소'로 변경된다.
B) 외부 서비스를 호출하여 환불을 요청한다.
결제 취소요청에 대한 응답을 하기 위해서 A와 B 모두 성공적으로 처리가 되어야할까? 만약 B가 30초 이상 걸리는 상황이라면 결제 취소요청에 대한 응답이 그 이상 걸리게 되는데 이를 기다려야할까? 혹은 만약 B는 성공했는데 최종적으로 A가 커밋을 하다가 실패가 난 경우 환불만 되고 주문의 상태는 '결제 취소'로 바뀌지 않고 그대로 남아버리게 되는데 이 경우는 어떻게 해야할까?
이러한 경우의 수를 차치하고 보더라도 다른 성격의 도메인 로직이 한 군데에 엮여서 구현될 것 같다. 즉 다른 도메인간에 강한 커플링이 발생하게 되는 것이다. 이는 높은 수정 비용을 발생시킨다.
이런 상황일때 '도메인 이벤트 패턴'을 적용하면 모든 처리를 하면서도 각각의 로직을 응집성 있게 관리할 수 있다.
책에서는 바운디드 컨텍스트간에 강결합이 발생한 경우라고 예를 들고 있는데 나는 이 표현이 좀 직관적으로 와닿지 않았다. 주문과 결제가 백퍼센트 바운디드 컨텍스트가 다르다고 할 수 있는지 잘 모르겠다. 이상해서 조금 더 찾아보니 에는 아래와 같이 도메인 이벤트 패턴을 다른 애그리거트간에 적용할 수 있는 것으로 소개되고 있다.
정리를 해보면 아래와 같다.
어떠한 기능을 수행하기 위해서 핵심 처리와 1개 이상의 후처리가 수반된다.
이 경우 응답을 하기 위해서 후처리의 완료 여부는 필요가 없다.
이 경우 도메인 이벤트 패턴을 적용할 수 있다. 특히 핵심 처리 주체와 후처리 주체가 다른 애그리거트 및 다른 시스템일 경우 도메인 이벤트 패턴을 적용하면 강결합을 피할 수 있어서 이점이 크다.
특별히 도메인에 있는 이벤트라고 다를게 없고 일반적인 이벤트 publish/subscribe 방식이다. 이벤트 자체가 있고, 이벤트 발생 주체(엔티티, 벨류, 도메인 서비스와 같은 도메인 객체)에서 new Event() 가 발생하면 리스닝하고 있는 핸들러가 이를 받아 처리하는 방식이다.
비동기 이벤트 처리는 이벤트를 발생시키는 스레드와 이벤트를 처리하는 핸들러의 스레드가 다른 경우를 뜻한다. 책에서는 네 가지 방법을 제시하고 있다.
@EnableAsync 로 비동기 기능 활성화 후 @Async 를 통해서 핸들러를 비동기로 동작시키는 방법이다. 이건 좀 위험한 것이 만약 로컬 핸들러가 이벤트 발생에 따른 후속처리를 하는 과정에서 서버가 죽거나 하는 경우에 이벤트가 유실된다.
카프카 혹은 래빗MQ 와 같은 메세지 큐를 사용하는 방식이다. 이벤트 실행 주체가 큐에 메세지를 발행하고 이를 리스닝 하고 있는 핸들러가 구독해서 이벤트를 처리하게 하는 방식이다.
이 경우에도 주의할 부분이 있는데, 메세지를 구독하는 쪽에서 자동 커밋을 할 경우 메세지를 처리하다가 핸들러가 죽어버리면 로컬 핸들러의 경우와 마찬가지로 이벤트가 유실된다. 그래서 이 경우 확실히 후처리를 끝낸 뒤 커밋을 하는 식으로 수동 커밋을 해주는게 좋을 것 같다.(이 내용은 책에 나와있는 것은 아니고 개인적인 의견을 정리)
그런데 수동 커밋을 했을때 또 문제가 뭐냐면 계속 특정 메세지 처리시 익셉션이 발생해서 커밋을 못하면 메세지가 쌓여서 consume 하지 못하는 일이 발생한다. 이 경우에는 일정 횟수 이상 try 를 하고 이를 초과하면 DB에 메세지를 쌓고 커밋을 해버리는 방식도 좋을 것 같다.
이건 포워더에서 일정 주기로 배치 형태로 저장소를 조회해서 이벤트 핸들러로 넘겨주는 식이다. 이 경우도 낮은 확률이겠지만 갑자기 서버가 죽으면 이벤트가 유실될 가능성이 있다. 왜 이런 부분은 책에서 지적하지 않는 건지 모르겠지만 책에는 이러한 유실 가능성에 대한 언급이 없다.
포워더가 이벤트를 가져오는 offset 을 계속 알고 있어야 한다.
포워더랑 유사한데 여기서는 포워더 대신 API 호출을 통해서 이벤트 내용을 가져온다. 포워더 방식과 마찬가지로 API 서버가 offset을 관리하고 있어야 한다.
위 그림은 동기식으로 주문 취소를 발생 시킨 흐름이다. 13번에서 예외가 발생하면 외부 시스템을 통한 결제 취소는 완료가 되었는데 DB 상에서 취소로 상태 변경을 못시키게 된다.
위 그림은 비동기식으로 이벤트를 처리한 것인데 위 동기식과 마찬가지의 문제가 발생할 수 있고, 반대로 9번은 성공했으나 12번 결제 취소가 실패하여 상태값은 주문 완료이지만 결제 취소가 안된 상태로 남아있는 상태가 될 수 있다.
책에서는 이러한 경우의 수를 줄이기 위해서 이벤트 핸들러가 기존 트랜잭션의 작동을 보고 나중에 동작하도록 하는 @TransactionalEventListener 를 사용하도록 권하고 있다. phase 를 AFTER_COMMIT 으로 설정하여 트랜잭션이 성공한 경우에만 핸들러가 동작하도록 하면 경우의 수가 줄어들기 때문이다.
위 예시는 이벤트 핸들러가 처리하는 부분에서 DB 관련 처리가 발생하지 않고 있으나 만약 이벤트 핸들러가 처리하는 후처리 부분에서 DB 관련 작업이 발생하면 트랜잭션 전파(propagation) 에 신경을 써야한다.
메인 처리에서 insert 가 작동하고 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 를 사용할 경우 이미 부모 트랜잭션이 커밋된 상황이기 때문에 후처리에서의 insert가 작동하지 않는다.
예를 들어 아래 코드를 실행할 경우 imageUrl 은 update 되지 않는다.
해결을 위해서는 후처리에서 새로운 트랜잭션을 할당 받아서 처리를 해야한다. 참고로 새로운 트랜잭션을 할당 받는 것이지 스레드를 할당 받는 것은 아니다. 새로운 스레드에서 후처리를 하기 위해서는 @Async 까지 걸어줘야한다. 아무튼 후처리에서 insert 를 위해서 아래 처럼 propagation 옵션을 설정해줘야한다.
아무튼 이벤트 처리 방식을 적용한다면 DB 트랜잭션 관리를 신경을 많이 써야한다. 구현 방식에 따라서 이벤트 유실도 꼼꼼하게 확인을 해서 문제가 없도록 방어적으로 구조를 짜야한다.
에서 똑같은 사례를 다루고 있다.