1편에서 @EventListener로 코드를 분리해도, 리스너가 같은 트랜잭션 안에서 커밋 전에 실행되기 때문에 장애 전파와 데이터 불일치 문제가 그대로 남는다는 걸 확인했다. 핵심은 "코드를 어디에 두느냐"가 아니라 트랜잭션 경계를 어디에 긋느냐다. 이 경계를 제어하는 도구가 @TransactionalEventListener이다.
외부 부수효과와 트랜잭션의 충돌
DB 작업과 외부 시스템 호출은 근본적으로 다르다. DB 작업은 트랜잭션이 실패하면 롤백할 수 있다. 하지만 S3에 올린 파일, 발송된 이메일, 전송된 푸시 알림은 롤백할 수 없다. 한번 실행되면 되돌릴 방법이 없는 작업, 이것이 외부 부수효과다.
이런 부수효과가 트랜잭션 안에 있으면 두 가지 문제가 생긴다.
외부 호출?"} TX -->|"호출 성공 → 커밋 실패"| P1["파일은 S3에 있는데
DB에 기록 없음"] TX -->|"호출이 오래 걸림"| P2["DB 커넥션이
불필요하게 점유됨"] style TX fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style P1 fill:#FFE6E6,stroke:#F44336,stroke-width:2px,color:#000 style P2 fill:#FFE6E6,stroke:#F44336,stroke-width:2px,color:#000
첫 번째는 데이터 불일치다. 부수효과는 성공했는데 트랜잭션이 롤백되면, 시스템 간 상태가 어긋난다. 두 번째는 커넥션 고갈이다. 외부 호출이 느릴수록 커넥션을 잡고 있는 시간이 길어지고, 다른 요청이 커넥션을 못 얻어 전체 시스템이 느려진다.
해결 원칙은 단순하다. 트랜잭션이 확정된 뒤에 부수효과를 실행하면 된다. 커밋이 성공했다는 보장이 있을 때 S3에 올리고, 이메일을 보내고, 알림을 쏘는 것이다.
@TransactionalEventListener의 등장 배경
@EventListener는 publishEvent() 호출 시점에 리스너를 실행한다. 트랜잭션이 아직 진행 중인 시점이다. 커밋될지 롤백될지 모르는 상태에서 외부 호출을 하는 셈이다.
주황 영역에서 S3 업로드가 실행되는 시점에, 트랜잭션의 운명은 아직 정해지지 않았다. 결국 분홍 영역에서 롤백이 일어나면 S3에는 고아 파일이 남는다. 이 문제를 해결하려면, 리스너가 트랜잭션의 결과를 알고 난 뒤에 실행되어야 한다.
@TransactionalEventListener는 정확히 이 목적으로 만들어졌다. 리스너의 실행 시점을 트랜잭션의 특정 단계에 바인딩한다.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void uploadImage(ClothesCreatedEvent event) {
s3Service.put(event.getClothesId(), ...);
}
phase = AFTER_COMMIT을 지정하면, 이 리스너는 트랜잭션이 성공적으로 커밋된 뒤에만 실행된다. 커밋이 실패하면 아예 실행되지 않는다. 같은 흐름을 @TransactionalEventListener로 바꾸면 이렇게 달라진다.
리스너 실행 안 함 Spring-->>Service: 즉시 리턴 rect rgb(232, 248, 232) Note over Service: 트랜잭션 커밋 성공! end rect rgb(240, 248, 255) Note over Spring, S3: 커밋 확정 후 리스너 실행 Spring->>Listener: handle() Listener->>S3: 파일 업로드 S3-->>Listener: 성공 end
핵심 차이는 4번 단계다. publishEvent()를 호출해도 리스너가 바로 실행되지 않는다. Spring이 이벤트를 등록만 해두고, 트랜잭션이 커밋된 뒤(파란 영역)에 비로소 리스너를 호출한다. 초록 영역에서 커밋이 확정되었으므로, 파란 영역에서 S3에 파일을 올려도 데이터 불일치가 생기지 않는다.
@TransactionalEventListener는 기본 phase가 AFTER_COMMIT이다. phase를 생략하면 AFTER_COMMIT으로 동작한다.
트랜잭션 단계의 설계 의도
@TransactionalEventListener는 네 가지 트랜잭션 단계를 지원한다. 각 단계는 단순한 실행 시점이 아니라, 설계 의도가 담겨 있다.
데이터 정합성 확인"]:1 AC_DESC["외부 부수효과
S3, 이메일, 알림"]:1 AR_DESC["실패 보상 처리
임시 데이터 정리"]:1 AComp_DESC["무조건 실행
리소스 해제, 로그"]:1 style BC fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style AC fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style AR fill:#FFE6E6,stroke:#F44336,stroke-width:2px,color:#000 style AComp fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style BC_DESC fill:#FFF3E0,stroke:#FF9800,stroke-width:1px,color:#000 style AC_DESC fill:#E8F8E8,stroke:#4CAF50,stroke-width:1px,color:#000 style AR_DESC fill:#FFE6E6,stroke:#F44336,stroke-width:1px,color:#000 style AComp_DESC fill:#E8F4F8,stroke:#2196F3,stroke-width:1px,color:#000
각 단계별로 어떤 상황에서 쓰는지, 왜 그 시점이어야 하는지를 살펴보자.
AFTER_COMMIT
가장 많이 쓰는 단계이자 기본값이다. 트랜잭션이 성공적으로 커밋된 직후에 실행된다. 설계 의도는 명확하다 — DB 변경이 확정된 뒤에만 외부 부수효과를 실행하겠다는 것이다.
- S3 파일 업로드
- 이메일 발송
- 푸시 알림 전송
- 외부 API 호출
이 모든 작업은 "DB에 데이터가 확실히 저장된 뒤"에 실행되어야 의미가 있다. 옷이 등록되지 않았는데 팔로워에게 "새 옷이 등록됐습니다" 알림을 보내면 안 된다.
@TransactionalEventListener // 기본이 AFTER_COMMIT
public void sendNotification(ClothesCreatedEvent event) {
notificationService.notifyFollowers(event.getOwnerId(), "새 옷 등록");
}
AFTER_ROLLBACK
트랜잭션이 롤백된 직후에 실행된다. "본 작업이 실패했을 때 뒷정리"를 하는 단계다.
- 미리 할당한 외부 리소스 해제
- 임시 파일 삭제
- 실패 로그 기록
- 보상 트랜잭션 트리거
예를 들어, 트랜잭션 시작 전에 임시 파일을 만들었는데 트랜잭션이 실패하면, 그 임시 파일을 정리해야 한다.
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void cleanupTempFile(ClothesCreateFailedEvent event) {
fileService.deleteTempFile(event.getTempFileId());
}
AFTER_COMPLETION
트랜잭션이 커밋되든 롤백되든, 결과에 관계없이 항상 실행된다. Java의 finally 블록과 같은 개념이다.
- 리소스 해제
- 메트릭 기록
- 감사 로그
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
public void recordMetrics(ClothesCreatedEvent event) {
metricsService.recordAttempt("clothes.create");
}
BEFORE_COMMIT
커밋 직전에 실행된다. 아직 트랜잭션이 살아 있으므로, 여기서 예외를 던지면 트랜잭션이 롤백된다. 커밋 전 마지막 검증에 쓴다.
- 데이터 정합성 최종 검증
- 연관 테이블 일관성 확인
- 비즈니스 규칙 크로스체크
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void validateConsistency(OrderCreatedEvent event) {
if (inventoryService.isOutOfStock(event.getItemId())) {
throw new OutOfStockException(); // 트랜잭션 롤백됨
}
}
BEFORE_COMMIT은 @EventListener와 실행 시점이 비슷하지만, 결정적 차이가 있다. @EventListener는 publishEvent() 호출 시점에 즉시 실행되고, BEFORE_COMMIT은 실제 커밋 직전에 실행된다. 서비스 메서드의 나머지 코드가 모두 끝난 뒤 커밋 시점에 호출되므로, 최종 상태를 기준으로 검증할 수 있다.
이 네 단계를 트랜잭션 흐름 위에 놓으면 실행 순서가 명확해진다.
주황 영역의 BEFORE_COMMIT은 커밋 전에 실행되므로, 여기서 예외가 나면 커밋이 일어나지 않는다. 초록 영역에서 커밋이 확정된 후, 파란 영역에서 AFTER_COMMIT과 AFTER_COMPLETION이 순서대로 실행된다. 롤백이 일어나면 AFTER_COMMIT 대신 AFTER_ROLLBACK이 실행되고, AFTER_COMPLETION은 어느 쪽이든 실행된다.
AFTER_COMMIT 리스너에서 DB를 쓸 때의 함정
AFTER_COMMIT 리스너는 트랜잭션이 이미 끝난 뒤에 실행된다. 이 사실이 만드는 함정이 있다. 리스너 안에서 DB에 쓰려고 하면 어떻게 될까?
@TransactionalEventListener
public void createFeed(ClothesCreatedEvent event) {
Feed feed = Feed.create(event.getClothesId());
feedRepository.save(feed); // 이 저장은 제대로 동작할까?
}
이 코드의 동작은 직관과 다르다. feedRepository.save()는 @Transactional이 필요한 쓰기 작업인데, 현재 활성 트랜잭션이 없다. 커밋이 끝난 시점이기 때문이다.
분홍 영역에서 보이듯, 리스너가 실행되는 시점에는 트랜잭션이 이미 닫혔다. DB 쓰기 작업은 트랜잭션이 필요하므로 실패한다.
해결 방법은 리스너에 새로운 트랜잭션을 열어주는 것이다. Propagation.REQUIRES_NEW를 사용하면 기존 트랜잭션과 무관하게 독립적인 트랜잭션을 생성한다.
@Component
public class FeedEventListener {
private final FeedService feedService;
@TransactionalEventListener
public void createFeed(ClothesCreatedEvent event) {
feedService.createFeedForClothes(event.getClothesId());
}
}
실제 DB 쓰기는 별도 서비스에 위임하고, 그 서비스 메서드에 REQUIRES_NEW를 건다.
@Service
public class FeedService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void createFeedForClothes(Long clothesId) {
Feed feed = Feed.create(clothesId);
feedRepository.save(feed);
}
}
이렇게 하면 리스너가 호출될 때 새 트랜잭션이 열리고, 그 안에서 DB 쓰기가 정상 동작한다.
초록 영역에서 새 트랜잭션이 열리면서 DB 쓰기가 성공한다. 트랜잭션 1과 트랜잭션 2는 완전히 독립적이다.
AFTER_COMMIT 리스너에서 DB를 읽는 것은 문제없다. 읽기 전용 작업은 트랜잭션이 없어도 되기 때문이다. 문제가 되는 건 쓰기 작업이다. 쓰기가 필요하면 반드시 REQUIRES_NEW로 새 트랜잭션을 열어야 한다.
리스너에 직접 @Transactional을 붙이면 안 되는 이유
"그냥 리스너 메서드에 @Transactional을 붙이면 되지 않나?"라고 생각할 수 있다.
// 이렇게 하면 동작할까?
@TransactionalEventListener
@Transactional
public void createFeed(ClothesCreatedEvent event) {
feedRepository.save(Feed.create(event.getClothesId()));
}
이 방식은 동작하지 않는다. @Transactional의 기본 전파 속성은 REQUIRED인데, 이 속성은 "기존 트랜잭션이 있으면 참여하고, 없으면 새로 만든다"는 의미다. 문제는 AFTER_COMMIT 시점에 기존 트랜잭션이 논리적으로는 끝났지만 물리적으로는 아직 정리 중인 상태라는 점이다. Spring은 이 애매한 상태의 트랜잭션에 참여하려다 제대로 동작하지 못한다. 그래서 REQUIRES_NEW로 명시적으로 새 트랜잭션을 열어야 하고, 같은 클래스 내 메서드 호출은 프록시를 타지 않으므로 별도 서비스로 분리하는 것이 정석이다.
fallbackExecution 옵션
@TransactionalEventListener는 이름 그대로 트랜잭션이 있을 때만 동작한다. 발행자에 트랜잭션이 없으면 리스너가 아예 실행되지 않는다.
// 트랜잭션 없이 이벤트 발행
public void processWithoutTransaction() {
eventPublisher.publishEvent(new ClothesCreatedEvent(clothesId));
// @TransactionalEventListener는 실행되지 않는다!
}
이벤트는 발행됐는데 리스너가 조용히 무시된다. 에러도 없고 로그도 없다. 디버깅하기 어려운 상황이다.
fallbackExecution = true를 설정하면, 트랜잭션이 없는 상황에서도 리스너가 실행된다.
@TransactionalEventListener(fallbackExecution = true)
public void sendNotification(ClothesCreatedEvent event) {
notificationService.notifyFollowers(event.getOwnerId(), "새 옷 등록");
}
이 옵션의 동작을 정리하면 이렇다.
트랜잭션이 있는가?"} Publish --> Check Check -->|"있다"| Phase["트랜잭션 단계에
맞춰 실행"] Check -->|"없다"| Fallback{"fallbackExecution
= true?"} Fallback -->|"true"| Run["즉시 실행"] Fallback -->|"false (기본)"| Skip["실행 안 함"] style Publish fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style Check fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style Phase fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style Fallback fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style Run fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style Skip fill:#FFE6E6,stroke:#F44336,stroke-width:2px,color:#000
트랜잭션이 없을 때 "즉시 실행"된다는 것은, @EventListener와 동일하게 동기적으로 바로 실행된다는 뜻이다.
fallbackExecution은 "트랜잭션이 없는 경우에도 안전하게 실행될 수 있는" 리스너에만 써야 한다. 트랜잭션이 없다는 건 DB 변경이 확정되지 않았다는 뜻일 수도 있으므로, 외부 부수효과를 실행해도 괜찮은지 신중하게 판단해야 한다.
전체 흐름 정리
이벤트 기반 설계에서 @EventListener와 @TransactionalEventListener가 각각 어떤 역할을 하는지, 전체 흐름으로 정리하자.
같은 트랜잭션"] TEL --> Phase["트랜잭션 단계
바인딩"] Phase --> AC["AFTER_COMMIT"] Phase --> AR["AFTER_ROLLBACK"] AC --> Side["외부 부수효과
S3, 이메일, 알림"] AR --> Comp["보상 처리
임시 데이터 정리"] style Event fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style EL fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style TEL fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style Sync fill:#F3E5F5,stroke:#9C27B0,stroke-width:2px,color:#000 style Phase fill:#F3E5F5,stroke:#9C27B0,stroke-width:2px,color:#000 style AC fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style AR fill:#FFE6E6,stroke:#F44336,stroke-width:2px,color:#000 style Side fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style Comp fill:#FFE6E6,stroke:#F44336,stroke-width:2px,color:#000
@EventListener는 코드 구조를 분리하는 도구이고, @TransactionalEventListener는 실행 타이밍까지 분리하는 도구다. 외부 부수효과는 AFTER_COMMIT에, 실패 보상은 AFTER_ROLLBACK에 배치하는 것이 기본 패턴이다.
하지만 @TransactionalEventListener만으로는 해결되지 않는 문제가 있다. 리스너 자체가 실패하면 어떻게 하는가? 커밋 후에 실행되는 리스너는 트랜잭션 밖이므로 롤백도 불가능하다. 이 리스너의 실패를 어떻게 허용하고 보상할 것인지는 3편에서 다룬다.
자주 하는 실수
AFTER_COMMIT 시점에는 트랜잭션이 이미 끝났다. 리스너에서 repository.save()를 직접 호출하면 TransactionRequiredException이 발생한다. DB 쓰기가 필요하면 반드시 별도 서비스에 @Transactional(propagation = Propagation.REQUIRES_NEW)를 걸고 위임해야 한다.
[!DANGER] 트랜잭션 없이 이벤트를 발행했는데 리스너가 안 도는 경우
@TransactionalEventListener는 발행자에 트랜잭션이 있어야 동작한다. 테스트 코드나 스케줄러에서 트랜잭션 없이 이벤트를 발행하면 리스너가 조용히 무시된다. 에러도 로그도 없어서 원인을 찾기 어렵다. fallbackExecution = true를 설정하거나, 발행자에 @Transactional을 붙여야 한다.
[!DANGER] @EventListener로 외부 부수효과를 실행하는 것
@EventListener는 트랜잭션 커밋 전에 실행된다. 여기서 S3 업로드나 이메일 발송을 하면, 이후 트랜잭션이 롤백될 때 되돌릴 방법이 없다. 외부 부수효과는 반드시 @TransactionalEventListener의 AFTER_COMMIT 단계에서 실행해야 한다.
[!DANGER] 리스너 메서드에 @Transactional만 붙이고 REQUIRES_NEW를 안 쓰는 것
@Transactional의 기본 전파 속성은 REQUIRED다. AFTER_COMMIT 시점에 이미 끝난 트랜잭션에 참여하려다 제대로 동작하지 못한다. REQUIRES_NEW를 명시해야 하고, 프록시 우회를 피하기 위해 별도 서비스로 분리하는 것이 정석이다.