주문 완료 email은 전송됐는데, 정작 주문 저장은 rollback됐어요.이상하죠? 주문이 저장된 뒤 email을 보내도록 코드를 작성했는데, 마지막 DB 작업에서 예외가 나면 이런 일이 생길 수 있어요.
ApplicationEventPublisher는 무엇을 분리할까요?@EventListener는 언제, 어느 thread에서 실행될까요?- Transaction이 commit된 뒤에만 처리하려면 어떻게 해야 할까요?
- 내부 event는 Kafka나 RabbitMQ message와 무엇이 다를까요?
- 어떤 실패는 event로 풀지 않는 편이 나을까요?
Application event는 component 사이의 직접 의존성을 줄일 수 있지만, 그 자체로 비동기 실행이나 안전한 전달을 보장하지는 않아요.
이 글은 Spring Boot 4.x와 Spring Framework 7.x 흐름을 기준으로 작성했어요.
ApplicationEventPublisher, @EventListener, transaction phase라는 핵심 모델은 기존 Spring Boot 3.x에서도 같은 방향으로 읽을 수 있어요.주문 하나가 끝난 뒤 할 일이 계속 늘어나요
처음에는 주문 service가 주문만 저장했어요.src/main/java/com/example/order/OrderService.java
- 주문 완료 email을 보내요.
- 재고 할당을 시작해요.
- 회원의 첫 주문 coupon을 확인해요.
- 주문 생성 metric을 기록해요.
OrderService가 직접 호출하면 어떻게 될까요?
OrderService는 자신이 직접 수행하지 않는 일의 dependency까지 모두 알아야 해요. Email 변경 때문에 주문 service 생성자가 바뀌고, coupon 기능을 끄기 위해 주문 핵심 흐름을 수정하는 일이 생기죠.
Application event는 이 질문 중 “주문 service가 후속 component를 직접 알아야 하나요?”를 풀 때 쓸 수 있어요.
Publisher는 사건을 알리고, listener는 관심 있는 사건을 받아요
먼저 주문이 생성됐다는 event를 만들어요. Javarecord를 쓰면 한 번 만들어진 사실을 간결한 immutable 값으로 표현할 수 있어요.
src/main/java/com/example/order/OrderPlaced.java
ApplicationEvent를 상속할 필요는 없어요. publishEvent(Object)에 일반 객체를 넘기면 Spring이 내부에서 payload event로 감싸 matching listener에 전달해요.
이제 주문 service는 구체적인 email sender나 coupon issuer 대신 ApplicationEventPublisher만 알아요.
src/main/java/com/example/order/OrderService.java
src/main/java/com/example/notification/OrderNotificationListener.java
OrderService는 OrderNotificationListener를 import하지 않아요. Listener도 주문을 생성하는 method를 몰라요. 둘은 OrderPlaced라는 event 계약으로 연결돼요.
직접 dependency 여러 개가 publisher 하나로 줄었어요. 새 listener를 추가해도 주문 service는 바뀌지 않죠. 이것이 application event가 만들어주는 첫 번째 느슨함이에요.
근데 publishEvent는 queue에 넣는 동작이 아니에요
여기서 가장 많이 생기는 오해가 있어요.
publishEvent(...)도 돌아와요.
이 흐름에서는 email 전송 시간이 주문 API 응답 시간에 포함돼요. Listener가 예외를 던지면 기본적으로 publisher까지 예외가 전파되고, 아직 transaction 안이라면 주문 transaction도 rollback될 수 있어요.
따라서 기본 @EventListener는 이렇게 읽는 편이 정확해요.
이 기본값이 나쁜 것은 아니에요. 같은 process 안에서 빠르게 관심사를 분리하고, listener 실패를 원래 use case 실패로 취급하고 싶을 때는 오히려 이해하기 쉬워요.
문제는 동기 호출인데 비동기 queue처럼 믿는 순간 생겨요.
일반 @EventListener는 transaction commit보다 먼저 움직일 수 있어요
Opening에서 본 이상한 장면으로 돌아가 볼게요.
OrderPlaced를 받은 listener가 email을 보낸 뒤, 마지막 point 저장에서 예외가 났다고 해 볼게요.
DB에서는 주문이 사라졌지만 외부로 나간 email은 되돌릴 수 없어요. Event 이름이 OrderPlaced여도 발행 시점에는 transaction이 아직 commit되지 않았던 거예요.
이 장면에서 필요한 질문은 “event인가요?”보다 “어느 transaction phase에서 처리해야 하나요?”예요.
Commit 뒤에 처리하려면 @TransactionalEventListener를 써요
주문이 실제 commit된 뒤에만 email을 보내고 싶다면 listener를 transaction에 연결할 수 있어요.
src/main/java/com/example/notification/OrderNotificationListener.java
AFTER_COMMIT은 기본 phase라 생략할 수도 있지만, 처음 읽는 코드에서는 실행 시점을 명시하면 의도가 더 잘 보여요.
이 listener는 event가 발행되자마자 body를 실행하지 않아요. 현재 transaction에 작업을 등록해 두었다가 commit이 성공한 뒤 호출돼요. Transaction이 rollback되면 AFTER_COMMIT listener는 실행되지 않아요.
Spring이 제공하는 phase는 네 가지예요.
활성 transaction이 없으면 기본적으로 실행되지 않아요
@TransactionalEventListener에는 중요한 조건이 하나 더 있어요. Event가 발행될 때 활성 transaction이 없으면 기본적으로 listener가 실행되지 않아요.
AFTER_COMMIT에 연결할 transaction도 없어요. 그래서 event는 발행됐지만 listener가 조용히 실행되지 않는 것처럼 보일 수 있어요.
fallbackExecution = true를 켜면 transaction이 없을 때도 listener를 실행할 수 있어요.
- Transaction이 있으면 commit 뒤에 실행해요.
- Transaction이 없으면 commit을 기다리지 않고 실행해요.
AFTER_COMMIT에서 DB를 다시 쓰는 일은 별도 경계가 필요해요
AFTER_COMMIT이라는 이름을 보면 listener 안에서 repository를 호출해도 새 transaction이 자동으로 생길 것 같아요. 사실은 아니에요.
Commit은 이미 끝났어요. Transaction resource가 잠시 접근 가능한 상태로 보일 수는 있지만, 그 시점의 새 변경이 별도 commit으로 이어진다고 기대하면 안 돼요.
예를 들어 주문 이력을 반드시 DB에 남겨야 한다면 두 선택을 구분해야 해요.
새 transaction을 선택한다면 listener와 writer를 다른 bean으로 나눠 프록시 경계를 통과시켜야 해요.
src/main/java/com/example/order/OrderHistoryWriter.java
비동기로 돌리려면 @Async라는 별도 선택이 필요해요
Email 전송 때문에 주문 응답이 느려지는 문제를 피하려고 listener를 비동기로 실행할 수 있어요.
먼저 async method 실행을 활성화해요.
src/main/java/com/example/order/AsyncConfig.java
@Async를 붙여요.
src/main/java/com/example/notification/OrderNotificationListener.java
@Async는 thread를 바꾸는 도구예요. Message를 저장하거나 재시도하거나 다른 instance로 보내주는 도구가 아니에요.
앞의 관측성 글에서 봤듯이 async 경계에서는 trace context가 이어지는지도 확인해야 해요. 실패를 publisher가 받지 못하므로 error log, metric, alert도 listener 쪽에 필요해져요.
@Order는 business workflow를 만드는 도구가 아니에요
Listener가 여러 개면 실행 순서를 정하고 싶어질 수 있어요.
- 순서가 바뀌면 business 결과가 달라지나요?
- 앞 작업의 결과를 뒤 작업이 입력으로 사용하나요?
- 하나가 실패하면 나머지도 중단해야 하나요?
- 사용자가 한 화면에서 전체 성공 여부를 바로 알아야 하나요?
Event payload는 작은 과거 사실로 만들어요
Event에는 무엇을 담아야 할까요? 편하다는 이유로 JPA Entity 전체를 넘기면 경계가 흐려져요.
Event 이름과 payload는 “listener가 지금 무엇을 해라”보다 “무슨 일이 언제 일어났는가”를 표현해야 해요. Password, access token, 주민등록번호 같은 secret과 민감정보는 event에 넣지 않는 것이 기본이에요. Async 처리나 log가 붙는 순간 예상보다 넓은 곳에 남을 수 있기 때문이에요.
Test는 발행과 처리 시점을 따로 증명해요
Application event test에서 “method가 정상 반환됐다”만 확인하면 중요한 경계를 놓쳐요. Spring Test의@RecordApplicationEvents를 사용하면 test 중 발행된 event를 확인할 수 있어요.
src/test/java/com/example/order/OrderServiceTest.java
OrderPlaced가 발행됐다는 계약을 증명해요. Email이 실제로 전송됐는지, commit 뒤에만 listener가 움직였는지까지 증명하지는 않아요.
@TransactionalEventListener(AFTER_COMMIT)를 test할 때는 test 자체의 transaction도 주의해야 해요. Spring test가 마지막에 rollback한다면 commit phase가 오지 않아 listener가 실행되지 않는 것이 정상이에요.
특히 async test에서 고정된
sleep만 넣으면 느리고 불안정해져요. 완료 신호를 기다릴 수 있는 test 도구나 관측 가능한 상태를 사용하고, timeout 안에 원하는 결과가 생기는지 확인하는 편이 좋아요.
내부 event와 message broker event는 같은 말이 아니에요
Application event를 사용하다 보면 자연스럽게 “그럼 Kafka나 RabbitMQ도 같은 event 아닌가요?”라는 질문이 생겨요. 업무에서 “event”라는 단어를 함께 쓰지만 실행 경계는 크게 달라요.
여기서도 broker를 쓴다고 자동으로 exactly-once business 처리가 완성되지는 않아요. Producer DB transaction과 message 발행 사이의 빈틈, 중복 delivery, ordering, retry, dead-letter queue, idempotency를 따로 설계해야 해요.
내부 application event는 broker를 쓰기 전에 component 경계를 연습하기 좋은 도구예요. 하지만 나중에 broker로 바꿀 계획이라는 이유만으로 전달 보장 차이를 무시하면 안 돼요.
AFTER_COMMIT application event는 rollback 전에 외부 작업이 나가는 문제를 줄여요. 하지만 commit 직후 process가 종료되면 event 처리를 잃을 수 있어요. Outbox는 business 변경과 발행할 message를 같은 DB transaction에 남겨 그 빈틈을 줄이는 별도 패턴이에요.
Reactive transaction에서는 context 전달 방식이 달라요
여기까지 예제는 Spring MVC와 thread-bound transaction manager를 기준으로 했어요. 이 경우 transaction context는 현재 thread와 연결돼 있어요. R2DBC처럼 reactive transaction manager를 사용하면 transaction context는 thread-local이 아니라 Reactor context에 있어요. 그래서 reactive transactional event에서는 필요한 transaction context를 event source에 담아 전달하는TransactionalEventPublisher 흐름을 확인해야 해요.
처음에는 여기까지만 기억해도 충분해요.
- MVC와 JDBC/JPA의 thread-bound 모델을 reactive 코드에 그대로 옮기지 않아요.
@Async와 Reactor는 둘 다 “비동기”로 불리지만 context 전달 방식이 같지 않아요.- Transactional listener가 필요하다면 현재 사용하는 transaction manager와 공식 문서를 함께 확인해요.
실무에서는 이 체크리스트로 선택해 보세요
새 후속 작업이 생겼다고 모두 event로 바꿀 필요는 없어요.직접 호출이 더 잘 보이는 경우
- 반드시 성공해야 원래 use case도 성공이에요.
- 실행 순서가 business 규칙이에요.
- 앞 작업의 반환값이 뒤 작업에 필요해요.
- 실패를 바로 사용자에게 알려야 해요.
Application event가 잘 맞는 경우
- 같은 application 안에서 이미 일어난 사실을 여러 component가 관찰해요.
- Publisher가 구체적인 listener를 몰라도 돼요.
- Listener 추가와 제거가 핵심 use case를 바꾸지 않아요.
- 동기, transaction phase, async 실패 의미를 팀이 명확히 정할 수 있어요.
Broker나 outbox를 검토해야 하는 경우
- 다른 service나 process가 받아야 해요.
- 재시작 뒤에도 event를 잃으면 안 돼요.
- Traffic을 완충하거나 consumer가 독립적인 속도로 처리해야 해요.
- Retry, DLQ, idempotency, 장기 보관이 필요해요.
이 event는 시점에 발행되고, phase에서 처리되며, listener가 실패하면 되고, process가 종료되면 방식으로 복구한다.빈칸을 채우기 어렵다면 event 이름은 있어도 운영 계약은 아직 없는 상태예요.
참고한 링크
- Spring Framework 공식 문서: Application Events
- Spring Framework 공식 문서: Transaction-bound Events
- Spring Framework API: ApplicationEventPublisher
- Spring Framework API: EventListener
- Spring Framework API: TransactionalEventListener
- Spring Framework 공식 문서: TestContext Application Events
자, 정리해볼까요?
ApplicationEventPublisher는 publisher가 구체적인 후속 component를 직접 알지 않게 해 같은 application 안의 의존성을 줄여줘요.- Spring application event는 기본적으로 호출 thread에서 동기 실행돼요. Queue, 별도 thread, 저장, 재시도를 자동으로 제공하지 않아요.
- 일반
@EventListener는 transaction commit 전에 실행될 수 있고, listener 예외가 publisher와 원래 transaction에 영향을 줄 수 있어요. @TransactionalEventListener의 기본 phase는AFTER_COMMIT이며, 활성 transaction이 없으면 기본적으로 listener가 실행되지 않아요.AFTER_COMMIT에서 새 DB 변경이 자동 commit된다고 기대하면 안 돼요. 원래 transaction에 포함할지, 별도 transaction이나 durable 전달을 만들지 정해야 해요.@Async는 실행 thread를 바꾸지만 event를 저장하지 않아요. 실패 관측, context propagation, process 종료 시 유실을 따로 설계해야 해요.- Business 순서가 중요하다면
@Order숫자에 숨기기보다 명시적인 workflow나 더 구체적인 결과 event로 표현하는 편이 좋아요. - 다른 service로 안전하게 전달해야 한다면 Kafka, RabbitMQ, outbox, retry, DLQ, idempotency가 필요한 다음 단계예요.