도메인 주도 개발 시작하기 : 10장 이벤트

일단 해볼게·2025년 9월 21일
0

book

목록 보기
28/31

10.1 시스템 간 강결합 문제

  • 강결합 문제 예시
    • 쇼핑몰에서 구매를 취소하는 경우 환불하는 과정
      • 환불 기능을 제공하는 도메인 서비스를 파라미터로 받고 취소 도메인 기능에서 도메인 서비스 실행

        public class Order {
        
            // 외부 서비스를 실행하기 위해 도메인 서비스를 파라미터로 전달받음
            public void cancel(RefundService refundService) {
                verifyNotYetShipped();
                this.state = OrderState.CANCELED;
                this.refundStatus = State.REFUND_STARTED;
        
                try {
                    refundService.refund(getPaymentId());
                    this.refundStatus = State.REFUND_COMPLETED;
                } catch (Exception ex) {
                    // TODO: 예외 처리 로직 작성 (ex 로그 기록, 상태 변경 등)
                }
            }
      • 또는 응용 서비스에서 환불 기능 실행

        public class CancelOrderService {
        
            private RefundService refundService;
        
            @Transactional
            public void cancel(OrderNo orderNo) {
                Order order = findOrder(orderNo);
                order.cancel();
                order.refundStarted();
        
                try {
                    refundService.refund(order.getPaymentId());
                    order.refundCompleted();
                } catch (Exception ex) {
                    // TODO: 예외 처리 로직 작성
                }
            }
    • 보통 결제 시스템은 외부에 존재해서 환불 서비스 호출
      • 발생하는 문제
        • 외부 서비스가 정상이 아닌 경우 트랜잭션 처리 방법
          • 익셉션 발생하면 롤백? 커밋?
          • 주문 취소 상태로 변경하고 환불만 나중에 리트라이
        • 성능
          • 외부 시스템 응답 시간이 길어지면 대기 시간도 길어진다.
        • 설계 문제
          • 주문 로직과 결제 로직이 섞이는 문제
            • 주문 도메인 객체에 영향
            • 주문 취소 시 취소 알림이 가야한다면?
      • 해결 방안
        • 비동기 이벤트 사용하여 강결합을 낮춘다.

10.2 이벤트 개요

  • 이벤트 : 과거에 벌어진 어떤 것
    • 암호를 변경한 이벤트, 주문 취소 이벤트
    • 이벤트가 발생했다? → 상태가 변경됐다.
      • 이벤트 이후 원하는 동작 수행

  • 이벤트 생성 주체 : 엔티티, 밸류, 도메인 서비스 같은 도메인 객체

  • 이벤트 디스패처 : 이벤트 퍼블리싱 (이벤트 발생), 생성 주체와 이벤트 연결

  • 이벤트 핸들러 : 생성된 이벤트에 반응

  • 이벤트 종류: 클래스 이름으로 이벤트 종류를 표현

  • 이벤트 발생시간

  • 추가 데이터: 주문번호 신규 배송지 정보 등 이벤트와 관련된 정보

  • 이벤트 예시 > 변경된 배송지 정보를 물류 서비스에 전송하는 핸들러

    public class ShippingInfoChangedHandler {
    
        @EventListener(ShippingInfoChangedEvent.class)
        public void handle(ShippingInfoChangedEvent evt) {
            shippingInfoSynchronizer.sync(
                evt.getOrderNumber(),
                evt.getNewShippingInfo()
            );
        }
    }
    
    • 이벤트 핸들러가 작업을 수행하는데 필요한 데이터 담기
      • 데이터 부족 시 필요한 데이터를 읽기 위한 API 호출 또는 DB에서 조회
  • 이벤트 용도

    • 트리거
      • 도메인 상태가 바뀔 때 다른 후처리 필요한 경우

    • 서로 다른 시스템 간의 데이터 동기화
      • 배송지 변경 시 바뀐 배송지 정보 전송
        • 주문 도메인, 외부 배송 서비스
  • 이벤트 장점

    • 서로 다른 도메인 로직이 섞이는 것을 방지

    • 기능을 확장해도 도메인 로직은 수정할 필요가 없다.

10.3 이벤트, 핸들러, 디스패처 구현

  • 이벤트 관련 코드
    • 이벤트 클래스: 이벤트를 표현한다.
    • 디스패처: 스프링이 제공하는 ApplicationEventPublisher를 이용한다.
    • Events: 이벤트를 발행한다. 이벤트 발행을 위해 ApplicationEventPublisher# 사용한다.
    • 이벤트 핸들러: 이벤트를 수신해서 처리한다. 스프링이 제공하는 기능을 사용한다.
  • 이벤트 클래스 이름 정할 때 과거 시제를 사용
  • 모든 이벤트가 공통으로 갖는 프로퍼티 → 추상 클래스로 구현하고 상속 받기
public class Events {

    private static ApplicationEventPublisher publisher;

    static void setPublisher(ApplicationEventPublisher publisher) {
        Events.publisher = publisher;
    }

    public static void raise(Object event) {
        if (publisher != null) {
            publisher.publishEvent(event);
        }
    }
}
@Configuration
public class EventsConfiguration {

    @Autowired
    private ApplicationContext applicationContext;

    @Bean
    public InitializingBean eventsInitializer() {
        return () -> Events.setPublisher(applicationContext);
    }
}
  • InitializingBean : 스프링 빈 객체를 초기화할 때 사용
public void cancel() {
    verifyNotYetShipped();
    this.state = OrderState.CANCELED;
    Events.raise(new OrderCanceledEvent(number.getNumber()));
}
  • OrderCanceledEvent를 만들어서 이벤트 발행
@Service
public class OrderCanceledEventHandler {

    private final RefundService refundService;

    public OrderCanceledEventHandler(RefundService refundService) {
        this.refundService = refundService;
    }

    @EventListener(OrderCanceledEvent.class)
    public void handle(OrderCanceledEvent event) {
        refundService.refund(event.getOrderNumber());
    }
}
  • OrderCanceledEvent 타입 객체 전달
  • 이벤트 처리 흐름

10.4 동기 이벤트 처리 문제

  • 외부 서비스에 영향을 받는 문제
    // 1. 응용 서비스 코드
    @Transactional
    public void cancel(OrderNo orderNo) {
        Order order = findOrder(orderNo);
        order.cancel(); // order.cancel()에서 OrderCanceledEvent 발생
    }
    
    // 2. 이벤트를 처리하는 코드
    @Service
    public class OrderCanceledEventHandler {
    
        private final RefundService refundService;
    
        public OrderCanceledEventHandler(RefundService refundService) {
            this.refundService = refundService;
        }
    
        @EventListener(OrderCanceledEvent.class)
        public void handle(OrderCanceledEvent event) {
            // refundService.refund()에서 느려지거나 익셉션 발생 시 어떻게 처리할지 고려 필요
            refundService.refund(event.getOrderNumber());
        }
    }
    
    • refundService.refund()가 외부 환불 서비스와 연동한다고 가정하면?
      • 구매 취소가 실패하는 것과 같다.
      • 문제점
        • 시스템 내 성능 저하
        • 트랜잭션 길이가 길어짐
      • 해결 방안
        • 구매 취소 상태값은 처리하고 환불만 리트라이하거나 수동 처리
          • 내부 로직 처리, 외부 서비스 후처리
        • 이벤트를 비동기로 처리
        • 이벤트와 트랜잭션 연계

10.5 비동기 이벤트 처리

  • 회원 가입 신청 이후 이메일 보내는 상황

    • 회원 가입 신청 이후 바로 검증 이메일이 도착할 필요는 없다.
    • 주문 취소도 마찬가지로 바로 결제를 취소하지 않아도 된다.
      • 특정 기간 내에 결제 취소가 이루어지면 된다.
  • ‘A 하면 최대 언제까지 B 하라’ 가 포인트

    • 일정 시간 안에 재시도, 수동 처리
  • 비동기 이벤트 처리 방법

    • 로컬 핸들러를 비동기로 실행

      • 이벤트 핸들러를 별도 스레드로 실행
        • @Async
    • 메시지 큐 사용

      • 이벤트가 발생하면 이벤트 디스패처는 메시지 큐에 보낸다.
      • 메시지 큐 → 메시지 리스너 → 이벤트 핸들러 이용해서 처리
      • 필요하다면 이벤트를 발생시키는 도메인 기능과 메시지 큐에 이벤트를 저장하는 절차를 한 트랜잭션으로 묶어야 한다. → 글로벌 트랜잭션
        • 장점
          • 안전하게 이벤트를 메시지 큐에 전달
        • 단점
          • 전체 성능 저하 가능
        • 글로벌 트랜잭션을 지원하지 않는 메시징 시스템 존재
          • RabbitMQ → 지원 / Kafka → 미지원
    • 이벤트 저장소와 이번트 포워더 사용

      • 이벤트를 DB에 저장한 뒤에 별도 프로그램을 이용해서 이벤트 핸들러에 전달
        • 스케줄러, 배치 등
      • 도메인의 상태 변화와 이벤트 저장이 로컬 트랜잭션으로 처리
      • 핸들러가 이벤트 처리에 실패할 경우 포워더는 다시 이벤트 저장소에서 이벤트를 읽어와 핸들러를 실행
    • 이벤트 저장소와 이벤트 제공 API 사용

    • 포워더 방식과 차이점은 이벤트를 전달하는 방식이 다르다.

    • API 방식은 외부 핸들러가 API 서버를 통해 이벤트 목록을 가져간다.

    • API 방식에서는 이벤트 목록을 요구하는 외부 핸들러가 자신이 어디까지 이벤트를 처리했는지 기억

      • 스토리지에서 이벤트 ID나 타임스탬프 추적

      • offset 대신 cursor 기반 페이징을 하면 5개 이벤트 고정적으로 제공 가능

        SELECT *
        FROM member
        WHERE created_at < :lastCreatedAt
        ORDER BY created_at DESC
        LIMIT 5;
    • 자동 증가 컬럼 주의 사항

      • 트랜잭션 커밋 시점에 자동 증가 컬럼 DB 반영
        • 성능 저하 위험
      • 마지막 자동 증가 칼럼 값이 10인 상태에서 A 트랜잭션이
        insert 쿼리를 실행한 뒤에 B 트랜잭션이 insert 쿼리를 실행했다면 A는 11 을, B는 12를 자동 증가 칼럼 값으로 사용하게 된다. 그런데 B 트랜잭션이 먼저 커밋되고 그다음에 A 트랜잭션이 커밋되면 12가 DB에 먼저 반영되고 그다음 11 이 반영된다.
        - 해결방안
        - 트랜잭션 격리 레벨 높이기
        - CDC 사용
        - CDC란?
        - 데이터베이스에 일어나는 변경 이벤트(INSERT, UPDATE, DELETE) 를 실시간으로 캡처해서 로그/스트림으로 흘려보내는 기술
        - ex) Debezium
        - AUTO_INCREMENT 값 부여 순서가 아닌 DB 반영 순서에 맞춰 이벤트 소비
        - 문제 상황:
        - A 트랜잭션: id=11 예약 → 늦게 커밋
        - B 트랜잭션: id=12 예약 → 먼저 커밋
        - 결과: DB에선 12 → 11 순서로 기록

10.6 이벤트 적용 시 추가 고려 사항

  • 이벤트 소스를 EventEntry에 추가할지 여부

    • Order가 발생시킨 이벤트만 조회하기 → 이벤트에 발생 주체 정보 추가
    • 포워더에서 전송 실패를 얼마나 허용할 것이냐 → 재전송 횟수 제한 → DLQ 적용
    • 이벤트 손실 → 로컬 핸들러 (비동기)를 이용하면 이벤트 유실
    • 이벤트 순서 → 이벤트 발생 순서대로 외부 시스템에 전달해야할 경우 이벤트 저장소를 사용하는 것이 좋다.
    • 이벤트 재처리 → 처리한 순번의 이벤트가 도착하면 무시하는 방식
      • 이벤트 멱등(Idempotency) → DB나 Redis에 event_id 기록
  • 주문 취소와 환불 기능 예시

    • 주문 취소 기능은 주문 취소 이벤트를 발생시킨다.

    • 주문 취소 이벤트 핸들러는 환불 서비스에 환불 처리를 요청한다.

    • 환불 서비스는 외부 API를 호출해서 결제를 취소한다.

      • 동기로 실행할 때의 흐름
      • 비동기 실행 흐름
      • @TransacationalEventListener로 트랜잭션 성공 이후 이벤트 실행
        • 각 시점 별 실패 시 재처리 방식 고려
profile
시도하고 More Do하는 백엔드 개발자입니다.

0개의 댓글