[TIL] 트랜잭션 이벤트 발행 타이밍 문제 — @TransactionalEventListener vs publishEvent() (#196)

트랜잭션 커밋 전 publishEvent()를 호출했을 때 발생한 타이밍 문제와 @TransactionalEventListener(AFTER_COMMIT)afterCommit() 해결 방식을 정리한 글이다.

📝 TIL (Today I Learned)
@Transactional 안에서 publishEvent()를 호출하면 @Async 리스너가 커밋 전 데이터를 읽으려다 실패할 수 있다. 이 문제는 TransactionSynchronizationManager.afterCommit() 또는 @TransactionalEventListener(AFTER_COMMIT)로 해결한다.


들어가며

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 이벤트를 사용할 때는 다음 규칙을 기억하면 된다.

  1. 리스너가 @Async라면, 커밋 전 데이터를 읽을 수 없다.
  2. 이벤트 발행 측을 수정할 수 있다면, TransactionSynchronizationManager.registerSynchronization().afterCommit()을 사용한다.
  3. 리스너 측에서만 처리하고 싶다면, @TransactionalEventListener(phase = AFTER_COMMIT)을 사용한다.
  4. 어떤 방식이든 afterCommit() 콜백 안에서는 엔티티를 직접 참조하지 않고, 미리 추출한 원시 값(Long id 등)을 사용한다.

결론적으로 @Async + publishEvent() 조합에서는 “커밋 이후에 소비되도록 보장하는 장치”를 반드시 넣어야 한다.