본문 바로가기

Spring Event Driven (수정필요)

@6uiw2025. 11. 4. 23:45

이벤트 드리븐(Event-Driven) 구조란?

이벤트 드리븐 구조는 시스템에서 발생한 사건(event)을 중심으로 흐름을 설계하는 구조

  • 이벤트를 발행하는 주체 → 이벤트를 처리하는 리스너/핸들러 → 필요 시 후속 처리
  • 장점: 결합도 낮음, 확장성 좋음, 비동기 처리 가능

 

이벤트 드리븐을 사용하는 이유

이유 설명
느슨한 결합(Low Coupling) 발행자와 수신자가 직접 연결되지 않아 코드 변경 최소화
리스너는 이벤트를 구독해 필요한 작업만 처리 
확장성 새로운 리스너/서비스 추가가 쉬움
MSA 환경에서 서비스가 늘어나도 이벤트 큐(RabbitMQ/Kafka)를 통해 자연스럽게 확장 가능
비동기 처리 이벤트를 발행하고, 리스너가 별도 스레드에서 처리 가능
메인 흐름(blocking)을 막지 않고 후속 처리를 수행
트랜잭션 안전 AFTER_COMMIT 사용 시 이벤트가 실패해도 트랜잭션 롤백과 독립적 처리 가능
장애 격리 서비스 간 직접 호출을 줄임 -> 서비스 간 장애 전파 최소화, 재처리 가능

 

 

 Spring ApplicationEvent 기반 이벤트

  • 동일 JVM 내에서만 동작
  • @EventListener 또는 @TransactionalEventListener 사용
  • @Async로 비동기 처리 가능
  • TransactionPhase 설정으로 트랜잭션과 연계 가능

장점

  • 구현 단순
  • 트랜잭션과 밀접하게 연계 가능

단점

  • 멀티 서비스(MSA)에서는 이벤트 전달 불가
  • JVM 종료 시 이벤트 손실 가능
기준 ApplicationEvent RabbitMQ/Kafka
서비스 구조 단일 JVM
->네트워크 호출 없음 → 속도 빠름
MSA, 분산
결합도 낮음 낮음
이벤트 신뢰성 애플리케이션 JVM이 종료되면 이벤트 손실 가능 브로커 저장 → 재시도 가능
트랜잭션 연계 가능 별도 관리 필요

 

 

 

이벤트 드리븐 남용의 문제점 

결합도도 낮고, 확장성도 좋고, 비동기 처리가 가능하여 이점이 굉장히 많지만, 그렇다고 이벤트 발행을 남용해선 안된다.

불필요한 이벤트 발행 시 발생할 문제점에 대해 알아보자. 

 

① 오버헤드 발생

  • 이벤트 발행/리스너 호출 구조가 추가됨 → 불필요한 성능 저하
  • 간단한 CRUD나 내부 연산 같은 빠른 메서드에는 부적합

② 디버깅과 추적 어려움

  • 이벤트가 비동기이면 실행 순서가 명확하지 않음
  • 모든 메서드가 이벤트 기반이면 문제 발생 시 원인 추적이 어려워짐

③ 불필요한 복잡도 증가

  • 단일 서비스/단순 기능에서는 직접 호출로 충분
  • 이벤트 기반 설계는 규모가 크거나 비동기 처리 필요할 때만 의미 있음

 

 

그렇다면 언제 이벤트 드리븐을 사용하는 것이 좋고, 언제 지양해야 할까?

 

 

이벤트 드리븐 사용이 좋은 서비스

① 주문 취소/결제 이벤트 처리

  • 이유: 주문 상태가 바뀔 때 여러 기능이 독립적으로 반응해야 함
  • 예시:
    • 주문 취소 → 결제 환불 이벤트 발행 → 결제 서비스가 환불 처리
    • 주문 취소 → 재고 서비스가 재고 복구
    • 주문 취소 → 통계 서비스가 취소 통계 업데이트

② 알림/푸시/메일

  • 이유: 메인 주문 흐름(blocking)에 영향을 주면 안 됨
  • 예시:
    • 주문 완료 → 고객에게 푸시 알림
    • 배달 상태 변경 → 고객, 배달 기사에게 알림 발송
    • 후기 요청, 프로모션 메시지

③ 통계/로그 집계

  • 이유: 데이터 수집이나 통계는 느슨한 결합으로 독립 처리 가능
  • 예시:
    • 주문 이벤트 → 매출 통계 업데이트
    • 취소 이벤트 → 취소율 통계 집계
    • 배달 완료 → 배달 시간 통계 계산

④ 외부 서비스 연동

  • 이유: 외부 API 호출 지연이나 실패가 메인 로직에 영향을 주면 안 됨
  • 예시:
    • 결제 승인 후 회계 시스템 전송
    • 배달 완료 후 리뷰/포인트 시스템 연동

 

 

2️⃣ 이벤트 드리븐 사용이 적합하지 않은 서비스

① 주문 생성/수정/삭제 같은 핵심 CRUD

  • 이유: 메인 로직이므로 즉시 DB 반영과 결과 확인 필요
  • 예시:
    • 주문 생성 시 DB에 즉시 저장 → 주문 ID 반환
    • 메뉴 등록/수정/삭제 → 관리자 화면에 바로 반영

② 인증/권한 확인

  • 이유: 사용자가 요청한 기능 처리 직전에 즉시 검증 필요
  • 예시:
    • 로그인, 토큰 검증
    • 권한 체크, 접근 제어

③ 배달 기사 매칭

  • 이유: 실시간 처리 필요, 이벤트 지연 시 배달 지연 가능
  • 예시:
    • 주문 들어오면 바로 배달 기사 매칭
    • 배달 기사 위치 기반 매칭

 

간단 예제

주문 생성 후 취소 요청을 받고 취소처리하는 과정

 

시나리오: 주문 생성 → 주문 취소 → 이벤트 발행 → 리스너 처리(후속작업으로 알림발송과 통계 집계)

(H2 DB 활용)

[서비스 cancelOrder()]
       |
       v
publisher.publishEvent(OrderCanceledEvent)
       |
       v
ApplicationEventMulticaster
       |
       v
@EventListener / @TransactionalEventListener
       |
       v
리스너 실행 (DB 조회, 로그 출력 등 후속작업)

 

애플리케이션에 @EnableAsync 추가

@EnableAsync : Spring에서 비동기(Asynchronous) 메서드 실행을 가능하게 해주는 어노테이션
 - 이벤트 발행 후 리스너 메서드가 별도 스레드에서 실행되도록 사용
 - 발행자(Service)는 기다리지 않고 바로 다음 로직 수행 가능

 

@SpringBootApplication
@EnableAsync
public class SpringEventApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringEventApplication.class, args);
    }
}

 

Order 엔티티  생성 (필요한 것만 간략하게)

import jakarta.persistence.*;

import java.util.UUID;

@Entity
@Table(name = "orders")
public class Order {

    @Id
    private String id;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    private String cancelReason;

    public Order() {
        this.id = UUID.randomUUID().toString();
        this.status= OrderStatus.CREATED;
    }

    public void cancel(String reason) {
        this.cancelReason = reason;
        this.status = OrderStatus.CANCELED;
    }

    public String getId() { return id; }
    public OrderStatus getStatus() { return status; }
    public void setStatus(OrderStatus status) { this.status = status; }
    public String getCancelReason() { return cancelReason; }
    public void setCancelReason(String cancelReason) { this.cancelReason = cancelReason; }

}

enum OrderStatus {
    CREATED, CANCELED, COMPLETED

}

 

Repository

package com.example.eventdemo.repository;

import com.example.eventdemo.domain.Order;
import org.springframework.data.jpa.repository.JpaRepository;

public interface OrderRepository extends JpaRepository<Order, String> {
}

 

이벤트 객체

OrderCanceledEvent

package com.exproject.springevent;

//이벤트 객체 생성
public class OrderCanceledEvent {
    private final String orderId;
    private final String reason;

    public OrderCanceledEvent(String orderId, String reason) {
        this.orderId = orderId;
        this.reason = reason;
    }

    public String getOrderId() { return orderId; }
    public String getReason() { return reason; }
}

 

OrderService ( 서비스 로직 실행, 이벤트 발행 )

주문 취소시 주문 상태를 "CANCELED"로 변경, 취소사유 저장 

import org.aspectj.weaver.ast.Or;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    private final ApplicationEventPublisher publisher;
    private final OrderRepository orderRepository;



    public OrderService(ApplicationEventPublisher publisher, OrderRepository orderRepository) {
        this.publisher = publisher;
        this.orderRepository = orderRepository;
    }

    @Transactional
    public Order createOrder() {
        Order order = new Order();
        orderRepository.save(order);
        System.out.println("{Service] 주문 생성 : " + order.getId());
        return order;
    }


    //주문 취소
    @Transactional
    public void cancelOrder(String orderId, String reason) {
        Order order = orderRepository.findById(orderId).orElseThrow();
        order.cancel(reason);
        orderRepository.save(order);

    //이벤트 발행
        OrderCanceledEvent event = new OrderCanceledEvent(orderId, reason);

        //이벤트 발행 시 ApplicationEventPublisher가 호출되어 등록된 리스너 중 이벤트 타입(OrderCanceledEvent)에 맞는 메서드 탐색
        publisher.publishEvent(event);
        System.out.println("{Service] 주문 취소 처리 완료: " + orderId);

    }
}

 

OrderEventListener
후속 작업이 있다고 가정 

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

import java.util.UUID;

@Slf4j
@Component
@RequiredArgsConstructor
public class OrderEventListener {

    private final OrderRepository orderRepository;
    private final NotificationService notificationService;
    private final StatisticsService statisticsService;


    //매개변수와 일치하는 클래스를 찾아 이벤트 매핑
    @Async     //별도의 스레드에서 리스너 실행
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) //트랜잭션 커밋 후 호출
    public void handleOrderCanceledEvent(OrderCanceledEvent event) {
        Order order = orderRepository.findById(event.getOrderId()).orElseThrow();
        System.out.println("[Listener] 리스너 실행 - DB 조회 : 주문 상태 = " + order.getStatus() + ", 취소 사유 = " +order.getCancelReason());
        String orderId = order.getId();

        // 1️⃣ 알림 발송 (시간 소요)
        notificationService.sendOrderCanceledNotification(orderId);

        // 2️⃣ 통계 집계 (시간 소요)
        statisticsService.updateCancellationStats(orderId);
        log.info("[Listener] 이벤트 수신 - 후속 작업 완료: orderId={}", orderId);

    }


}

 

실행해보기

import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

@Component
public class TestRunner implements CommandLineRunner {

    private final OrderService orderService;

    public TestRunner(OrderService orderService) {
        this.orderService = orderService;
    }

    @Override
    public void run(String... args) throws Exception {
        System.out.println("[Controller] 주문 생성 요청");
        var order = orderService.createOrder();
        System.out.println("[Controller] 주문 생성 완료: " + order.getId());

        System.out.println("[Controller] 주문 취소 요청");
        long start = System.currentTimeMillis();
        orderService.cancelOrder(order.getId(), "고객 요청");
        long end = System.currentTimeMillis();

        System.out.println("[Controller] 주문 취소 서비스 완료, 소요시간(ms): " + (end - start));
        System.out.println("[Controller] 주문 상태: " +order.getStatus());

        System.out.println("[Controller] 메인 스레드 종료");
    }
}

 

결과물 

[Controller] 주문 생성 요청

[Service] 주문 생성 : b1bca7cc-ed84-499c-b53f-60c8712cc9a1

[Controller] 주문 생성 완료: b1bca7cc-ed84-499c-b53f-60c8712cc9a1
[Controller] 주문 취소 요청

[Service] 주문 취소 처리 완료: b1bca7cc-ed84-499c-b53f-60c8712cc9a1

[Controller] 주문 취소 서비스 완료, 소요시간(ms): 9
[Controller] 주문 상태: CREATED
[Controller] 메인 스레드 종료

[Listener] 리스너 실행 - DB 조회 : 주문 상태 = CANCELED, 취소 사유 = 고객 요청
고객 알림 발송 완료 - orderId=b1bca7cc-ed84-499c-b53f-60c8712cc9a1
취소 통계 업데이트 완료 - orderId=b1bca7cc-ed84-499c-b53f-60c8712cc9a1
2025-11-01T19:29:10.403+09:00  INFO 13123 --- [springEvent] [         task-1] c.e.springevent.OrderEventListener       : [Listener] 이벤트 수신 - 후속 작업 완료: orderId=b1bca7cc-ed84-499c-b53f-60c8712cc9a1

 

위의 예제는 사실 하나의 이벤트 리스너에 여러 도메인의 관심사를 한 번에 처리하고 있기 때문에 좋은 코드라고는 생각하지 않는다. (작은 규모의 프로젝트에서는 문제가 없겠지만?)

DDD 구조나 확장성을 고려하면 도메인 별로 리스너를 분리하여 각각의 리스너에서 이벤트를 처리하는 것이 좋다고 생각한다. (간단한 예제이기 때문에 테스트 결과물에서 이벤트 드리븐 방식의 이점이 드러나지도 않아서 아쉬웠다.)

 

 

 


 

 

 

이렇게 어떤 하나의 행위가 일어날 때 연쇄적으로 발생해야 하는 추가적인 후속 작업이 있다면 이벤트 드리븐 방식을 사용하여 결합도를 낮추고 응답 속도를 개선하는 것이 좋아보인다. 하지만 이벤트 드리븐 방식을 사용하면 비동기적 실행이기 때문에 로그 추적이 어렵고, 트랜잭션의 경계가 불명확해질 수도 있다. 그렇다면 이벤트 드리븐 방식의 대안에는 어떤 것들이 있을까?

 

방식 특징 선호 상황
동기 호출 (serviceA → serviceB → serviceC) 순서대로 호출, 트랜잭션 일관성 보장 결제 실패 시 전체 롤백이 필요할 때
비동기 이벤트 (ApplicationEvent) 트랜잭션 커밋 후 비동기로 처리 각 서비스 간 결합도 낮추고 빠른 응답 원할 때
메시지 브로커 기반 (Kafka/RabbitMQ) 완전 분리된 마이크로서비스 간 통신 서비스 간 완전 분리, 장애 격리, 확장성 필요할 때
Saga 패턴 여러 서비스 간 분산 트랜잭션 보상 처리 결제/환불/재고 복원 등 "원자성"이 필요한 경우

 

모놀리식 구조에서는 Event 방식이, 시스템의 규모가 커져서 각 도메인이 모듈화되어 있다면 Kafka/RabbitMQ 등 메시지 브로커 기반 방식에 Saga패턴을 적용하는 것이 권장된다. 

 

상황에 따라 어떤 방식을 선택해야할지 간단하게 생각해보면 다음과 같다. 

상황 대안
주문 취소 시 결제/재고/알림 등 여러 동작이 동시에 필요한가? 이벤트 드리븐이 적합
단순한 내부 로직만 있다면? 서비스 내부에서 직접 호출(동기)하는 게 더 간단하고 안전
트랜잭션 일관성이 최우선이라면? 동기 호출 or Saga 패턴 고려.
확장성, 결합도 낮추는 게 목적이라면? 이벤트 드리븐 구조

 

요약하자면 이벤트 드리븐은 “서로 다른 서비스가 동시에 반응해야 하는 시점”에 쓰는 게 정답이고, 단일 트랜잭션 안에서 끝나는 단순 로직엔 오히려 독이 된다.

 

 

6uiw
@6uiw :: LOG.INFO("MING's DEVLOG")

개발을 하면서 공부한 기록을 남깁니다

목차