본문 바로가기
Spring|Spring-boot

도메인간 바운디드 컨텍스트 강결합의 대책 "이벤트"

by oncerun 2023. 1. 4.
반응형

이벤트 발생과 출판을 위해서 스프링이 제공하는 ApplicationEventPublisher를 사용할 수 있다.

ApplicationEventPublisher의 publishEvent() 메서드를 통해 이벤트를 발생시킬 수 있다.

 

참고로 ApplicationContext는 ApplicationEventPublisher를 상속하고 있기에 초기화 클래스로 사용할 수 있다. 

빈 설정 시 다음과 같이 설정할 수 있다.

 

@Bean
public InitializingBean eventsInitializer(){
	retun () -> Events.setPublisher(applicationContext);
}

 

ApplicationEventPublisher를 setPublisher메서드로 주입받는 Events 클래스는 필요 상황에 따라 알맞게 구현하자.

 

 

로컬 핸들러를 비동기로 실행

 

이벤트 핸들러를 비동기로 실행하기 위해선 이벤트 핸들러를 별도의 스레드로 실행하는 방법이다.

 

@Async 어노테이션과 @EventListener( xxx.class) 를 통해 이벤트 핸들러를 실행할 수 있다.

 

비동기를 위해서 보통 별도의 스레드 풀을 설정하는데 이 경우 각자 서버 상황에 맞게 구현한 스레드 풀을 이용하도록 하는 것도 좋다.

 

하지만 로컬 핸들러를 이용해서 이벤트를 비동기로 처리할 경우 이벤트 처리에 실패하면 이벤트를 유실하게 된다.

따라서 이벤트 처리 실패시 대응할 수 있는 별도의 Retry 로직이나 그에 준하는 대응이 필요하다.

 

메시지 큐를 이용

 

메시지 큐 종류로는 카프카나 RabbitMQ 등이 존재한다. 이러한 메시징 시스템을 이용하는 것도 하나의 방법이 될 수 있다.

이벤트가 발생하면 이벤트 디스패처는 이벤트를 메시지 큐에 보낸다.

그러면 메시지 큐는 이벤트를 이벤트 리스너에 전달하고, 메시지 리스너는 알맞은 이벤트 핸들러를 이용해서 이벤트를 처리한다. 

 

이 경우 이벤트를 메시지 큐에 저장하는 과정과 메시지 큐에서 이벤트를 읽어와 처리하는 과정은 별도 스레드나 프로세스로 처리된다.

 

 

이 경우 실패에 처리하기 위한 대응이 필요하다.

 

이벤트 실패에 대해선 이벤트 종류에 따라 재처리 로직을 작성하면 되지만  비동기 특성상 DB 작업이 우선 처리될 가능성이 높고 그 이후에 이벤트가 발생될 것이다. 

 

이 경우 DB 작업 시 예외 발생 시 메시징 시스템에 존재하는 이벤트 처리가 골치 아플 수 있다. 

 

이는 DB 트랜잭션이 성공한 이후에 이벤트를 등록하도록 하는 방안으로 처리할 수도 있을 것 같고

메시징 시스템이 글로벌 트랜잭션을 지원한다면 해당 기능을 사용해도 될 것이다.

 

다만 글로벌 트랜잭션을 사용하면 안정성은 보장될 수 있지만 전체 성능이 떨어질 수 있다.

 

 

이벤트 저장소와 이벤트 포워더 사용

 

흐름은 다음과 같다.

 

도메인 -> 이벤트 디스패처 -> 로컬 핸들러 -> 저장소 -> (스케줄링)포워더 -> 이벤트 핸들러

 

이벤트 저장 밸류 객체를 정의하고, 저장하기 위한 인터페이스와 구현체이다. 

 

실제 로컬 핸들러에서 이벤트를 저장하기만 하면 된다.

 

포워더는 일정 주기로 Event가 저장된 저장소에서 이벤트를 읽어와 이벤트 핸들러에 전달하면 된다.

 

@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 (processedCount > 0) {
            saveNextOffset(nextOffset + processedCount);
        }
    }
}

 

이때 이벤트의 처리량은 포워더에게 책임이 있으니 적절한 이벤트를 관리하도록 작성해주어야 한다.

 

이 경우 예외 발생에 대해 고민할 수 있지만  포워더 저장소를 사용하는 경우에는 이벤트 발생 코드와 이벤트 저장 처리를 하나의 트랜잭션으로 처리하면 트랜잭션 성공 시에만 이벤트가 DB에 저장되기 때문에 실패 시 이벤트가 실행되는 참사는 발생하지 않는다.

 

 

이벤트 저장소와 이벤트 제공 API 사용

 

REST API는 offset과 limit를 통해 이벤트 목록을 제공하는 API만 존재하면 된다. 

이후는 외부 이벤트 핸들러의 책임이다.

 

@RestController
public class EventApi {
    private EventStore eventStore;

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

    @RequestMapping(value = "/api/events", method = RequestMethod.GET)
    public List<EventEntry> list(
            @RequestParam("offset") Long offset,
            @RequestParam("limit") Long limit) {
        return eventStore.get(offset, limit);
    }
}

 

API 방식과 포워더 방식 차이점은 이벤트를 전달하는 방식에 있다.

포워드 방식이 포워더를 이용해서 이벤트를 외부에 전달하는 방식이라면 API 방식은 외부 핸들러가 API 서버를 통해 이벤트 목록을 가져가는 방식이다. 

 

이는 이벤트 정보를 어디까지 처리했는지 관리해야 하는 위치가 달라짐을 의미한다.

포워더는 자신이 관리하여 이벤트 핸들어에게 전달하지만 API 방식을 이용하는 외부 핸들러 같은 경우 외부 핸들러가 어디까지 이벤트를 처리했는지 관리해야 한다.

 

 

이벤트 발생 순서대로 외부 시스템에 전달해야 할 경우, 이벤트 저장소를 사용하는 것이 좋다.

이벤트 저장소는 저장소에 이벤트 발생 순서를 저장하고 그 순서대로 이벤트 목록을 제공하기 때문이다.

반면 메시징 시스템은 사용기술에 따라 이벤트 발생 순서와 메시지 전달 순서가 다를 수도 있다.

 

DB 트랜잭션 관점

 

이벤트를 처리할 때는 DB 트랜잭션을 함께 고려해야 한다. 

 

예를 들어 보자.

 

주문 취소  시 주문 취소 이벤트가 발생한다.

이벤트 핸들러는 환불 서비스에 환불을 요청한다.

환불 서비스는 외부 API를 이용하여 결제를 취소한다.

 

이러한 과정을 동기적으로 표현하면 다음과 같은 순서로 진행된다..

 

1. Controller에서 주문 취소 서비스 호출

2. 주문 취소 서비스에서 도메인 로직 실행

3. 도메인 로직에서 이벤트 등록

4. 이벤트 핸들러가 실행되어 환불 서비스에 환불 요청

5. 환불 서비스가 결제 취소 API 호출

6. 외부 결제 모듈에서 응답

7. 환불 서비스에서 응답.

8. 이벤트 핸들러에서 응답.

9. 도메인 로직에서 응답

10. 응용 서비스 계층에서 데이터베이스 update 요청

11. 트랜잭션 커밋

12. 사용자에게 응답

 

암울한 상황을 강제로 시나리오 해보자.

 

10~11번 사이에서 익셉션이 발생했다. 어떻게 복구할 것인가?

비동기로 이벤트를 처리한다고 해도 DB 트랜잭션을 고려해야 한다.

이 경우 도메인 로직에서 이벤트를 등록하고 이벤트 핸들러에서 타 도메인 서비스를 실행하는 부분을 비동기로 처리한다고 하자. 

 

그렇다면 이 경우는 디비 트랜잭션 커밋이 우선적으로 완료되고 이후에 외부 결제 모듈에 API 응답을 기다리고 있다.

이 경우 외부 결제 모듈에서 실패가 된다면 불일치 문제를 어떻게 해결해야할까?

 

가장 좋은 방법은 복잡한 상황을 조금 단순화된 방법으로 처리할 수 있는지 고민하는 것이다.

@TransactionalEventListener(
	classes = OrderCanceledEvent.class,
    phase = TransactionPhase.AFTER_COMMIT
)
public void handle(OrderCanceledEvent event){
	refundService.refund(event.getOrderNumber());
}

TrasactionPhase.AFTER_COMMIT을 지정했다. 이 값을 사용하면 스프링은 트랜잭션 커밋에 성공한 뒤에 핸들러 메서드를 실행한다. 

중간에 예외가 발생해서 트랜잭션이 롤백되면 핸들러 메서드를 실행하지 않는다. 

트랜잭션이 성공함을 보장할 때만 이벤트가 실행된다. 트랜잭션 실패 + 이벤트 실행을 방지할 수 있게 되었다.

 

이벤트 실패의 경우 Retry 로직을 작성하여 이벤트를 재실행하도록 한다. 

 

그렇다고 지속적인 실패가 발생할 수 있기 때문에 다음을 고려해보아야 한다.

 

이벤트의 실행의 멱등성을 보장하도록 구현한다. 중복된 실행에 대해 어느 정도 안정성을 보장해줄 수 있다.

특정 이벤트 호출에 대해 리미트를 주다가 한계치에 다다르면 해당 이벤트를 소멸시키거나 디비에 저장하여 처리하는 방법이다. 

 

 

 

 

반응형

'Spring|Spring-boot' 카테고리의 다른 글

Standalone Application  (0) 2023.01.22
스프링 부트란?  (0) 2023.01.21
Apache Web Server and Spring Boot embedded tomcat 연동  (0) 2022.11.12
Spring Transaction Propagation  (0) 2022.10.16
Spring Transaction Option  (0) 2022.10.15

댓글