본문 바로가기

Programming/Spring

Spring @TransactionalEventListener AFTER_COMMIT 함정: 저장 로직이 반영되지 않는 이유

추천캐릭터 2026. 7. 27. 08:00
728x90

Spring @TransactionalEventListener AFTER_COMMIT 함정: 저장 로직이 반영되지 않는 이유

도입부

주문 트랜잭션이 성공한 뒤 이메일을 보내거나 후속 상태를 저장할 때 @TransactionalEventListener를 사용할 수 있다. 기본 실행 시점은 AFTER_COMMIT이므로 커밋 성공 후에만 처리해야 하는 작업에 잘 맞는다.

문제는 리스너에서 엔티티를 수정했는데 예외 없이 끝나면서도 DB에 반영되지 않는 상황이다. AFTER_COMMIT은 이름 그대로 기존 트랜잭션의 커밋이 완료된 뒤 실행되기 때문이다.

외부 알림처럼 DB 저장이 필요 없는 작업은 AFTER_COMMIT에서 처리할 수 있다. 리스너에서 DB를 변경해야 한다면 별도의 REQUIRES_NEW 트랜잭션이 필요하고, 메시지 유실까지 막아야 한다면 아웃박스 패턴을 검토해야 한다.

@TransactionalEventListener란?

일반 @EventListener는 이벤트가 발행되면 기본적으로 그 자리에서 리스너를 실행한다. 이벤트를 발행한 메서드가 나중에 롤백돼도 이메일이나 외부 API 호출은 이미 끝났을 수 있다.

@TransactionalEventListener는 실행 시점을 트랜잭션 단계에 연결한다.

단계 실행 시점 대표 용도
BEFORE_COMMIT 커밋 직전 같은 트랜잭션 안의 검증·추가 작업
AFTER_COMMIT 커밋 성공 후 알림, 캐시 무효화, 후속 작업
AFTER_ROLLBACK 롤백 후 실패 기록, 보상 작업 신호
AFTER_COMPLETION 커밋 또는 롤백 후 공통 정리와 관측성

기본값은 AFTER_COMMIT이다. 활성 트랜잭션 밖에서 이벤트가 발행되면 기본적으로 실행되지 않으며, 필요할 때만 fallbackExecution = true를 사용할 수 있다.

쉬운 예시: 주문 생성 후 이벤트 발행

이벤트는 불변 record로 정의한다.

public record OrderCreatedEvent(Long orderId) {
}

주문 저장과 이벤트 발행은 같은 트랜잭션에서 수행한다.

아래 Spring 예제들은 트랜잭션 흐름에 집중하기 위해 생성자 주입 코드를 생략했다.

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final ApplicationEventPublisher publisher;

    @Transactional
    public Long createOrder(String email) {
        Order order = orderRepository.save(
                Order.create(email)
        );

        publisher.publishEvent(
                new OrderCreatedEvent(order.getId())
        );

        return order.getId();
    }
}

이벤트는 메서드 안에서 발행되지만 AFTER_COMMIT 리스너는 주문이 실제로 커밋된 뒤 실행된다. 주문 저장이 롤백되면 리스너도 실행되지 않는다.

함정: 리스너의 엔티티 수정이 저장되지 않는다

다음 코드는 자연스러워 보이지만 기대대로 동작하지 않을 수 있다.

@TransactionalEventListener(
        phase = TransactionPhase.AFTER_COMMIT
)
public void handle(OrderCreatedEvent event) {
    Order order = orderRepository.findById(event.orderId())
            .orElseThrow();

    order.markNotificationRequested();
}

리스너가 실행될 때 영속성 컨텍스트나 DB 커넥션이 접근 가능한 상태로 남아 있을 수 있다. 조회와 수정도 예외 없이 끝날 수 있다. 그러나 원래 트랜잭션은 이미 커밋됐으므로 변경 감지 결과를 다시 커밋할 단계가 없다.

Spring 공식 Javadoc도 AFTER_COMMIT, AFTER_ROLLBACK, AFTER_COMPLETION에서 실행한 데이터 변경은 트랜잭션 자원에 커밋되지 않는다고 경고한다.

해결: 별도 서비스에서 REQUIRES_NEW 사용

후속 DB 저장은 별도 빈으로 분리해 새 트랜잭션을 시작한다.

@Service
public class NotificationStatusService {

    private final OrderRepository orderRepository;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void markRequested(Long orderId) {
        Order order = orderRepository.findById(orderId)
                .orElseThrow();

        order.markNotificationRequested();
    }
}

리스너는 이 서비스를 호출한다.

@Component
public class OrderEventListener {

    private final NotificationStatusService statusService;

    @TransactionalEventListener
    public void handle(OrderCreatedEvent event) {
        statusService.markRequested(event.orderId());
    }
}

별도 빈으로 분리하는 이유는 Spring 트랜잭션이 프록시를 통해 적용되기 때문이다. 같은 클래스에서 this.markRequested()로 호출하면 REQUIRES_NEW가 적용되지 않을 수 있다. 자세한 원리는 @Transactional 실전 함정 정리에서 확인할 수 있다.

단순히 기본 전파 속성의 @Transactional을 추가하는 것은 해결책이 아니다. Spring Framework 6.1 이상은 트랜잭션 이벤트 리스너에 @Transactional을 함께 사용할 때 REQUIRES_NEW 또는 NOT_SUPPORTED만 지원하도록 제한한다.

외부 API와 아웃박스는 언제 사용할까?

이메일이나 푸시 호출은 주문 커밋 후 실행해야 하므로 AFTER_COMMIT과 잘 맞는다. 다만 기본 리스너는 같은 요청 스레드에서 동기 실행되므로 외부 API가 느리면 사용자 응답도 늦어진다.

@Async를 더하면 스레드는 분리되지만 작업이 영속 저장되는 것은 아니다. 커밋 직후 애플리케이션이 종료되면 메모리에 있던 이벤트가 사라질 수 있다.

요구 수준 권장 방식
실패해도 재시도가 필요 없는 알림 AFTER_COMMIT
요청 지연만 줄이면 되는 비핵심 작업 AFTER_COMMIT + @Async
반드시 전달해야 하는 메시지 아웃박스 테이블 + 발행 워커
중복 시 문제가 생기는 작업 아웃박스 + 멱등성 키

아웃박스는 주문과 “발행할 이벤트”를 같은 DB 트랜잭션에 저장한다. 별도 워커가 미발행 행을 읽어 외부 시스템으로 전달하므로 DB 커밋과 메시지 발행 사이의 유실 가능성을 줄인다.

실무에서 확인할 점

  • fallbackExecution = true는 트랜잭션 유무에 따라 실행 시점이 달라질 수 있으므로 신중하게 사용한다.
  • @Transactional 테스트는 기본적으로 롤백되므로 AFTER_COMMIT 리스너가 실행되지 않는 것이 정상이다. 테스트 메서드 안에서 결과까지 검증하려면 TestTransaction으로 실제 커밋 지점을 만든다.
  • 외부 API 실패는 이미 커밋된 주문을 되돌리지 않는다. 재시도와 보상 흐름이 필요하다.
  • 재시도를 허용하면 중복 이벤트가 생길 수 있으므로 주문 ID와 이벤트 종류를 이용한 멱등성 처리가 필요하다.

핵심 요약

  • @TransactionalEventListener의 기본 단계는 AFTER_COMMIT이다.
  • AFTER_COMMIT에서는 원래 트랜잭션이 이미 끝났다.
  • 후속 DB 저장은 별도 빈의 REQUIRES_NEW로 분리한다.
  • @Async는 스레드를 분리하지만 이벤트 유실을 막지는 않는다.
  • 반드시 전달해야 하는 작업은 아웃박스와 멱등성을 사용한다.

마무리

AFTER_COMMIT은 “후속 작업도 주문 트랜잭션에 포함한다”는 뜻이 아니라 “주문 트랜잭션 성공 후 후속 작업을 시작한다”는 뜻이다. 이 차이를 이해하면 저장되지 않는 엔티티와 비동기 이벤트 유실을 줄일 수 있다.

DB 커넥션과 후속 트랜잭션의 관계를 운영 관점에서 더 살펴보려면 HikariCP 커넥션 풀 설정도 함께 참고할 수 있다.

공식 자료:

728x90