에디블로그
Engineer's Field Notes

AI 자동화로 매일
한 편씩 쓰는
엔지니어 운영 노트

Claude Code · 자동화 파이프라인 · 사고 회고까지. 잘 굴러간 기록 + 깨진 흔적도 같이 남깁니다.

사람이 할 수 있는 일은,
AI도 할 수 있어야 합니다.
매일 한 편 쓰면서 검증 중.
— 이번 주 가장 많이 읽힌 글 TOP 3
백엔드/프레임워크 & 언어

[Spring] @TransactionalEventListener 정리: AFTER_COMMIT, 이벤트와 트랜잭션 타이밍

반응형
[Spring] @TransactionalEventListener 정리: AFTER_COMMIT, 이벤트와 트랜잭션 타이밍

[Spring] @TransactionalEventListener 정리: AFTER_COMMIT, 이벤트와 트랜잭션 타이밍

"주문이 롤백됐는데 고객한테 주문 완료 푸시가 나갔어요." 스프링 이벤트를 쓰다 한 번씩 겪는 사고예요. 이벤트로 결합을 끊는 것까지는 좋았는데, 리스너가 언제 실행되는지를 안 따진 거죠. 그 타이밍을 트랜잭션에 맞춰주는 게 @TransactionalEventListener예요.

이벤트로 부가 로직을 분리하는 패턴, @EventListener의 타이밍 사고, 그리고 AFTER_COMMIT의 동작과 두 가지 함정(DB 쓰기 증발·동기 실행)을 차례로 풀어볼게요. @Transactional 동작 원리를 알고 보면 전부 자연스러워요.

01. 왜 이벤트인가, 핵심에서 부가를 떼기

스프링 이벤트로 결합 끊기. 주문 서비스가 알림과 포인트와 통계를 직접 호출하면 부가 기능 장애가 주문 실패가 되지만, 이벤트를 발행하면 각 부가 기능이 리스너로 분리되어 주문은 구독자를 모른다

주문 생성에 알림·포인트·통계가 따라붙는다고 직접 호출로 엮으면, 알림 장애가 주문 실패가 되고 부가 기능이 늘 때마다 주문 코드를 고쳐요. 이벤트로 바꾸면 주문은 "주문 생성됨"을 발행하고 끝이에요.

@Service
@RequiredArgsConstructor
public class OrderService {
    private final OrderRepository orderRepository;
    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public void create(OrderCommand cmd) {
        Order order = orderRepository.save(Order.from(cmd));
        eventPublisher.publishEvent(new OrderCreatedEvent(order.getId()));
    }
}

알림·포인트·통계는 각자 리스너로 구독해요. 발행자는 구독자를 몰라요. 여기까지가 교과서고, 문제는 다음이에요 — 그 리스너, 언제 실행되나요?

02. @EventListener의 사고, 롤백됐는데 알림 발송

EventListener 타이밍 사고. 트랜잭션 안에서 이벤트가 발행되어 리스너가 즉시 푸시를 발송했는데 직후 재고 차감 실패로 롤백되면, DB엔 주문이 없는데 고객은 주문 완료 알림을 받는다. AFTER_COMMIT은 커밋 확정 후에만 실행돼 이를 막는다

기본 @EventListener는 발행 즉시, 발행한 트랜잭션 안에서 실행돼요. 그래서 이 시나리오가 가능해요 — 주문 저장 → 이벤트 발행 → 리스너가 푸시 발송 → 그 직후 재고 차감 실패로 전체 롤백. DB엔 주문이 없는데 고객은 "주문 완료" 알림을 받은 거예요. 푸시·메일·외부 API처럼 롤백이 안 되는 부수효과를 트랜잭션 확정 전에 실행한 게 사고의 본질이에요.

해법이 @TransactionalEventListener예요.

@Component
public class OrderNotificationListener {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onOrderCreated(OrderCreatedEvent event) {
        pushService.send(event.getOrderId());   // 커밋 확정 후에만 실행
    }
}

AFTER_COMMIT(기본값)이면 커밋이 확정된 뒤에만 리스너가 돌아요. 롤백되면 아예 실행 안 되고요. "DB 상태와 외부 부수효과의 일치"가 구조적으로 보장돼요. 반대로 롤백 시에만 실행(AFTER_ROLLBACK)이나 커밋 직전(BEFORE_COMMIT)도 phase로 고를 수 있어요 — 검증 로직은 BEFORE_COMMIT이 어울려요.

03. AFTER_COMMIT의 함정 ①, 그 안의 DB 쓰기는 증발해요

AFTER_COMMIT 리스너의 두 함정. 리스너 시점엔 트랜잭션이 이미 끝나 그 안의 DB 쓰기는 저장되지 않으므로 REQUIRES_NEW가 필요하고, 기본이 동기 실행이라 느린 리스너가 응답 시간에 더해지므로 Async를 함께 쓴다

AFTER_COMMIT 리스너 안에서 repository.save()를 했는데 저장이 안 되는 미스터리가 있어요. 이유는 이름 그대로예요 — 리스너가 도는 시점엔 트랜잭션이 이미 커밋되고 끝나 있어요. "커밋에 참여"가 아니라 "커밋 후"라서, 그 안의 쓰기는 합류할 트랜잭션이 없어요. 스프링은 기존 트랜잭션의 잔재 동기화 구간에서 실행하기 때문에 새 커밋도 자동으로 일어나지 않아요. 조용히 증발해요.

리스너에서 DB를 써야 하면 새 트랜잭션을 명시해요.

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)   // 새 트랜잭션 필수
public void onOrderCreated(OrderCreatedEvent event) {
    historyRepository.save(OrderHistory.from(event));
}

앞 편에서 본 REQUIRES_NEW가 정확히 필요한 자리예요.

04. 함정 ②, 기본은 동기 — 응답 시간에 더해져요

@TransactionalEventListener도 기본은 같은 스레드의 동기 실행이에요. 커밋 직후, 응답이 나가기 전에 리스너가 돌아요. 리스너가 외부 API를 2초 기다리면 사용자 응답도 2초 늦어요. 리스너의 예외도 (커밋은 이미 됐지만) 호출 흐름에 영향을 줄 수 있고요.

그래서 "커밋 후 + 비동기" 조합이 흔해요 — @Async를 같이 붙이는 거예요. 단 비동기로 넘어가는 순간 새로운 함정 세트(스레드풀·예외 증발·컨텍스트 미전파)가 열리는데, 그게 바로 다음 편 주제예요.

그리고 솔직한 한계 하나 — AFTER_COMMIT + @Async여도 "커밋됐는데 이벤트 처리는 유실"이 가능해요. 커밋 직후 서버가 죽으면 리스너는 영영 안 돌아요. 푸시 하나면 감수할 수 있지만, "커밋됐으면 반드시 처리돼야 하는" 메시지(타 시스템 연동 등)라면 인메모리 이벤트로는 부족해요. 그 보장이 필요할 때 꺼내는 게 아웃박스 패턴이고, 분산 패턴 시리즈에서 다뤄요.

05. 자주 만나는 문제

롤백됐는데 알림·메일이 나갔어요

@EventListener의 즉시 실행이에요. 부수효과 리스너는 @TransactionalEventListener(AFTER_COMMIT)로 바꿔요.

AFTER_COMMIT 리스너에서 save가 안 돼요

트랜잭션이 이미 끝난 시점이라서예요. REQUIRES_NEW를 명시해요.

이벤트 발행했는데 리스너가 아예 안 돌아요

@TransactionalEventListener는 트랜잭션이 없으면 기본적으로 실행되지 않아요. 트랜잭션 밖에서 발행한 이벤트가 조용히 무시되는 거예요(fallbackExecution = true로 바꿀 수 있지만, 먼저 "왜 트랜잭션이 없지?"를 봐야 해요 — 자기호출일 수도요).

리스너 때문에 응답이 느려요

동기 실행이에요. @Async를 붙이되, 다음 편의 비동기 함정(예외·스레드풀)을 같이 챙겨요.

리스너를 언제 부를까

스프링 이벤트의 모든 결정은 한 질문으로 모여요 — "리스너가 언제 도는가". 상황에 따라 답이 갈려요.

  • 푸시·메일·외부 API 같은 부수효과라면 → AFTER_COMMIT. @EventListener의 즉시 실행은 롤백과 어긋나 사고가 나요.
  • 리스너 안에서 DB를 써야 한다면 → AFTER_COMMIT + REQUIRES_NEW. 커밋 후라 합류할 트랜잭션이 없어서예요.
  • 리스너가 느리다면 → @Async 추가. 단 그 순간 비동기 함정 세트가 열려요.
  • 유실이 절대 안 되는 메시지라면 → 인메모리 이벤트 밖, 아웃박스의 영역이에요.

방금 미뤄둔 @Async의 함정들은 바로 다음 글에서 정면으로 풀어요. @Transactional 동작 원리를 깔고 보면 위 분기가 전부 자연스러워요.

출처: Spring Framework — Transaction-bound Events · Spring — Application Events

반응형

📚 같이 보면 좋은

"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."