Spring Event 도입기 (2) - 리스너 밖으로 전파되는 예외를 처리하라, Spring Event Exception Handling

이번 글에서는 Spring Event 도입기 1장에서 언급한 이벤트 리스너 밖으로 전파된 예외의 처리에 대해 다룬다. Spring Event의 자세한 사용 방법은 1장에서 소개하고 있다.

 

Spring Event 도입기 (1) - 핵심 로직을 변경으로부터 보호하라

핵심 로직 변경에 따른 문제 발생과 해결 문제 - 가독성 저하 현재 사내에서 운영하는 서비스에는 사용자의 요청을 받으면 엔티티를 생성해 저장하고 메시지 큐에 발행한다라는 핵심 로직이 존

devpanpan.tistory.com

 

 

리스너의 사용 방법에 따른 분류

리스너는 동기/비동기로, 발행 즉시/발행 주체의 트랜잭션 범위에 따라 다양하게 정의할 수 있다. 이 글에서는 리스너의 사용 사례를 아래의 4가지로 분류하고 각각의 예외 처리 방법을 살펴본다.

  • @EventListener - 발행 즉시 실행되는 동기 이벤트 리스너
  • @TransactionalEventListener - 트랜잭션 범위에 따라 실행되는 동기 이벤트 리스너
  • @EventListener with @Async - 발행 즉시 실행되는 비동기 이벤트 리스너
  • @TransactionalEventListener with @Async - 트랜잭션 범위에 따라 실행되는 비동기 이벤트 리스너

 

 

사용할 코드

폭탄을 던지는(이벤트 발행) 폭탄 서비스가 있다. 인자로 받는 폭탄 타입은 위에서 분류한 4가지 리스너이다.

public enum BombType {

    EVENT_LISTENER,
    TRANSACTIONAL_EVENT_LISTENER,
    ASYNC_EVENT_LISTENER,
    ASYNC_TRANSACTIONAL_EVENT_LISTENER,
    ;

}
@Service
@RequiredArgsConstructor
public class BombService {

    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public void throwBomb(BombType bombType) {
        eventPublisher.publishEvent(new BombEvent(bombType, System.currentTimeMillis()));
    }

}

각 리스너는 폭탄이 자신의 타입과 일치하면 예외를 전파한다.

@Component
@RequiredArgsConstructor
public class BombEventListener {

    @EventListener
    public void handleBomb(BombEvent event) {
        if (BombType.EVENT_LISTENER.equals(event.bombType())) {
            throw new RuntimeException("Event Listener Bomb!!!!!!!!!!");
        }
    }

    @TransactionalEventListener
    public void handleTransactionalBomb(BombEvent event) {
        if (BombType.TRANSACTIONAL_EVENT_LISTENER.equals(event.bombType())) {
            throw new RuntimeException("Transactional Event Listener Bomb!!!!!!!!!!");
        }
    }

    @Async("customExecutor")
    @EventListener
    public void handleAsyncBomb(BombEvent event) {
        if (BombType.ASYNC_EVENT_LISTENER.equals(event.bombType())) {
            throw new RuntimeException("Async Event Listener Bomb!!!!!!!!!!");
        }
    }

    @Async("customExecutor")
    @TransactionalEventListener
    public void handleAsyncTransactionalBomb(BombEvent event) {
        if (BombType.ASYNC_TRANSACTIONAL_EVENT_LISTENER.equals(event.bombType())) {
            throw new RuntimeException("Async Transactional Event Listener Bomb!!!!!!!!!!");
        }
    }

}

 

 

@EventListener

예외 발생 테스트

아래와 같이 동기 이벤트 리스너에서 예외를 터트리도록 이벤트를 발행하는 테스트를 실행시켜 보았다.

@SpringBootTest
public class BombServiceTests {

    @Autowired
    BombService bombService;

    @Test
    void eventListener_throw_propagate() {
        assertThrows(RuntimeException.class, () -> bombService.throwBomb(BombType.EVENT_LISTENER));
    }

}

결과는 다음과 같다.

이벤트 리스너에서 던진 예외가 발행 주체에게 전파되었음을 알 수 있다.

예외 처리

동기 이벤트 리스너를 사용하는 경우 예외가 발행 주체에게 전파되므로, 우리가 직접 예외 처리를 할 수 있으니 OK라고 생각할 수 있다. 그러나, 이벤트의 사용 목적이 발행하는 쪽에서 실제로 어떤 처리가 이루어지는지 모르게 함으로써 강결합을 분리하는 것임을 생각하면, 이벤트만 던졌을 뿐인데 알 수 없는 작업에 대한 예외가 넘어오는 것은 바람직하지 않은 동작이다.

리스너에 따른 개별적 예외 처리가 필요하다면 try-catch로 감싸 이를 해결할 수 있다. 그러나 모든 동기 이벤트 리스너에 일괄적으로 동일한 예외처리를 적용하고자 한다면, 전역 예외처리기를 이용할 필요가 있다.

전역 예외처리기를 등록하는 방법은 구글링을 통해 쉽게 찾아볼 수 있었다. ApplicationEventMulticaster를 커스텀하고 예외 핸들러를 등록해주면 된다.

@Slf4j
@Component
public class EventListenerExceptionHandler implements ErrorHandler {

    @Override
    public void handleError(Throwable t) {
        log.error("이벤트 리스너 실행 중 예외 발생", t);
    }

}
@Slf4j
@Configuration
public class EventConfig {

    @Bean
    public ApplicationEventMulticaster applicationEventMulticaster(EventListenerExceptionHandler exceptionHandler) {
        SimpleApplicationEventMulticaster multicaster = new SimpleApplicationEventMulticaster();
        multicaster.setErrorHandler(exceptionHandler);
        return multicaster;
    }

}

단순한 처리기라면 람다식을 통해 등록해줄 수도 있으나, 별도 클래스로 분리하는 편이 로그에서 처리 주체를 알 수 있어 좋다.

이제 위의 테스트 코드를 다시 실행하면 예외를 전파하지 않았다며 실패하게 된다. 그리고 아래와 같이 예외 핸들러에서 에러 로그를 찍고 있는 모습을 확인할 수 있다.

 

 

@TransactionalEventListener

@EventListener 와 @TransactionalEventListener 는 예외를 처리하는 방식이 다르다. 애당초 이 리스너를 소유하고 호출하는 관리 주체도 다르기 때문이다. (아래와 같이 패키지도 다르다.)

import org.springframework.context.event.EventListener;
import org.springframework.transaction.event.TransactionalEventListener;

예외 발생 테스트

아래와 같이 동기 트랜잭션 이벤트 리스너에서 예외를 터트리도록 이벤트를 발행하는 테스트를 실행시켜 보았다.

@SpringBootTest
public class BombServiceTests {

    @Autowired
    BombService bombService;

    @Test
    void transactionalEventListener_throw_notPropagate() {
        assertDoesNotThrow(() -> bombService.throwBomb(BombType.TRANSACTIONAL_EVENT_LISTENER));
    }

}

예외는 전파되지 않고 테스트가 성공한다. 즉, 어디선가 이 예외를 처리하고 전파하지 않았다는 의미이다. 그런데 에러 로그가 어디에도 없다.

(어? 나는 에러 로그 있는데? 하는 분들을 위해 결론부터 먼저 말하자면 해당 글에서 사용한 프로젝트의 Spring 버전은 6.0.9로, 이후 버전을 사용하는 사람들은 에러 로그가 있을 수도 있다.)

 

 

@TransactionalEventListener는 예외를 무시한다?

분명 리스너에서 예외를 전파했음에도, 예외에 대한 적절한 처리가 이루어지지 않았다.

그 이유를 알아보기 위해서는 이벤트 리스너의 로직을 실행하는 실행 주체를 찾아 전파된 예외를 어떻게 처리하고 있는지 알아봐야 했다.

예외를 최초로 던지는 시점에 브레이크 포인트를 잡고 예외의 상위 전파 흐름을 분석했다.

예외를 처리하는 것은 TransactionSynchronizationUtils 라는 클래스였다. 그런데 여기서 호출되는 메서드가 예외를 디버그 로그로 찍고 있었다!

결론적으로 이는 Spring GitHub 리포지토리 #17162 이슈에서 거론되어 해당 커밋에서 Fix되었다고 한다. 그리고 Spring 6.0.11 릴리즈에서 해당 변경 내용이 적용되었다.

 

 

예외 처리

하지만 여전히, 위의 클래스에서 에러를 잡아 처리하고 있기 때문에 별도 처리기를 등록할 수 없는 점은 동일하다. 단순히 에러 로그만 남기고 종료하려는 경우에는 상관 없으나, 앞선 예시와 마찬가지로 일괄적 예외 처리를 위한 전역 핸들러를 적용하고자 한다면 어떻게 해야 할까?

이 부분은 생각보다 복잡하다. 예외를 처리할 수 있는 기능은 있으나, 이를 등록할 수 있는 경로를 마련해주지 않았기 때문에, 앞선 예시와 같이 단순히 에러 핸들러를 만들고 어디 등록한다고 끝나지 않았다.

다시 TransactionSynchronizationUtils 코드를 보자. 해당 유틸 클래스는 리스너 로직을 호출하는 클래스 리스트를 소유하고 리스너 로직을 호출한 뒤, 예외가 전파되면 로그를 찍는다.

public static void invokeAfterCompletion(@Nullable List<TransactionSynchronization> synchronizations,
            int completionStatus) {

        if (synchronizations != null) {
            for (TransactionSynchronization synchronization : synchronizations) {
                try {
                    synchronization.afterCompletion(completionStatus);
                }
                catch (Throwable ex) {
                    logger.debug("TransactionSynchronization.afterCompletion threw exception", ex);
                }
            }
        }
    }

그리고 synchronization.afterCompletion에 의해 수행되는 로직은 TransactionalApplicationListenerSynchronization 내에 존재한다. 예외가 발생하면, 콜백이 있을 때 콜백 로직을 수행한 뒤 예외를 전파하고 있음을 알 수 있다.

@Override
public void afterCompletion(int status) {
    TransactionPhase phase = this.listener.getTransactionPhase();
    if (phase == TransactionPhase.AFTER_COMMIT && status == STATUS_COMMITTED) {
        processEventWithCallbacks();
    }
    else if (phase == TransactionPhase.AFTER_ROLLBACK && status == STATUS_ROLLED_BACK) {
        processEventWithCallbacks();
    }
    else if (phase == TransactionPhase.AFTER_COMPLETION) {
        processEventWithCallbacks();
    }
}

private void processEventWithCallbacks() {
    this.callbacks.forEach(callback -> callback.preProcessEvent(this.event));
    try {
        this.listener.processEvent(this.event);
    }
    catch (RuntimeException | Error ex) {
        this.callbacks.forEach(callback -> callback.postProcessEvent(this.event, ex));
        throw ex;
    }
    this.callbacks.forEach(callback -> callback.postProcessEvent(this.event, null));
}

그렇다면 어떻게든 저 콜백을 등록해주면 되는 일 아닌가? 콜백에 해당하는 타입의 클래스를 만들고, postProcessEvent 메서드를 오버라이딩하고, 예외가 null이 아닐 때 예외처리 로직을 수행한다면 위의 코드 상 전역 예외처리처럼 동작할 것 같다.

근데 콜백을 어떻게 등록해주지? 아쉽게도 위 클래스는 Bean이 아니기 때문에 커스텀 Bean을 사용하도록 하는 방식으로 해결할 수는 없었다. 대신, 이들을 생성하는 생성 주체인 팩토리 클래스가 Bean으로 등록이 가능했기 때문에, 팩토리 클래스를 커스터마이징했다.

public class CustomTransactionalEventListenerFactory extends TransactionalEventListenerFactory {

    private final List<SynchronizationCallback> callbackList;

    public CustomTransactionalEventListenerFactory(List<SynchronizationCallback> synchronizationCallbacks,
                                                   int order) {
        super();
        super.setOrder(order);
        this.callbackList = synchronizationCallbacks;
    }

    @Override
    public ApplicationListener<?> createApplicationListener(String beanName, Class<?> type, Method method) {
        ApplicationListener<?> applicationListener = super.createApplicationListener(beanName, type, method);
        if (applicationListener instanceof TransactionalApplicationListener<?> listener) {
            callbackList.forEach(listener::addCallback);
        }
        return applicationListener;
    }

}

이제 위에서 구현한 커스텀 팩토리를 Bean으로 등록하면 된다. 유의할 점은, Order에 의해 어떤 팩토리가 활성화될지 결정되므로 실제 팩토리 객체의 order보다 더 우선도가 높은 order 값을 줘야 한다.

@Bean
public TransactionalEventListenerFactory customTransactionalEventListenerFactory(List<SynchronizationCallback> callbackList) {
    return new CustomTransactionalEventListenerFactory(callbackList, 1);
}

콜백은 Bean으로 등록할 필요가 없으면 Bean 생성 시점에 객체를 생성해서 전달해도 된다. 나는 다른 Bean들을 주입받아 사용하고자 아래와 같이 Bean으로 등록했다.

@Slf4j
@Component
public class EventListenerExceptionHandler implements ErrorHandler {

    @Override
    public void handleError(Throwable t) {
        log.error("이벤트 리스너 실행 중 예외 발생", t);
    }

}

이제 다시 테스트를 실행시켜보자. 아래와 같이 콜백으로 등록된 에러 핸들러 메서드가 실행되었음을 확인할 수 있다.

끝나지 않은 문제 - Bean의 활성화 순서

해당 파트는 전역 예외 핸들러를 Bean으로 등록하는 과정에서 발생한 문제에 대해 다루고 있다. 핸들러를 Bean으로 등록할 생각이 없다면 이 부분은 건너뛰어도 좋다.

먼저 위와 같은 예외 처리가 잘 동작됨을 확인하고, 핸들러에 이것저것 Bean을 주입받아 예외처리를 하려고 했더니 핸들러에 Bean을 하나라도 주입받으면 오류가 나기 시작했다.

그 이유는, 핸들러 Bean의 주입 시점(커스텀 팩토리 Bean의 생성 시점)이 다른 Bean들의 생성 시점보다 빨랐기 때문이다. 즉, 핸들러 Bean에 주입할 Bean이 아직 없는 시점에 핸들러 Bean의 생성을 시도했다는 것이다.

이 순서를 조정하는 방법은 결국 찾지 못했기 때문에, 일단 핸들러 Bean을 모두 null로 초기화한 후 스프링이 모든 빈을 ApplicationContext에 등록하고 ApplicationStartedEvent를 발행하면 이를 리스닝하여 Bean들을 직접 찾아 주입해주는 방식으로 문제를 해결했다.

하지만 이 구현 방식은 여러모로 위험한 방법임에 주의해야 한다. 리스너 내에서 Bean을 찾지 못한 경우 애플리케이션의 Start를 Fail로 처리할 수 없기도 하고, 코드 가독성도 저하되고, 어쨌든 컨텍스트에 직접 접근하여 Bean을 꺼내오는 과정에서 Human Error가 발생하지 않을 것이라는 보장이 없기 때문이다.

근본적인 문제점은…

책임 전가 같은 결론이지만, 근본적인 문제점은 콜백을 등록해줄 수 있는 퍼블릭 인터페이스의 부재라고 생각한다. 적어도 ApplicationEventMulticaster Bean을 등록할 때와 같이, 콜백을 등록할 수 있는 통로가 미리 마련되어 있었다면 이렇게 이미 잘 만들어진 팩토리를 어색하게 재정의하고 등록할 필요가 없지 않았을까 싶다.

 

 

@EventListener + @Async

예외 발생 테스트

아래와 같이 비동기 이벤트 리스너에서 예외를 터트리도록 이벤트를 발행하는 테스트를 실행시켜 보았다. 비동기 리스너인만큼 별도 스레드에서 전파된 예외가 처리되기 전에 테스트 코드가 종료될 수도 있으므로 2초 지연을 추가하였다.

@SpringBootTest
public class BombServiceTests {

    @Autowired
    BombService bombService;

        @Test
    void asyncEventListener_throw_notPropagate() throws InterruptedException {
        assertDoesNotThrow(() -> bombService.throwBomb(BombType.ASYNC_EVENT_LISTENER));
        Thread.sleep(2000);
    }

}

결과는 아래와 같다. SimpleAsyncUncaughtExceptionHandler 라는 핸들러에서 에러 로그를 찍고 있다.

예외 처리

로그를 통해 확인했듯, 비동기 이벤트 리스너인 경우 예외 처리 책임은 @Async 처리 로직 쪽에 있다.

이 글은 Spring Async에 대한 글이 아니므로 처리 책임이 Async 쪽에 있다 정도만 밝히고, Spring Async의 예외 처리에 대한 내용은 생략할 것이다. 본 글의 가장 아래 첨부한 예제 코드 리포지토리에는 자세한 예외 처리 코드가 실려 있다.

 

 

@TransactionalEventListener + @Async

아래와 같이 비동기 트랜잭셔널 이벤트 리스너에서 예외를 터트리도록 이벤트를 발행하는 테스트를 실행시켜 보았다. 마찬가지로 2초 지연을 추가하였다.

@SpringBootTest
public class BombServiceTests {

    @Autowired
    BombService bombService;

        @Test
    void asyncTransactionalEventListener_throw_notPropagate() throws InterruptedException {
        assertDoesNotThrow(() -> bombService.throwBomb(BombType.ASYNC_TRANSACTIONAL_EVENT_LISTENER));
        Thread.sleep(2000);
    }

}

결과는 비동기 이벤트 리스너와 동일하게 SimpleAsyncUncaughtExceptionHandler 라는 핸들러에서 에러 로그를 찍고 있다.

예외 처리

비동기 이벤트 리스너와 처리 주체가 동일하므로, 처리 방법도 동일하다.

 

 

요약 정리

  1. 동기 이벤트 리스너는 리스너의 실행 주체가 ApplicationEventMulticaster이며, 해당 클래스는 에러 핸들러를 등록할 수 있도록 public method를 제공한다.
  2. 동기 트랜잭셔널 리스너는 리스너의 실행 주체 TransactionalApplicationListenerSynchronization이 예외 발생 시 예외를 담아 콜백을 호출하는데, 콜백을 등록해 줄 방법이 없기 때문에 팩토리 클래스를 커스터마이징하여 임의로 콜백을 등록해주었다.
  3. 비동기 이벤트 리스너는 모두 Spring Async 예외 처리 방법을 따른다.

 

 

코드

해당 글에서 사용한 예제 코드는 아래 저장소에서 확인할 수 있다.

 

GitHub - hyh1016/spring-event-playground: Repository containing sample code using spring event, written in Java 17 & Spring Boot

Repository containing sample code using spring event, written in Java 17 & Spring Boot 3.1 - GitHub - hyh1016/spring-event-playground: Repository containing sample code using spring event, writ...

github.com