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 흐름 정리
- 이벤트 처리 흐름을 시퀀스 다이어그램으로 정리
- 도메인 기능 실행
- 도메인 기능은 Events.raise()를 이용해서 이벤트 발생
- Events.raise()는 스프링이 제공하는 ApplicationEventPublisher를 이용해서 이벤트를 출판
- 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 애너테이션을 사용하면 손쉽게 비동기로 이벤트 핸들러 실행 가능
- @EnableAsysnc 애너테이션을 사용해서 비동기 기능을 활성화
- 이벤트 핸들러 메서드에 @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()); } }
- 스프링이 제공하는 @Async 애너테이션을 사용하면 손쉽게 비동기로 이벤트 핸들러 실행 가능
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: 이벤트 저장소에 보관할 데이터
- 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를 사용하는 클라이언트는 일정 간격으로 호출
- 가장 마지막에 처리한 데이터의 offset 인 lastOffset을 구한다. 저장한 lastOffset 이 없으면 0을 사용
- 마지막에 처리한 lastOffset을 offset으로 사용해 API 호출
- API 결과로 받은 데이터를 처리
- 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 이벤트 적용 시 추가 고려사항
- 이벤트를 구현할 때 추가로 고려할 점이 있다.
- 이벤트 소스를 EventEntry에 추가할지 여부
- 특정 주체가 발생시킨 이벤트만 조회하는 기능을 구현할 수 없음
- 이벤트에 발생 주체 정보를 추가 해야함
- 포워더에서 전송 실패를 얼마나 허용할 것인가?
- 특정 이벤트에서 계속 실패하면 어떻게 될까?
- 포워더를 구현할 때는 실패한 이벤트의 재전송 횟수 제한을 두어야 한다.
- </aside>
- 이벤트 손실
- 로컬 핸들러를 이용해서 이벤트를 비동기로 처리할 경우 이벤트 처리에 실패하면 이벤트를 유실하게 된다.
- 이벤트 순서
- 이벤트 발생 순서대로 외부 시스템에 전달 할 경우 이벤트 저장소를 사용하는 것이 좋음
- 메시징 시스템은 사용 기술에 따라 이벤트 발생 순서와 메시지 전달 순서가 다를 수 있다.
- 이벤트 재처리
- 마동일한 이벤트를 다시 처리해야 할때 이벤트를 어떻게 할지?
- 이미 처리한 이벤트가 도착하면 해당 이벤트를 무시하는 것
- 이벤트를 멱등으로 처리하는 방법
- </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
'도메인 주도 개발 스터디' 카테고리의 다른 글
Chapter 11 CQRS (0) | 2023.11.09 |
---|---|
Chapter 9 도메인 모델과 바운디드 컨텍스트 (0) | 2023.11.08 |
Chapter 8 애그리거트 트랜잭션 관리 (0) | 2023.11.08 |
Chapter 7 도메인 서비스 (0) | 2023.11.08 |
Chapter 6 응용 서비스와 표현 영역 (0) | 2023.11.08 |