본문 바로가기

책리뷰/도메인 주도 개발 시작하기(DDD핵심 개념 정리부터 구현까지)

도메인 주도 개발(DDD) 시작하기 이벤트

728x90
반응형

목차

    시스템간 강결합 문제

    • 쇼핑몰에서 구매 취소시 환불처리가 필요함
    • 환불기능을 실행하는 주체는 주문 도메인 엔티티가 될 수 있음 
    • 보통 결제 시스템은 외부에 존재하기 때문에 외부 서비스가 아닐 경우 트랜잭션 처리가 애매함
      • 주문은 취소상태로 변경 후 환불만 나중에 시도하는 방시긍로 처리할 수 있음 
      • 외부 시스템 응답 시간이 길어지면 대기 시간이 길어지기 때문에 성능에 대한 이슈도 있음 

     

    • 위와같이 도메인 객체에 서비스를 전달하면 Order도메인인데 결제 도메인의 환불 로직이 섞이게 됨 
      • 환불 기능이 바뀌면 Order도 영향을 받을 수 있음 

    • 주문 취소후 환불과 함께 취소 통지를 해야한다면 외부 서비스가 두 개로 증가하고 
      트랜잭션 처리가 더 복잡해짐 

     

    ☝️ 강한 결합을 없앨 수 있는 방법은 이벤트를 사용하는 것이다.

    비동기 이벤트를 사용하면 두 시스템간의 결합을 낮출 수 있다.

     


    이벤트 개요

    이벤트란?

    • 과거에 벌어진 어떤것 
    • 사용자가 암호를 변경한 것 혹은 주문을 취소 한 것 등 상태가 변경됐다는 것을 의미

     

    이벤트 관련 구성요소

    • 도메인 모델에서 이벤트 생성주체 : 엔티티, 밸류, 도메인 서비스 -> 도메인 로직 실행해서 상태가 바뀌면 관련 이벤트 발생 
    • 이벤트 핸들러 : 이벤트 생성 주체가 발생한 이벤트에 반응함
    • 이벤트 디스패처 : 이벤트 생성주체와 이벤트 핸들러를 연결해주는 것 (이벤트를 전달받아서 이벤트 핸들러에 전달)

     

    이벤트 구성

    • 이벤트 종류 : 클래스 이름으로 이벤트 종류 표현
    • 이벤트 발생시간 
    • 추가데이터 : 주문번호, 신규배송지정보 등 이벤트와 관련된 정보 

     

    public class ShippingInfoChangedEvent {
        
        private String orderNumber;
        private long timestamp;
        private ShippingInfo newShippingInfo;
    
        // 생성자, getter
    }
    • 배송지 변경할 때 발생하는 이벤트를 위한 클래스 (이미 벌어진 것으로 표현하기 때문에 과거 시제)
    • 이벤트 발생 주체는 Order애그리거트 
      • Order애그리거트에서 배송지 변경 기능을 구현한 후 배송지 정보를 변경한 후 이벤트 발생시킴

     

    public class Order {
    
        public void changeShippingInfo(ShippingInfo newShippingInfo) {
            verifyNotShipped();
            setShippingInfo(newShippingInfo);
            Events.raise(new ShippingInfoChangedEvent(number, newShippingInfo));
        }
    }
    • Events.raise()는 디스패처를 통해 이벤트를 전파하는 기능을 제공 

     

    public class ShippingInfoChangedHandler {
        
        @EventListener(ShippingInfoChangedEvent.class)
        public void handle(ShippingInfoChangedEvent evt) {
            shippingInfoSynchronizer.sync(evt.getOrderNumber(),
                                          evt.getNewShippingInfo());
        }
    }
    • 핸들러는 디스패처로부터 이벤트를 전달받아 작업 수행 
    • 변경된 배송지 정보를 물류서비스에 전송하는 핸들러 

     

    public class ShippingInfoChangedHandler {
        
        @EventListener(ShippingInfoChangedEvent.class)
        public void handle(ShippingInfoChangedEvent evt) {
            // 이벤트가 필요한 데이터를 담고 있지 않으면,
            // 이벤트 핸들러는 리포지터리, 조회 API, 직접 DB 접근 등의
            // 방식을 통해 필요한 데이터를 조회해야함
            Order order = orderRepository.findById(evt.getOrderNo());
            shippingInfoSynchronizer.sync(evt.getOrderNumber(),
                                          evt.getNewShippingInfo());
        }
    }
    • 데이터가 부족하면 핸들러는 필요한 데이터를 읽기 위해 관련API호출 혹은 DB를 읽어와야함 

     

    이벤트 용도

    • 트리거 : 도메인 상태가 바뀔 때 다른 후처리가 필요하면 트리거로 이벤트를 사용할 수 있음 
    • 서로 다른 시스템 간 데이터 동기화 
      • 배송지 변경시 외부 배송서비스에 바뀐 배송지 전송 

    • 주문 취소시 환불처리를 위한 트리거 사용 

     

    이벤트 장점

    • 서로 다른 도메인 로직이 섞이는 것을 방지할 수 있음
    • 기능 확장도 용이함 
      • 구매 취소 시 환불과 함께 이메일 취소 내용 보낼 경우 이벤트 핸들러를 구현하면됨 

     

     


    이벤트, 핸들러, 디스패처 구현

    • 이벤트 클래스 : 이벤트를 표현
    • 디스패처 : 스프링이 제공하는 ApplicationEventPublisher 이용
    • Events : 이벤트를  발행 > ApplicationEventPublisher사용
    • 이벤트 핸들러 : 이벤트를 수신해서 처리 (스프링 제공 기능 사용)

     

    이벤트클래스

    • 이벤트 자체를 위한 상위타입은 없음. 원하는 클래스를 이벤트로 사용하면 됨
    • OrderCanceledEvent와 같이 클래스 뒤에 접미사로 Event사용 (과거시제사용)
    public class OrderCanceledEvent {
        // 이벤트는 핸들러에서 이벤트를 처리하는 데 필요한 데이터를 포함한다.
        private String orderNumber;
    
        public OrderCanceledEvent(String number) {
            this.orderNumber = number;
        }
    
        public String getOrderNumber() {
            return orderNumber;
        }
    
    }

     

     

    public abstract class Event {
        private long timestamp;
    
        public Event() {
            this.timestamp = System.currentTimeMillis();
        }
    
        public long getTimestamp() {
            return timestamp;
        }
    }
    public class OrderCanceledEvent extends Event {
        private String orderNumber;
    
        public OrderCanceledEvent(String number) {
            super();
            this.orderNumber = number;
        }
    
        public String getOrderNumber() {
            return orderNumber;
        }
    
    }
    • 모든 이벤트가 공통으로 갖는 프로퍼티가 존재한다면 관련 상위 클래스를 만들 수 있음 

     

    Events클래스와 ApplicationEventPublisher

    • 이벤트 발생과 출판을 위해 Spring이 제공하는 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의 raise()는 ApplicationEventPublisher가 제공하는 publishEvent() 메서드를 이용해 이벤트를 발생시킴 
    • Events 클래스가 사용할 Application Event Publisher 객체는 setPublisher()메서드를 통해서 전달받음 

     

    @Configuration
    public class EventsConfiguration {
        @Autowired
        private ApplicationContext applicationContext;
    
        @Bean
        public InitializingBean eventsInitializer() {
            return () -> Events.setPublisher(applicationContext);
        }
    
    }
    • eventInitializer() 메서드는 InitializingBean 타입 객체를 빈으로 설정 
      • 스프링 빈 객체를 초기화할 때 사용하는 Interface -> 이 기능을 이용해 Events클래스 초기화 
    • ApplicationContext는 ApplicationEventPublisher를 상속하고 있으므로 Events클래스 초기화시 ApplicationContext를 전달

     

    이벤트 발생과 이벤트 핸들러

    public class Order {
    
        public void cancel() {
            verifyNotShipped();
            this.state = OrderState.CANCELED;
            Events.raise(new OrderCanceledEvent(number.getNumber()));
        }
    }
    • 이벤트를 발생시킬 코드는 Events.raise()를 사용 
    • 구매 취소 로직 수행 후 Events.raise()를 이용해서 관련 이벤트를 발생시킴 

     

    @Service
    public class OrderCanceledEventHandler {
        private RefundService refundService;
    
        public OrderCanceledEventHandler(RefundService refundService)  {
            this.refundService = refundService;
        }
    
        @EventListener(OrderCanceledEvent.class)
        public void handle(OrderCanceledEvent event) {
            refundService.refund(event.getOrderNumber());
        }
    
    }
    • @EventListener 어노테이션을 사용해서 핸들러를 구현한다 
    • ApplicationEventPublisher#publishEvent() 메서드 실행할 때 OrderCanceledEvent타입 객체 전달시 OrderCanceledEvent.class값을 갖는 @EventListener애너테이션을 붙인 메서드를 찾아 실행함.

     

    흐름정리

    • 응용서비스와 동일한 트랜잭션 범위에서 이벤트 핸들러 실행 

    1. 도메인 기능을 실행
    2. 도메인 기능은 Events.raise()를 이용해서 이벤트를 발생시킴 
    3. Events.raise()는 스프링이 제공하는 ApplicationEventPublisher를 이용해 이벤트를 출판 
    4. ApplicationEventPublisher는 @EventListener(이벤트타입.class) 에너테이션이 붙은 메서드 찾아 실행 

     


    동기 이벤트 처리 문제

    • 외부서비스에 영향을 받는 문제를 해소해야함 
    • 위에서 주문 취소시 OrderCanceledEvent를 발생시키는데 refundService.fund()가 외부 환불 서비스와 연동하고 환불기능이 느려지면 cancel()메서드도 함께 느려짐 
    • 트랜잭션 롤백시 구매취소도 실패가됨 
    • 외부시스템과 연동 동기처리시 이벤트를 비동기로하거나 이벤트와 트랜잭션 연계하는 방법이 있다. 

    비동기 이벤트 처리

    • 회원가입 시 검증을 위해 이메일을 보내는 서비스가 많은데 바로 메일이 도착할 필요는 없음 
    • 결제 역시 주문 취소 후 바로 이루어지지 않아도 됨 
    • 이벤트를 비동기로 구현하는 방법
      • 로컬핸들러를 비동기로 실행하기
      • 메시지 큐를 사용하기
      • 이벤트 저장소와 이벤트 포워더 사용하기
      • 이벤트 저장소와 이벤트 제공 API 사용하기 

     

    로컬핸들로 비동기 실행

    • 이벤트 핸들러를 별도 스레드로 실행하는 것.
    • 스프링이 제공하는 @Async 어노테이션을 사용하면 쉽게 비동기로 실행할 수 있음 
      • @EnableAsync 어노테이션을 이용해 비동기 기능을 활성화
      • 이벤트 핸들러 메서드에 @Async 어노테이션을 붙임
    @EnableAsync
    @SpringBootApplication
    public class TestApplication {
    
    	public static void main(String[] args) {
    		SpringApplication.run(TestApplication.class, args);
    	}
    
    }
    @Service
    public class OrderCanceledEventHandler {
    
        @Async
        @EventListener(OrderCanceledEvent.class)
        public void handle(OrderCanceledEvent event) {
            refundService.refund(event.getOrderNumber());
        }
    
    }
    • OrderCanceledEvent가 발생하면 handle() 메서드를 별도 스레드를 이용해서 비동기로 실행

     

    메시징 시스템을 이용한 비동기 구현

    • 카프카나 래빗MQ와 같은 메시징 시스템을 이용 

    • 메시지큐는 이벤트를 메시지 리스너에게 전달, 메시지 리스너는 알맞은 이벤트 핸들러를 이용해 이벤트 처리 
    • 이벤트를 메시지큐에 저장하는 과정, 큐에서 이벤트를 읽어와 처리하는 과정은 별도 스레드나 프로세스로 처리 
    • 이벤트 발생시키는 도메인기능과 메시지 큐에 이벤트 저장 절차를 한 트랜잭션으로 묶어야함 
    • 도메인 기능 실행 결과를 DB에 반영하고 이 과정에서 발생한 이벤트를 메시지 큐에 저장하는 것을 같은 트랜잭션 범위에서 실행하려면 글로벌 트랜잭션이 필요함 
    • RabbitMQ는 글로벌트랜잭션을 지원, Kafka는 지원하지X

     

    이벤트 저장소를 이용한 비동기 처리

    • 이벤트를 DB에 저장한 후 별도 프로그램을 이용해 이벤트 핸들러에 전달

    • 이벤트 발생시 핸들러는 스토리지에 이벤트를 저장
    • 포워더는 주기적으로 이벤트를 가져와 이벤트 핸들러를 실행 (별도 스레드)
    • 이벤트 저장소로 동일한 DB 사용
    • 이벤트 처리가 실패할 경우 다시 저장소에서 이벤트를 읽어와 핸들러를 실행하면 됨

     

    • API방식과 포워더 방식의 차이점은 이벤트 전달 방식에 있음 
    • 포워더 방식이 포워더를 이용해 이벤트를 외부에 전달, API방식은 외부핸들러가 API서버를 통해 이벤트 목록을 가져감 
    • 포워더방식은 이벤트 처리 추적 역할이 포워더에 있고 API는 외부 핸들러가 어디까지 처리했는지 기억해야함 

    이벤트 적용 시 추가 고려 사항

    • 이벤트에 발생 주체 정보를 추가할 것인지 여부
    • 포워더에서 전송실패를 얼마나 허용할 것인지
    • 이벤트 손실
    • 이벤트 순서 
    • 이벤트 재처리 
    • 스프링은 @TransactionalEventListener 어노테이션을 지원함 
    728x90
    반응형