본문 바로가기
DDD&MSA

[DDD] 이벤트 처리하기 : 1. 동기 vs 비동기

by 덩라 2023. 7. 24.

본 포스팅은 DDD 를 공부하면서 정리하기 위한 포스팅입니다.

아래 내용에 사용된 코드는 책에서 인용했거나, 필자의 git에서 확인할 수 있습니다.

출처: 도메인 주도 개발 시작하기 - DDD 핵심 개념 정리부터 구현까지 (저자. 최범균)
git : https://github.com/dongha-byun/ddd-start

1. 강결합(High Coupling)

객체지향 프로그래밍 언어를 공부하다보면 결합도(Coupling) 라는 얘기를 한 번 쯤은 듣게 됩니다.

결합도란, 두 객체간의 연관성을 의미하며 어떤 객체 A를 수정했을 때, B 객체도 수정해야 한다면, A객체와 B객체는 결합도가 높다고 표현합니다. 강결합이란 이러한 결합도가 매우 강한 경우를 얘기합니다.

 

예를 들면, 쇼핑몰에서 주문을 취소하는 로직이 있다고 가정해보겠습니다.

상품을 주문한 시점에서 이미 결제가 됐을 것이기 때문에 주문이 취소되면 결제됐던 금액을 환불해줘야 할 것 입니다.

간략하게 코드로 살펴보면 아래와 같이 작성 될 수 있을 것 입니다.

public class OrderService {
    private final OrderRepository orderRepository;
    private final RefundService refundService;
    
    @Transactional
    public void cancel(Long orderId) {
        Order order = orderRepository.findById(orderId);
        order.cancel();
        
        try{
            refundService.refund(order.getPaymentId()); // 결제 당시 PG사에서 생성된 거래ID
            order.refund();
        } catch(Exception exception){
            // Exception 처리
        }
    }
}

 위 코드는 비지니스 흐름상 전혀 문제될 것이 없어 보입니다. 주문을 취소처리하고, 그에 맞는 결제를 환불요청하는 로직이 잘 정리되어 있다고 볼 수 있습니다. 하지만 이는 객체지향의 관점, 객체의 역할과 책임을 논하는데 있어 "OrderService 는 이름대로 '주문 응용서비스'인데, '결제 환불처리' 라는 역할을 수행하는 것이 맞는가" 라는 고민해볼 필요가 있습니다.

 

환불에 대한 비지니스 로직이 변경/추가 된다면, OrderService 의 수정을 피할 수 없게돼 수정해야되는 부분이 많아지는 단점이 생깁니다.

예를 들면, "환불결과를 사용자에게 알려줘야한다." 라는 기능이 추가된다면 아래처럼 될 것 입니다.

public class OrderService {
    private final OrderRepository orderRepository;
    private final RefundService refundService;
    
    //환불결과 통지 처리를 위한 추가
    private final NotifyService notifyService;
    
    @Transactional
    public void cancel(Long orderId) {
        Order order = orderRepository.findById(orderId);
        order.cancel();
        
        try{
            refundService.refund(order.getPaymentId()); // 결제 당시 PG사에서 생성된 거래ID
            order.refund();
            
            // 성공은 여기서 한다 쳐도, 실패는 어떻게?
            notifyService.notiResult(order.getOrdererUserInfo());
        } catch(Exception exception){
            // Exception 처리
        }
    }
}

이런식으로 주문취소 와 환불처리 가 같은 위치에서 로직을 실행하게 되면, 뭔가 새로운 내용이 추가되거나 기존내용이 변경되거나 할 때 마다 OrderService 가 점점 커지고, 수정되는 빈도가 높아지게 될 것 입니다. "정말 이게 최선인가?" 라는 생각을 해볼 필요가 있습니다.

 

이러한 문제가 모두 OrderService 와 RefundService, 주문 관련 도메인과 환불 관련 도메인의 강결합에서 발생하는 문제들입니다.

이러한 문제를 해결하기 위해 이벤트 라는 개념을 알아보려 합니다.

 

2. 이벤트(Event)

이벤트는 "과거에 발생한 무언가" 를 의미합니다.

예를 들면, "사용자가 비밀번호를 변경한다." 라는 상황이 있다면, "비밀번호가 변경됨" 이라는 이벤트가 발생했다고 할 수 있습니다.

그리고 "이벤트가 발생했다." 라고 하면, "무언가의 상태가 변경되었다." 를 의미하기도 합니다.

"비밀번호가 변경됨" 이벤트의 발생으로 비밀번호의 값이 변경되고, 그를 포함하는 사용자 정보의 상태가 변경되었다는 것입니다.

 

이벤트는 크게 2가지 경우에서 사용될 수 있습니다.

  1. 트리거 역할 : A 도메인의 상태가 변경될 때, B 도메인의 후처리를 위한 트리거 역할
  2. 데이터 동기화 : A 도메인의 데이터가 변경될 때, B 도메인의 데이터도 맞춰놓는 데이터 동기화 역할

그렇다면, 이벤트를 어떻게 개발하는지 간단하게 알아보겠습니다. 

상품 주문을 취소할 때, 상품의 재고수량을 원래대로 되돌리는 처리를 구현해보겠습니다.

먼저, 이벤트를 사용하지 않은 경우 주문취소 로직을 아래처럼 구현해봤습니다.

public class OrderService {
    private final OrderRepository orderRepository;
    private final ProductRepositroy productRepository;
    
    @Transactional
    public void cancel(Long orderId) {
        Order order = orderRepository.findById(orderId);
        order.cancel();
        
        order.getItems()
            .forEach(
                item -> decreaseOrderItemProductQuantity(item)
            );
    }
    
    private void decreaseOrderItemProductQuantity(OrderItem item) {
        Product product = productRepository.findById(item.getProductId());
        product.decreaseQuantity(item.getQuantity());
    }
}

위 코드는 OrderService 에서 Product 와 관련된 의존성을 포함하고 있다는 문제가 있습니다.

해당 문제를 이벤트를 활용해서 해결해보겠습니다.

 

먼저, Event 객체를 생성합니다.

public abstract class Event {
    private final long timestamp;

    public Event() {
        this.timestamp = System.currentTimeMillis();
    }

    public long getTimestamp() {
        return timestamp;
    }
}


/**
 * 주문이 취소 되었을 때, 상품의 갯수를 재계산하기 위한 이벤트.
 */
@Slf4j
public class OrderCanceledEvent extends Event {
    private final Long productId;
    private final int quantity;

    public OrderCancelEvent(Long productId, int quantity) {
        super();
        log.info("CREATE EVENT : OrderCancelEvent ==> productId={} / quantity={}", productId, quantity);
        this.productId = productId;
        this.quantity = quantity;
    }

    public Long getProductId() {
        return productId;
    }

    public int getQuantity() {
        return quantity;
    }

    @Override
    public String toString() {
        return "OrderCancelEvent{" +
                "productId=" + productId +
                ", quantity=" + quantity +
                '}';
    }
}

이벤트는 크게 3가지 정보를 포함하고 있어야 합니다.

  1. 어떤 이벤트 인지 : 발생한 이벤트가 어떤 이벤트인지를 명시해야 합니다. 보통은 이벤트 객체의 class 명으로 표현합니다. 이벤트 객체는 이벤트가 발생한 직후에 생성되므로, 이벤트 자체는 과거형으로 표시합니다. (필수는 아님, 가장 중요한건 팀 컨벤션)
  2. 발생시간 : 이벤트가 발생한 시간을 나타냅니다.
  3. 추가 데이터 : 이벤트가 발생 한 후, 어떤 데이터를 변경해야 한다면, 그에 필요한 데이터를 포함합니다. 위와 같은 경우, 주문 취소 후 상품의 재고수량 변경을 위해 1) 어떤 상품인지에 대한 ID 와 2) 변경할 수량 정보를 포함합니다.

 

다음은 이벤트를 실제로 이벤트를 발생시키기 위한 publisher 를 포함하는 객체입니다.

/**
 * 생성된 이벤트를 발행하는 publisher 역할을 수행
 */
public class Events {
    private static ApplicationEventPublisher publisher;

    static void setPublisher(ApplicationEventPublisher publisher) {
        Events.publisher = publisher;
    }

    /**
     * 생성된 이벤트를 발행하는 메서드
     * @param event
     */
    public static void raise(Event event) {
        if(publisher != null) {
            publisher.publishEvent(event);
        }
    }
}

@Configuration
public class EventConfiguration {

    @Autowired
    ApplicationContext applicationContext;

    @Bean
    public InitializingBean eventsInitializer() {
        // Events 의 ApplicationEventPublisher 는 ApplicationContext 의 부모클래스이므로
        // Events 초기화 시, ApplicationContext 를 인자로 사용한다.
        return () -> Events.setPublisher(applicationContext);
    }
}

 

이제, 해당 이벤트객체들을 활용하여 Order 와 Product 의 결합도를 낮춰보면 아래처럼 변경될 수 있습니다.

package hello.ddd.event.basic.order.application;

@Transactional(readOnly = true)
@Service
public class OrderService {
    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional
    public Order cancel(Long id) {
        Order order = orderRepository.findById(id);
        order.cancel();

        order.getItems()
                .forEach(
                        // 기존에 productRepository 를 사용하지 않고, 이벤트를 발생시키는 방식으로 대체
                        //this::calculateItemProductQuantity
                        item -> Events.raise(
                            new OrderCanceledEvent(
                                item.getProductId(), 
                                item.getQuantity()
                            )
                        )
                );

        return order;
    }
}

/////////////////////////////////////////////////////////////////////////////////
package hello.ddd.event.basic.product.event;

/**
 * event handler 가 동작하는 시점은, 이벤트가 발생한 이후 이므로
 * class 명을 작성할 때, 해당 이벤트가 과거에 발생한 것임을 명시하기 위해 과거형을 써준다.
 */
@Slf4j
@Service
public class OrderCanceledEventHandler {
    private final ProductRepository productRepository;

    public OrderCanceledEventHandler(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Transactional
    @EventListener(OrderCancelEvent.class)
    public void handler(OrderCancelEvent event) {
        log.info("EVENT HANDLER CALLED! => event={}", event.toString());
        Product product = productRepository.findById(event.getProductId());
        product.increaseQuantity(event.getQuantity());
    }
}

각 class의 상단을 보시면, package 가 각각 "hello.ddd.event.basic.order" 과 "hello.ddd.event.basic.product" 로 되어있는 것을 확인할 수 있습니다. 이렇게, 앞서 Order 가 가지고 있던 Product 의 결합도가 없어진 것을 확인할 수 있습니다.

 

아래 데이터를 토대로 테스트를 했을 때, 결과가 잘 나오는 것을 확인할 수 있습니다.

테스트 데이터
실행 로그
실행결과 : status 200 성공
실행 완료 후, 상품 재고 수량 확인

 

 

3. 동기 처리의 문제점

A쇼핑몰에 주문취소 기능이 있어서 사용자들이 배송이 시작되지 않은 주문을 취소하고 금액을 환불 받는 기능이 있다고 가정해봅시다.

"환불 처리"를 같은 서비스에서 처리하는 것이 아니라 외부 PG사를 통해 처리하게 된다면, 이를 동기로 처리할 경우 큰 문제가 될 수 있습니다. 아래 같은 흐름의 경우 입니다

위 같은 흐름의 경우, 내부 서비스에선 처리가 다 완료되어 외부 PG사의 결과를 기다리지만, PG사에서 무언가의 문제로 처리가 지연된다면 서비스를 이용하는 사용자는 이유도 모른체 지연되는 시간을 모두 기다려야 되는 상황에 직면하게 됩니다.

그렇게 되면, 사용자들의 입장에서 "아, A쇼핑몰은 환불처리가 너무 느리네... 그냥 쿠팡써야겠다..." 라고 생각할 수 도 있을 것 입니다.

 

앞서 적용해본 주문 취소 시, 상품 갯수를 갱신하는 코드를 예로 들어보겠습니다.

상품 쪽 시스템에 문제가 발생해 처리가 2초 정도 딜레이가 된다면 어떻게 될까요? 강제로 2초의 sleep 을 주고 테스트해보면, 실행 결과에 아래 같이 실행 시간이 나타나게 됩니다.

주문 취소 API가 결과는 "200 OK" 로 성공이지만, 실행 시간이 "4.17초"가 걸린 것을 볼 수 있습니다. 이는 상품 재고 수량 갱신이 2초씩 걸려 총 2개의 상품이 4초가 걸렸다는 이야기가 됩니다. 실행 로그를 보면 아래와 같이 2초간 딜레이가 걸림을 알 수 있습니다.

24초 -> 26초 -> 28초 로 각 실행이 2초씩 걸린 로그

만약, 이런 상품이 10개가 존재한다면, 외부 연동도 없는 주문 취소 기능이 20초가 걸리는 현상이 발생될 것입니다.

이렇게, 이벤트로 연결된 A로직과 B로직이 있을 때, 두 로직을 동기로 처리하게 된다면 B로직의 처리 시간이 전체 처리시간에 영향을 미치게 됩니다.

 

이럴 때, 비동기를 적용시키면 문제를 해결할 수 있습니다.

 

4. 비동기 처리 적용

이벤트를 비동기로 처리하기 위해서는 아래 2가지를 적용시켜야 합니다.

  1. @SpringBootApplication main class 에 @EnableAsync 적용
  2. 이벤트 핸들러 메서드에 @Async 적용
/**
 * @EnableAsync : 비동기 이벤트 처리를 위해 EventListener 를 비동기처리 하겠다는 어노테이션
 */
@EnableAsync
@SpringBootApplication
public class DddApplication {

	public static void main(String[] args) {
		SpringApplication.run(DddApplication.class, args);
	}

}

@Slf4j
@Transactional
@Service
public class OrderCanceledEventHandler {
    private final ProductRepository productRepository;

    public OrderCanceledEventHandler(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    /**
     * @Async : EventListener 를 비동기 처리한다는 의미
     */
    @Async
    @EventListener(OrderCancelEvent.class)
    public void handler(OrderCancelEvent event) throws InterruptedException {
        Thread.sleep(2000); // 강제 2초 sleep
        log.info("EVENT HANDLER CALLED! => event={}", event.toString());
        Product product = productRepository.findById(event.getProductId());
        product.increaseQuantity(event.getQuantity());
    }
}

위 처럼 비동기를 적용시키면, 아래와 같이 처리시간에 구애받지 않고 전체 로직을 처리할 수 있게 됩니다.

주문 취소 API 응답시간 144ms
각 이벤트 비동기 처리(소요시간 2초, 36초 -> 38초)

 

댓글