Spring Events 도메인 이벤트 가이드

Spring Events란? — 서비스 간 결합을 끊는 내부 메시징

주문이 완료되면 재고를 차감하고, 이메일을 보내고, 포인트를 적립해야 한다. 이 로직을 OrderService에 전부 넣으면 주문 서비스가 재고·알림·포인트 서비스에 강하게 결합된다. Spring Application Events는 이벤트 발행-구독(pub-sub) 패턴으로 서비스 간 결합을 끊고, 관심사를 분리하는 메커니즘이다.

ApplicationEventPublisher로 이벤트를 발행하면, @EventListener를 선언한 모든 리스너가 자동으로 호출된다. 같은 JVM 내부의 동기/비동기 이벤트 시스템이며, 외부 메시지 브로커(Kafka, RabbitMQ) 없이도 도메인 이벤트를 구현할 수 있다.

1. 기본 구조 — 이벤트 정의·발행·수신

1-1. 이벤트 클래스 정의

// Spring 4.2+ 부터 POJO면 충분 (ApplicationEvent 상속 불필요)
public record OrderCompletedEvent(
    String orderId,
    String customerId,
    long totalAmount,
    List<String> productIds,
    Instant completedAt
) {}

public record OrderCancelledEvent(
    String orderId,
    String customerId,
    String reason,
    Instant cancelledAt
) {}

// 공통 베이스가 필요하면 sealed interface
public sealed interface OrderEvent permits OrderCompletedEvent, OrderCancelledEvent {
    String orderId();
    String customerId();
    Instant timestamp();
}

1-2. 이벤트 발행

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public Order completeOrder(String orderId) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new NotFoundException(orderId));

        order.complete();
        orderRepository.save(order);

        // 이벤트 발행 — OrderService는 후속 처리를 모른다
        eventPublisher.publishEvent(new OrderCompletedEvent(
            order.getId(),
            order.getCustomerId(),
            order.getTotalAmount(),
            order.getProductIds(),
            Instant.now()
        ));

        return order;
    }
}

1-3. 이벤트 수신 — @EventListener

// 재고 서비스 — 주문 완료 시 재고 차감
@Component
@RequiredArgsConstructor
public class InventoryEventListener {

    private final InventoryService inventoryService;

    @EventListener
    public void handleOrderCompleted(OrderCompletedEvent event) {
        for (String productId : event.productIds()) {
            inventoryService.decreaseStock(productId);
        }
    }
}

// 알림 서비스 — 주문 완료 시 이메일 전송
@Component
@RequiredArgsConstructor
public class NotificationEventListener {

    private final EmailService emailService;

    @EventListener
    public void handleOrderCompleted(OrderCompletedEvent event) {
        emailService.sendOrderConfirmation(
            event.customerId(),
            event.orderId(),
            event.totalAmount()
        );
    }
}

// 포인트 서비스 — 주문 완료 시 포인트 적립
@Component
@RequiredArgsConstructor
public class PointEventListener {

    private final PointService pointService;

    @EventListener
    public void handleOrderCompleted(OrderCompletedEvent event) {
        long points = event.totalAmount() / 100;   // 1% 적립
        pointService.earn(event.customerId(), points);
    }
}

핵심: OrderService는 재고, 알림, 포인트 서비스의 존재를 모른다. 새로운 후속 처리가 필요하면 리스너만 추가하면 된다. Spring AOP와 함께 쓰면 횡단 관심사를 더욱 깔끔하게 분리할 수 있다.

2. @TransactionalEventListener — 트랜잭션과 이벤트 동기화

기본 @EventListener이벤트 발행 즉시 실행된다. 트랜잭션이 롤백되어도 이미 이메일이 발송되는 문제가 있다. @TransactionalEventListener는 트랜잭션 상태에 따라 실행 시점을 제어한다.

@Component
public class NotificationEventListener {

    // 트랜잭션 COMMIT 후에만 실행 (기본값)
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleOrderCompleted(OrderCompletedEvent event) {
        // 주문 트랜잭션이 커밋된 후에만 이메일 발송
        emailService.sendOrderConfirmation(event.customerId(), event.orderId());
    }

    // 롤백 후 실행
    @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
    public void handleOrderFailed(OrderCompletedEvent event) {
        log.error("Order transaction rolled back: {}", event.orderId());
        // 보상 로직
    }

    // 트랜잭션 완료 후 실행 (커밋/롤백 무관)
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
    public void handleOrderFinished(OrderCompletedEvent event) {
        metricsService.recordOrderAttempt(event.orderId());
    }

    // 커밋 전 실행 — 같은 트랜잭션 내에서 추가 작업
    @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
    public void handleBeforeCommit(OrderCompletedEvent event) {
        auditService.logOrderEvent(event);   // 같은 TX에서 감사 로그 저장
    }
}
Phase 실행 시점 사용 시점
AFTER_COMMIT 트랜잭션 커밋 후 이메일, 알림, 외부 API (기본값)
AFTER_ROLLBACK 트랜잭션 롤백 후 보상 로직, 에러 알림
AFTER_COMPLETION 커밋/롤백 후 메트릭, 리소스 정리
BEFORE_COMMIT 커밋 직전 같은 TX에서 감사 로그, 검증

주의: AFTER_COMMIT 리스너에서 예외가 발생해도 원래 트랜잭션은 이미 커밋되어 롤백되지 않는다. 리스너 내부에서 try-catch로 반드시 예외를 처리하라.

3. @Async + @TransactionalEventListener — 비동기 이벤트 처리

기본적으로 이벤트 리스너는 동기 실행된다. 이메일 발송에 3초가 걸리면 API 응답도 3초 늦어진다. @Async를 결합하면 비동기로 처리된다.

// 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("Async event handler failed: {} - {}", method.getName(), ex.getMessage());
            // Sentry, Slack 알림
        };
    }
}

// 비동기 이벤트 리스너
@Component
public class NotificationEventListener {

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

함정: @Async + @TransactionalEventListener 조합에서 리스너가 DB를 조회하면, 원래 트랜잭션은 이미 끝났으므로 새로운 트랜잭션이 필요하다. @Transactional 심화에서 다룬 REQUIRES_NEW를 사용하라.

@Async
@TransactionalEventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)   // 새 트랜잭션
public void handleOrderCompleted(OrderCompletedEvent event) {
    Order order = orderRepository.findById(event.orderId()).orElseThrow();
    // order 엔티티를 자유롭게 사용
}

4. 이벤트 순서 제어 — @Order

// 여러 리스너의 실행 순서 제어
@Component
public class InventoryEventListener {

    @EventListener
    @Order(1)    // 가장 먼저 실행
    public void handleOrderCompleted(OrderCompletedEvent event) {
        inventoryService.decreaseStock(event.productIds());
    }
}

@Component
public class PointEventListener {

    @EventListener
    @Order(2)    // 두 번째
    public void handleOrderCompleted(OrderCompletedEvent event) {
        pointService.earn(event.customerId(), event.totalAmount() / 100);
    }
}

@Component
public class NotificationEventListener {

    @EventListener
    @Order(3)    // 마지막
    public void handleOrderCompleted(OrderCompletedEvent event) {
        emailService.sendConfirmation(event.customerId());
    }
}

5. 조건부 이벤트 처리 — SpEL condition

@Component
public class VipNotificationListener {

    // 10만원 이상 주문만 VIP 알림
    @EventListener(condition = "#event.totalAmount() >= 100000")
    public void handleHighValueOrder(OrderCompletedEvent event) {
        vipService.notifyVipTeam(event.orderId(), event.totalAmount());
    }

    // 특정 상품이 포함된 주문만 처리
    @EventListener(condition = "#event.productIds().contains('prod_limited_001')")
    public void handleLimitedEditionOrder(OrderCompletedEvent event) {
        limitedEditionService.processSpecialOrder(event.orderId());
    }
}

// 여러 이벤트 타입을 하나의 리스너로
@EventListener({OrderCompletedEvent.class, OrderCancelledEvent.class})
public void handleAnyOrderEvent(Object event) {
    if (event instanceof OrderCompletedEvent completed) {
        auditLog("ORDER_COMPLETED", completed.orderId());
    } else if (event instanceof OrderCancelledEvent cancelled) {
        auditLog("ORDER_CANCELLED", cancelled.orderId());
    }
}

6. 이벤트 체이닝 — 리스너가 새 이벤트 발행

@Component
public class InventoryEventListener {

    // 반환값이 이벤트 객체면 자동 발행
    @EventListener
    public StockDepletedEvent handleOrderCompleted(OrderCompletedEvent event) {
        boolean stockDepleted = inventoryService.decreaseStock(event.productIds());

        if (stockDepleted) {
            return new StockDepletedEvent(event.productIds(), Instant.now());
        }
        return null;    // null이면 이벤트 발행 안 함
    }
}

// 재고 소진 이벤트를 수신하는 또 다른 리스너
@Component
public class PurchasingEventListener {

    @EventListener
    public void handleStockDepleted(StockDepletedEvent event) {
        purchasingService.triggerReorder(event.productIds());
    }
}

7. 도메인 이벤트 패턴 — AbstractAggregateRoot

Spring Data JPA의 AbstractAggregateRoot를 상속하면, 엔티티가 직접 이벤트를 등록하고 save() 시점에 자동 발행된다.

@Entity
public class Order extends AbstractAggregateRoot<Order> {

    @Id
    private String id;
    private OrderStatus status;
    private String customerId;
    private long totalAmount;

    public void complete() {
        this.status = OrderStatus.COMPLETED;

        // 도메인 이벤트 등록 — save() 시 자동 발행
        registerEvent(new OrderCompletedEvent(
            this.id, this.customerId, this.totalAmount,
            this.getProductIds(), Instant.now()
        ));
    }

    public void cancel(String reason) {
        this.status = OrderStatus.CANCELLED;

        registerEvent(new OrderCancelledEvent(
            this.id, this.customerId, reason, Instant.now()
        ));
    }
}

// Service에서는 save()만 호출
@Service
public class OrderService {

    @Transactional
    public Order completeOrder(String orderId) {
        Order order = orderRepository.findById(orderId).orElseThrow();
        order.complete();
        return orderRepository.save(order);   // 여기서 이벤트 자동 발행
    }
}

8. 테스트 전략

@SpringBootTest
class OrderEventTest {

    @Autowired private OrderService orderService;
    @Autowired private ApplicationEventPublisher publisher;

    @MockBean private EmailService emailService;
    @MockBean private InventoryService inventoryService;

    @Test
    void 주문_완료_시_이벤트_리스너_호출() {
        // Given
        Order order = createTestOrder();

        // When
        orderService.completeOrder(order.getId());

        // Then — 이벤트 리스너가 호출되었는지 검증
        verify(inventoryService).decreaseStock(anyList());
        verify(emailService).sendOrderConfirmation(any(), any(), anyLong());
    }

    // 이벤트 발행 자체를 검증하려면 @RecordApplicationEvents (Spring 5.3.3+)
    @Test
    @RecordApplicationEvents
    void 주문_완료_시_이벤트_발행(@Autowired ApplicationEvents events) {
        orderService.completeOrder("ord_123");

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

        OrderCompletedEvent event = events.stream(OrderCompletedEvent.class)
            .findFirst().orElseThrow();
        assertThat(event.orderId()).isEqualTo("ord_123");
    }
}

// 리스너 단위 테스트
@ExtendWith(MockitoExtension.class)
class InventoryEventListenerTest {

    @Mock private InventoryService inventoryService;
    @InjectMocks private InventoryEventListener listener;

    @Test
    void 재고_차감_호출() {
        var event = new OrderCompletedEvent(
            "ord_1", "cust_1", 50000,
            List.of("prod_1", "prod_2"), Instant.now()
        );

        listener.handleOrderCompleted(event);

        verify(inventoryService).decreaseStock(List.of("prod_1", "prod_2"));
    }
}

9. 운영 체크리스트

항목 권장 사항 위반 시 증상
TX 동기화 외부 부작용은 @TransactionalEventListener(AFTER_COMMIT) 롤백되었는데 이메일 발송됨
비동기 처리 느린 작업은 @Async 결합 API 응답 지연
예외 처리 리스너 내부에서 try-catch 필수 동기 리스너 예외가 발행자로 전파
이벤트 불변성 record 또는 불변 클래스 사용 리스너 간 데이터 오염
비동기 DB 접근 REQUIRES_NEW 트랜잭션 LazyInitializationException
스케일아웃 서비스 분리 시 Kafka/RabbitMQ로 전환 단일 JVM 한계

마무리 — 이벤트는 결합을 끊는 가장 우아한 방법이다

Spring Application Events는 외부 인프라 없이 도메인 이벤트를 구현하는 가장 가벼운 방법이다. ApplicationEventPublisher로 이벤트를 발행하고, @EventListener로 수신하며, @TransactionalEventListener로 트랜잭션과 동기화한다.

핵심 원칙은 세 가지다. 첫째, 외부 부작용(이메일, API 호출)은 반드시 AFTER_COMMIT에서 실행하라. 둘째, 느린 작업은 @Async로 비동기 처리하라. 셋째, 이벤트 객체는 불변(record)으로 만들어 리스너 간 데이터 오염을 방지하라. 나중에 마이크로서비스로 분리할 때 이벤트를 Kafka/RabbitMQ로 전환하면 코드 변경을 최소화할 수 있다.

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