Spring Boot에서 Transactional Event와 비동기 처리의 함정

· 약 11분 읽기 · 조회 6 #Spring Boot#트랜잭션#이벤트#비동기#아키텍처

주문 저장 뒤 알림 발송처럼 "커밋된 다음에 할 일"을 Spring 이벤트로 떼어 내면 코드는 깔끔해지지만, 이벤트가 언제 실행되고 어느 트랜잭션에 묶이는지 모르면 데이터가 조용히 사라진다. 이 글은 @EventListener, @TransactionalEventListener, @Async가 각각 어느 시점에 실행되는지 정리하고, 실무에서 자주 터지는 함정과 잃으면 안 되는 이벤트를 다루는 방법을 정리한 것이다.

계주에서 다음 주자가 먼저 달려 나가며 아직 앞 주자 손에 있는 바통을 향해 손을 뻗는 장면

한눈에 보기

방식 실행 시점 실행 스레드 리스너 예외의 영향
@EventListener publishEvent() 호출 즉시 발행한 스레드 발행 쪽으로 전파, 트랜잭션 롤백
@TransactionalEventListener(BEFORE_COMMIT) 커밋 직전 발행한 스레드 커밋이 취소됨
@TransactionalEventListener (기본 AFTER_COMMIT) 커밋 성공 뒤 발행한 스레드 이미 커밋됨, 되돌릴 수 없음
AFTER_ROLLBACK / AFTER_COMPLETION 롤백 뒤 / 완료 뒤(성공·실패 모두) 발행한 스레드 이미 끝남
@Async + AFTER_COMMIT 커밋 뒤 실행기 큐에서 꺼낼 때 다른 스레드 발행 쪽은 모름

요청 하나의 흐름: 트랜잭션 시작, 주문 저장, 이벤트 발행, 커밋 직전 리스너, 커밋, 커밋 뒤 리스너, 비동기 스레드 순서이며, 커밋 뒤 실행기 큐에 들어가 실행되기 전 구간에서 서버가 죽으면 이벤트가 사라진다

핵심은 하나다. 커밋 뒤에 실행되는 것은 무엇이든 원래 트랜잭션의 보호를 받지 못한다.

@EventListener는 같은 트랜잭션 안에서 바로 돈다

ApplicationEventPublisher.publishEvent()는 기본적으로 동기 호출이다. @EventListener 메서드는 발행한 스레드에서, 발행한 트랜잭션 안에서 그 자리에서 실행된다.

  • 리스너가 예외를 던지면 서비스 메서드까지 전파되어 주문 저장도 롤백된다.
  • 리스너가 외부 API를 3초 동안 부르면 트랜잭션도 3초 동안 커넥션을 잡는다.
  • 리스너에서 메일을 보냈는데 뒤에서 롤백되면, 저장되지 않은 주문의 메일이 나간다.

"이벤트로 분리했으니 독립적이다"라는 생각이 여기서 틀린다. 코드만 나뉘었을 뿐 실행은 한 덩어리다. 트랜잭션 안에서 외부 호출을 하면 생기는 문제는 트랜잭션 경계 글에서 다뤘다.

@TransactionalEventListener: 단계와 fallbackExecution

@TransactionalEventListener는 발행 시점에 리스너를 바로 부르지 않고, 현재 트랜잭션에 동기화 콜백으로 등록해 두었다가 지정한 단계에서 실행한다.

@Component
@RequiredArgsConstructor
class OrderNotificationListener {

    private final NotificationClient client;

    @TransactionalEventListener   // phase = AFTER_COMMIT 이 기본값
    public void on(OrderPlaced event) {
        client.send(event.orderId());
    }
}

알아 둘 동작

  • 기본 단계는 AFTER_COMMIT이다. 롤백되면 리스너는 실행되지 않는다. 롤백된 주문의 메일이 나가는 문제는 이것으로 막는다.
  • 발행할 때 활성 트랜잭션이 없으면 이벤트는 버려진다. 예외도 로그도 없이 리스너가 안 불린다. @Transactional을 빠뜨린 메서드나 배치에서 발행하면 이렇게 된다.
  • 트랜잭션 밖에서도 실행해야 하면 fallbackExecution = true를 준다. 이때는 즉시 실행되므로, 트랜잭션이 있을 때와 동작이 달라진다는 점을 리스너가 감당해야 한다.
  • BEFORE_COMMIT은 같은 트랜잭션 안의 마지막 검증·추가 쓰기에 쓴다. 여기서 예외가 나면 커밋이 취소된다.

커밋 뒤 리스너의 두 함정: 쓰기와 예외

함정 1: AFTER_COMMIT 리스너에서 저장한 데이터가 사라진다

커밋 뒤 콜백이 실행될 때 트랜잭션은 이미 커밋됐지만, 커넥션과 트랜잭션 자원은 아직 스레드에 묶여 있다. 이 상태에서 기본 전파(REQUIRED)의 @Transactional 메서드를 부르면 "이미 있는 트랜잭션"에 참여한 것으로 처리되고, 그 트랜잭션은 다시 커밋되지 않는다. 쓰기가 예외 없이 사라진다. Spring의 TransactionSynchronization.afterCommit 문서도 여기서는 REQUIRES_NEW를 쓰라고 안내한다.

@TransactionalEventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void on(OrderPlaced event) {
    pointRepository.save(Point.earn(event.memberId(), event.amount()));
}

Spring Framework 6.1부터는 @TransactionalEventListener에 REQUIRES_NEW·NOT_SUPPORTED가 아닌 @Transactional을 붙이면 시작할 때 오류를 내도록 바뀌었다. 쓰는 버전의 동작을 확인하고, 이전 버전이라면 직접 점검해야 한다.

함정 2: 커밋 뒤 예외는 되돌릴 곳이 없다

주문은 이미 커밋됐다. 리스너에서 알림 API가 실패해도 주문을 롤백할 방법이 없고, 예외는 대개 로그 한 줄로만 남는다. 재시도도 기록도 없으면 "주문은 있는데 알림은 없는" 상태가 쌓인다. 커밋 뒤 리스너의 실패는 리스너 안에서 잡아 재시도 대상으로 남겨야 한다.

@Async를 붙이면 이벤트는 메모리에만 있다

응답 시간을 줄이려고 @Async를 같이 붙이는 경우가 많다. 커밋 뒤 리스너 호출은 실행기 큐에 작업을 넣는 것으로 끝나고, 실제 실행은 다른 스레드에서 일어난다.

@Async
@TransactionalEventListener
public void on(OrderPlaced event) { ... }

이 구성에서 생기는 일

  • 이벤트가 큐에만 있다. 커밋과 실행 사이에 배포·장애로 JVM이 내려가면 큐에 있던 이벤트는 사라진다. DB에는 주문이 있고 어디에도 "보낼 것"이 남지 않는다.
  • 실행기가 무엇인지 모른다. Spring Boot는 applicationTaskExecutor라는 ThreadPoolTaskExecutor를 자동 구성하고, 기본 큐 용량은 사실상 무한이다. 부하가 몰리면 큐가 메모리를 먹으며 늘어난다. 큐를 제한하면 넘칠 때 TaskRejectedException이 나고, 커밋 뒤 콜백 안이라 조용히 묻힐 수 있다. Boot 없이 쓰거나 실행기 빈 구성이 바뀌면 작업마다 스레드를 만드는 SimpleAsyncTaskExecutor로 갈 수 있으니 버전과 빈 구성을 확인한다.
  • ThreadLocal이 따라오지 않는다. MDC의 추적 ID, SecurityContext, 트랜잭션은 새 스레드에 없다. 로그에서 요청 추적이 끊기고, 리스너 안에서 "현재 사용자"를 읽으면 비어 있다. TaskDecorator로 MDC를 복사하고, 보안 문맥은 이벤트에 필요한 값만 담아 넘긴다.
  • 엔티티를 넘기면 지연 로딩이 깨진다. 다른 스레드에서는 영속성 컨텍스트가 없어 LazyInitializationException이 난다. 이벤트에는 ID와 필요한 값만 담는다.
  • 순서가 보장되지 않는다. @Order는 동기 실행 순서만 정한다. 비동기로 넘긴 뒤에는 "생성 → 취소" 이벤트가 거꾸로 처리될 수 있다.

종료 때 큐를 비우도록 설정하면 배포 중 유실은 줄일 수 있다. 장애로 죽는 경우는 막지 못한다.

spring:
  task:
    execution:
      pool:
        core-size: 8
        max-size: 16
        queue-capacity: 1000
      shutdown:
        await-termination: true
        await-termination-period: 30s
server:
  shutdown: graceful

잃으면 안 되는 이벤트는 Outbox로

포인트 적립, 정산 연계처럼 빠지면 안 되는 일은 메모리 이벤트에 맡기지 않는다. 주문과 같은 트랜잭션에서 "보낼 일"을 테이블에 쓰고, 별도 처리기가 읽어 실행한다. 커밋되면 반드시 남고, 롤백되면 같이 사라진다. 테이블 설계와 릴레이 방식은 Outbox·Inbox 글에 정리했다.

@Transactional
public void placeOrder(OrderCommand cmd) {
    Order order = orderRepository.save(Order.from(cmd));
    outboxRepository.save(OutboxEvent.of("OrderPlaced", order.getId()));  // 같은 커밋
}

함께 지킬 것

  • 처리기는 실패하면 다시 시도하므로 같은 이벤트가 두 번 갈 수 있다. 받는 쪽은 이벤트 ID로 중복을 걸러 멱등하게 만든다.
  • Spring Modulith를 쓴다면 이벤트 발행 기록(Event Publication Registry)이 같은 역할을 한다. 미완료 이벤트를 테이블에 남겼다가 재시작 때 다시 보낸다.
  • 알림처럼 한두 건 빠져도 되는 일은 @Async + AFTER_COMMIT으로 충분하다. 기준은 "빠지면 누가 알아채고, 어떻게 복구하나"다.
이벤트 성격 권장 방식 이 방식이 없으면 생기는 일
같은 트랜잭션에서 함께 성공·실패해야 함 @EventListener 또는 BEFORE_COMMIT 절반만 저장된 데이터
커밋된 경우에만, 빠르게, 유실 일부 허용 @Async + AFTER_COMMIT 롤백된 주문의 알림 발송
커밋 뒤 DB 추가 쓰기 AFTER_COMMIT + REQUIRES_NEW 쓰기가 예외 없이 사라짐
절대 빠지면 안 됨 Outbox + 멱등 소비자 재시작·장애 때 조용한 유실

자주 하는 실수

  • 트랜잭션 없는 곳에서 발행한다. @TransactionalEventListener는 아무 말 없이 실행되지 않는다.
  • 커밋 뒤 리스너에서 기본 전파로 저장한다. 테스트에서도 성공처럼 보이고 데이터만 없다.
  • @Async를 붙이고 실행기를 설정하지 않는다. 무한 큐가 메모리를 먹고, 종료 때 쌓인 이벤트가 버려진다.
  • 이벤트에 엔티티를 그대로 담는다. 다른 스레드의 지연 로딩 예외와 오래된 값 읽기가 생긴다.
  • 리스너 실패를 로그로만 남긴다. 재시도 대상이 없으니 누락을 아무도 복구하지 못한다.

점검표

  • 모든 @TransactionalEventListener에 대해 발행 메서드가 트랜잭션 안인지 확인했다
  • fallbackExecution = true를 쓴 곳은 트랜잭션 유무에 따른 동작 차이를 검토했다
  • 커밋 뒤 리스너의 DB 쓰기는 REQUIRES_NEW로 새 트랜잭션에서 한다
  • 커밋 뒤 리스너의 실패를 잡아 재시도 대상으로 남긴다
  • @Async 실행기의 큐 용량과 거부 시 동작을 정했다
  • 종료 때 큐를 비우는 설정(await-termination)과 대기 시간을 넣었다
  • MDC 추적 ID가 비동기 스레드 로그에도 찍힌다
  • 이벤트에는 엔티티가 아니라 ID와 필요한 값만 담는다
  • 빠지면 안 되는 이벤트는 Outbox로 옮겼고 소비자는 이벤트 ID로 중복을 거른다

Transactional Event는 "커밋 뒤에 실행된다"는 편리함과 "커밋의 보호를 받지 못한다"는 대가를 함께 가져오므로, 이벤트마다 유실을 허용할 수 있는지부터 정하고 그에 맞는 단계와 저장 방식을 고른다.

댓글 0개

아직 댓글이 없습니다. 첫 댓글을 남겨보세요.

댓글 쓰기

← 목록으로 돌아가기