김동형수 개발기

도메인 주도 개발 시작하기 - 10, 11장 본문

책 스터디/[완료] DDD - 도메인 주도 개발 시작하기

도메인 주도 개발 시작하기 - 10, 11장

김동형수 2023. 5. 31. 18:07

10장 이벤트

 

10.1

쇼핑몰에서 구매를 취소하면 환불을 처리해야 한다.

도메인 기능에서 도메인 서비스를 실행하게 된다.

응용서비스에서 환불 기능을 실행할 수도 있다.

결제 시스템이 제공하는 환불서비스를 호출한다.

두 가지 문제가 발생할 수 있는데, 첫 번째는 외부 서비스가 정상이 아닐 경우 트랜잭션 처리

두 번째 문제는 성능에 대한 것. 환불을 처리하는 외부 시스템의 응답 시간이 길어지면 그 만큼 대기 시간도 길어진다.

외부 서비스 성능에 직접적인 영향을 받게 된다.

 

두 가지 문제 외에 도메인 객체에 서비스를 전달하면 추가로 설계상 문제가 나타날 수 있다.

로직이 섞이는 문제

주문 도메인 객체의 코드를 결제 도메인 때문에 변경할지도 모르는 상황은 좋아 보이지 않는다.

지금까지 언급한 문제가 발생하는 이유는 주문 바운디드 컨텍스트와 결제 바운디드 컨텍스트간의 강결합 때문이다.

이런 강한 결합을 없앨 수 있는 방법은 이벤트를 사용하는 것이다.

특히 비동기 이벤트를 사용하면 두 시스템 간의 결합을 크게 낮출 수 있다.

 

10.2

이벤트라는 용어는 과거에 벌어진 어떤 것을 의미

이벤트가 발생한다는 것은 상태가 변경됐다는 것을 의미한다.

 

이벤트 관련 구성요소 - 이벤트, 이벤트 생성 주체, 이벤트 디스패쳐, 이벤트 핸들러

도메인 모델에서 이벤트 생성 주체는 엔티티 밸류, 도메인 서비스와 같은 도메인 객체이다.

도메인 로직을 실행해서 상태가 바뀌면 관련 이벤트를 발생시킨다.

이벤트 핸들러는 이벤트 생성 주체가 발생한 이벤트에 반응한다.

이벤트 생성 주체와 이벤트 핸들러를 연결해 주는 것이 이벤트 디스패처다.

 

이벤트의 구성

  • 이벤트 종류
  • 이벤트 발생시간
  • 추가 데이터

이벤트 이름에는 과거 시제를 사용한다.

Events.raise()는 디스패처를 통해 이벤트를 전파하는 기능을 제공한다.

이벤트는 이벤트 핸들러가 작업을 수행하는 데 필요한 데이터를 담아야 한다.

이벤트는 데이터를 담아야 하지만 그렇다고 이벤트 자체와 관련 없는 데이터를 포함할 필요는 없다.

 

이벤트는 크게 두 가지 용도로 쓰인다. 

첫 번째 용도 트리거 - 도메인의 상태가 바뀔 때 다른 후처리가 필요하면 후처리를 실행하기 위한 트리거로 이벤트를 사용할 수 있다.

이벤트의 두 번째 용도는 서로 다른 시스템 간의 데이터 동기화이다.

 

이벤트를 사용하면 서로 다른 도메인 로직이 섞이는 것을 방지할 수 있다.

이벤트 핸들러를 사용하면 기능 확장도 용이하다.

 

10.3

이벤트 자체를 위한 상위 타입은 존재하지 않는다. 우너하는 클래스를 이벤트로 사용하면 된다. 이벤트 클래스의 이름을 결정할 때에는 과거 시제를 사용해야 한다는 점만 유의하면 된다.

이벤트 클래스는 이벤트를 처리하는 데 필요한 최소한의 데이터를 포함해야 한다.

 

이벤트 발생과 출판을 위해 스프링이 제공하는 ApplicationEventPublisher를 사용한다.

 

이벤트를 발생시킬 코드는 Events.raise() 메서드를 사용한다.

이벤트를 처리할 핸들러는 스프링이 제공하는 @EventListener 어노테이션을 사용해서 구현한다.

 

도메인 상태 변경과 이벤트 핸들러는 같은 트랜잭션 범위에서 실행된다.

 

10.4

이벤트를 이용해서 강결합을 해소해도 남은 문제는 외부 서비스에 영향을 받는 문제이다.

외부 서비스의 성능 저하가 바로 내 시스템의 성능 저하로 연결된다는 것을 의미.

성능 저하뿐만 아니라 트랜잭션도 문제가 된다.

외부 시스템과 연동을 동기로 처리할 때 발생하는 성능과 트랜잭션 범위를 해소하는 방법은 이벤트를 비동기로 처리하거나 이벤트와 트랜잭션을 연계하는 것이다.

 

10.5

실제 요구사항이 A하면 최대 언제까지 B하라 인 경우가 많다.

A하면은 이벤트로 볼 수 도 있다.

A 이벤트가 발생하면 별도 스레드로 B를 수행하는 핸들러를 실행하는 방식으로 요구사항을 구현할 수 있다.

  • 로컬 핸들러를 비동기로 실행하기
  • 메시지 큐를 사용하기
  • 이벤트 저장소와 이벤트 포워더 사용하기
  • 이벤트 저장소화 이벤트 제공 API 사용하기

로컬 핸들러 비동기 실행

이벤트 핸들러를 비동기로 실행하는 방법은 이벤트 핸들러를 별도 스레드로 실행하는 것이다.

  • @EnableAsync 어노테이션을 사용해서 비동기 기능을 활성화한다.
  • 이벤트 핸들러 메서드에 @Async 어노테이션을 붙인다.

메시징 시스템을 이용한 비동기 구현

카프카나 레빗MQ와 같은 메시징 시스템을 사용하는 것

트랜잭션 범위에서 실행하면 글로벌 트랜잭션이 필요하다.

글로벌 트랜잭션을 사용하면 안전하게 이벤트를 메시지 큐에 전달할 수 있는 장점이 있지만 글로벌 트랜잭션으로 인해 전체 성능이 떨어지는 단점도 있다.

동일 JVM에서 비동기 처리를 위해 메시지 큐를 사용하는 것은 시스템을 복잡하게 만들뿐이다.

래빗MQ는 글로벌 트랜잭션 지원과 클러스터 고가용성을 지원, 안정적으로 메시지 전달할 수 있는 장점

카프카는 글로벌 트랜잭션을 지원하진 않지만 높은 성능을 보여준다.

 

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

이벤트를 일단 DB에 저장한 뒤에 별도 프로그램을 이용해서 이벤트 핸들러에 전달하는 것

이벤트가 발생하면 핸들러는 스토리지에 이벤트를 저장한다. 포워더는 주기적으로 이벤트 장소에서 이벤트를 가져와 이벤트 핸들러를 실행한다. 포워더는 별도 스레드를 이용하기 때문에 이벤트 발생과 처리가 비동기로 처리된다.

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

 

이벤트 저장소 구현

포워더 방식과 API 모두 이벤트 저장소를 사용하므로 이벤트를 저장할 저장소가 필요하다.

EventStore는 이벤트 객체를 직렬화해서 payload에 저장한다.

contentType 은 'application/json'을 갖는다.

이벤트는 과거에 벌어진 사건이므로 데이터가 변경되지 않는다.

기능이 등록/조회밖에 없다.

 

이벤트 저장을 위한 이벤트 핸들러 구현

 

RestAPI 구현

lastOffset = offset + 데이터개수

이벤트 처리에 실패하면 다시 실패한 이벤트부터 읽어와 이벤트를 재처리할 수 있다.

 

포워더 구현

API 방식 클라이언트와 마찬가지로 마지막으로 전달한 이벤트의 offset을 기억해 두었다가 다음 조회 시점을 마지막으로 처리한 offset부터 이벤트를 가져오면 된다.

 

자동컬럼증가 Commit에 따른 데이터 누락이슈

 

10.6

이벤트를 구현할 때 추가로 고려할 점이 있다. 첫 번쨰는 이벤트 소스를 EventEntry에 추가할지 여부.

두 번째 포워더에서 전송 실패를 얼마나 허용할 것이냐에 대한 것이다.

세 번째 이벤트 손실에 대한 것, 로컬 핸들러를 이용해서 이벤트를 비동기로 처리할 경우 이벤트 처리에 실패하면 이벤트를 유실하게 된다.

네 번째 고려할 점은 이벤트 순서에 대한 것, 순번이 중요하다면 이벤트 저장소를 사용하는 것도 좋다. 메시징 시스템은 기술에 따라 이벤트 발생 순서와 메시지 전달 순서가 다를 수 있다.

다섯 번째 이벤트 재처리에 대한 것, 가장 쉬운 방법은 마지막으로 처리한 이벤트의 순번을 기억하는 것 이 외에 이벤트를 멱등으로 처리하는 방법도 있다.

 

DB트랜잭션 관점에서 고려할 점을 살펴본다.

이벤트 처리를 동기로 하든 비동기로 하든 히든 이벤트 처리 실패와 트랜잭션 실패를 함께 고려해야 한다.

스프링은 @TransactionalEventListener 어노테이션을 지원한다.

phase 속성 값으로 TransactionPhase.AFTER_COMMIT을 지정한다. 트랜잭션 커밋에 성공한 뒤에 핸들러 메서드를 실행한다.

트랜잭션이 롤백이 되면 핸들러 메서드를 실행하지 않는다.

이벤트 저장소로 DB를 사용해도 동일한 효과를 볼 수 있다.

이벤트 특성에 따라 재처리 방식을 결정하면 된다.

11장 CQRS

11.1

조회 화면 특성상 조회 속도가 빠를수록 좋은데 여러 애그리거트의 데이터가 필요하면 구현 방법을 고민해야한다.

이런 구현 복잡도를 낮추는 간단한 방법이 있는데 그것은 바로 상태 변경을 위한 모델과 조회를 위한 모델을 분리하는 것이다.

 

11.2

시스템이 제공하는 기능은 크게 두 가지로 나눌 수 있따. 하나는 상태를 변경하는 기능

또 다른 하나는 사용자 입장에서 상태 정보를 조회하는 기능이다.

상태 변경 기능은 주로 한 애그리거트의 상태를 변경한다.

반면 조회 기능은 두 개 이상의 애그리거트가 필요할 때가 많다.

상태를 변경하는 범위와 상태를 조회하는 범위가 정확하게 일치하기 않기 떄문에 단일 모델로 두 종류의 기능을 구현하면 모델이 불필요하게 복잡해진다. 단일 모델을 사용할 때 발생하는 복잡도를 해결하기 위해 사용하는 방법이 있는데 바로 CQRS이다.

 

CQRS 상태를 변경하는 명령, 상태를 제공하는 조회를 위한 모델을 분리하는 패턴이다.

CQRS는 복잡한 도메인에 적합나다.

CQRS를 사용하면 각 모델에 맞는 구현 기술을 선택할 수 있다.

명령 모델은 객체 지향 기반해서 도메인 모델을 구현하기 적당한 JPA를 사용해서 구현하고 조회 모델은 DB 테이블에서 SQL로 데이터를 조회할 때 좋은 마이바티스를 사용해서 구현하면 된다.

 

두 데이터 저장소간 데이터 동기화는 이벤트를 활용해서 처리한다. 명령 모델에서 상태를 변경하면 이벤트가 발생하고 조회 모델에 전달해서 변경 내역을 반영하면 된다.

변경 내역을 바로 조회 모델에 반영해야 한다면 글로벌 트랜잭션을 사용해 실시간 동기화 할 수 있다. 이 방법은 성능이 떨어진다는 단점이 있다.

특정 시간 안에만 동기화해도 된다면 비동기로 데이터를 전송하면 된다.

 

조회 기능 용청 비율이 월등히 높은 서비스를 만드는 개발팀은 조회 성능을 높이기 위해 다양한 기법을 사용한다.

대규모 트래픽이 발생하는 웹 서비스는 알개 모르게 CQRS를 적용하게 된다.

 

CQRS 패턴을 적용할 때 얻을 수 있는 장점은 명령 모델을 구현할 때 도메인 자체에 집중할 수 있다는 점이다.

조회 전용 모델을 사용하기 떄문에 조회 성능을 높이기 위한 코드가 명령 모델에 영향을 주지는 않는다.

단점 - 첫 번째 구현해야할 코드의 양 증가, 두 번째 더 많은 구현 기술이 필요

도메인이 복잡하지 않은데 CQRS를 도입하면 두 모델을 유지하는 비용만 높아지고 얻을 수 있는 이점은 없다.

CQRS 도입을 고민해보자. 

Comments