Spring ApplicationEvent 이벤트 설계

Spring ApplicationEvent란?

Spring의 ApplicationEvent는 애플리케이션 내부에서 이벤트 기반 통신을 구현하는 핵심 메커니즘입니다. 서비스 간 직접 의존성을 제거하고, 느슨한 결합(loose coupling)으로 비즈니스 로직을 분리할 수 있습니다. 주문 완료 후 이메일 발송, 재고 차감, 포인트 적립 등 부가 효과(side effect)를 메인 로직에서 분리하는 데 특히 유용합니다.

이벤트 발행: ApplicationEventPublisher

Spring 4.2+부터는 ApplicationEvent를 상속할 필요 없이 임의의 객체를 이벤트로 발행할 수 있습니다.

// 이벤트 정의 — 단순 레코드
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());
    }
}

public record UserRegisteredEvent(Long userId, String email, Instant occurredAt) {}

// 이벤트 발행
@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 EntityNotFoundException("Order", orderId));

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

        // 이벤트 발행 — 트랜잭션 내에서 발행됨
        eventPublisher.publishEvent(new OrderCompletedEvent(
            order.getId(),
            order.getUserId(),
            order.getTotalAmount()
        ));

        return order;
    }
}

이벤트 구독: @EventListener

@EventListener는 동기적으로 실행됩니다. 발행자와 같은 스레드, 같은 트랜잭션 내에서 실행되므로, 리스너에서 예외가 발생하면 발행자의 트랜잭션도 롤백됩니다.

@Component
@RequiredArgsConstructor
@Slf4j
public class OrderEventListener {

    private final InventoryService inventoryService;
    private final PointService pointService;

    // 기본: 동기 실행 (같은 트랜잭션)
    @EventListener
    public void handleOrderCompleted(OrderCompletedEvent event) {
        log.info("Order completed: {}", event.orderId());
        inventoryService.deductStock(event.orderId());
    }

    // 조건부 리스닝: SpEL 조건
    @EventListener(condition = "#event.totalAmount() > 100000")
    public void handleLargeOrder(OrderCompletedEvent event) {
        log.info("Large order detected: {} ({}원)", event.orderId(), event.totalAmount());
        pointService.addBonusPoints(event.userId(), 500);
    }

    // 이벤트 체이닝: 반환값이 새 이벤트로 발행됨
    @EventListener
    public PointsAwardedEvent awardPoints(OrderCompletedEvent event) {
        int points = event.totalAmount().intValue() / 100;
        return new PointsAwardedEvent(event.userId(), points);
    }
}

@TransactionalEventListener: 트랜잭션 페이즈 바인딩

실무에서 가장 중요한 패턴입니다. @TransactionalEventListener는 트랜잭션의 특정 단계에서 리스너를 실행합니다. 트랜잭션 커밋 후에만 이메일을 발송하거나 외부 API를 호출해야 할 때 필수입니다.

@Component
@RequiredArgsConstructor
@Slf4j
public class NotificationEventListener {

    private final EmailService emailService;
    private final SlackNotifier slackNotifier;

    // AFTER_COMMIT (기본값): 트랜잭션 성공 후 실행
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void sendOrderConfirmation(OrderCompletedEvent event) {
        // 트랜잭션이 커밋된 후에만 실행
        // → 롤백되면 이메일 발송 안 함 ✅
        emailService.sendOrderConfirmation(event.userId(), event.orderId());
    }

    // AFTER_ROLLBACK: 트랜잭션 롤백 후 실행
    @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
    public void notifyOrderFailure(OrderCompletedEvent event) {
        slackNotifier.alert("Order " + event.orderId() + " failed!");
    }

    // AFTER_COMPLETION: 커밋/롤백 상관없이 항상 실행
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
    public void logOrderResult(OrderCompletedEvent event) {
        log.info("Order {} processing completed", event.orderId());
    }

    // BEFORE_COMMIT: 커밋 직전 실행 (같은 트랜잭션)
    @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
    public void auditBeforeCommit(OrderCompletedEvent event) {
        // 이 리스너에서 예외 → 트랜잭션 롤백
        auditRepository.save(new AuditLog("ORDER_COMPLETED", event.orderId()));
    }
}
Phase 실행 시점 트랜잭션 상태 용도
BEFORE_COMMIT 커밋 직전 활성 (롤백 가능) 감사 로그, 유효성 검증
AFTER_COMMIT 커밋 성공 후 완료 알림, 외부 API, 캐시 갱신
AFTER_ROLLBACK 롤백 후 완료 실패 알림, 보상 처리
AFTER_COMPLETION 커밋 또는 롤백 후 완료 리소스 정리, 공통 로깅

@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-async-");
        executor.setRejectedExecutionHandler(new CallerRunsPolicy());
        executor.initialize();
        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) -> {
            log.error("Async event error in {}: {}", method.getName(), ex.getMessage(), ex);
        };
    }
}

@Component
public class AsyncOrderListener {

    // @Async + @TransactionalEventListener 조합
    @Async
    @TransactionalEventListener
    public void sendEmailAsync(OrderCompletedEvent event) {
        // 별도 스레드에서 실행
        // 트랜잭션 커밋 후 + 비동기 = 안전하고 빠름
        emailService.sendOrderConfirmation(event.userId(), event.orderId());
    }

    // 주의: @Async 리스너의 예외는 발행자에게 전파되지 않음
    // AsyncUncaughtExceptionHandler로 별도 처리 필요
}

AFTER_COMMIT 리스너의 트랜잭션 함정

AFTER_COMMIT 리스너 내에서 DB 쓰기를 하면 트랜잭션이 이미 완료된 상태라 저장이 실패하거나 무시됩니다. 이 문제를 해결하려면 새 트랜잭션을 명시적으로 시작해야 합니다.

@Component
@RequiredArgsConstructor
public class OrderFollowUpListener {

    private final NotificationRepository notificationRepository;

    // ❌ 안티패턴: AFTER_COMMIT에서 DB 쓰기
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void saveNotification(OrderCompletedEvent event) {
        // 트랜잭션 없음 → 저장 실패 가능!
        notificationRepository.save(new Notification(event.userId(), "주문 완료"));
    }

    // ✅ 해결: REQUIRES_NEW 트랜잭션
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveNotificationFixed(OrderCompletedEvent event) {
        // 새 트랜잭션에서 실행 → 정상 저장
        notificationRepository.save(new Notification(event.userId(), "주문 완료"));
    }
}

이벤트 기반 아키텍처 설계 패턴

실무에서는 도메인 이벤트를 체계적으로 관리하는 패턴이 필요합니다. Spring Modulith의 이벤트 방식과 유사하게, 도메인 엔티티에서 이벤트를 수집하는 패턴을 적용할 수 있습니다.

// AbstractAggregateRoot 활용: 엔티티에서 이벤트 등록
@Entity
public class Order extends AbstractAggregateRoot<Order> {

    @Id @GeneratedValue
    private Long id;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    private BigDecimal totalAmount;
    private Long userId;

    public Order complete() {
        this.status = OrderStatus.COMPLETED;
        // 이벤트를 엔티티에 등록 → save() 시 자동 발행
        registerEvent(new OrderCompletedEvent(this.id, this.userId, this.totalAmount));
        return this;
    }

    public Order cancel(String reason) {
        this.status = OrderStatus.CANCELLED;
        registerEvent(new OrderCancelledEvent(this.id, this.userId, reason));
        return this;
    }
}

// Repository save() 시 등록된 이벤트가 자동 발행됨
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;

    @Transactional
    public Order completeOrder(Long orderId) {
        Order order = orderRepository.findById(orderId).orElseThrow();
        order.complete();
        return orderRepository.save(order);
        // save() 호출 시 registerEvent로 등록된 이벤트 자동 발행!
    }
}

이벤트 리스너 테스트

이벤트 발행과 리스너 동작을 검증하는 테스트 전략입니다.

@SpringBootTest
class OrderEventTest {

    @Autowired private OrderService orderService;
    @Autowired private ApplicationEvents events; // Spring 6.1+
    @MockBean private EmailService emailService;

    // ApplicationEvents로 발행된 이벤트 검증
    @Test
    @RecordApplicationEvents
    void shouldPublishEventOnOrderComplete() {
        orderService.completeOrder(1L);

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

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

    // 리스너가 호출되었는지 검증
    @Test
    void shouldSendEmailAfterOrderComplete() {
        orderService.completeOrder(1L);

        verify(emailService, timeout(5000))
            .sendOrderConfirmation(anyLong(), eq(1L));
    }
}

운영 베스트 프랙티스

  • 외부 호출은 AFTER_COMMIT: 이메일, API 호출, 메시지 큐 발행은 반드시 @TransactionalEventListener(phase = AFTER_COMMIT)을 사용하세요
  • AFTER_COMMIT + DB 쓰기: 새 트랜잭션(REQUIRES_NEW)이 필요합니다 — 기존 트랜잭션은 이미 완료 상태
  • 비동기 리스너 에러 처리: @Async 리스너의 예외는 발행자에게 전파되지 않으므로 별도 핸들러 필수
  • AbstractAggregateRoot 활용: 도메인 엔티티에서 이벤트를 등록하면 발행 시점을 JPA save()와 자동 연동할 수 있습니다
  • 이벤트는 불변 객체: Java record로 정의하여 불변성과 명확한 계약을 보장하세요
  • 이벤트 폭풍 주의: 이벤트 체이닝이 깊어지면 디버깅이 어려워집니다 — 2단계 이하로 유지하세요
위로 스크롤
WordPress Appliance - Powered by TurnKey Linux