도메인 주도 개발 스터디

Chapter 10 이벤트

막이86 2023. 11. 9. 16:39
728x90

도메인 주도 개발 시작하기 10장을 요약한 내용입니다.

10.1 시스템 간 강결합 문제

  • 쇼핑몰에서 구매를 취소하면 환불을 처리해야 한다.
    • 환불 기능을 실행하는 주체는 주문 도메인 엔티티가 될 수 있다.
    • 도메인 객체에서 환불 기능을 실행하려면 환불 기능을 제공하는 도메인 서비스를 파라미터로 전달 받고 도메인 서비스를 실행하게 된다.
public class Order {
	// 외부 서비스를 실행하기 위해 도메인 서비스를 파라미터로 전달받음
	public void cancel(RefundService refundService) {
		verifyNotYetShipped();
		this.state = OrderState.CANCELED;
		
		this.refundStatus = State.REFUND_STARTED;
		try {
			refundService.refund(getPaymentId());
			this.refundStatus = State.REFUND_COMPLETED;
		} catch (Exception ex) {

		}
	}
}
  • 응용 서비스에서 환불 기능을 실행 할 수도 있다.
public class CancelOrderService {
	private RefundService refundService;
	
	@Transactional
	public void cancel(OrderNo orderNo) {
		Order order = findOrder(orderNo);
		order.cancel();
		
		order.refundStarted();
		try {
			refundService.refund(order.getPaymentId());
			order.refundCompleted()
		} catch (Exception ex) {

		}
	}
}
  • 보통 결제 시스템은 외부에 존재하므로 RefundService는 외부에 있는 결제 시스템이 제공하는 환불 서비스를 호출
    • 외부 서비스가 정상이 아닐경우 트랜잭션 처리를 어떻게 해야할지?
    • 환불 기능을 실행하는 과정에서 익셉션이 발생하면 트랜잭션을 롤백 해야할까?
  • 외부의 환불 서비스를 실행하는 과정에서 익셉션이 발생하면 환불이 실패했으므로 롤백 하는 것이 맞아 보임
    • 반드시 롤백해야 하는 것은 아님
    • 취소 상태로 변경하고 환불만 나중에 다시 시도 할 수 있음
  • 환불을 처리하는 외부 시스템의 응답 시간이 길어지면 그만큼 대기 시간도 길어진다.
    • 외부 서비스 성능에 직접적인 영향을 받게 됨
  • 도메인 객체에 서비스를 전달하면 추가로 설계상 문제가 나타날 수 있다.
    • 주문 로직과 결제 로직이 섞이는 문제
    • 환불 기능이 변경되면 주문도 영향을 받게 됨
  • 주문 취소한 뒤에 환불뿐만 아니라 다른 기능이 추가 된다면?
    • 환불 도메인 서비스와 동일하게 파라미터로 서비스를 받도록 구현하면 로직이 섞이는 문제가 더 커지고 트랜잭션 처리가 복잡해진다.
    public class Order {
    	public void cancel(RefundService refundService, NotiService notiSvc) {
    		verifyNotYetShipped();
    		this.state = OrderState.CANCELED;
    		
    		// 주문 + 결제 + 통지 로직이 섞임
    		// refundService는 성고하고, notiSvc는 실패하면?
    		// refundService와 NotiSvc 중 무엇을 먼저 처리하나?
    	}
    }
    
  • 주문 바운디드 컨텍스트와 결제 바운디드 컨텍스트간의 강결합 때문이다.
    • 강결합을 없앨 수 있는 방법으로 이벤트를 사용하는 것
    • 비동기 이벤트를 사용하면 두 시스템 간의 결합을 크게 낮출 수 있다.

10.2 이벤트 개요

  • 이벤트(event)라는 용어는 ‘과거에 벌어진 어떤 것’을 의미
    • 사용자가 암호를 변경한 것을 ‘암호를 변경했음 이벤트’가 벌어졌다고 할 수 있다.
  • 이벤트가 발생한다는 것은 상태가 변경됐다는 것을 의미
    • ‘암호 변경됨 이벤트’가 발생한 이유는 회원이 암호를 변경했기 때문
  • 도메인 모델에서도 UI 컴포넌트와 유사하게 도메인의 상태 변경을 이벤트로 표현할 수 있다.
    • 도메인의 상태 변경과 관련된 요구사항을 이벤트를 이용해서 구현할 수 있다.
    • ‘주문을 취소할 때 이메일을 보낸다’라는 요구사항에서 ‘주문을 취소할 때’는 주문이 취소 상태로 바뀌는 것을 의미
    • ‘주문 취소됨 이벤트’를 활용해서 구현할 수 있다.

10.2.1 이벤트 관련 구성요소

  • 도메인 모델에 이벤트를 도입하려면 네 개의 구성요소인 이벤트, 이벤트 생성 주체, 이벤트 디스패처(퍼블리셔), 이벤트 핸들러(구독자)를 구현 해야한다.
  • 이벤트 생성 주체는 엔티티, 밸류, 도메인 서비스 와 같은 도메인 객체
  • 이벤트 핸들러는 이벤트 생성 주체가 발생한 이벤트에 반응
    • 이벤트를 전달 받아 이벤트에 담긴 데이터를 이용해서 원하는 기능을 실행
  • 이벤트 생성 주체와 이벤트 핸들러를 연결해주는 것이 이벤트 디스패처
    • 이벤트 생성 주체는 이벤트를 생성해서 디스패처에 이벤트를 전달
    • 디스패처는 해당 이벤트를 처리할 수 있는 핸들러에 이벤트를 전파

10.2.2 이벤트의 구성

  • 이벤트는 발생한 이벤트에 대한 정보를 담는다.
    • 이벤트 종류: 클래스 이름으로 이벤트 종류를 표현
    • 이벤트 발생 시간
    • 추가 데이터: 주문번호, 신규 배송지 정보 등 이벤트와 관련된 정보
  • 배송지를 변경할 때 발생하는 이벤트
    • 클래스 이름을 보면 Changed라는 과거 시제를 사용
    • 이벤트는 현재 기준으로 과거에 벌어진 것을 표현하기 때문에 이벤트 이름에는 과거 시제를 사용
    public class ShippingInfoChangedEvent {
    	private String orderNumber;
    	private String long timestamp;
    	private String ShippingInfo newShippingInfo;
    }
    
  • 이벤트를 발생하는 주체는 Order 애그리거트
    • 배송지 정보를 변경한 뒤에 이벤트를 발생
    public class Order {
    	public void changeShippingInfo(ShippingInfo newShippingInfo) {
    		verifyNotYetShipped();
    		setShippingInfo(newShippingInfo);
    		Events.raise(new ShippingInfoChangedEvent(number, newShippinInfo));
    	}
    }
    
  • ShippingInfoChangedEvent를 처리하는 핸들러는 디스패처로부터 이벤트를 전달받아 필요한 작업을 수행
  • public class ShippingInfoChangeHandler { @EventListener(ShippingInfoChangedEvent.class) public void handle(ShippingInfoChangedEvent evt) { shippingInfoSynchronizer.sync(evt.getOrderNumber(), evt.getNewShippingInfo()); } }
  • 이벤트는 이벤트 핸들러가 작업을 수행하는 데 필요한 데이터를 담아야 한다.
    • 데이터가 부족하면 핸들러는 필요한 데이터를 읽기 위해 관련 API를 호출하거나 DB에서 데이터를 직접 읽어와야 한다.
    public class ShippingInfoChangeHandler {
    	@EventListener(ShippingInfoChangedEvent.class)
    	public void handle(ShippingInfoChangedEvent evt) {
    		// 이벤트가 필요한 데이터를 담고 있지 않으면 필요한 데이터를 조회해야 한다. 
    		Order order = orderRepository.findById(evt.getOrderNo());
    		shippingInfoSynchronizer.sync(evt.getOrderNumber(), evt.getNewShippingInfo());
    	}
    }
    
  • 이벤트는 데이터를 담아야 하지만 그렇다고 이벤트 자체와 관련 없는 데이터를 포함할 필요는 없다.

10.2.3 이벤트 용도

  • 이벤트는 크게 두가지 용도로 쓰인다.
    • 트리거(Trigger) 용도로 도메인의 상태가 바뀔때 다른 후처리가 필요하면 후처리를 실행하기 위한 트리거로 이벤트를 사용할 수 있다.
    • 주문 취소 이벤트를 트리거로 사용
      • 환불 처리를 위한 트리거로 주문 취소 이벤트를 사용
    • 서로 다른 시스템간의 데이터 동기화 용도
      • 배송지를 변경하면 외부 배송 서비스에 바뀐 배송지 정보를 전송
      • 외부 배송 서비스와 배송지 정보를 동기화

10.2.4 이벤트 장점

  • 이벤트를 사용하면 서로 다른 도메인 로직이 섞이는 것을 방지할 수 있다.
  • 구매 취소 로직에 이벤트를 적용함으써 환불 로직이 없어진 것을 알 수 있다.
  • 이벤트 핸들러를 사용하면 기능 확장도 용이하다.
    • 구매 취소 시 환불과 함께 이메일로 취소 내용을 보내고 싶다면 이메일 발송을 처리하는 핸들러를 구현하면 된다.
    • 기능 확장해도 구매 취소 로직은 수정할 필요가 없다.

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

  • 이벤트와 관련된 코드
    • 이벤트 클래스: 이벤트를 표현
    • 디스패처: 스프링이 제공하는 ApplicationEventPublisher를 사용
    • Evnets: 이벤트를 발행한다. 이벤트 발행을 위해 ApplicationEventPublisher를 사용
    • 이벤트 핸들러: 이벤트를 수신해서 처리한다. 스프링이 제공하는 기능을 사용

10.3.1 이벤트 클래스

  • 이벤트 자체를 위한 상위 타입은 존재하지 않는다.
  • 이벤트 클래스의 이름을 결정할 때에는 과거 시제를 사용해야 한다는 점만 유의하면 된다.
    • OrderCanceledEent와 같이 클래스이름 뒤에 접미사로 Event를 사용해서 이벤트로 사용하는 클래스라는 것을 명시적으로 표현할 수 있다.
    • OrderCanceled처럼 간결함을 위해 과거 시제만 사용할 수도 있다.
  • 이벤트 클래스는 이벤트를 처리하는 데 필요한 최소한의 데이터를 포함해야 한다.
    • 주문 취소됨 이벤트는 적어도 주문번호를 포함해야 관련 핸들러에 후속 처리를 할 수 있다.
    public class OrderCanceledEvent {
    	/ 이벤트를 처리하는 데 필요한 데이터를 포함
    	private String orderNumber;
    }
    
  • 모든 이벤트가 공통으로 갖는 프로퍼티가 존재한다면 괄녀 상위 클래스를 만들 수도 있다.
  • public abstract class Event { private long timestamp; public Event() { this.timestamp = System.currentTimeMillis(); } public long getTimestamp() { return timestamp; } }
  • 발생 시간이 필요한 이벤트 클래스는 Event 클래스를 상속받아 구현하면 된다.
  • public class OrderCanceledEvent extends Event { private String orderNumber; public OrderCanceledEvent(String number) { super(); this.orderNumber = number; } }

10.3.2 Events 클래스와 ApplicationEventPublisher

  • 이벤트 발생과 출판을 위해 스프링이 제공하는 ApplicationEventPublisher를 사용
    • 스프링 컨테이너는 ApplicationEventPublisher도 된다.
    • Events 클래스는 ApplicationEventPublisher를 사용해서 이벤트를 발생시키도록 구현
    public class Events {
    	private static ApplicationEventPublisher publisher;
    
    	static void setPublisher(ApplicationEventPublisher publisher) {
    		Events.publisher = publisher;
    	}
    
    	public static void raise(Objet event) {
    		if (publisher != null) {
    			publisher.publishEvent(event);
    		}
    	}
    }
    
  • Events 클래스의 raise 메서드는 ApplicationEventPublisher가 제공하는 publishEvent 메서드를 이용해서 이벤트를 발생시킨다.
  • 스프링 설정 클래스
  • @Configuration public class EventsConfiguration { @Autowired private ApplicationContext applicationContext; @Bean public InitializingBean eventsInitializer() { return () -> Events.setPublisher(applicationContext); } }

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

  • 이벤트를 발생시킬 코드는 Events.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; } @EventListener(OrderCanceledEvent.class) public void handle(OrderCanceledEvent event) { refundService.refund(event.getOrderNumber()); } }

10.3.4 흐름 정리

  • 이벤트 처리 흐름을 시퀀스 다이어그램으로 정리
    1. 도메인 기능 실행
    2. 도메인 기능은 Events.raise()를 이용해서 이벤트 발생
    3. Events.raise()는 스프링이 제공하는 ApplicationEventPublisher를 이용해서 이벤트를 출판
    4. ApplicationEventPublisher는 @EventListener 애너테이션이 붙은 메서드를 찾아 실행
  • 응용 서비스와 동일한 트랜잭션 범위에서 이벤트 핸들러를 실행하고 있다.
    • 도메인 상태 변경과 이벤트 핸들러는 같은 트랜잭션 범위에서 실행

10.4 동기 이벤트 처리 문제

  • 이벤트를 사용해서 강결합 문제는 해소했지만 아직 남아 있는 문제가 하나 있다.
    • 외부 서비스에 영향을 받는 문제
    // 1. 응용 서비스 코드 
    // 외부 연동 과정에서 익셉션이 발생하면 트랜잭션 처리는?
    @Transactional 
    public void cancel(OrderNo orderNo) {
    	Order order = findOrder(orderNo);
    	order.cancel(); // order.cancel() 에서 OrderCanceledEvent 발생
    }
    
    // 2. 이벤트를 처리하는 코드
    @Service
    public class OrderCanceledEventHandler {
    	@EventListener(OrderCanceledEvent.class)
    	public void handle(OrderCanceledEvent event) {
    		// refund() 가 느려지거나 익셉션이 발생하면?
    		refundService.refund(event.getOrderNumber())
    	}
    }
    
  • 외부 시스템의 성능저하가 내 시스템의 성능 저하로 연결된다.
  • refundService.refund() 에서 익셉션이 발생하면 cancel() 메서드의 트랜잭션은 롤백 해야할까?
  • 외부 시스템과의 연동을 동기로 처리할 때 발생하는 성능과 트랜잭션 범위 문제를 해소하는 방법은 이벤트를 비동기로 처리하거나 이벤트와 트랜잭션을 연계하는 것

10.5 비동기 이벤트 처리

  • 이벤트를 비동기로 구현할 수 있는 방법은 다양함
    • 로컬 핸들러를 비동기로 실행
    • 메시지 큐를 사용하기
    • 이벤트 저장소와 이벤트 포워더 사용
    • 이벤트 저장소와 이벤트 제공 API 사용

10.5.1 로컬 핸들러 비동기 실행

  • 이벤트 핸들러를 비동기로 실행하는 방법은 이벤트 핸들러를 별도 스레드로 실행하는 것
    • 스프링이 제공하는 @Async 애너테이션을 사용하면 손쉽게 비동기로 이벤트 핸들러 실행 가능
      1. @EnableAsysnc 애너테이션을 사용해서 비동기 기능을 활성화
      2. 이벤트 핸들러 메서드에 @Async 애너테이션을 붙인다.
      @SpringBootApplication
      @EnableAsync
      public class ShopApplication {
      	public static void main(String[] args) {
      		SpringApplication.run(ShopApplication.class, args);
      	}
      }
      
    • 비동기로 실행할 이벤트 핸들러 메서드에 @Async 애너테이션만 붙이면 된다.
    • @Service public class OrderCanceledEventHandler { @Async @EventListener(OrderCanceledEvent.class) public void handle(OrderCanceledEvent event) { refundService.refund(event.getOrderNumber()); } }

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

  • 비동기로 이벤트를 처리해야 할 때 사용하는 또 다른 방법은 카프카(Kafka)나 래빗MQ(RabbitMQ)와 같은 메시징 시스템을 사용
  • 이벤트가 발생하면 이벤트 디스패처는 이벤트를 메시지큐에 보낸다.
  • 메시지 큐는 이벤트를 메시지 리스너에 전달
  • 메시지 리스너는 알맞은 이벤트 핸들러를 이용해서 이벤트를 처리
    • 이벤트를 저장하는 과정과 메시지 큐에 이벤트를 읽어와 처리하는 과정은 별로 스레드나 프로세스로 처리
  • 필요하다면 이벤트를 발생시키는 도메인 기능과 메시지 큐에 이벤트를 저장하는 절차를 한 트랜잭션으로 묶어야 한다.
    • 같은 트랜잭션 범위에서 실행하려면 글로벌 트랜잭션이 필요하다.
  • 글로벌 트랜잭션을 사용하면 안전하게 이벤트를 메시지 큐에 전달할 수 있음
    • 글로벌 트랜잭션으로 인해 전체 성능이 떨어지는 단점
    • 글로벌 트랜잭션을 지원하지 않는 메시징 시스템도 있음
  • 래빗MQ처럼 많이 사용되는 메시징 시스템은 글로벌 트랜잭션 지원과 함께 클러스터와 고가용성을 지원하기 때문에 안정적으로 메시지를 전달 할 수 있다.
  • 카프카는 글로벌 트랜잭션을 지원하지 않지만 다른 메시징 시스템에 비해 높은 성능을 보여준다.

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

  • 이벤트를 비동기로 처리하는 또 다른 방법은 이벤트를 일단 DB에 저장한 뒤에 벼로 프로그램을 이용해서 이벤트 핸들러에 전달
    • 이벤트가 발생하면 핸들러는 스토리지에 이벤트를 저장
    • 포워더는 주기적으로 이벤트 저장소에서 이벤트를 가져와 이벤트 핸들러를 실행
    • 포워더는 별도 스레드를 이용하기 때문에 이벤트 발생과 처리가 비동기로 처리
  • 이벤트 저장소를 이용하는 다른 방법은 이벤트를 외부에 제공하는 API를 사용
    • API 방식은 외부 핸들러가 API 서버를 통해 이벤트 목록을 가져간다.
    • 포워더 방식은 이벤트를 어디까지 처리했는지 추적하는 역활이 포워더에 있다.
    • API 방식에서는 이벤트 목록을 요구하는 외부 핸들러가 어디까지 이벤트를 처리했는지 기억 해야한다.

이벤트 저장소 구현

  • 포워더 방식과 API 방식 모두 이벤트 저장소를 사용하므로 이벤트를 저장할 저장소가 필요하다.
    • EventEntry: 이벤트 저장소에 보관할 데이터
      • 이벤트를 식별하기 위한 id, 이벤트 타입인 type, 직렬화한 데이터 현식인 contentType, 이벤트 데이터 자체인 payload, 이벤트 시간인 timestamp를 갖는다.
      • EventStore: 이벤트를 저장하고 조회하는 인터페이스를 제공
      • JdbcEventStore: JDBC를 이용한 EventStore 구현 클래스
      • EventApi: REST API를 이용해서 이벤트 목록을 제공하는 컨트롤러
  • EventEntry 클래스는 이벤트 데이터를 정의
  • public class EventEntry { private Long id; private String type; private String contentType; private String payload; private long timestamp; public EventEntry(String type, String contentType, String payload) { this.type = type; this.contentType = conentType; this.payload = payload; this.timestamp = System.currentTimeMillis(); } public EventEntry(String type, String contentType, String payload, long timestamp) { this.type = type; this.contentType = conentType; this.payload = payload; this.timestamp = timestamp; } // getter }
  • EventStore 인터페이스
    • 이벤트는 과거에 벌어진 사건이므로 데이터가 변경되지 않는다.
    • 새로운 이벤트를 추가하는 기능과 조회하는 기능만 제공
    public interface EventStore {
    	void save(Object event);
    	List<EventEntry> get(long offset, long limit);
    }
    
  • EventStore 인터페이스를 구현한 JdbcEventStore 클래스
  • @Component public class JdbcEventStore implements EventStore { private ObjectMapper objectMapper; private JdbcTemplate jdbcTemplate; public JdbcEventStore(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate) { this.objectMapper = objectMapper; this.jdbcTemplate = jdbcTemplate; } @Override public void save(Object event) { EventEntry entry = new EventEntry(event.getClass().getName(), "application/json", toJson(event)); jdbcTemplate.update("insert into evententry (type, content_type, payload, timestamp) values(?, ?, ?, ?)", ps -> { ps.setString(1, entry.getType()); ps.setString(2, entry.getContentType()); ps.setString(3, entry.getPayload()); ps.setTimestamp(4, new Timestamp(entry.getTimestamp())); }); } private String toJson(Object event) { try { return objectMapper.writeValueAsString(event); } catch (JsonProcessingException e) { throw new PayloadConvertException(e); } } @Override public List<EventEntry> get(long offset, long limit) { return jdbcTemplate.query("select * from evententry order by id asc limit ?, ?", ps -> { ps.setLong(1, offset); ps.setLong(2, limit); }, (rs, rowNum) -> { return new EventEntry( rs.getLong("id"), rs.getString("type"), rs.getString("content_type"), rs.getString("payload"), rs.getTimestamp("timestamp").getTime()); ) }); } }
  • EventEntry를 저장할 evententry 테이블의 DDL
  • create table evententry ( id int not null AUTO_INCREMENT PRIMARY KEY, `type` varchar(255), `content_type` varchar(255), payload MEDIUMTEXT, `timestamp` datetime ) character set utf8mb4;

이벤트 저장을 위한 이벤트 핸들러 구현

@Component
public class EventStoreHandler {
	private EventStore eventStroe;
	
	public EventStoreHandler(EventStore eventStore) {
		this.eventStore = eventStore;
	}

	@EventListener(Event.class)
	public void handle(Event event) {
		eventStore.save(event);
	}
}

REST API 구현

  • API를 사용하는 클라이언트는 일정 간격으로 호출
    1. 가장 마지막에 처리한 데이터의 offset 인 lastOffset을 구한다. 저장한 lastOffset 이 없으면 0을 사용
    2. 마지막에 처리한 lastOffset을 offset으로 사용해 API 호출
    3. API 결과로 받은 데이터를 처리
    4. offset + 데이터 개수를 lastOffset으로 저장
@RestController
public class EventApi {
	private EventStore eventStore;

	public EventApi(EventStore eventStore) {
		this.eventStore = eventStore;
	}

	@GetMapping("/api/events")
	public List<EventEntry> list(@RequestParam("offset") Long offset, @RequestParam Long limit) {
		return eventStore.get(offset, limit);
	}
}
  • 마지막에 처리한 lastOffset을 저장하는 이유는 같은 이벤트를 중복해서 처리하지 않기 위해서
    • 1분 주기로 이벤트를 조회하는 상황
  • 클라이언트 API를 이용해서 언제든지 원하는 이벤트를 가져올 수 있기 때문에 이벤트 처리에 실패하면 다시 실패한 이벤트부터 읽어와 이벤트를 재처리할 수 있다.
  • API 서버가 장애가 발생한 경우에도 주기적으로 재시도를 해서 API 서버가 살아나면 이벤트를 처리할 수 있다.

포워더 구현

  • 포워더는 API 방식의 클라이언트 구현과 유사하다.
  • 포워더는 일정 주기로 EventStore에서 이벤트를 읽어와 이벤트 핸들러에 전달하면 된다.
  • @Component public class EventForwarder { private static final int DEFAULT_LIMIT_SIZE = 100; private EventStore eventStore; private OffsetStore offsetStore; private EventSender eventSender; private int limitSize = DEFAULT_LIMIT_SIZE; public EventForwarder(EventStore eventStore, OffsetStore offsetStore, EventSender eventSender) { this.eventStore = eventSTore; this.offsetStore = offsetStore; this.eventSender = eventSender; } @Scheduled(initialDelay = 1000L, fixedDelay = 1000L) public void getAndSend() { long nextOffset = getNextOffset(); List<EventEntry> events = eventStore.get(nextOffset, limitSize); if (!events.isEmpty()) { int processedCount = sendEvent(events); if (proccessCount > 0) { saveNextOffset(nextOffset + processedCount); } } } private long getNextOffset() { return offsetStore.get(); } private int sendEvent(List<EventEntry> events) { int processedCount = 0; try { for (EventEntry entry: events) { eventSender.send(entry); processedCount++; } } catch (Exception ex) { } return processedCount; } private void saveNextOffset(long nextOffset) { offsetStore.update(nextOffset); } }
  • sendEvent 메서드는 파라미터로 전달받은 이벤트를 eventSender.send()를 이용해서 차례대로 발송
    • 익셉션이 발생하면 이벤트 전송을 멈추고 전송에 성공한 이벤트 개수를 리턴
    • 전송에 성공한 이벤트 개수를 리터하기 때문에 저장하는 offset은 최종적으로 정송에 성고한 이벤트를 기준으로 다음 이벤트에 대한 offset이다.

자동 증감 칼럼 주의 사항

  • 자동 증감 칼럼은 insert 쿼리를 실행하는 시점에 값이 증가하지만 실제 데이터는 트랜잭션을 커밋하는 시점에 DB에 반영
  • insert 쿼리를 실행해서 자동 증가 컬럼이 증가했더라도 트랜잭션을 커밋하기 전에 조회하면 증가한 값을 가진 레코드는 조회되지 않는다.
  • 커밋 시점에 따라 DB에 반영되는 시점이 달라질 수 있다.
  • 마지막 컬럼 값이 10인 상태
    • A 트랜잭션이 insert 쿼리 실행한 뒤에 B 트랜잭션이 insert 쿼리를 실행
    • A는 11, B는 12를 자동 증가 칼럼 값으로 사용
    • B 트랜잭션이 먼저 커밋되고 그 다음에 A 트랜잭션이 커밋되면 12가 DB에 먼저 반영된다.
    • 만약 B 트랜잭션과 A 트랜잭션 커밋 사이에 데이터를 조회한다면 11은 조회가 되지 않고 12만 조회되는 상황이 발생
  • 이런 문제가 발생하지 않도록 하려면 ID를 기준으로 데이터를 지연 조회하는 방식을 사용해야 함

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

  • 이벤트를 구현할 때 추가로 고려할 점이 있다.
  1. 이벤트 소스를 EventEntry에 추가할지 여부
    1. 특정 주체가 발생시킨 이벤트만 조회하는 기능을 구현할 수 없음
    2. 이벤트에 발생 주체 정보를 추가 해야함
  2. 포워더에서 전송 실패를 얼마나 허용할 것인가?
    1. 특정 이벤트에서 계속 실패하면 어떻게 될까?
    2. 포워더를 구현할 때는 실패한 이벤트의 재전송 횟수 제한을 두어야 한다.
    <aside> 👉 처리에 실패한 이벤트를 생략하지 않고 별도 실패용 DB나 메시지 큐에 저장하기도 한다. 처리에 실패한 이벤트를 물리적인 저장소에 남겨두면 이후 실패 이유 분석이나 후처리에 도움이 된다.
  3. </aside>
  4. 이벤트 손실
    1. 로컬 핸들러를 이용해서 이벤트를 비동기로 처리할 경우 이벤트 처리에 실패하면 이벤트를 유실하게 된다.
  5. 이벤트 순서
    1. 이벤트 발생 순서대로 외부 시스템에 전달 할 경우 이벤트 저장소를 사용하는 것이 좋음
    2. 메시징 시스템은 사용 기술에 따라 이벤트 발생 순서와 메시지 전달 순서가 다를 수 있다.
  6. 이벤트 재처리
    1. 마동일한 이벤트를 다시 처리해야 할때 이벤트를 어떻게 할지?
    2. 이미 처리한 이벤트가 도착하면 해당 이벤트를 무시하는 것
    3. 이벤트를 멱등으로 처리하는 방법
    <aside> 👉 연산을 여러번 적용해도 결과가 달라지지 않는 성질을 멱등성이라고 한다. 같은 이벤트가 여러번 적용해도 결과가 같으므로 이벤트 핸들러는 멱등성을 갖는다.
  7. </aside>

10.6.1 이벤트 처리와 DB 트랜잭션 고려

  • 이벤트를 처리할 때는 DB 트랜잭션을 함께 고려해야 한다.
    • 주문 취소 기능은 주문 취소 이벤트를 발생
    • 주문 취소 이벤트 핸들러는 환불 서비스에 환불 처리 요청
    • 환불 서비스는 외부 API를 호출해서 결제를 취소
  • 이벤트를 동기로 처리할 경우
    • 12번 과정까지 다 성공하고 13번 과정에서 DB를 업데이트하는데 실패하는 상황
    • 결제는 취소됐는데 DB에는 주문 취소되지 않은 상태로 남는다
  • 이벤트를 비동기로 처리할 경우
    • 12번 과정에서 외부 API 호출에 실패하면 DB에는 주문이 취소된 상태인데 결제는 취소되지 않은 상태
  • 이벤트를 처리를 동기로 하든 비동기로 하든 이벤트 처리 실패와 트랜잭션 실패를 함께 고려해야 한다.
    • 트랜잭션이 성공할 때만 이벤트 핸들러를 실행하는 방법
  • 스프링은 @TransactionalEventListener 애너테이션을 지원
    • 트랜잭션 상태에 따라 이벤트 핸들러를 실행할 수 있게 한다.
    • AFTER_COMMIT: 트랜잭션 커밋에 성공한 뒤에 핸들러 메서드를 실행
    • 핸들러를 실행했는데 트랜잭션이 롤백 되는 상황은 발생하지 않는다.
    @TransactionalEventListner(
    	classes = OrderCanceledEvent.class,
    	phase = TransactionPhase.AFTER_COMMIT
    )
    public void handle(OrderCanceledEvent event) {
    	refundService.refund(event.getOrderNumber());
    }
    
  • 이벤트 저장소로 DB를 사용해도 동일한 효과를 볼 수 있다.
    • 이벤트 발생 코드와 이벤트 저장 처리를 한 트랜잭션으로 처리하면 된다.
728x90