Spring Event (2/2)

이전 편: [Spring Event] 1. 이벤트 기반 설계란

이 문서가 시리즈의 마지막 편입니다.

시리즈 안내

1. 이벤트 기반 설계란

2. @TransactionalEventListener ← 현재 문서

@EventListener의 문제

이벤트 기반 설계란에서 봤듯이, @EventListener발행자의 트랜잭션 안에서 실행된다. 이게 왜 문제일까? 구체적인 시나리오를 보자.

주문이 저장되면 이메일을 보내는 리스너가 있다고 하자.

@EventListener
public void sendEmail(OrderCompletedEvent event) {
    Order order = orderRepository.findById(event.getOrderId());
    emailService.send(order.getUserEmail(), "주문 완료");
}

이 리스너는 publishEvent() 시점에 즉시 실행된다. 그런데 이 시점에서는 트랜잭션이 아직 커밋되지 않았다. 두 가지 문제가 생긴다.

  • 문제 1 : 리스너가 이메일을 보낸 뒤에 트랜잭션이 롤백되면? 주문은 취소됐는데 "주문 완료" 이메일이 나간다
  • 문제 2 : 리스너에서 예외가 터지면? 이메일 서버 장애 때문에 주문 트랜잭션 전체가 롤백된다
sequenceDiagram participant Service as OrderService participant Spring as Spring participant Listener as EmailListener participant Email as 이메일 서버 Note over Service: 트랜잭션 시작 Service->>Service: order 저장 (DB) Service->>Spring: publishEvent(OrderCompletedEvent) Spring->>Listener: sendEmail(event) Listener->>Email: 이메일 발송 Email-->>Listener: 성공 Listener-->>Spring: 완료 Spring-->>Service: 리턴 Note over Service: 트랜잭션 커밋 시도 Note over Service: ❌ 다른 이유로 롤백! Note over Email: 이미 이메일은
발송된 상태...

부가 로직을 분리하려고 이벤트를 도입했는데, 트랜잭션 타이밍 때문에 오히려 꼬이는 상황이다. 트랜잭션이 확실히 커밋된 후에 리스너를 실행하고 싶다면 @TransactionalEventListener를 써야 한다.

@TransactionalEventListener 기본 사용법

사용법은 간단하다. @EventListener@TransactionalEventListener로 바꾸면 된다.

@TransactionalEventListener
public void sendEmail(OrderCompletedEvent event) {
    emailService.send(event.getUserEmail(), "주문 완료");
}
  • 이 리스너는 publishEvent() 시점에 실행되지 않는다
  • 발행자의 트랜잭션이 커밋된 후에 실행된다
  • 트랜잭션이 롤백되면 리스너는 아예 실행되지 않는다
sequenceDiagram participant Service as OrderService participant TX as 트랜잭션 participant Spring as Spring participant Listener as EmailListener TX->>Service: 트랜잭션 시작 Service->>Service: order 저장 (DB) Service->>Spring: publishEvent(OrderCompletedEvent) Note over Spring: 이벤트를 등록만 해둠
(아직 실행하지 않음) Service-->>TX: 리턴 TX->>TX: 커밋 완료 ✅ TX->>Spring: 커밋 완료 알림 Spring->>Listener: sendEmail(event) Note over Listener: 커밋 후 실행!
안전하다

트랜잭션이 성공적으로 커밋된 후에야 리스너가 실행되므로, "주문은 취소됐는데 이메일이 나가는" 상황이 원천적으로 방지된다.

phase 옵션

기본값은 AFTER_COMMIT이지만, 다른 시점에 리스너를 실행할 수도 있다.

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void afterCommit(OrderCompletedEvent event) { ... }

@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void afterRollback(OrderCompletedEvent event) { ... }

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
public void afterCompletion(OrderCompletedEvent event) { ... }

@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void beforeCommit(OrderCompletedEvent event) { ... }
phase실행 시점대표적 용도
AFTER_COMMIT커밋 성공 후 (기본값)알림 발송, 외부 API 호출, 로그 기록
AFTER_ROLLBACK롤백 후실패 알림, 보상 트랜잭션 트리거
AFTER_COMPLETION커밋이든 롤백이든 완료 후리소스 정리, 임시 파일 삭제
BEFORE_COMMIT커밋 직전커밋 전 유효성 검증, 감사 로그

실무에서 90% 이상은 AFTER_COMMIT을 사용한다. "트랜잭션이 성공한 후에 부가 작업을 수행한다"는 패턴이 압도적으로 많기 때문이다.

AFTER_ROLLBACK 활용 예

결제 연동 시, 외부 PG사에 결제 요청을 보낸 후 내부 DB 저장이 실패하면 PG사에 결제 취소를 요청해야 한다. 이런 보상 로직에 AFTER_ROLLBACK이 유용하다.

리스너에서 DB를 수정해야 할 때

@TransactionalEventListener(phase = AFTER_COMMIT) 리스너는 한 가지 함정이 있다. 리스너가 실행되는 시점에는 발행자의 트랜잭션이 이미 끝난 상태다.

@TransactionalEventListener
public void onBinaryContentCreated(BinaryContentCreatedEvent event) {
    binaryContentStorage.put(event.getId(), event.getBytes());
    // DB에 상태를 업데이트하고 싶다면?
    binaryContentService.updateStatus(event.getId(), Status.SUCCESS);
}

updateStatus()가 DB를 수정하려면 트랜잭션이 필요하다. 하지만 발행자의 트랜잭션은 이미 커밋되어 닫혀 있다. 이 상태에서 DB 수정을 시도하면 조용히 무시되거나 예외가 발생한다.

해결책은 updateStatus() 메서드에 새로운 트랜잭션을 열어주는 것이다.

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void updateStatus(UUID id, Status status) {
    BinaryContent content = binaryContentRepository.findById(id)
        .orElseThrow();
    content.updateStatus(status);
}
  • REQUIRES_NEW는 기존 트랜잭션과 무관하게 새 트랜잭션을 시작한다
  • 리스너 시점에 기존 트랜잭션이 없으므로, 어차피 새 트랜잭션이 열리긴 하지만 명시적으로 선언하는 것이 의도가 명확하다
sequenceDiagram participant Service as OrderService participant TX1 as 트랜잭션 #1 participant Listener as Listener participant TX2 as 트랜잭션 #2 TX1->>Service: 트랜잭션 시작 Service->>Service: 데이터 저장 Service-->>TX1: 리턴 TX1->>TX1: 커밋 완료 ✅ TX1->>Listener: 이벤트 전달 Note over Listener: 트랜잭션 #1은
이미 끝남 Listener->>TX2: REQUIRES_NEW
새 트랜잭션 시작 TX2->>TX2: DB 업데이트 TX2->>TX2: 커밋 완료 ✅
REQUIRES_NEW 없이 DB 수정 시

@TransactionalEventListener(AFTER_COMMIT) 리스너 안에서 @Transactional(기본 전파)을 가진 메서드를 호출하면, 이미 완료된 트랜잭션에 참여하려다 변경 사항이 반영되지 않는다. 에러도 나지 않아서 디버깅이 어렵다.

fallbackExecution 옵션

@TransactionalEventListener는 트랜잭션이 없는 컨텍스트에서 이벤트가 발행되면 리스너가 실행되지 않는다. 트랜잭션의 커밋/롤백을 기준으로 동작하기 때문이다.

// 트랜잭션 없이 이벤트 발행
public void someNonTransactionalMethod() {
    eventPublisher.publishEvent(new SomeEvent());
    // @TransactionalEventListener는 실행되지 않는다!
}

만약 트랜잭션이 없어도 리스너가 실행되기를 원한다면, fallbackExecutiontrue로 설정한다.

@TransactionalEventListener(fallbackExecution = true)
public void onEvent(SomeEvent event) {
    // 트랜잭션이 있으면 커밋 후 실행
    // 트랜잭션이 없으면 즉시 실행
}
상황fallbackExecution = false (기본값)fallbackExecution = true
트랜잭션 있음커밋 후 실행커밋 후 실행
트랜잭션 없음실행 안 됨즉시 실행
기본값의 함정

테스트 코드에서 트랜잭션을 명시하지 않고 이벤트를 발행하면, 리스너가 호출되지 않아서 "왜 안 되지?" 하고 헤맬 수 있다. 의도적으로 트랜잭션 없이도 실행되어야 한다면 fallbackExecution = true를 명시하자.

정리

@EventListener@TransactionalEventListener는 용도가 다르다.

구분@EventListener@TransactionalEventListener
실행 시점publishEvent() 즉시트랜잭션 커밋/롤백 후
트랜잭션 관계발행자와 같은 트랜잭션발행자의 트랜잭션 종료 후 실행
리스너 예외 시발행자 트랜잭션 롤백발행자에 영향 없음
DB 수정 시같은 트랜잭션 활용REQUIRES_NEW 필요
트랜잭션 없을 때항상 실행실행 안 됨 (fallbackExecution으로 조정)

부가 로직을 이벤트로 분리하는 대부분의 경우, @TransactionalEventListener가 올바른 선택이다. 핵심 트랜잭션이 확정된 후에 부가 작업을 수행하는 것이 안전하기 때문이다.