Spring Event 비동기 처리

Spring Event 기반 아키텍처란?

Spring의 ApplicationEvent 시스템은 컴포넌트 간 결합도를 낮추면서 도메인 이벤트를 전파하는 핵심 메커니즘이다. 주문 완료 후 이메일 발송, 재고 차감, 알림 전송 같은 부수 효과(side effect)를 서비스 코드에서 분리할 수 있다.

이 글에서는 ApplicationEventPublisher부터 @TransactionalEventListener, 비동기 처리, 실전 에러 핸들링까지 심층적으로 다룬다.

1. 이벤트 정의와 발행

Spring 4.2 이후 ApplicationEvent 상속 없이 POJO로 이벤트를 정의할 수 있다.

// 도메인 이벤트 정의
public record OrderCompletedEvent(
    Long orderId,
    Long userId,
    BigDecimal totalAmount,
    Instant occurredAt
) {
    public OrderCompletedEvent(Long orderId, Long userId, BigDecimal totalAmount) {
        this(orderId, userId, totalAmount, Instant.now());
    }
}

// 이벤트 발행
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public Order completeOrder(Long orderId) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));
        
        order.complete();
        orderRepository.save(order);

        eventPublisher.publishEvent(new OrderCompletedEvent(
            order.getId(), order.getUserId(), order.getTotalAmount()
        ));

        return order;
    }
}

ApplicationEventPublisher는 Spring 컨테이너가 자동 주입한다. 이벤트를 발행하면 등록된 모든 리스너가 호출된다.

2. @EventListener vs @TransactionalEventListener

두 어노테이션의 차이는 실행 시점이다.

구분 @EventListener @TransactionalEventListener
실행 시점 즉시 (동기) 트랜잭션 커밋 후 (기본)
트랜잭션 참여 발행자의 트랜잭션 내 별도 실행
실패 시 영향 발행자 롤백 가능 발행자에 영향 없음
사용 시나리오 검증, 동기 처리 알림, 외부 연동
@Component
@RequiredArgsConstructor
@Slf4j
public class OrderEventHandler {

    private final EmailService emailService;
    private final InventoryService inventoryService;

    // 트랜잭션 커밋 후 실행 (기본: AFTER_COMMIT)
    @TransactionalEventListener
    public void sendOrderConfirmation(OrderCompletedEvent event) {
        log.info("주문 완료 이메일 발송: orderId={}", event.orderId());
        emailService.sendOrderConfirmation(event.orderId(), event.userId());
    }

    // phase 지정 가능
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void updateInventory(OrderCompletedEvent event) {
        log.info("재고 차감 처리: orderId={}", event.orderId());
        inventoryService.deductStock(event.orderId());
    }

    // 롤백 시에만 실행
    @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
    public void handleOrderFailure(OrderCompletedEvent event) {
        log.error("주문 실패 보상 처리: orderId={}", event.orderId());
    }
}

AFTER_COMMIT이 기본값이라 주문 저장이 확정된 후에만 이메일이 발송된다. 트랜잭션이 롤백되면 리스너는 실행되지 않는다. Spring의 트랜잭션 전파 전략에 대해서는 Spring Transaction 전파 전략 글도 참고하자.

3. 비동기 이벤트 처리

기본적으로 이벤트 리스너는 동기 실행된다. 이메일 발송이 3초 걸리면 API 응답도 3초 지연된다. @Async로 해결한다.

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("event-");
        executor.setRejectedExecutionHandler(new CallerRunsPolicy());
        executor.initialize();
        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) -> 
            log.error("비동기 이벤트 처리 실패: method={}", method.getName(), ex);
    }
}

@Component
public class AsyncOrderEventHandler {

    @Async
    @TransactionalEventListener
    public void sendNotification(OrderCompletedEvent event) {
        // 별도 스레드에서 실행 — API 응답에 영향 없음
        notificationService.push(event.userId(), "주문이 완료되었습니다!");
    }
}

⚠️ 주의: @Async + @TransactionalEventListener 조합 시 리스너 내부에서 새 트랜잭션이 필요하면 @Transactional(propagation = REQUIRES_NEW)를 명시해야 한다.

4. 조건부 리스너와 이벤트 체이닝

SpEL 조건으로 특정 조건에서만 리스너를 실행하거나, 리스너가 새로운 이벤트를 반환해 체이닝할 수 있다.

// 조건부 실행: 10만원 이상 주문만 처리
@TransactionalEventListener(condition = "#event.totalAmount.compareTo(T(java.math.BigDecimal).valueOf(100000)) >= 0")
public void sendVipNotification(OrderCompletedEvent event) {
    vipService.notifyLargeOrder(event.orderId());
}

// 이벤트 체이닝: 리턴값이 새 이벤트로 발행됨
@EventListener
public InventoryDeductedEvent handleOrder(OrderCompletedEvent event) {
    inventoryService.deduct(event.orderId());
    return new InventoryDeductedEvent(event.orderId());
}

// 체이닝된 이벤트 수신
@EventListener
public void onInventoryDeducted(InventoryDeductedEvent event) {
    log.info("재고 차감 완료 후속 처리: orderId={}", event.orderId());
}

5. 이벤트 저장소 패턴 (Outbox)

비동기 이벤트는 유실 위험이 있다. 프로덕션에서는 Outbox 패턴으로 이벤트를 DB에 먼저 저장한 뒤 비동기로 처리한다.

@Entity
@Table(name = "domain_events")
public class DomainEventEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String eventType;

    @Column(columnDefinition = "TEXT")
    private String payload;

    @Enumerated(EnumType.STRING)
    private EventStatus status; // PENDING, PROCESSED, FAILED

    private Instant createdAt;
    private Instant processedAt;
}

@Component
@RequiredArgsConstructor
public class OutboxEventHandler {

    private final DomainEventRepository eventRepository;
    private final ObjectMapper objectMapper;

    @TransactionalEventListener
    public void persistEvent(OrderCompletedEvent event) {
        DomainEventEntity entity = new DomainEventEntity();
        entity.setEventType(event.getClass().getSimpleName());
        entity.setPayload(objectMapper.writeValueAsString(event));
        entity.setStatus(EventStatus.PENDING);
        entity.setCreatedAt(Instant.now());
        eventRepository.save(entity);
    }
}

// 스케줄러로 PENDING 이벤트 처리
@Scheduled(fixedDelay = 5000)
@Transactional
public void processOutbox() {
    List<DomainEventEntity> pending = eventRepository
        .findByStatusOrderByCreatedAt(EventStatus.PENDING);
    
    for (DomainEventEntity event : pending) {
        try {
            dispatchEvent(event);
            event.setStatus(EventStatus.PROCESSED);
            event.setProcessedAt(Instant.now());
        } catch (Exception e) {
            event.setStatus(EventStatus.FAILED);
            log.error("Outbox 이벤트 처리 실패: id={}", event.getId(), e);
        }
    }
}

Outbox 패턴은 이벤트 유실을 방지하고 재처리를 가능하게 한다. 스케줄러 분산 락은 Spring Scheduler 분산 락 글을 참고하자.

6. 테스트 전략

@SpringBootTest
class OrderEventTest {

    @Autowired
    private ApplicationEventPublisher publisher;

    @MockBean
    private EmailService emailService;

    @Test
    void 주문_완료시_이메일_발송() {
        // given
        var event = new OrderCompletedEvent(1L, 100L, BigDecimal.valueOf(50000));

        // when
        publisher.publishEvent(event);

        // then
        verify(emailService, timeout(3000))
            .sendOrderConfirmation(1L, 100L);
    }

    // 이벤트 캡처 테스트
    @Autowired
    private ApplicationEvents events; // Spring 6.1+

    @Test
    @RecordApplicationEvents
    void 이벤트_발행_검증() {
        orderService.completeOrder(1L);

        assertThat(events.stream(OrderCompletedEvent.class).count())
            .isEqualTo(1);
    }
}

마무리

Spring Event 시스템은 서비스 간 결합도를 줄이는 가장 가벼운 방법이다. @TransactionalEventListener로 트랜잭션 안전성을 확보하고, @Async로 성능을 개선하며, Outbox 패턴으로 이벤트 유실을 방지하자. 도메인 이벤트 기반 설계는 결국 마이크로서비스 전환의 첫걸음이기도 하다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux