Skip to main content
주문 완료 email은 전송됐는데, 정작 주문 저장은 rollback됐어요.
이상하죠? 주문이 저장된 뒤 email을 보내도록 코드를 작성했는데, 마지막 DB 작업에서 예외가 나면 이런 일이 생길 수 있어요.
개발자는 “event를 발행했으니 나중에 처리되겠지”라고 생각하기 쉬워요. 근데요, Spring의 application event는 기본적으로 message queue가 아니에요. 같은 application 안에서 listener를 찾아 지금 호출 흐름 안에서 event를 건네는 기능에 더 가까워요. 앞 글에서는 Auth API의 한 요청이 async 작업과 다른 service로 넘어갈 때 metric, trace, log를 연결했어요. 이번에는 범위를 다시 한 application 안으로 좁혀 볼게요.
  • 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
그런데 요구사항은 금방 늘어나죠.
  1. 주문 완료 email을 보내요.
  2. 재고 할당을 시작해요.
  3. 회원의 첫 주문 coupon을 확인해요.
  4. 주문 생성 metric을 기록해요.
이 일을 모두 OrderService가 직접 호출하면 어떻게 될까요?
코드가 틀렸다는 뜻은 아니에요. 주문 완료 뒤 무엇이 반드시 일어나야 하는지 한눈에 보인다는 장점도 있어요. 하지만 후속 작업이 계속 늘어나면 OrderService는 자신이 직접 수행하지 않는 일의 dependency까지 모두 알아야 해요. Email 변경 때문에 주문 service 생성자가 바뀌고, coupon 기능을 끄기 위해 주문 핵심 흐름을 수정하는 일이 생기죠. Application event는 이 질문 중 “주문 service가 후속 component를 직접 알아야 하나요?”를 풀 때 쓸 수 있어요.

Publisher는 사건을 알리고, listener는 관심 있는 사건을 받아요

먼저 주문이 생성됐다는 event를 만들어요. Java record를 쓰면 한 번 만들어진 사실을 간결한 immutable 값으로 표현할 수 있어요.
src/main/java/com/example/order/OrderPlaced.java
Spring event라고 해서 반드시 ApplicationEvent를 상속할 필요는 없어요. publishEvent(Object)에 일반 객체를 넘기면 Spring이 내부에서 payload event로 감싸 matching listener에 전달해요. 이제 주문 service는 구체적인 email sender나 coupon issuer 대신 ApplicationEventPublisher만 알아요.
src/main/java/com/example/order/OrderService.java
Publisher는 “누가 받을지”를 정하지 않아요. 주문 생성이라는 사실만 application context에 알려요. Email component는 그 event에 관심 있다고 표시해요.
src/main/java/com/example/notification/OrderNotificationListener.java
OrderServiceOrderNotificationListener를 import하지 않아요. Listener도 주문을 생성하는 method를 몰라요. 둘은 OrderPlaced라는 event 계약으로 연결돼요. 직접 dependency 여러 개가 publisher 하나로 줄었어요. 새 listener를 추가해도 주문 service는 바뀌지 않죠. 이것이 application event가 만들어주는 첫 번째 느슨함이에요.
SendOrderEmail은 특정 listener에게 일을 시키는 명령처럼 들려요. OrderPlaced는 주문이 생성됐다는 과거의 사실이라 email, coupon, metric처럼 여러 관심사가 각자 반응하기 쉬워요.

근데 publishEvent는 queue에 넣는 동작이 아니에요

여기서 가장 많이 생기는 오해가 있어요.
이 한 줄을 보면 “event를 어딘가에 넣고 바로 다음 줄로 가겠구나”라고 생각하기 쉬워요. 기본 동작은 달라요. Spring application event multicaster는 기본적으로 listener를 publisher를 호출한 thread에서 동기 실행해요. Listener가 끝나야 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는 네 가지예요.
반드시 지켜야 하는 business invariant는 가능한 한 domain과 service의 원래 transaction 흐름에서 검증하는 편이 좋아요. 중요한 검증이 listener 여러 곳에 흩어지면 주문이 왜 실패했는지 따라가기 어려워져요.

활성 transaction이 없으면 기본적으로 실행되지 않아요

@TransactionalEventListener에는 중요한 조건이 하나 더 있어요. Event가 발행될 때 활성 transaction이 없으면 기본적으로 listener가 실행되지 않아요.
이 method에 실제 transaction 경계가 없다면 AFTER_COMMIT에 연결할 transaction도 없어요. 그래서 event는 발행됐지만 listener가 조용히 실행되지 않는 것처럼 보일 수 있어요. fallbackExecution = true를 켜면 transaction이 없을 때도 listener를 실행할 수 있어요.
하지만 이 옵션은 의미를 바꿔요.
  • Transaction이 있으면 commit 뒤에 실행해요.
  • Transaction이 없으면 commit을 기다리지 않고 실행해요.
즉, “commit된 주문만 처리한다”는 강한 계약이 필요하다면 fallback으로 증상을 덮지 말고 publisher의 transaction 경계가 왜 사라졌는지 먼저 확인해야 해요.
Publisher가 실제 Spring transaction 안에서 호출됐는지, self-invocation 때문에 @Transactional 프록시를 우회하지 않았는지, transaction이 commit이 아니라 rollback된 것은 아닌지 확인하세요.

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
이 선택은 원래 주문 transaction과 독립적이에요. 이력 저장이 실패해도 이미 commit된 주문은 되돌아가지 않아요. “별도 transaction을 만들었다”와 “반드시 전달된다”도 같은 말이 아니고요.

비동기로 돌리려면 @Async라는 별도 선택이 필요해요

Email 전송 때문에 주문 응답이 느려지는 문제를 피하려고 listener를 비동기로 실행할 수 있어요. 먼저 async method 실행을 활성화해요.
src/main/java/com/example/order/AsyncConfig.java
그다음 listener에 @Async를 붙여요.
src/main/java/com/example/notification/OrderNotificationListener.java
이제 commit 뒤 listener 호출이 executor에 넘겨지고 publisher thread는 listener 완료를 기다리지 않아요. 그 대신 실패 의미도 달라져요. @Async는 thread를 바꾸는 도구예요. Message를 저장하거나 재시도하거나 다른 instance로 보내주는 도구가 아니에요. 앞의 관측성 글에서 봤듯이 async 경계에서는 trace context가 이어지는지도 확인해야 해요. 실패를 publisher가 받지 못하므로 error log, metric, alert도 listener 쪽에 필요해져요.
재시작이나 장애 뒤에도 반드시 보내야 하는 email이라면 memory 안의 async event만으로는 부족해요. 전송 상태를 DB에 남기거나 outbox와 broker를 사용해 재처리 가능한 경계를 만들어야 해요.

@Order는 business workflow를 만드는 도구가 아니에요

Listener가 여러 개면 실행 순서를 정하고 싶어질 수 있어요.
동기 listener 사이의 우선순위를 표현할 수는 있어요. 하지만 “재고 예약이 성공해야 email을 보낸다”가 실제 business 규칙이라면 숫자 순서에 숨기지 않는 편이 좋아요. 그 요구에는 다음처럼 결과 event를 나누거나, 하나의 orchestrator가 순서를 직접 조정하는 편이 더 선명해요.
Event가 계속 event를 낳는 구조도 무조건 좋은 것은 아니에요. 한 요청의 실제 흐름을 찾기 위해 listener 열 개를 돌아다녀야 한다면 직접 호출보다 더 강한 보이지 않는 결합이 생긴 거예요. 다음 질문으로 판단해 보세요.
  • 순서가 바뀌면 business 결과가 달라지나요?
  • 앞 작업의 결과를 뒤 작업이 입력으로 사용하나요?
  • 하나가 실패하면 나머지도 중단해야 하나요?
  • 사용자가 한 화면에서 전체 성공 여부를 바로 알아야 하나요?
대부분 “예”라면 event listener 여러 개보다 명시적인 application service가 workflow를 보여주는 편이 좋아요.

Event payload는 작은 과거 사실로 만들어요

Event에는 무엇을 담아야 할까요? 편하다는 이유로 JPA Entity 전체를 넘기면 경계가 흐려져요.
이 모양은 listener가 lazy loading에 기대거나 Entity를 수정하게 만들 수 있어요. Async listener라면 원래 persistence context가 이미 닫힌 뒤일 수도 있고요. 보통은 listener가 사건을 식별하는 데 필요한 immutable 값만 담는 편이 좋아요.
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
이 test는 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, 장기 보관이 필요해요.
Code review에서는 Annotation보다 아래 문장을 먼저 완성해 보세요.
이 event는 시점에 발행되고, phase에서 처리되며, listener가 실패하면 되고, process가 종료되면 방식으로 복구한다.
빈칸을 채우기 어렵다면 event 이름은 있어도 운영 계약은 아직 없는 상태예요.

참고한 링크

자, 정리해볼까요?

  • 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가 필요한 다음 단계예요.
다음 글에서는 application process 밖으로 event를 보내 볼게요. Kafka와 RabbitMQ가 각각 어떤 문제를 풀고, message ordering, retry, dead-letter queue, idempotency를 왜 함께 설계해야 하는지 이어서 살펴볼 거예요.