[TIL] 트랜잭션 이벤트 발행 타이밍 문제 — @TransactionalEventListener vs publishEvent() (#196)
트랜잭션 커밋 전 publishEvent()를 호출했을 때 발생한 타이밍 문제와 @TransactionalEventListener(AFTER_COMMIT) 및 afterCommit() 해결 방식을 정리한 글이다.
📝 TIL (Today I Learned)
@Transactional안에서publishEvent()를 호출하면@Async리스너가 커밋 전 데이터를 읽으려다 실패할 수 있다. 이 문제는TransactionSynchronizationManager.afterCommit()또는@TransactionalEventListener(AFTER_COMMIT)로 해결한다.
- 들어가며
- 1. 문제 상황
- 2. 왜 이런 일이 생기는가
- 3. 해결책 1: TransactionSynchronizationManager.afterCommit()
- 4. 해결책 2: @TransactionalEventListener(AFTER_COMMIT)
- 5. 선택 가이드
- 정리
들어가며
Spring 애플리케이션에서는 비즈니스 로직이 끝난 뒤 이벤트를 발행하는 패턴을 자주 사용한다. 하지만 트랜잭션과 비동기가 함께 얽히면 타이밍 문제가 쉽게 발생한다.
실제로 친구 1:1 통화 기능을 구현하면서 다음 버그를 겪었다.
- 친구 통화 수락 시 Agora 녹음 시작이 실패한다.
- Call 레코드를 찾을 수 없다는 에러가 발생한다.
흐름 자체는 단순하다. 통화 정보를 DB에 저장하고 CallStartedEvent를 발행한 뒤, 리스너에서 녹음을 시작한다. 겉보기에는 정상처럼 보이지만 이벤트 발행 시점에 함정이 있다.
1. 문제 상황
// FriendCallService.java (버그가 있던 코드)
@Transactional
public CallTokenResponse acceptOffer(Long offerId, Long calleeId) {
// ... Call 엔티티 생성 및 저장
call.startCall();
// 트랜잭션이 아직 커밋되지 않은 상태에서 이벤트 발행
eventPublisher.publishEvent(new CallStartedEvent(call.getId(), channelName));
return response;
}
// CallEventListener.java
@Async("recordingTaskExecutor")
@EventListener
public void handleCallStarted(CallStartedEvent event) {
// 이 시점에 DB에 Call이 없을 수 있음
RecordingRequest request = RecordingRequest.of(event.getCallId(), event.getChannelName());
agoraRecordingService.startRecording(request); // Call 조회 실패
}
2. 왜 이런 일이 생기는가
핵심은 @Async 리스너가 별도 스레드에서 실행된다는 점이다. 이벤트를 발행하는 메서드는 트랜잭션 내부에서 동작하고 있지만, 리스너는 그 트랜잭션 바깥에서 DB를 조회한다.
READ COMMITTED 격리 수준에서는 커밋되지 않은 데이터를 읽을 수 없으므로, 리스너에서 Call 레코드를 찾지 못하는 현상이 발생한다.
// @Async 없이 동기 리스너로 처리한 경우
@EventListener
public void handleCallStarted(CallStartedEvent event) {
agoraRecordingService.startRecording(...); // 같은 스레드, 같은 트랜잭션
}
동기 @EventListener는 같은 스레드/같은 트랜잭션 컨텍스트이므로 커밋 전 데이터 접근이 가능하다. 하지만 다른 문제가 생긴다.
- 리스너 예외가 원래 트랜잭션까지 롤백시킨다.
- 외부 I/O(Agora API) 실패가 통화 수락 자체 실패로 번진다.
즉, 사이드 이펙트를 핵심 트랜잭션과 분리하려고 @Async를 쓰면, 이번에는 타이밍 문제가 드러난다.
3. 해결책 1: TransactionSynchronizationManager.afterCommit()
// FriendCallService.java (수정된 코드)
@Transactional
public CallTokenResponse acceptOffer(Long offerId, Long calleeId) {
// ... Call 엔티티 생성 및 저장
call.startCall();
final Long callId = call.getId();
final String finalChannelName = channelName;
// 트랜잭션 커밋 이후에 이벤트 발행
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
eventPublisher.publishEvent(new CallStartedEvent(callId, finalChannelName));
}
});
return response;
}
TransactionSynchronizationManager는 현재 트랜잭션에 콜백을 등록하는 저수준 API이다. afterCommit()에 등록한 코드는 커밋 직후 실행된다.
수정 후에는 다음 흐름이 된다.
- 트랜잭션 내부에서
afterCommit()콜백만 등록한다. - 실제 이벤트 발행은 커밋이 끝난 뒤 실행된다.
@Async리스너가 조회할 때는 이미 DB 반영이 완료된 상태이다.
주의할 점도 있다. afterCommit() 안에서 엔티티를 직접 건드리지 말고, 필요한 값을 미리 뽑아둬야 한다.
// ❌ 위험: afterCommit 안에서 엔티티 접근
afterCommit() {
eventPublisher.publishEvent(new CallStartedEvent(call.getId(), ...));
}
// ✅ 안전: 미리 값을 추출
final Long callId = call.getId();
afterCommit() {
eventPublisher.publishEvent(new CallStartedEvent(callId, ...));
}
4. 해결책 2: @TransactionalEventListener(AFTER_COMMIT)
Spring은 이 문제를 위한 전용 어노테이션을 제공한다.
@Async("taskExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleFriendCallOfferCreated(FriendCallOfferCreatedEvent event) {
try {
fcmNotificationService.sendFriendCallOfferNotification(...);
} catch (Exception e) {
log.error("FCM 알림 전송 실패", e);
}
try {
webSocketEventService.sendFriendCallOfferNotification(...);
} catch (Exception e) {
log.error("WS 알림 전송 실패", e);
}
}
@TransactionalEventListener(phase = AFTER_COMMIT)을 사용하면 리스너가 커밋 이후에만 실행된다. 이벤트 발행 측 코드를 바꾸지 않고 수신 측에서 처리 타이밍을 제어할 수 있다.
5. 선택 가이드
| 구분 | TransactionSynchronizationManager | @TransactionalEventListener |
|---|---|---|
| 수정 위치 | 이벤트 발행 측 | 이벤트 수신 측 |
| 적용 범위 | 특정 발행 지점 | 해당 리스너 전체 |
| 트랜잭션 없이 호출 시 | 등록 자체가 실패할 수 있음 | 기본적으로 이벤트를 무시함 |
| 유연성 | beforeCommit, afterCompletion 등 세밀한 훅 제공 | phase 기반 제어 |
트랜잭션 없는 컨텍스트에서도 이벤트가 발행될 수 있다면 @TransactionalEventListener의 기본 동작(AFTER_COMMIT + no tx 시 무시)을 반드시 고려해야 한다. 필요하면 fallbackExecution = true를 사용한다.
정리
문제의 본질은 트랜잭션 커밋 시점과 이벤트 소비 시점이 어긋난다는 점이다.
| 구분 | 동기 @EventListener | @Async @EventListener | @Async @TransactionalEventListener(AFTER_COMMIT) |
|---|---|---|---|
| 실행 스레드 | 동일 | 별도 | 별도 |
| 트랜잭션 컨텍스트 | 공유 | 없음 | 없음 |
| 커밋 전 데이터 접근 | 가능 | 불가 | 커밋 후 실행으로 안전 |
| 리스너 예외 영향 | 원 트랜잭션 롤백 가능 | 독립 | 독립 |
Spring 이벤트를 사용할 때는 다음 규칙을 기억하면 된다.
- 리스너가 @Async라면, 커밋 전 데이터를 읽을 수 없다.
- 이벤트 발행 측을 수정할 수 있다면, TransactionSynchronizationManager.registerSynchronization().afterCommit()을 사용한다.
- 리스너 측에서만 처리하고 싶다면, @TransactionalEventListener(phase = AFTER_COMMIT)을 사용한다.
- 어떤 방식이든 afterCommit() 콜백 안에서는 엔티티를 직접 참조하지 않고, 미리 추출한 원시 값(Long id 등)을 사용한다.
결론적으로 @Async + publishEvent() 조합에서는 “커밋 이후에 소비되도록 보장하는 장치”를 반드시 넣어야 한다.