DaehunGwak / study-start-ddd

'도메인 주도 개발 시작하기: DDD 핵심 개념 정리부터 구현까지' 책 스터디
5 stars 3 forks source link

10주차 - 10.3 이벤트, 핸들러, 디스패처 ~ 11. CQRS #11

Open DaehunGwak opened 1 year ago

DaehunGwak commented 1 year ago

진도

  1. 이벤트 (10.3. 이벤트, 핸들러, 디스패처 구현 ~)
  2. CQRS

일정

kdg0209 commented 1 year ago

이벤트



CORS 패턴



하나의 데이터 베이스를 사용하여 나누는 경우



데이터 베이스를 분리하는 경우



결국은 CQRS 패턴은 코드상에서 잘 녹일 수 있도록 하는것도 좋다고 생각합니다. 하나의 서비스 레이아웃에서 모든 것을 하려고 하지 않고 QueryService, CommandService를 나누는 것도 한가지 방법이며, CQRS를 통해 CUD의 행위가 이루어지는 비지니스 로직에 R과 관련된 행위가 내포되는 것을 분리하여 예쁜 코드를 짜야할거 같습니다.

DaehunGwak commented 1 year ago

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

적용 시 주의사항

이벤트 처리와 DB 트랜잭션 고려

단일 모델의 단점

CQRS

CQRS 장단점

chilling1000 commented 1 year ago
  1. 이벤트 클래스는 최소한의 정보를 가지며, 모든 이벤트가 공통으로 갖는 필드가 있다면, 상위 클래스를 선언해서 사용할 수도 있다. 예를 들면, 모든 이벤트는 시간을 포함할 수도 있으므로, Event 라는 추상 클래스를 만들고, 시간에 대한 필드를 추가해서 사용할 수도 있다.

  2. 이벤트 클래스와 ApplicationEventPublisher 인터페이스는 서로 상호작용한다. 이벤트를 발행하기 위해서는 ApplicationEventPublisher 인터페이스를 의존해서 사용한다. 따라서, 흐름은 다음과 같다.

    1. Application Layer 즉, 서비스단에서 도메인 메소드를 호출.
    2. 호출된 도메인 메소드내에서 이벤트 생성
    3. 생성된 이벤트는 ApplicationEventPublisher가 발행하여 처리

    여기서 이벤트를 도메인 메소드가 아닌, 서비스 메소드에서 실행하고 싶다면, 서비스 클래스에서 ApplicationEventPublisher 인터페이스를 의존성 주입 후, 사용하면 된다.

    
    @Service
    public TestService {
    
    private final TestRepository testRepository;
    private final ApplicationEventPublisher publisher;
    
    @Transactional
    public void executeTest(Long id) {
        Test test = testRepository.findById(id).orElseThrow(TestNotFoundException::new);
    
        test.executeTest();
    
        publisher.publishEvent(new TestDoneEvent(test.getName(), test.getTime()));
    }
    }

public TestDoneEventHandler { private final Notifier notifer;

@EventListener(TestDoneEvent.class)
public void handle(TestDoneEvent event) {
    notifier.send(event);
}

}


3. 이벤트 처리 시, 강결합 문제가 해결되더라도, 동기 문제가 발생하는지 확인해야 한다. 즉, 트랜잭션의 범위를 어디까지 설정하느냐도 매우 중요한 부분이다.
   그래서 비동기 이벤트 처리 방법이 있는데 이 방법으로 인해, 우리는 살면서 최소 1번 이상은 경험해봤을 것이다. 바로 번호 및 이메일 인증할 때이다.
   어떤 사이트에 가입하려고 핸드폰 번호 입력 후, 인증 번호 전송 버튼을 클릭했을 때, 아주 가끔씩 문자가 오지 않을 때가 있다. 이때가 바로 비동기 이벤트 처리가 실패되었다는 뜻이다.

4. 비동기 이벤트 핸들링 방법은 여러 가지가 있다.
   1. 로컬 핸들러 = @Async 를 메소드에 붙여서 사용하는 방식이다.
   2. 메시지큐 = 카프카나 래빗MQ 등 여러 메시징 시스템을 사용하는 것이며, 이러한 메시징 시스템을 비동기적으로 처리된다.
   3. 이벤트 저장소 이용 = DB에 이벤트를 저장 후, 별도 프로그램(스케쥴러, Rest API 등)을 이용해서 이벤트 핸들러에 전달해서 처리한다.
     - 구현 방법은 Data JPA와 다르게, 엔티티를 통한 JPARepository 저장 방식이 아니라, 인프라스트럭처 레이어에서 직접 구현을 한다.

■ API 방식 public EventEntry { private Long id; <- 이벤트 고유 ID private String type; <- 이벤트 타입 Ex) 환불처리이벤트, 메시지전송이벤트 등등 private contentType; <- Http ContentType과 동일 Ex) Application/json 등 private paylod; <- 직렬화된 순수 데이터 (JSON 형식 등) private LocalDateTime time; <- 발생 시간 데이터 }

public interface EventStore { void save(Object event); <- 특정 타입이 지정되지 않으므로, Object로 파라미터를 받는다. List get(long offset, long limit); <- 페이징 방식처럼 기준점을 정하여, 데이터를 가져온다. }

public class JdbcEventStore implements EventStore { private ObjectMapper objectMapper; <- 직렬화해주는 헬퍼 private JdbcTemplate jdbcTemplate;

...

}

■ 포워더 방식 public class EventForwarder { private static final int LIMIT_SOZE = 100;

private final EventStore eventStore; <- API 방식의 EventStore와 동일
private final OffsetStore offsetStore; <- API 방식의 EventStore와 비슷한 형식
private final EventSender eventSender; <- API 방식의 EventStore와 비슷한 형식

@Scheduled(initialDelay = 1000L, fixedDelay = 1000L)
public void getAndSend() {
...
}

}


패키지 구조가 지금까지 봐온 DDD와 조금 다르다. 왜냐하면, EventEntry와 EventStore는 api 라는 패키지에 위치하기 때문이다.
그래서 api 패키지는 곧 domain 패키지와 같다고 볼 수 있다. 그리고 JdbcEventStore는 똑같이 infra 레이어에 위치한다.

5. 포워더 방식의 단점은 이벤트 전송이 실패할 경우, 실패한 이벤트부터 재처리하지만, 문제는 여기서 계속 이벤트가 실패된다면 무한 루프에 빠지게 되는 셈이다.
   이러한 방법을 개선하기 위해서는 재전송 횟수에 제한을 두는 방법이 있다. 그리고 실패로 인해 데이터 무시를 방지하기 위해, DB에 실패 로그를 저장할 수도 있다.

6. 이벤트를 처리할 때는 트랜잭션을 잘 고려해야 하며, 트랜잭션이 성공했을 때만 이벤트를 발생시키는 기능도 유용하게 사용할 수 있다.
developer-wonjin commented 1 year ago

DDD START! 9장 BoundedContext

1. 이벤트 등장인물

1.1. 이벤트

1.2. 이벤트 디스패처

public class Events {
    private static ThreadLocal<List<EventHandler<?>>> handlers =
            new ThreadLocal<>();

    // '이벤트 처리중 여부'를 보관하는 threadLocal --> 이게 어떨 때 쓰이는거지?
    private static ThreadLocal<Boolean> publishing =
            new ThreadLocal<Boolean>() {
                @Override
                protected Boolean initialValue() {
                    return Boolean.FALSE;
                }
            };

    public static void raise(Object event) {
        // [질문] 도메인모델에서 이벤트를 터트렸는데도 처리안하고 끝내는 경우가 있다?
        // 이게 왜 무한재귀를 방지해주는 거임?
        if (publishing.get()) return;

        try {
            publishing.set(Boolean.TRUE);

            List<EventHandler<?>> eventHandlers = handlers.get();
            if (eventHandlers == null) return;
            for (EventHandler handler : eventHandlers) {
                if (handler.canHandle(event)) {
                    handler.handle(event);
                }
            }
        } finally {
            publishing.set(Boolean.FALSE);
        }
    }

    // 메소드 파라미터로 EventHandler<?> handler 함수형인터페이스를 갖음
    public static void handle(EventHandler<?> handler) {
        if (publishing.get()) return;

        List<EventHandler<?>> eventHandlers = handlers.get();
        if (eventHandlers == null) {
            eventHandlers = new ArrayList<>();
            handlers.set(eventHandlers);
        }
        eventHandlers.add(handler);
    }  

    // Thread가 작업을 끝 마치면 내용물을 싹다 비워준다.
    // Thread가 소멸되지 않고 threadPool로 반환돼 재사용되기 때문
    public static void reset() {
        if (!publishing.get()) {
            handlers.remove();
            asyncHandlers.remove();
        }
    }
}

1.3. 이벤트 처리 핸들러

public interface EventHandler<T> {
    void handle(T event);

    default boolean canHandle(Object event) {
        Class<?>[] typeArgs = TypeResolver.resolveRawArguments(
                EventHandler.class, this.getClass());
        return typeArgs[0].isAssignableFrom(event.getClass());
    }
}

1.4. 사용코드

@Service
public class CancelOrderService {
    private OrderRepository orderRepository;
    private RefundService refundService;
    private CancelPolicy cancelPolicy;

    @Transactional
    public void cancel(OrderNo orderNo, Canceller canceller) {
        //[등록] 이벤트 처리핸들러 구현체 (람다식으로 handle메소드를 구현)
        Events.handle((OrderCanceledEvent evt) -> refundService.refund(evt.getOrderNumber()));

        Order order = findOrder(orderNo);
        if (!cancelPolicy.hasCancellationPermission(order, canceller)) {
            throw new NoCancellablePermission();
        }

        //메소드 내부에서 이벤트가 발행됨. Events.raise 호출
        order.cancel(); 

        //[해제]
        Events.reset();
    }
@Entity
@Table(name = "purchase_order")
@Access(AccessType.FIELD)
public class Order {  
    public void cancel() {
        verifyNotYetShipped();
        this.state = OrderState.CANCELED;
        Events.raise(new OrderCanceledEvent(number.getNumber()));
    }
    ...
}

1.5 [정리] 동기 이벤트

1.6. 프록시를 이용한 RESET

@Aspect
@Order(0) // service => reset => transaction 순으로 프록시객체 러시아인형만들기
@Component
public class EventsResetProcessor {

    //[질문] 서비스 메소드 중첩 실행갯수 저장할 threadLocal 변수
    private ThreadLocal<Integer> nestedCount = new ThreadLocal<Integer>() {
        @Override
        protected Integer initialValue() {
            return new Integer(0);
        }
    };

    @Around("execution(public * com.myshop..*Service.*(..))")
    public Object doReset(ProceedingJoinPoint joinPoint) throws Throwable {
        nestedCount.set(nestedCount.get() + 1);
        try {
            return joinPoint.proceed();
        } finally {
            nestedCount.set(nestedCount.get() - 1);
            if (nestedCount.get() == 0) {
                Events.reset();
            }
        }
    }
}

1.7 동기 이벤트의 문제점

서비스 => 도메인모델 ==> 이벤트디스패처 => 이벤트처리핸들러

2. 비동기 이벤트

2.1. 로컬핸들러

도메인모델 => 이벤트 디스패처 => 이벤트처리 비동기핸들러

2.2.메세징 시스템

[시스템1]

도메인 모델 => 이벤트 디스패처 => RabbitMQ

[시스템2]

RabbitMQ => MessageListener => 이벤트 처리핸들러

2.3. 이벤트 저장소

[이벤트 처리방식 2가지]

[위 둘의 공통점]

[모든 응용서비스에 아래 코드를 적용해야함]

public class CancelOrderService {
    private OrderRepository orderRepository;
    private RefundService refundService;
    private CancelPolicy cancelPolicy;

    @Transactional
    public void cancel(OrderNo orderNo, Canceller canceller) {
        // 모든 응용서비스에 핸들러 등록 서비스를 넣어줘야함
        Events.handle((OrderCanceledEvent evt) -> refundService.refund(evt.getOrderNumber()));
        ...       

        // 발생된 이벤트를 처리할 수 있게끔
        order.cancel();    
        Events.reset();
    }
}

2.4. 추가로 고려할 점

progress0407 commented 1 year ago

10. 이벤트

10.1 시스템 간 강결합 문제

me. 두 관심사가 묶여있으니 이것을 분리하자

  1. 트랜잭션 관심사 분리

  2. 외부 API 호출일 경우 성능에 대한 문제

10.2 이벤트 개요

10.2.3 이벤트 용도

어따 쓸거얌?? ><

  1. 트리거
    • 도멘 상태 변경 후 후처리
  2. 데이터 동기화
    • ex.1) 배송지 변경후 외부 배송 서비스와 동기화
    • ex.2) CQRS 를 위한 조회를 동기화

10.2.4. 이벤트 장점

관심사의 분리로 인한 기능 확장 유리

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

생략! 여러가지 구현 스타일이 나뉜당...

10.4 동기 이벤트 처리 문제

동기적으로 진행되면 외부 API 등이 엮여있을 경우

  1. 타 서비스의 성능 저하가 전파된다
  2. 트랜잭션으로 묶일 필요가 없을 때도 묶이게 된다

10.5 비동기 이벤트 처리

사실 많은 경우가 A한 후에 B 하기A하면 언제까지 B 하기인 경우가 많다

B를 실패한 경우 재시도/ 수동 처리를 해도 되기도 함

-> B하기를 별도 쓰레드 핸들러로 구현

105.1 로컬 핸들러

@Async

스프링이 알아서 비동기로 쓰레드 만들어줌

10.5.2 메시지 큐

토끼MQ, 갓프카

10.5.3 이벤트 저장소를 이용한 비동기 처리

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

  1. 어떤 애그리거트가 발행했는지에 대한 정보가 필요하다면 추가할 것
  2. 특정 이벤트가 계속 전송 실패를 해서 다음 이벤트를 전송 못한다면? 최대 전송 횟수 제한 걸기
  3. 이벤트 실패시 이벤트 히스토리에 대한 손실
  4. 이벤트 순서가 보장되야 할 경우
  5. 동일 이벤트가 다시 올 경우에 대한 처리

10.6.1 이벤트 처리와 DB 트랜잭션 고려

@Transactional(phase = TransactionPhase.AFTER_COMMIT)

CQRS

11.1 단일 모델의 장점

ORM 기법은 도메인의 상태를 변경하는 데는 적합하지만 조회화면은 고려할 게 많아서 복잡하게 만드는 원인 중 하나!

11.2 CQRS

Command Query Responsibility Segregation

조회와 명령을 분리하자!

11.2.1 웹과 CQRS

명령과 조회, 즉 쓰기와 읽기의 비율은 대체 1 : 9 ~ 2 : 8 정도야!

암달의 법칙을 생각해보자~~

대략적으로 요청 100개 와서 100초 20이 쓰기, 80이 읽기라면...

쓰기 성능을 2배 감소 : 20 -> 40 읽기 성능을 2배 증가 : 80 -> 40

결과: 100초 -> 80초 으로...

11.2.2 CQRS 장단점