1. 요약
실행 순서: @Order 어노테이션 없이는 실행 순서가 보장되지 않으며, 메서드명과 무관하게 무작위로 실행됩니다. 순서 보장이 필요하다면 반드시 @Order를 명시해야 합니다.
예외 처리: 한 리스너에서 예외가 발생해도 다른 리스너들의 실행은 보장되지만, @Async 없이는 동기 방식으로 순차 실행되므로 중간 리스너의 지연이나 블로킹이 후속 리스너들의 실행 시간에 직접적인 영향을 미칩니다.
성능 영향: 비동기 처리(@Async)가 없는 경우 모든 리스너가 하나의 스레드에서 순차 실행되므로, 특정 리스너의 긴 처리 시간이 전체 이벤트 처리 시간을 증가시키고 후속 리스너들의 실행을 지연시킵니다.
가장 중요한 설계 원칙
@Order를 사용하여 실행 순서를 명시적으로 지정할 수 있지만, 근본적으로는 각 이벤트 리스너가 서로 영향을 미치지 않도록 설계하는 것이 가장 좋습니다. 리스너들이 독립적으로 동작하도록 설계하면 실행 순서나 한 리스너의 실패가 다른 리스너에게 영향을 주지 않아 더 안정적이고 확장 가능한 시스템을 구축할 수 있습니다. 순서 의존성이 필요한 경우에만 @Order를 사용하고, 가능한 한 리스너 간 결합도를 낮추는 것이 이상적입니다.
2. 들어가며
Spring에서 이벤트 기반 아키텍처를 구현할 때 @TransactionalEventListener를 사용하면 트랜잭션 커밋 후에 이벤트를 처리할 수 있습니다. 하지만 여러 개의 리스너가 하나의 이벤트를 구독할 때, 의도하지 않은 실행 순서나 예외 처리 문제를 만날 수 있습니다.
특히 @Async 없이 동기 방식으로 여러 리스너를 실행할 때 다음과 같은 의문이 생깁니다:
- 리스너들이 순차적으로 실행되는가?
- 하나의 리스너가 실패했을 때 다른 리스너들이 영향을 받는가?
이는 Spring의 이벤트 발행 메커니즘과 리스너 실행 순서 결정 방식과 관련이 있습니다. 이 글에서는 실제 테스트를 통해 이러한 문제들의 원인과 해결 방법을 살펴보겠습니다.
2. 문제 상황
다음과 같이 하나의 이벤트를 여러 리스너가 구독하는 상황을 가정해봅시다
public class EventTest {
@TransactionalEventListener(Event.class)
public void handleA(Event event) {
log.info("handleA called!");
}
@TransactionalEventListener(Event.class)
public void handleB(Event event) {
log.info("handleB called!");
}
@TransactionalEventListener(Event.class)
public void handleC(Event event) {
log.info("handleC called!");
}
}
이 코드를 실행하면 다음과 같은 결과가 나타납니다
실행 결과 (첫 번째 실행)
handleA called!
handleB called!
handleC called!
실행 결과 (두 번째 실행)
handleC called!
handleA called!
handleB called!
매번 실행할 때마다 리스너들의 실행 순서가 달라집니다. 순서가 일정하지 않습니다.
3. 원인 분석
3-1. Spring Event 처리 메커니즘
Spring은 ApplicationEventPublisher를 통해 이벤트를 발행하고, 등록된 모든 리스너에게 이벤트를 전달합니다. @TransactionalEventListener의 경우 기본적으로 트랜잭션 커밋 이후(AFTER_COMMIT)에 실행됩니다.
일반적인 경우의 정상 동작 흐름은 다음과 같습니다
1. ApplicationEventPublisher.publishEvent(event) 호출
2. 트랜잭션 커밋 완료 대기
3. 등록된 모든 @TransactionalEventListener 메서드 실행
3-2. 리스너 실행 순서 결정 메커니즘
Spring은 리스너들을 내부적으로 Set 또는 리플렉션 기반으로 수집합니다. 명시적인 순서 지정이 없으면 다음 요소들에 의해 순서가 결정됩니다
- JVM의 리플렉션 API가 반환하는 메서드 순서 (비결정적)
- 클래스 로딩 순서
- 메서드의 메모리 상 위치
이러한 요소들은 실행 환경, JVM 버전, 클래스 로더에 따라 달라질 수 있어 순서가 보장되지 않습니다.
3-3. 문제 발생 시나리오
메서드명을 변경해도 순서는 여전히 무작위입니다
@TransactionalEventListener(Event.class)
public void handleBBBB(Event event) { // 메서드명 변경
log.info("handleBBBB called!");
}
실행 결과
# 첫 번째 실행
handleC called!
handleA called!
handleBBBB called!
# 두 번째 실행
handleC called!
handleBBBB called!
handleA called!
메서드명에 따라 순서가 정해지는 것은 아닙니다.
3-4. 동기 실행 방식의 영향
@Async가 없는 경우 모든 리스너는 하나의 스레드에서 순차적으로 실행됩니다
트랜잭션 커밋 → 리스너A 실행 → 리스너B 실행 → 리스너C 실행
(모두 같은 스레드에서 순차 실행)
다음과 같은 순서로 문제가 발생합니다
- 첫 번째 리스너 실행 시작
- 첫 번째 리스너가 완료될 때까지 대기
- 두 번째 리스너 실행 시작
- 두 번째 리스너가 완료될 때까지 대기
- 세 번째 리스너 실행 (이 지점에서 앞선 리스너들의 지연이 누적됨)
4. 해결 방법
방법 1: @Order 어노테이션 사용 (가장 권장)
가장 권장되는 방법은 @Order 어노테이션으로 명시적인 실행 순서를 지정하는 것입니다.
import org.springframework.core.annotation.Order;
public class EventTest {
@TransactionalEventListener(Event.class)
@Order(1) // 첫 번째로 실행
public void handleA(Event event) {
log.info("handleA called!");
}
@TransactionalEventListener(Event.class)
@Order(2) // 두 번째로 실행
public void handleB(Event event) {
log.info("handleB called!");
}
@TransactionalEventListener(Event.class)
@Order(3) // 세 번째로 실행
public void handleC(Event event) {
log.info("handleC called!");
}
}
실행 결과
# 1
handleA called!
handleB called!
handleC called!
# 2
handleA called!
handleB called!
handleC called!
# 3
handleA called!
handleB called!
handleC called!
모든 실행에서 동일한 순서가 보장됩니다.
이 방법이 좋은 이유
- 명확하고 예측 가능한 실행 순서 보장
- 코드만 보고도 실행 순서를 쉽게 파악 가능
- 추가 설정 불필요
주의사항
- 숫자가 작을수록 먼저 실행됩니다
- 같은 Order 값을 가진 리스너들 간의 순서는 보장되지 않습니다
방법 2: 별도의 클래스로 분리
각 리스너를 별도의 클래스로 분리하고 @Order를 클래스 레벨에 적용할 수 있습니다.
@Component
@Order(1)
public class EventHandlerA {
@TransactionalEventListener(Event.class)
public void handle(Event event) {
log.info("handleA called!");
}
}
@Component
@Order(2)
public class EventHandlerB {
@TransactionalEventListener(Event.class)
public void handle(Event event) {
log.info("handleB called!");
}
}
@Component
@Order(3)
public class EventHandlerC {
@TransactionalEventListener(Event.class)
public void handle(Event event) {
log.info("handleC called!");
}
}
이 방법의 제약사항
- 클래스가 많아져 관리 포인트가 증가
- 간단한 리스너에는 과도한 구조
방법 3: 순서가 중요하지 않은 경우 명시적 문서화
순서가 정말 중요하지 않다면, 주석으로 명확히 표시합니다.
public class EventTest {
// 이 리스너들은 실행 순서가 보장되지 않으며, 순서와 무관하게 동작합니다
@TransactionalEventListener(Event.class)
public void handleA(Event event) {
log.info("handleA called!");
}
@TransactionalEventListener(Event.class)
public void handleB(Event event) {
log.info("handleB called!");
}
}
언제 이 방법을 사용하면 좋은지
- 각 리스너가 완전히 독립적인 작업을 수행
- 리스너 간 의존성이 전혀 없는 경우
- 예: 독립적인 로깅, 메트릭 수집
5. 예외 처리 동작
여러 리스너 중 하나가 실패했을 때의 동작을 살펴봅시다.
중간 리스너에서 예외 발생
public class EventTest {
@TransactionalEventListener(Event.class)
@Order(1)
public void handleA(Event event) {
log.info("handleA called!");
}
@TransactionalEventListener(Event.class)
@Order(2)
public void handleB(Event event) {
log.info("handleB called!");
throw new RuntimeException("Error!"); // 이 지점에서 예외 발생
}
@TransactionalEventListener(Event.class)
@Order(3)
public void handleC(Event event) {
log.info("handleC called!");
}
}
실행 결과
# 1
handleA called!
handleB called!
>> Exception Error!
handleC called!
# 2
handleA called!
handleB called!
>> Exception Error!
handleC called!
한 리스너에서 예외가 발생해도 다른 리스너들의 실행은 보장됩니다.
중간 리스너가 블록(Block)되는 경우
@Async가 없는 상태이기 때문에 하나의 스레드로 호출되고 있으니, 중간 리스너가 오래 걸리는 작업을 수행하는 경우 이후 리스너들의 실행이 지연됩니다.
public class EventTest {
@TransactionalEventListener(Event.class)
@Order(1)
public void handleA(Event event) {
log.info("handleA called!");
}
@TransactionalEventListener(Event.class)
@Order(2)
public void handleB(Event event) {
log.info("handleB called!");
Thread.sleep(3_000); // 3초 대기
}
@TransactionalEventListener(Event.class)
@Order(3)
public void handleC(Event event) {
log.info("handleC called!");
}
}
실행 결과
# 1
15:01:14.883 handleA called!
15:01:14.883 handleB called!
15:01:17.883 handleC called!
# 2
15:01:17.917 handleA called!
15:01:17.917 handleB called!
15:01:20.917 handleC called!
중간 리스너의 처리 시간이 길어지면 이후에 있는 리스너에 영향을 미칩니다.
6. 정리
Spring의 @TransactionalEventListener에서 하나의 이벤트를 여러 개의 리스너가 처리할 때는 실행 순서와 예외 처리 메커니즘을 이해하는 것이 중요합니다.
핵심 내용 요약:
실행 순서: @Order 어노테이션 없이는 실행 순서가 보장되지 않으며, 메서드명과 무관하게 무작위로 실행됩니다. 순서 보장이 필요하다면 반드시 @Order를 명시해야 합니다.
예외 처리: 한 리스너에서 예외가 발생해도 다른 리스너들의 실행은 보장되지만, @Async 없이는 동기 방식으로 순차 실행되므로 중간 리스너의 지연이나 블로킹이 후속 리스너들의 실행 시간에 직접적인 영향을 미칩니다.
성능 영향: 비동기 처리(@Async)가 없는 경우 모든 리스너가 하나의 스레드에서 순차 실행되므로, 특정 리스너의 긴 처리 시간이 전체 이벤트 처리 시간을 증가시키고 후속 리스너들의 실행을 지연시킵니다.
8. 참고 자료
- Spring Framework Reference - Transaction Management - 트랜잭션 이벤트 리스너 공식 문서
- Spring Framework Reference - Application Events - Spring 이벤트 발행 메커니즘
- Spring @Order Annotation - Order 어노테이션 사용법
'개발 > Java' 카테고리의 다른 글
| Spring에서 MultipartFile 비동기 처리 시 임시 파일 삭제 문제 해결하기 (0) | 2025.10.17 |
|---|---|
| Spring Boot - ObjectMapper 생성시 초기 configuration (0) | 2024.04.23 |
| Failed to start bean 'documentationPluginsBootstrapper' (0) | 2024.01.30 |