[Spring] @TransactionalEventListener(AFTER_COMMIT) 에서 왜 Update가 되지않는걸까?

주현·2025년 11월 19일

Spring

목록 보기
11/11

🧩 문제상황

주문을 진행하면 주문 저장API가 호출되며 주문이 저장되고, 그 과정에서 이벤트를 발행합니다.이 이벤트는 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)로 설정된 리스너에 의해 처리됩니다. 이벤트 리스너는 Outbox_event 테이블의 status값을 변경하여 save()를 통해 업데이트를 진행하고있습니다.
동작은 잘 되지만, 실제 데이터베이스에는 변경사항이 반영이 되지않는 문제가 발생했습니다.

  • 스프링 AFTER_COMMIT 리스너 실행 ✔️
  • AFTER_COMMIT 리스너에 @Transactional이 선언되어 있으나 → DB에 변경사항 반영 ❌

<실제 코드>

[Service]

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class AddOrderInputPort implements AddOrderUseCase {

    private final MemberOutputPort memberOutputPort;
    private final StoreOutputPort storeOutputPort;
    private final CouponIssueOutputPort couponIssueOutputPort;
    private final MenuOutputPort menuOutputPort;
    private final OrderOutputPort orderOutputPort;
    private final PaymentOutputPort paymentOutputPort;
    private final ApplicationEventPublisher eventPublisher;
    private final ObjectMapper objectMapper;

    private final String eventType = "OrderCreated";
    private final String aggregateType = "Order";


    @Override
    @Transactional
    public OrderOutputDTO create(Long memberId, Long storeId, OrderInputDTO request) throws JsonProcessingException {
        Member member = memberOutputPort.findById(memberId)
                .orElseThrow(()-> new BusinessException(CommonErrorCode.USER_NOT_FOUND));

        Store store = storeOutputPort.findById(storeId)
                .orElseThrow(()-> new BusinessException(CommonErrorCode.STORE_NOT_FOUND));

        CouponIssue couponIssue = Optional.ofNullable(request.getCouponIssueId())
                .map(id -> couponIssueOutputPort.findByIdAndMemberId(id,memberId)
                        .orElseThrow(() -> new BusinessException(CommonErrorCode.COUPON_NOT_FOUND)))
                .orElse(null);

        //쿠폰 검증로직
        validateAndUseCoupon(couponIssue);

        Order order = Order.create(member, store, couponIssue, request.getDeliveryAddress(),request.getComment());

        // menuId 리스트 추출
        List<Long> menuIds = request.getOrderMenuInfoDTOList().stream()
                .map(OrderMenuInfoDTO::getMenuId)
                .toList();

        Map<Long, Menu> menuMap = menuOutputPort.findAllById(menuIds).stream()
                .collect(Collectors.toMap(Menu::getId, m -> m));

        List<OrderMenu> orderMenus = request.getOrderMenuInfoDTOList().stream()
                .map(dto -> OrderMenu.create(order, menuMap.get(dto.getMenuId()), dto.getCount()))
                .toList();

        order.addOrderMenus(orderMenus);
        //할인율 등 수행
        order.applyCoupon(couponIssue);

        orderOutputPort.save(order);

        paymentOutputPort.save(Payment.create(order,order.getFinalPrice()));

        //이벤트 생성
        OrderCreatedEvent orderCreatedEvent = createOrderEvent(memberId,order.getId(),eventType);

        String payload = objectMapper.writeValueAsString(orderCreatedEvent);
        OutboxEvent outboxEvent = OutboxEvent.create(aggregateType,orderCreatedEvent.getOrderId(),orderCreatedEvent.getEventType(),payload);

        //이벤트 발행
        eventPublisher.publishEvent(outboxEvent);

        return OrderOutputDTO.mapToDTO(order.getId());
    }

    private void validateAndUseCoupon(CouponIssue couponIssue) {
        if (couponIssue == null) {
            return; // 쿠폰 없는 경우 그냥 통과
        }
        //사용여부
        if (couponIssue.getCouponIssueStatus() == CouponIssueStatus.USED) {
            throw new BusinessException(CommonErrorCode.COUPON_ALREADY_USED);
        }
        //사용기간 여부
        if (!couponIssue.getCoupon().isUsableNow(LocalDateTime.now())) {
            throw new BusinessException(CommonErrorCode.COUPON_EXPIRED);
        }

        // 최종적으로 사용 처리 (상태 변경만 수행)
        couponIssue.couponUse();
    }
}

orderOutputPort.save(order)를 호출하면 서비스 메서드의 기존 @Transactional 트랜잭션 안에서 주문이 저장됩니다. 이후 이벤트를 발행하면, @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)로 등록된 리스너는 현재 트랜잭션에 참여한 상태에서 실행됩니다.

따라서,

  • 리스너 메서드가 예외를 던지면 전체 트랜잭션이 롤백되고
  • 리스너 메서드가 정상 종료되면 함께 커밋됩니다.

즉, BEFORE_COMMIT 리스너는 기존 트랜잭션과 동일한 경계 안에서 동작하게됩니다.

그 후, @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) 리스너가 잘 동작하고 커밋이 된 후, @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 리스너가 실행이 됩니다.

해당 리스너는 phase가 after_commit으로 설정되어 있기 때문에 리스너 자체에는 트랜잭션이 없습니다. 그러나 리스너에 @Transactional를 붙였기 때문에, 서비스에서 새로운 트랜잭션이 생성되고 outbox_event의 status가 update되기를 기대했습니다. 하지만 db에 변경사항이 반영이 되지않았습니다.

그래서 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)를 사용하면 @Transactional 사용에 뭔가 다른게 있나싶어, 트랜잭션이 살아있는지 로그를 찍어보았습니다.
그런데 트랜잭션 또한 활성화가 되어있었습니다. 또한, 실제로 업데이트하고 리스너 내부로 반환된 outboxEvent의 엔티티 객체를 살펴보면 제가 원했던 대로 변경된 값을 가지고 있었습니다. 이것은 영속화된 객체의 값은 변경되었기 때문이 아닌가 생각됩니다.


❓원인 테스트❓

해당 문제에 원인을 찾기 위해 테스트 및 레퍼런스 체크를 진행했습니다.

  1. 이벤트 리스너에 @Transactional을 메소드 단위로 따로 빼서 진행하기
  2. save()가 아닌 jpa의 EntityManager를 주입받아서 merge() 호출해 보기
  3. @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) 으로 하면 잘 되는지 확인해 보기
  4. 레퍼런스 검색해보기
  5. @Transactional()를 없애보기
  6. @Transactional(propagation = Propagation.REQUIRES_NEW)로 새 트랜잭션 만들기

진행해본 결과, @Transactional(propagation = Propagation.REQUIRES_NEW)로 실행할 경우 변경사항이 DB에 정상적으로 반영되는 것을 확인할 수 있었습니다.

이로 인해 궁금증이 생겼습니다.
기존 트랜잭션이 커밋되고 종료된 이후, @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)로 등록된 리스너가 호출될 때, 리스너 메서드에 @Transactional을 붙여도 새로운 트랜잭션이 생성되지 않는 것일까요?

일반적으로 @Transactional이 붙은 메서드는 기본 전파 속성인 REQUIRED 때문에, 기존 트랜잭션이 존재하면 그 트랜잭션에 참여하고, 없으면 새 트랜잭션을 생성합니다.
그런데 왜 AFTER_COMMIT 이벤트 리스너에서는 트랜잭션이 생성되지 않는 것일까요?


✔️ 트랜잭션은 왜 생성되지 않는걸까?

먼저 트랜잭션이 어떻게 동작되는지 트랜잭션이 실제 커밋되는 processCommit()메서드 내용을 알아보겠습니다.

📍 트랜잭션 인터셉터

트랜잭션 인터셉터는 스프링 AOP를 활용해 @Transactional이 붙은 메서드의 실행을 가로채 트랜잭션 처리를 당담합니다. invoke()메서드는 실제 트랜잭션 경계를 설정하고 관리하는 invokeWithinTransaction()메서드를 호출합니다. 해당 메서드는 트랜잭션의 시작과 종료까지의 흐름을 관리하는 핵심 로직입니다.

public class TransactionInterceptor extends TransactionAspectSupport implements MethodInterceptor, Serializable {

    @Nullable
    public Object invoke(MethodInvocation invocation) throws Throwable {
        Class<?> targetClass = invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis()) : null;
        Method var10001 = invocation.getMethod();
        Objects.requireNonNull(invocation);
        return this.invokeWithinTransaction(var10001, targetClass, invocation::proceed);
    }
}

📍 TransactionAspectSupport의 invokeWithinTransaction 메서드

해당 메서드는 대상 메서드의 실행전후로 트랜잭션을 시작하고 종료하는 로직을 포함하고 있습니다. 비즈니스 로직 성공적으로 실행한 후, commitTransactionAfterReturning(txInfo)메소드를 호출해 트랜잭션을 커밋합니다.

public abstract class TransactionAspectSupport implements BeanFactoryAware, InitializingBean {

@Nullable
    protected Object invokeWithinTransaction(Method method, @Nullable Class<?> targetClass, final InvocationCallback invocation) throws Throwable {
        TransactionAttributeSource tas = this.getTransactionAttributeSource();
        TransactionAttribute txAttr = tas != null ? tas.getTransactionAttribute(method, targetClass) : null;
        TransactionManager tm = this.determineTransactionManager(txAttr, targetClass);
        if (this.reactiveAdapterRegistry != null && tm instanceof ReactiveTransactionManager rtm) {
            boolean isSuspendingFunction = KotlinDetector.isSuspendingFunction(method);
            boolean hasSuspendingFlowReturnType = isSuspendingFunction && "kotlinx.coroutines.flow.Flow".equals((new MethodParameter(method, -1)).getParameterType().getName());
            ReactiveTransactionSupport txSupport = (ReactiveTransactionSupport)this.transactionSupportCache.computeIfAbsent(method, (key) -> {
                Class<?> reactiveType = isSuspendingFunction ? (hasSuspendingFlowReturnType ? Flux.class : Mono.class) : method.getReturnType();
                ReactiveAdapter adapter = this.reactiveAdapterRegistry.getAdapter(reactiveType);
                if (adapter == null) {
                    String var10002 = String.valueOf(method.getReturnType());
                    throw new IllegalStateException("Cannot apply reactive transaction to non-reactive return type [" + var10002 + "] with specified transaction manager: " + String.valueOf(tm));
                } else {
                    return new ReactiveTransactionSupport(adapter);
                }
            });
            return txSupport.invokeWithinTransaction(method, targetClass, invocation, txAttr, rtm);
        } else {
            PlatformTransactionManager ptm = this.asPlatformTransactionManager(tm);
            String joinpointIdentification = this.methodIdentification(method, targetClass, txAttr);
            if (txAttr != null && ptm instanceof CallbackPreferringPlatformTransactionManager cpptm) {
                ThrowableHolder throwableHolder = new ThrowableHolder();

                Object result;
                try {
                    result = cpptm.execute(txAttr, (statusx) -> {
                        TransactionInfo txInfo = this.prepareTransactionInfo(ptm, txAttr, joinpointIdentification, statusx);

                        RuntimeException runtimeException;
                        try {
                            Object retVal = invocation.proceedWithInvocation();
                            if (retVal != null && vavrPresent && TransactionAspectSupport.VavrDelegate.isVavrTry(retVal)) {
                                retVal = TransactionAspectSupport.VavrDelegate.evaluateTryFailure(retVal, txAttr, statusx);
                            }

                            Object var15 = retVal;
                            return var15;
                        } catch (Throwable var13) {
                            if (txAttr.rollbackOn(var13)) {
                                if (var13 instanceof RuntimeException) {
                                    runtimeException = (RuntimeException)var13;
                                    throw runtimeException;
                                }

                                throw new ThrowableHolderException(var13);
                            }

                            throwableHolder.throwable = var13;
                            runtimeException = null;
                        } finally {
                            this.cleanupTransactionInfo(txInfo);
                        }

                        return runtimeException;
                    });
                } catch (ThrowableHolderException var25) {
                    throw var25.getCause();
                } catch (TransactionSystemException var26) {
                    if (throwableHolder.throwable != null) {
                        this.logger.error("Application exception overridden by commit exception", throwableHolder.throwable);
                        var26.initApplicationException(throwableHolder.throwable);
                    }

                    throw var26;
                } catch (Throwable var27) {
                    if (throwableHolder.throwable != null) {
                        this.logger.error("Application exception overridden by commit exception", throwableHolder.throwable);
                    }

                    throw var27;
                }

                if (throwableHolder.throwable != null) {
                    throw throwableHolder.throwable;
                } else {
                    return result;
                }
            } else {
                TransactionInfo txInfo = this.createTransactionIfNecessary(ptm, txAttr, joinpointIdentification);

                Object retVal;
                try {
                    retVal = invocation.proceedWithInvocation();
                } catch (Throwable var23) {
                    this.completeTransactionAfterThrowing(txInfo, var23);
                    throw var23;
                } finally {
                    this.cleanupTransactionInfo(txInfo);
                }

                if (retVal != null && txAttr != null) {
                    TransactionStatus status = txInfo.getTransactionStatus();
                    if (status != null) {
                        label195: {
                            if (retVal instanceof Future) {
                                Future<?> future = (Future)retVal;
                                if (future.isDone()) {
                                    try {
                                        future.get();
                                    } catch (ExecutionException var28) {
                                        Throwable cause = var28.getCause();
                                        Assert.state(cause != null, "Cause must not be null");
                                        if (txAttr.rollbackOn(cause)) {
                                            status.setRollbackOnly();
                                        }
                                    } catch (InterruptedException var29) {
                                        Thread.currentThread().interrupt();
                                    }
                                    break label195;
                                }
                            }

                            if (vavrPresent && TransactionAspectSupport.VavrDelegate.isVavrTry(retVal)) {
                                retVal = TransactionAspectSupport.VavrDelegate.evaluateTryFailure(retVal, txAttr, status);
                            }
                        }
                    }
                }this.commitTransactionAfterReturning(txInfo); 
                return retVal;
            }
        }
    }
    
        protected void commitTransactionAfterReturning(@Nullable TransactionInfo txInfo) {
        if (txInfo != null && txInfo.getTransactionStatus() != null) {
            if (this.logger.isTraceEnabled()) {
                this.logger.trace("Completing transaction for [" + txInfo.getJoinpointIdentification() + "]");
            }

            txInfo.getTransactionManager().commit(txInfo.getTransactionStatus());
        }

    }

}

해당 코드에서는 트랜잭션이 시작된 후
retVal = invocation.proceedWithInvocation();를 통해 실제 비즈니스 메서드를 호출합니다.

예외 발생 시,
completeTransactionAfterThrowing(txInfo, ex); 를 호출하여 트랜잭션을 롤백하고,

finally 블록에서는
cleanupTransactionInfo(txInfo); 를 통해 트랜잭션 정보를 정리합니다.

반대로, 비즈니스 로직이 정상적으로 완료되면
commitTransactionAfterReturning(txInfo); 를 호출하여 트랜잭션을 커밋합니다.

즉, 트랜잭션은 AOP 프록시가 메서드 호출 전후에 트랜잭션을 시작하고 종료(commit/rollback)하며, 예외 처리와 정리까지 담당하는 구조입니다.

📍 커밋 시,AbstractPlatformTransactionManager 클래스의 commit() 메서드를 호출

  • commit() 메서드는 내부적으로 processCommit()을 호출합니다.
  • processCommit()에서는 실제 트랜잭션 커밋 절차를 수행하며,
    • 커밋 전후에 필요한 동기화 콜백 (예: beforeCommit, beforeCompletion, afterCommit)
    • 트랜잭션 종료 후 리소스 정리 등을 처리합니다.
public abstract class AbstractPlatformTransactionManager implements PlatformTransactionManager, ConfigurableTransactionManager, Serializable {
	public final void commit(TransactionStatus status) throws TransactionException {
        if (status.isCompleted()) {
            throw new IllegalTransactionStateException("Transaction is already completed - do not call commit or rollback more than once per transaction");
        } else {
            DefaultTransactionStatus defStatus = (DefaultTransactionStatus)status;
            if (defStatus.isLocalRollbackOnly()) {
                if (defStatus.isDebug()) {
                    this.logger.debug("Transactional code has requested rollback");
                }

                this.processRollback(defStatus, false);
            } else if (!this.shouldCommitOnGlobalRollbackOnly() && defStatus.isGlobalRollbackOnly()) {
                if (defStatus.isDebug()) {
                    this.logger.debug("Global transaction is marked as rollback-only but transactional code requested commit");
                }

                this.processRollback(defStatus, true);
            } else {
                this.processCommit(defStatus);
            }
        }
    }
}

private void processCommit(DefaultTransactionStatus status) throws TransactionException {
        try {
            boolean beforeCompletionInvoked = false;
            boolean commitListenerInvoked = false;

            try {
                boolean unexpectedRollback = false;
                this.prepareForCommit(status);
                this.triggerBeforeCommit(status);
                this.triggerBeforeCompletion(status);
                beforeCompletionInvoked = true;
                if (status.hasSavepoint()) {
                    if (status.isDebug()) {
                        this.logger.debug("Releasing transaction savepoint");
                    }

                    unexpectedRollback = status.isGlobalRollbackOnly();
                    this.transactionExecutionListeners.forEach((listener) -> {
                        listener.beforeCommit(status);
                    });
                    commitListenerInvoked = true;
                    status.releaseHeldSavepoint();
                } else if (status.isNewTransaction()) {
                    if (status.isDebug()) {
                        this.logger.debug("Initiating transaction commit");
                    }

                    unexpectedRollback = status.isGlobalRollbackOnly();
                    this.transactionExecutionListeners.forEach((listener) -> {
                        listener.beforeCommit(status);
                    });
                    commitListenerInvoked = true;
                    this.doCommit(status);
                } else if (this.isFailEarlyOnGlobalRollbackOnly()) {
                    unexpectedRollback = status.isGlobalRollbackOnly();
                }

                if (unexpectedRollback) {
                    throw new UnexpectedRollbackException("Transaction silently rolled back because it has been marked as rollback-only");
                }
            } catch (UnexpectedRollbackException var18) {
                this.triggerAfterCompletion(status, 1);
                this.transactionExecutionListeners.forEach((listener) -> {
                    listener.afterRollback(status, (Throwable)null);
                });
                throw var18;
            } catch (TransactionException var19) {
                if (this.isRollbackOnCommitFailure()) {
                    this.doRollbackOnCommitException(status, var19);
                } else {
                    this.triggerAfterCompletion(status, 2);
                    if (commitListenerInvoked) {
                        this.transactionExecutionListeners.forEach((listener) -> {
                            listener.afterCommit(status, var19);
                        });
                    }
                }

                throw var19;
            } catch (Error | RuntimeException var20) {
                if (!beforeCompletionInvoked) {
                    this.triggerBeforeCompletion(status);
                }

                this.doRollbackOnCommitException(status, var20);
                throw var20;
            }

            try {
                this.triggerAfterCommit(status);
            } finally {
                this.triggerAfterCompletion(status, 0);
                if (commitListenerInvoked) {
                    this.transactionExecutionListeners.forEach((listener) -> {
                        listener.afterCommit(status, (Throwable)null);
                    });
                }

            }
        } finally {
            this.cleanupAfterCompletion(status);
        }

    }

즉, 스프링 트랜잭션 매니저는 단순히 DB 커밋만 하는 것이 아니라, 트랜잭션 경계 내의 모든 이벤트와 리소스를 관리하면서 커밋을 수행합니다.

전체적인 흐름을 정리해보면,

  1. 트랜잭션 인터셉터가 @Transactional 메서드를 가로채서 트랜잭션을 시작합니다.
  2. 비즈니스 로직이 실행되고, 정상적으로 완료되면 트랜잭션 매니저의 commit() 메서드를 호출합니다.
  3. commit() 메서드 내에서 processCommit() 메서드를 통해 트랜잭션 커밋 절차를 진행합니다.
  4. 트랜잭션이 커밋되면 TransactionSynchronizationUtils.triggerAfterCommit()이 호출되어, TransactionalEventListener의 afterCommit() 메서드가 실행됩니다.
  5. 트랜잭션이 완전히 종료되면 TransactionSynchronizationManager.clear() 메서드를 통해 트랜잭션 상태 정보가 초기화됩니다.

@Transactional 어노테이션이 내부적으로 어떻게 흘러가는지 알게 된다면 특정한 상황에 왜 내 기대한 것대로 안 된지 알 수 있어서 적었습니다. 좀 더 세부적으로 보겠습니다.

먼저 AbstractPlatformTransactionManager 클래스를 보겠습니다.

해당 클래스는 추상클래스로 PlatformTransactionManager인터페이스를 구현하며, JPA,JDBC 등 데이터접근 기술에 대한 공통적인 트랜잭션 관리 기능을 제공합니다.

AbstractPlatformTransactionManager 클래스의 주요 역할로는

  • 트랜잭션 수명 주기 관리
  • 트랜잭션 전파 및 격리 수준 처리
  • 트랜잭션 동기화 관리
    TransactionSynchronizationManager를 활용하여 트랜잭션 동기화를 관리하고, 트랜잭션의 시작과 종료 시점에 필요한 콜백을 처리합니다.

이 있습니다.

AbstractPlatformTransactionManager의 processCommit()메서드를 확인하겠습니다.
processCommit() 메서드를 통해 실제 트랜잭션 커밋 이뤄집니다.

private void processCommit(DefaultTransactionStatus status) throws TransactionException {
        try {
            boolean beforeCompletionInvoked = false;
            boolean commitListenerInvoked = false;

            try {
                boolean unexpectedRollback = false;1. 커밋 위한 준비
                this.prepareForCommit(status);2. 커밋 전 처리
                this.triggerBeforeCommit(status);3. 완료 전 처리
                this.triggerBeforeCompletion(status);
                beforeCompletionInvoked = true;4. 실제 커밋 수행
                if (status.hasSavepoint()) {
                    if (status.isDebug()) {
                        this.logger.debug("Releasing transaction savepoint");
                    }

                    unexpectedRollback = status.isGlobalRollbackOnly();
                    this.transactionExecutionListeners.forEach((listener) -> {
                        listener.beforeCommit(status);
                    });
                    commitListenerInvoked = true;
                    status.releaseHeldSavepoint();
                } else if (status.isNewTransaction()) {
                    if (status.isDebug()) {
                        this.logger.debug("Initiating transaction commit");
                    }

                    unexpectedRollback = status.isGlobalRollbackOnly();
                    this.transactionExecutionListeners.forEach((listener) -> {
                        listener.beforeCommit(status);
                    });
                    commitListenerInvoked = true;
                    this.doCommit(status);
                } else if (this.isFailEarlyOnGlobalRollbackOnly()) {
                    unexpectedRollback = status.isGlobalRollbackOnly();
                }

                if (unexpectedRollback) {
                    throw new UnexpectedRollbackException("Transaction silently rolled back because it has been marked as rollback-only");
                }
            } catch (UnexpectedRollbackException var18) {
                this.triggerAfterCompletion(status, 1);
                this.transactionExecutionListeners.forEach((listener) -> {
                    listener.afterRollback(status, (Throwable)null);
                });
                throw var18;
            } catch (TransactionException var19) {
                if (this.isRollbackOnCommitFailure()) {
                    this.doRollbackOnCommitException(status, var19);
                } else {
                    this.triggerAfterCompletion(status, 2);
                    if (commitListenerInvoked) {
                        this.transactionExecutionListeners.forEach((listener) -> {
                            listener.afterCommit(status, var19);
                        });
                    }
                }

                throw var19;
            } catch (Error | RuntimeException var20) {
                if (!beforeCompletionInvoked) {
                    this.triggerBeforeCompletion(status);
                }

                this.doRollbackOnCommitException(status, var20);
                throw var20;
            }

            try {5. 커밋 후 처리
                this.triggerAfterCommit(status);
            } finally {6. 완료 처리
                this.triggerAfterCompletion(status, 0);
                if (commitListenerInvoked) {
                    this.transactionExecutionListeners.forEach((listener) -> {
                        listener.afterCommit(status, (Throwable)null);
                    });
                }

            }
        } finally {7. 정리 작업
            this.cleanupAfterCompletion(status);
        }

    }
  • @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    는 5번 단계에서 처리됩니다.
  • 이 시점 DB커밋은 완료되지만, 트랜잭션 작업은 남아있습니다.
  • 7번 cleanupAfterCompletion에서 최종적으로 모든 트랜잭션 리소스가 정리됩니다.

DB 트랜잭션이 종료되었는지는 어떻게 알수있을까?

doCommit 메서드를 보면 총 4개의 트랜잭션 매니저가 이 코드를 구현하고있습니다.

현재 저는 JPA를 사용하므로, JpaTransactionManager의 doCommit()메서드를 살펴 보았습니다.

    protected void doCommit(DefaultTransactionStatus status) {
        JpaTransactionObject txObject = (JpaTransactionObject)status.getTransaction();
        if (status.isDebug()) {
            this.logger.debug("Committing JPA transaction on EntityManager [" + String.valueOf(txObject.getEntityManagerHolder().getEntityManager()) + "]");
        }

        try {
            EntityTransaction tx = txObject.getEntityManagerHolder().getEntityManager().getTransaction();
            tx.commit();
        } catch (RollbackException var6) {
            Throwable var5 = var6.getCause();
            if (var5 instanceof RuntimeException runtimeException) {
                DataAccessException dae = this.getJpaDialect().translateExceptionIfPossible(runtimeException);
                if (dae != null) {
                    throw dae;
                }
            }

            throw new TransactionSystemException("Could not commit JPA transaction", var6);
        } catch (RuntimeException var7) {
            throw DataAccessUtils.translateIfNecessary(var7, this.getJpaDialect());
        }
    }

doCommit()메서드 내부의 try문에 tx.commit()을 하는 것을 볼 수 있습니다. 이는 EntityTransacion.commit()을 사용하여 실제 데이터베이스 트랜잭션을 커밋하는 것입니다. 이 시점에서 데이터베이스 레벨의 트랜잭션은 더 이상 존재하지 않게 됩니다.

그렇기에, 데이터베이스 트랜잭션은 doCommit()호출 후 종료되며, after_Commit 리스너가 실행되기 전에 이미 데이터베이스 트랜잭션이 종료된 것입니다. 이때 데이터베이스 작업을 수행하려면 별도로 트랜잭션을 시작해야 합니다.

간단히 정리해보면

트잰잭션 흐름을 제어하는 소스 코드를 보면 알듯이, after_Commit 리스너는 processCommit()메서드가 실행되는 동안 그 내부에서 실행됩니다. 그렇기에 after_Commit 리스너에서 Update를 제대로 못하는 이유입니다.

  1. 트랜잭션 시작 & 커밋
    • DB 작업 수행
    • 트랜잭션 커밋
  2. TransactionSynchronizationUtils.triggerAfterCommit() 호출
    • 등록된 모든 TransactionSynchronization에 대해
    • afterCommit() 메서드 호출
  3. TransactionSynchronizationUtils.triggerAfterCompletion() 호출
    • 모든 동기화 객체의 afterCompletion() 호출
    • 트랜잭션 관련 리소스 정리

흐름을 보면 트랜잭션이 시작되고 데이터베이스 작업이 수행됩니다. 트랜잭션이 커밋되면 TransactionSynchronizationUtils.triggerAfterCommit()메서드가 호출됩니다. 여기서 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 어노테이션이 붙은 이벤트 리스너 메서드가 실행됩니다.

여기서 중요한 점이 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)이 붙은 이벤트 리스너 메서드는 doCommit() 메서드가 호출되어 데이터베이스 트랜잭션이 실제로 커밋된 직후, 즉 DB 트랜잭션이 이미 종료된 시점에 실행됩니다.

하지만 이 시점에서는 데이터베이스 트랜잭션만 종료되었을 뿐, 스프링의 트랜잭션 컨텍스트(TransactionSynchronizationManager)는 아직 정리되지 않은 상태로 유지되고 있습니다.
따라서 AFTER_COMMIT 리스너 내에서 @Transactional(기본 전파속성 REQUIRED)을 사용하더라도, 아직 열려 있는 스프링 트랜잭션 컨텍스트 때문에 새로운 DB 트랜잭션이 생성되지 않으며, 추가적인 DB 작업도 정상적으로 수행되지 않습니다.
이 시점에서 새로운 DB 트랜잭션을 시작하려면 명시적으로 REQUIRES_NEW 를 사용해야 합니다.

모든 트랜잭션 처리 과정이 완료된 뒤에는 cleanupAfterCompletion(status)가 호출되고, 내부에서 TransactionSynchronizationManager.clear()를 통해 스프링 트랜잭션 컨텍스트와 모든 동기화 정보가 완전히 제거됩니다.
이 시점 이후에는 비로소 새로운 데이터베이스 트랜잭션을 자유롭게 생성할 수 있는 상태가 됩니다.

결과적으로 AFTER_COMMIT 이벤트 리스너에서 DB 업데이트가 정상적으로 반영되지 않았던 이유는,
processCommit()이 아직 완전히 종료되기 전,

  • DB 트랜잭션은 이미 doCommit()에서 끝났지만
  • 스프링 트랜잭션 컨텍스트는 정리되지 않은 상태

에서 AFTER_COMMIT 리스너가 실행되기 때문입니다.
즉, 스프링 트랜잭션은 “남아 있지만 종료 직전의 과도기 상태”이기 때문에, REQUIRED로는 새로운 트랜잭션을 만들지 못하고 DB 작업도 반영되지 않게 됩니다.


정리하면,

  1. 트랜잭션의 생명주기를 먼저 이해해야 합니다.
  • “트랜잭션이 커밋되었다”는 말은
    → DB 트랜잭션이 종료되었다는 의미일 뿐,
    → 스프링 트랜잭션 컨텍스트가 종료되었다는 의미는 아닙니다.

스프링의 트랜잭션 컨텍스트는
processCommit() 메서드가 완전히 종료된 후에야 정리됩니다.

  1. AFTER_COMMIT 시점에서는 다음과 같은 상태가 됩니다.
  • DB 트랜잭션은 doCommit()을 통해 이미 종료.
  • 하지만 스프링의 트랜잭션 컨텍스트는 계속 유지.
  • 따라서 스프링 입장에서는 “트랜잭션이 존재하는 중”이라고 판단.
  • 그러나 실제 DB 트랜잭션은 없으므로 DB 작업은 불가능합니다.
  1. 이 시점에서 DB 작업을 수행하려면 REQUIRES_NEW를 사용해야 합니다.
    기본 전파 속성인 REQUIRED는
    “현재 트랜잭션이 있다면 그대로 참여한다”는 규칙을 따르므로,
    스프링 트랜잭션 컨텍스트가 살아있는 AFTER_COMMIT 시점에서는
    새로운 트랜잭션을 생성하지 않습니다.

따라서 DB 트랜잭션이 존재하지 않는 상태에서
DB 작업을 시도하게 되고,
결과적으로 "no transaction is in progress" 오류가 발생합니다.

반면, REQUIRES_NEW는 다음을 수행합니다.

  • 기존 트랜잭션 컨텍스트 분리(Pause)
  • 새로운 스프링 트랜잭션 컨텍스트 생성
  • 새로운 DB 커넥션 획득.
  • 새로운 DB 트랜잭션 시작.

따라서 AFTER_COMMIT에서도 정상적으로 DB 작업을 처리할 수 있습니다.


마무리

이 문제는 “AFTER_COMMIT은 이미 DB 트랜잭션은 끝났지만
스프링 트랜잭션 컨텍스트는 아직 살아 있는 시점”이라는
특수한 구조에서 발생합니다.

스프링은 트랜잭션 컨텍스트가 남아 있으므로
@Transactional(REQUIRED) 메서드 호출 시
새로운 트랜잭션을 시작하지 않습니다.

그러나 DB 트랜잭션은 이미 종료된 상태이므로
DB 작업을 시도하면 오류가 발생합니다.

따라서 이 구간에서 DB 작업이 필요하다면
반드시 @Transactional(propagation = REQUIRES_NEW)로
새로운 트랜잭션을 강제로 만들고 사용해야 합니다.

0개의 댓글