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

글쓴이: JAVA/Spring Boot 백엔드 개발 10년차. SI로 시작해 현재는 자사 서비스 백엔드를 맡고 있다.

2021년 가을, 결제 후속 처리를 이벤트로 바꾸는 리팩터링을 맡았다. 코드 리뷰에서는 “깔끔해졌다”는 말을 들었는데, 배포하고 열흘 만에 결제는 정상 승인됐는데 주문이 사라진 건이 37건 나왔다. 그 뒤로 넉 달 동안 리스너 하나를 네 번 고쳤다.

이 글은 @TransactionalEventListener 사용법을 정리한 글이 아니다. 쿠폰 발급, 재고 차감, 푸시 알림을 결제에서 떼어 내다가 어디서 틀렸고 무엇으로 바로잡았는지 순서대로 적은 기록이다. 중간에 고친 방법 두 개는 실패했고, 하나는 고친 줄 알았는데 다른 사고를 만들었다. 그 실패까지 같이 적는다.

payOrder() 하나에 모든 일이 들어 있었다

당시 결제 완료 처리는 OrderService.payOrder() 메서드 하나였다. PG 승인 결과를 받으면 주문 상태를 PAID로 바꾸고, 적립 쿠폰을 발급하고, 재고를 줄이고, 푸시 알림을 보냈다. 전부 한 트랜잭션 안에서 순서대로 돌았다. 메서드는 180줄이었고, 쿠폰 정책이 바뀔 때마다 결제 담당인 내가 쿠폰 코드를 고쳐야 했다.

리팩터링 목표는 단순했다. 결제는 “결제가 끝났다”는 사실만 알리고, 나머지는 각자 듣고 처리하게 한다. 스프링의 ApplicationEventPublisher로 이벤트를 던지고 리스너를 붙이면 결제 코드에서 쿠폰·재고·알림 의존성이 전부 빠진다.

public record OrderPaidEvent(Long orderId, Long memberId, int amount) { }

@Transactional
public void payOrder(Long orderId, PgResult pg) {
    Order order = orderRepository.findById(orderId).orElseThrow();
    order.markPaid(pg.approvalNo());
    eventPublisher.publishEvent(new OrderPaidEvent(orderId, order.getMemberId(), order.getAmount()));
}

@Component
@RequiredArgsConstructor
public class CouponEventHandler {
    private final CouponService couponService;

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

payOrder()는 15줄로 줄었다. 재고와 알림도 같은 모양의 리스너로 옮겼다. 테스트는 전부 통과했고 리뷰도 바로 승인됐다. 이때 나는 @EventListener가 발행한 그 자리에서, 같은 스레드와 같은 트랜잭션 안에서 동기로 실행된다는 사실을 알고는 있었다. 다만 그게 무슨 뜻인지는 아직 몰랐다.

배포 열흘째, 승인된 결제 37건의 주문이 사라졌다

쿠폰팀이 새 적립 정책을 배포한 날이었다. 정책 테이블에 등급 하나가 빠져 있어서, 그 등급 회원이 결제하면 issueRewardCoupon()에서 예외가 났다. 예전 구조였어도 같은 버그였을 것이다. 하지만 이벤트로 바꾸고 나니 그 예외가 이벤트를 발행한 payOrder()까지 그대로 올라왔고, 주문 트랜잭션 전체가 롤백됐다.

문제는 PG 승인은 이미 끝났다는 점이었다. 고객 카드에서는 돈이 빠져나갔는데 우리 DB에는 주문이 결제 전 상태로 남았다. 오후 2시부터 5시 사이에 37건이었다. CS 문의가 들어오고 나서야 알았고, 그날 밤 PG 관리자 화면에서 한 건씩 승인 취소를 했다.

회고에서 정리한 원인은 한 문장이었다. 결제 확정과 쿠폰 발급은 함께 죽고 살 이유가 없는데, 내가 같은 트랜잭션에 묶어 두었다. 이벤트로 코드를 나눴을 뿐 실행 경계는 하나도 나누지 않았던 것이다.

첫 번째 수습: try-catch로 감쌌는데 여전히 롤백됐다

당일 밤 급한 대로 리스너 안에서 예외를 잡았다. 쿠폰 발급이 실패해도 로그만 남기고 넘어가면 결제는 살 거라고 생각했다.

@EventListener
public void onOrderPaid(OrderPaidEvent event) {
    try {
        couponService.issueRewardCoupon(event.memberId(), event.amount());
    } catch (Exception e) {
        log.error("쿠폰 발급 실패. orderId={}", event.orderId(), e);
    }
}

스테이징에서 일부러 쿠폰 예외를 내 보니 이번에는 UnexpectedRollbackException이 났다. CouponService에는 클래스 단위로 @Transactional이 붙어 있었다. 기본 전파 속성인 REQUIRED라서 쿠폰 발급은 결제 트랜잭션에 그대로 참여했다. 그 안에서 런타임 예외가 나면 스프링은 바깥 트랜잭션 전체에 rollback-only 표시를 남긴다. 내가 바깥에서 예외를 잡아도 그 표시는 지워지지 않는다. 결국 payOrder()가 커밋하려는 순간 롤백됐다. 이 함정은 나중에 @Transactional 자주 틀리는 5가지 함정에도 한 항목으로 정리했다.

try-catch는 증상을 가린 것도 아니었다. 결제가 실패하는 건 똑같고 에러 메시지만 더 알아보기 어려워졌을 뿐이다. 실행 시점 자체를 결제 트랜잭션 밖으로 빼야 했다.

두 번째 수습: AFTER_COMMIT으로 미뤘더니 쿠폰이 저장되지 않았다

다음 날 @TransactionalEventListener로 바꿨다. phase를 AFTER_COMMIT으로 두면 결제 트랜잭션이 커밋된 뒤에만 리스너가 돈다. 결제가 롤백되면 쿠폰 발급은 아예 호출되지 않는다. 롤백된 주문에 쿠폰이 나가는 사고도 같이 막힌다.

그런데 배포 후 하루 동안 발급된 적립 쿠폰이 0건이었다. 로그에는 “쿠폰 발급 완료”가 정상적으로 찍혀 있었다. 원인은 커밋이 끝난 트랜잭션이었다. AFTER_COMMIT 시점에 트랜잭션은 이미 커밋됐지만 커넥션과 동기화 정보는 아직 스레드에 묶여 있다. REQUIRED로 선언된 쿠폰 서비스는 새 트랜잭션을 열지 않고 끝난 트랜잭션에 참여했다. INSERT는 실행됐지만 커밋해 줄 주체가 없어서 그대로 사라졌다.

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

REQUIRES_NEW로 새 트랜잭션을 열자 쿠폰이 저장됐다. 참고로 지금 쓰는 Spring Framework 6.1부터는 @TransactionalEventListener 메서드에 REQUIRES_NEW나 NOT_SUPPORTED가 아닌 @Transactional을 붙이면 애플리케이션이 뜰 때 에러를 낸다. 2021년의 Spring 5.3은 아무 경고 없이 실행됐고, 그래서 하루를 통째로 잃었다.

고친 줄 알았던 것들: 사라지는 쿠폰과 마이너스 재고

결제는 더 이상 롤백되지 않았다. 하지만 한 달쯤 지나 두 가지가 새로 드러났다.

첫째, 쿠폰이 조용히 사라졌다. AFTER_COMMIT 리스너에서 난 예외는 결제를 롤백시키지 않는 대신 아무 데도 남지 않는다. 쿠폰 DB가 잠깐 느렸던 날 발급 실패가 50건 넘게 나왔는데, 재시도 장치가 없으니 그대로 끝이었다. 배포 중 서버가 내려가면서 커밋 직후 리스너가 돌기 전에 끊긴 건도 있었다. 결제는 확정됐고 쿠폰은 영영 오지 않는다.

그래서 결제 트랜잭션 안에서 “쿠폰 발급 대기” 행을 같이 저장하도록 바꿨다. 결제와 함께 커밋되니 서버가 죽어도 기록은 남는다. 리스너는 이 행을 보고 쿠폰을 발급한 뒤 완료로 바꾸고, 1분마다 도는 스케줄러가 10분 넘게 대기 중인 행을 다시 처리한다. 재시도로 쿠폰이 두 번 나가지 않도록 쿠폰 테이블에 (order_id, coupon_type) 유니크 키를 걸었다. 지금 돌이켜 보면 아웃박스 패턴을 작게 만든 셈이다.

둘째, 타임세일에서 재고가 마이너스 12까지 내려갔다. 재고 차감까지 쿠폰과 같은 방식으로 AFTER_COMMIT으로 옮긴 게 실수였다. 재고는 결제보다 먼저, 또는 결제와 같은 트랜잭션에서 확인해야 하는 값이다. 커밋 뒤로 미루면 그 사이에 다른 주문들이 같은 재고를 보고 결제를 끝낸다. 재고는 다시 결제 트랜잭션 안으로 넣고, 조건부 UPDATE 한 줄로 바꿨다.

@Modifying
@Query("UPDATE Stock s SET s.quantity = s.quantity - :qty " +
       "WHERE s.productId = :productId AND s.quantity >= :qty")
int decrease(@Param("productId") Long productId, @Param("qty") int qty);

// payOrder() 안에서
if (stockRepository.decrease(productId, qty) == 0) {
    throw new SoldOutException(productId);   // 결제 확정 전에 실패시키고 PG 승인을 취소한다
}

영향받은 행이 0이면 품절로 보고 결제 확정 전에 실패시킨다. 서버 여러 대에서 같은 상품을 동시에 노리는 경우는 이후 Redis 분산락을 도입하면서 한 번 더 손봤다. 이벤트로 떼어 낸다고 다 좋은 게 아니었다. 결제와 운명을 같이해야 하는 작업은 이벤트 밖에 두어야 했다.

푸시 알림은 비동기로, 그런데 큐가 넘쳤다

푸시 알림은 성격이 달랐다. 외부 푸시 서버를 타서 느릴 수 있고, 실패해도 고객이 알림만 못 받는다. AFTER_COMMIT 리스너는 요청 스레드에서 돌기 때문에 푸시 서버가 2초 걸리면 결제 응답도 2초 늦어진다. 그래서 @Async를 붙여 별도 스레드로 보냈다.

석 달째에 푸시 업체가 반나절 동안 느려졌다. 스프링 부트가 기본으로 만들어 주는 비동기 실행기는 스레드 8개에 대기 큐 크기 제한이 없다. 알림 작업이 큐에 계속 쌓여 수만 건이 됐고, 힙 사용량이 계속 올라가서 결국 서버 한 대를 재시작했다. 그때 큐에 있던 알림은 전부 사라졌다. 게다가 반환값이 void인 @Async 메서드에서 난 예외는 호출한 쪽으로 돌아오지 않아서, 그동안 실패한 알림이 몇 건인지도 알 수 없었다.

@Bean(name = "pushExecutor")
public ThreadPoolTaskExecutor pushExecutor() {
    ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
    ex.setCorePoolSize(4);
    ex.setMaxPoolSize(8);
    ex.setQueueCapacity(500);                        // 무제한 큐를 쓰지 않는다
    ex.setThreadNamePrefix("push-");
    ex.setRejectedExecutionHandler((r, e) ->
            log.warn("푸시 큐 가득 참, 이번 알림은 버림"));
    return ex;
}

@Async("pushExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderPaidPush(OrderPaidEvent event) {
    try {
        pushService.send(event.memberId(), event.orderId());
    } catch (Exception e) {
        log.error("푸시 발송 실패. orderId={}", event.orderId(), e);
        metrics.counter("push.fail").increment();
    }
}

푸시 전용 실행기를 따로 두고 큐를 500으로 막았다. 큐가 차면 알림을 버리고 경고만 남긴다. 결제 알림은 앱 주문 내역에서도 확인할 수 있어서 못 보내도 되는 작업이라고 기획팀과 합의했다. 실패 횟수는 지표로 내보내서 대시보드에서 바로 보이게 했다.

테스트는 왜 이 사고를 하나도 못 잡았나

사고를 정리하면서 가장 오래 붙잡은 질문이다. 우리 통합 테스트는 클래스에 @Transactional을 붙여 테스트가 끝나면 롤백하는 방식이었다. 그러면 결제 트랜잭션이 커밋되지 않으니 AFTER_COMMIT 리스너는 테스트에서 한 번도 실행되지 않는다. 쿠폰이 저장되지 않는 버그도, 재고가 늦게 줄어드는 문제도 테스트에서는 애초에 일어날 수가 없었다.

이벤트 리스너를 검증하는 테스트는 @Transactional을 빼고, 테스트가 끝날 때 직접 데이터를 지우게 바꿨다. 실제로 커밋시키고, 리스너가 남긴 결과를 DB에서 확인한다. 비동기 리스너는 Awaitility로 최대 3초까지 기다리게 했다. 테스트는 느려졌지만 그 뒤로 같은 종류의 사고는 운영에 나가기 전에 잡혔다.

작업별로 자리를 정한 기준

넉 달에 걸쳐 고치고 나서 결제 후속 작업이 각각 어디에서 도는지 표로 남겼다. 새 후속 작업이 생기면 이 표에 한 줄을 추가하는 것부터 시작한다.

작업실패하면 생기는 일실행 위치실패 처리
주문 상태 확정결제 무효결제 트랜잭션 본체예외로 전체 롤백 후 PG 승인 취소
재고 차감과판매결제 트랜잭션 안(이벤트 아님)조건부 UPDATE가 0건이면 품절 처리
적립 쿠폰 발급고객 불만, 정합성 문제AFTER_COMMIT + REQUIRES_NEW대기 행 + 스케줄러 재시도, 유니크 키로 중복 방지
푸시 알림알림만 안 옴AFTER_COMMIT + @Async 전용 실행기로그와 지표만 남기고 버림

이듬해 쿠폰과 정산이 별도 서비스로 나가면서 이 이벤트는 메시지 브로커로 옮겨 갔다. 이때 고른 이유는 Kafka vs RabbitMQ 선택 기준에 적었다. 스프링 이벤트는 한 애플리케이션 안에서만 통한다. 서비스 경계를 넘는 순간 이 표의 “실패 처리” 칸을 브로커가 맡게 된다.

다시 한다면 처음부터 할 것

  • 이벤트로 나누기 전에 표부터 만든다. 작업마다 “결제와 함께 롤백돼야 하는가”를 먼저 답한다. 그렇다면 그 작업은 이벤트로 떼어 내지 않는다. 재고가 그랬다.
  • @EventListener는 같은 트랜잭션이라는 걸 리뷰 체크리스트에 적는다. 코드가 분리돼 보여도 예외와 롤백은 분리되지 않는다.
  • 커밋 이후 작업은 처음부터 기록을 남긴다. 결제 트랜잭션 안에 대기 행을 함께 저장하고, 재시도와 중복 방지 키를 같이 설계한다. 리스너 예외는 아무도 대신 알려 주지 않는다.
  • @Async에는 반드시 전용 실행기와 큐 크기를 준다. 기본 실행기의 무제한 큐는 외부 시스템이 느려지는 날 메모리 문제로 돌아온다.
  • 리스너 테스트는 실제로 커밋시킨다. 롤백 테스트만으로는 AFTER_COMMIT 경로가 한 줄도 실행되지 않는다.

37건의 승인 취소를 하던 밤에 쓴 메모가 아직 남아 있다. “코드를 나눈 것과 실패를 나눈 것은 다르다.” 이벤트를 도입할 때 코드가 깔끔해지는 건 덤이다. 실제로 정해야 하는 건 각 작업이 언제 실행되고, 실패하면 누가 책임지느냐다.

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

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

댓글 남기기