@TransactionalEventListener, 코드가 진화하는 4단계

이벤트로 호출을 끊는 것은 시작일 뿐입니다. 진짜 안정성은 이벤트가 트랜잭션의 어느 경계에서 실행되는가를 통제하는 데서 나옵니다. 같은 트랜잭션에서 동기로 둘지, 커밋 이후로 미룰지, 비동기로 떼어낼지에 따라 멀쩡히 끝난 주문이 통째로 롤백되기도 하고 쿠폰이 유령처럼 사라지기도 합니다.

이 글은 개념 설명서가 아니라, 하나의 주문 결제 시나리오에 요구사항이 하나씩 붙을 때마다 리스너가 어떻게 바뀌는지 따라가는 기록입니다. 결제를 완료하면 네 가지가 일어나야 한다고 해봅시다. 주문 상태 확정(실패하면 결제 무효), 적립 쿠폰 발급(정합성 중요), 재고 차감(과판매 위험), 푸시 알림(실패해도 알림만 안 옴). 처음엔 이 넷을 payOrder() 한 메서드에서 순서대로 호출했습니다. 동작은 합니다. 다만 결제 로직과 알림 로직이 한 트랜잭션에 엉켜 있는 게 문제였습니다. 여기서부터 출발합니다.

1단계 — 일단 이벤트로 분리하다

결제 완료라는 사실을 이벤트로 뽑고, 쿠폰 발급을 리스너로 옮겼습니다.

public record OrderPaidEvent(Long orderId, Long memberId, int amount) { }
@Component
@RequiredArgsConstructor
public class CouponEventHandler {
    private final CouponService couponService;

    @EventListener
    public void onOrderPaid(OrderPaidEvent event) {
        couponService.issueRewardCoupon(event.memberId(), event.amount());
    }
}

코드는 깔끔해졌지만 함정이 숨어 있습니다. 기본 @EventListener는 발행한 그 순간, 같은 트랜잭션 안에서 동기로 실행됩니다. 쿠폰 발급이 아직 payOrder의 트랜잭션 안에 들어 있다는 뜻입니다.

2단계 — 사고: 쿠폰 버그가 결제를 날린다

쿠폰 발급 로직에 버그가 생겨 예외가 터졌다고 해봅시다. 1단계 구조에서는 리스너 예외가 발행 지점까지 전파되어 payOrder의 트랜잭션이 통째로 롤백됩니다. 결제는 정상이었는데 쿠폰 코드 한 줄 때문에 주문까지 사라지는 상황입니다.

핵심은 이렇습니다. 결제 확정과 쿠폰 발급은 운명을 같이할 이유가 없습니다. 주문은 무조건 살아야 하고, 쿠폰은 그 뒤에 따라붙는 작업입니다. 그렇다면 트랜잭션이 커밋된 뒤에 쿠폰을 처리하도록 시점을 미뤄야 합니다.

3단계 — 커밋 이후로 미루다

@EventListener@TransactionalEventListener로 바꾸고 phase만 지정하면 됩니다.

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onOrderPaid(OrderPaidEvent event) {
    couponService.issueRewardCoupon(event.memberId(), event.amount());
}

AFTER_COMMIT은 결제 트랜잭션이 확정된 다음에만 리스너를 실행합니다. 결제가 롤백되면 쿠폰 발급은 아예 호출되지 않으니, 롤백된 주문에 쿠폰이 잘못 나가는 사고를 원천 차단합니다.

여기서 반드시 짚을 함정이 하나 있습니다. AFTER_COMMIT 시점에는 이미 트랜잭션이 끝났기 때문에, 리스너 안에서 다시 DB에 쓰면 그 쓰기는 커밋되지 않고 조용히 무시될 수 있습니다. 그래서 위 코드처럼 @Transactional(propagation = REQUIRES_NEW)로 별도 트랜잭션을 새로 열어야 합니다. 이 한 줄을 빼먹어 “발급 코드는 분명 돌았는데 쿠폰이 DB에 없다”며 헤매는 경우가 정말 많습니다.

phase는 네 가지가 있지만 실무에서는 사실상 AFTER_COMMIT이 대부분입니다. 나머지는 AFTER_ROLLBACK(실패 보상), AFTER_COMPLETION(자원 정리·로깅), BEFORE_COMMIT(커밋 전 검증) 정도로 기억해 두면 됩니다.

4단계 — 부가 작업만 비동기로 떼어내다

남은 건 푸시 알림입니다. 외부 푸시 서버를 타니 느릴 수 있고, 실패해도 결제 흐름엔 지장이 없습니다. 이런 작업까지 동기로 두면 알림 응답을 기다리느라 사용자 응답 속도가 함께 느려집니다. 성격이 다른 만큼 도구도 분리해 @Async로 별도 스레드에서 돌립니다.

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderPaidPush(OrderPaidEvent event) {
    try {
        pushService.send(event.memberId());
    } catch (Exception e) {
        log.error("푸시 알림 발송 실패. memberId={}", event.memberId(), e);
    }
}

비동기는 공짜가 아닙니다. 가장 큰 함정은 예외가 호출 측과 끊겨 소리 없이 증발한다는 점입니다. 그래서 리스너 안에서 반드시 예외를 잡아 로그를 남깁니다. 안 그러면 나중에 “왜 알림이 안 갔지?” 하며 한참 헤매게 됩니다. (@Async를 쓰려면 설정에 @EnableAsync가 필요합니다.)

정리하며

네 단계를 거치자 처음의 비대한 메서드가 이렇게 정리됐습니다. 결제 확정은 본 트랜잭션에서, 쿠폰·재고는 커밋 이후 별도 트랜잭션에서 동기로, 알림은 비동기로. 각 작업이 자기 성격에 맞는 자리를 찾아간 것이죠.

기준은 단순합니다. 본 거래와 함께 죽고 살아야 하는 작업은 동기 @EventListener로, 성공이 확정된 뒤 따라붙는 작업은 AFTER_COMMIT + REQUIRES_NEW로, 실패해도 무방한 부가 작업은 @Async + 예외 로깅으로 분리합니다. 다음 결제 후속 로직을 설계할 때, 코드를 쌓기 전에 한 번 물어보시기 바랍니다. “이 작업은 결제와 운명을 같이해야 하는가, 아니면 그저 따라오기만 하는가?”

참고: Spring 공식 문서 — Transaction-bound Events

“@TransactionalEventListener, 코드가 진화하는 4단계”에 대한 3개의 생각

댓글 남기기