단일 모델의 단점

  • 주문 내역 조회 기능을 구현하려면 여러 애그리것에서 데이터를 가져와야 한다.
  • Order에서 주문 정보를 가져와야하고 Product에서 상품이름을 가져와야하고 Member에서 회원 이름과 아이디를 가져와야 한다.

  • 조회화면 특성상 빠를수록 좋은데 여러 애그리것에서 데이터를 가져와야 할 경우 구현 방법을 고민해봐야 한다.
  • 3장에서 언급한 ID를 이용해서 애그리것을 참조하는 방식을 사용하면 즉시로딩 방식과 같은 JPA의 쿼리 관련 최적화 기능을 사용할 수 없다.
  • 이는 한번의 select 쿼리로 조회화면에 필요한 데이터를 읽어올 수 없어 조회속도에 문제가 생길 수 있다.
  • 애그리것간의 연관을 ID가 아니라 직접 참조하는 방식으로 연결해도 고민거리가 생긴다.
  • 조회 화면의 특성에 따라 같은 연관도 즉시 로딩이나 지연 로딩으로 처리해야 하기 때문이다.
  • 경우에 따라 DBMS가 제공하는 전용기능을 이용해 조회 쿼리를 작성해야 해서 JPA의 네이티브 쿼리를 사용해야 할 수도 있다.

  • 이런 고민이 발생하는 이유는 시스템의 상태를 변경할 때와 조회할 때 단일 도메인 모델을 사용하기 떄문이다.
  • 객체 지향으로 도메인 모델을 구현할 떄 주로 사용하는 ORM 기법은 Order.cancel()이나 Order.changeShippingInfo() 처럼 도메인의 상태를 변경하는데 적합하지만 주문 상세 조회화면 처럼 여러 애그리것에서 데이터를 가져와 출력하는 기능을 구현하기에는 고려할 것들이 많아서 구현을 복잡하게 만드는 원인이 된다.
  • 이런 구현 복잡도를 낮추는 간단한 방법이 있는데 그것은 바로 상태변경을 위한 모델과 조회를 위한 모델을 분리하는 것이다.

CQRS

  • 시스템이 제공하는 기능은 크게 두가지로 나누어 생각할수 있다.
  • 하나는 상태를 변경하는 것이고 하나는 상태 정보를 조회하는 것이다.
  • 도메인 모델 관점에서 상태 변경은 주로 한 애그리것의 상태를 변경한다.
  • 예를 들어 주문 취소 기능과 배송지 정보 변경 기능은 한 개의 Order 애그리것을 변경한다.
  • 반면에 조회 기능은 한 애그리것의 데이터를 조회할 수도 있지만 두개이상의 애그리것에서 데이터를 조회할수도 있다. ( 앞에서 얘기함 주문 상세정보조회 )

  • 단일 모델로 두 종류 ( 상태 변경, 상태 조회 ) 의 기능을 구현하면 모델이 불필요하게 복잡해진다.
  • 이럴때 CQRS를 사용하여 복잡도를 해결한다.

  • CQRS는 상태를 변경하는 명령 모델과 상태를 제공하는 조회 모델을 분리하는 패턴이다.
  • CQRS는 복잡한 도메인에 적합하다.
  • 도메인이 복잡할수록 명령기능과 조회 기능이 다루는 데이터 범위에 차이가 발생하는데 이 두기능을 단일 모델로 처리하면 조회 기능의 로딩속도를 위해 모델 구현이 필요이상으로 복잡해지는 문제가 발생한다.
  • 예를 들어 온라인 쇼핑에서 다양한 차원에서 주문/판매 통계를 조회해야 한다고 해보자.
  • JPA기반의 단일 도메인 모델을 사용하면 통계값을 빠르게 조회하기 위해 JPA와 관련된 다양한 성능관련 기능을 모델에 적용해야 한다.
  • 이런 도메인에 CQRS를 적용하면 통계를 위한 조회 모델을 별도로 만들기 때문에 조회 떄문에 도메인이 복잡해지는 것을 막을 수 있다.

  • CQRS를 사용하면 각 모델에 맞는 구현 기술을 선택할 수 있다.
  • 예를 들어, 명령 모델은 객체 지향에 기반해서 도메인 모델을 구현하기에 적당한 JPA를 사용해서 구현하고, 조회 모델은 DB 테이블에서 SQL로 데이터를 조회할 떄 좋은 MyBatis를 사용해서 구현할수 있다.

출처: https://minkukjo.github.io/dev/2020/11/24/DDD-11/

  • 위의 그림을 보면 조회 모델에는 응용 서비스가 존재하지 않는다.
  • 단순 데이터를 읽어와 조회하는 기능은 로직이 복잡하지 않기 떄문에 컨트롤러에서 바로 DAO를 실행해도 무방하다.
  • 물론 데이터를 표현 영역에 전달하는 과정에서 몇가지 로직이 필요하다면 응용 서비스를 두고 로직을 구현하면 된다.

출처: https://minkukjo.github.io/dev/2020/11/24/DDD-11/

  • 위 그림은 명령 모델과 조회 모델의 설계를 보여주고 있다.
  • 상태 변경을 위한 명령 모델은 객체를 기반으로 한 도메인 모델을 이용해서 구현한다.
  • 반면에 조회 모델은 주문 요약 목록을 제공할때 필요한 정보를 담고 있는 데이터 타입을 이용한다.
  • 두 모델 모두 주문과 관련되어 있지만 명령 모델은 상태를 변경하는 도메인 로직을 수행하는데 초점을 맞춰 설계했고, 조회 모델은 화면에 보여줄 데이터를 조회하는데 초점을 맞춰 설계 했다

  • 명령모델과 조회모델이 같은 구현기술을 사용할 수도 있다.
  • 이 내용은 5장의 조회 전용 기능에서 설명한다.
  • 명령모델과 조회모델이 다른 데이터 저장소를 사용할 수도 있다.
  • 명령 모델은 트랜잭션을 지원하는 RDBMS를 사용하고 조회 모델은 조회 성능이 좋은 메모리 기반 NoSQL을 사용할 수 있을것이다.

출처: https://minkukjo.github.io/dev/2020/11/24/DDD-11/

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

  • 명령 모델과 조회 모델이 서로 다른 데이터 저장소를 사용할 경우 데이터 동기화 시점에 따라 구현방식이 달라질 수 있다.
  • 명령 모델에서 데이터가 바뀌자마자 변경 내역을 바로 조회 모델에 반영해야 한다면 동기 이벤트와 글로벌 트랜잭션을 사용해서 실시간으로 동기화를 할 수 있다.
  • 하지만 10장에서 언급한것처럼 글로벌 트랜잭션은 전반적인 성능이 떨어지는 단점이 있다.

  • 서로 다른 저장소의 데이터를 특정 시간 안에만 동기화 하면 된다면 비동기로 데이터를 전송해도 된다.
  • 예를 들어 통계 처리 목적으로 조회 전용 저장소를 구축했다고 하자, 통계 데이터는 수초, 수분 또는 1시간 단위로 최근 데이터를 반영해도 문제가 되지 않을 때가 많다.
  • 이러한 경우라면 비동기로 데이터를 보냄으로써 데이터 동기화로 인해 명령 모델의 성능이 나빠지지 않도록 할 수 있다.

웹과 CQRS

  • 일반적으로 웹서비스는 상태를 변경하는 요청보다 상태를 조회하는 요청이 많다.
  • 포털이나 대형 온라인 쇼핑몰과 같이 조회 기능 요청 비율이 월등히 높은 서비스를 만드는 개발팀은 조회 성능을 높이기 위해 다양한 기법을 사용한다.
  • 기본적으로 쿼리 최적화를 해서 쿼리 실행 속도 자체를 높이고 메모리에 조회 데이터를 캐시해서 응답 속도를 높이기도 한다. 조회 전용 저장소를 따로 사용하기도 한다.

  • 이렇게 조회 성능을 높이기 위해 다양한 기법을 사용하는 것은 결과적으로 CQRS를 적용하는 것과 같은 효과를 만든다.
  • 메모리에 캐시하는 데이터는 DB에 보관된 데이터를 그대로 저장하기 보다는 화면에 맞는 모양으로 변환한 데이터를 캐시할 때 성능이 더 유리하다. 즉 조회전용 모델을 캐시하는 것이다.
  • 비슷하게 조회 속도를 높이기 위해 쿼리를 최적화 한다는 것은 조회 화면에 보여질 데이터를 빠르게 읽어올 수 있도록 쿼리를 작성한다는 것이다.

  • 대규모 트래픽이 발생하는 웹 서비스는 알게 모르게 CQRS를 적용하게 된다.
  • 단지 명시적으로 명령모델과 조회모델을 구분하지 않을 뿐이다.
  • 조회 속도를 높이기위해 별도 처리를 하고 있다면 명시적으로 명령모델과 조회모델을 구분하자.
  • 이를 통해 조회 기능 떄문에 명령이 복잡해지는 것을 방지하고 명령 모델에 관계없이 조회 기능에 특화된 구현 기법을 보다 쉽게 적용할 수 있다.

CQRS 장단점

장점

  • CQRS 패턴을 적용할떄 얻을 수 있는 장점중 하나는 명령 모델을 구현할 떄 도메인 자체에 집중할수 있다는 것이다.
  • 복잡한 도메인은 주로 상태 변경 로직이 복잡한데 명령모델과 조회모델을 구분하면 조회성능을 위한 코드가 명령모델에 없으므로 도메인 로직을 구현하는데 집중할 수 있다.
  • 또한 명령 모델에서 조회 관련 로직이 사라져 복잡도를 낮춰준다.

  • 또 다른 장점은 조회 성능 향상에 유리하다는것
  • 조회 단위로 캐시기술을 적용할 수 있고 조회에 특화된 쿼리를 마음대로 사용할 수도 있다.
  • 캐시뿐만 아니라 조회 전용 저장소를 사용하면 조회 처리량을 대폭 늘릴 수도 있다.
  • 조회 전용 모델을 사용하기 떄문에 조회 성능을 높이기 위한 코드가 명령 모델에 영향을 주지 않는다.

단점

  • 첫번째 단점은 구현해야 될 코드가 더 많다는 점이다.
  • 단일 모델을 사용할떄 발생하는 복잡함때문에 발생하는 구현 비용과 조회 전용 모델을 만들떄 발생하는 구현 비용을 따져봐야 한다.
  • 도메인이 복잡하거나 대규모 트래픽이 발생하는 서비스라면 조회 전용 모델을 만드는것이 향후 유지보수에 유리할 수 있다.
  • 반면에 도메인이 단순하거나 트래픽이 많지않은 서비스라면 조회 전용 모델을 만들 이유가 없다.

  • 두번째 단점은 명령모델과 조회모델을 다른구현 기술을 사용해서 구현하기도하고 경우에 따라 다른저장소를 사용하기도 하고 데이터 동기화를 위해 메시징 시스템을 도입해야 할수도 있다.
  • 그렇기 떄문에 더 많은 구현기술이 필요하다는것이 단점이다.

  • 이런부분들을 잘 트레이드 오프하여 CQRS를 도입할지 결정해야 한다.
  • 도메인이 복잡하지 않은데 CQRS를 도입하면 두 모델을 유지하는 비용만 높아지고 얻을 수 있는 이점은 없다.
  • 반면에 트래픽이 높은 서비스인데 단일모델을 고집하면 유지보수 비용이 오히려 높아질 수 있으므로 CQRS 도입을 고려해보자.

시스템 간 강결합 문제

  • 쇼핑몰에서 구매를 취소하면 환불을 처리해야 한다. 이떄 환불 기능을 하는 주체는 주문 도메인 엔티티가 될 수 있다.

  • 도메인 서비스를 파라미터로 전달받고 취소 도메인 기능에서 도메인 서비스를 실행하게 된다.
public class Order {
  public void cancel(RefundService refundService) {
    verifyNotYetShipped(); // 주문 로직
    this.state = OrderState.CANCELED; // 주문 로직

    this.refundStatus = State.REFUND_STARTED; // 결제 로직
    try { // 결제 로직
      refundSvc.refund(getPaymentId()); // 결제 로직
      this.refundStatus = State.REFUND_COMPLETED; // 결제 로직
    } catch(Exception ex) { // 결제 로직
      ... // 결제 로직
    } // 결제 로직
  }
}

출처: https://minkukjo.github.io/dev/2020/11/20/DDD-10/

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

  • 보통 결제 시스템은 외부에 존재하므로 RefundService는 외부의 환불 시스템 서비스를 호출하는데, 이때 두 가지 문제를 발생한다.
  • 첫 번째 문제는 외부 서비스가 정상이 아닐경우 트랜잭션 처리를 어떻게 해야할지 애매하다는것
    • 환불처리중 익셉션이 생겼을 경우 롤백을 해야하는가 안해야 하는가 ?
      • 무조건 롤백이라고 생각할수 있지만 주문은 취소상태로 변경하고 환불만 나중에 다시 시도하는 방식으로 처리할 수 있다.
  • 두번째는 성능에 관한 문제
    • 외부시스템의 응답이 길어지면 그만큼 대기 시간이 발생한다.
    • 환불 처리기능이 30초 걸리면 주문 취소 기능은 30초+@가 된다.
    • 즉, 외부 서비스 성능에 직접적인 영향을 받는 문제가 있다.

  • 두가지 문제 외에 도메인 객체에 서비스를 전달하면 추가로 설계상 문제가 나타날 수 있다.
  • 주문 로직과 결제 로직이 섞이는 문제가 있다.
    • Order는 주문을 표현하는 도메인인데 결제 도메인의 환불 관련 로직이 섞이게 된다.
    • 의존성이 생겨 변경시 같이 변경해야되는 문제가 발생함.

  • 또하나의 문제는 기능을 추가할 때 발생한다.
  • 만약 주문을 취소한 뒤에 환불뿐만 아니라 취소했다는 내용을 통지해야 한다면 ?
  • 환불 도메인 서비스와 동일하게 파라미터로 통지 서비스를 받도록 구현하면 앞서 언급한 로직이 섞이는 문제가 더 커지고 트랜잭션 처리가 더 복잡해진다.
  • 게다가 영향을 주는 외부서비스가 두개나 증가해버렸다.

  • 지금까지 언급한 문제가 발생하는 이유는 주문 Bounded Context와 결제 Bounded Context 간의 강결합 때문이다.
  • 주문이 결제와 강하게 결합되어 있어서 주문 Bounded Context가 결제 Bounded Context에 영향을 받는것이다.
  • 이러한 결합을 없애는 방법이 있는데 그것은 바로 이벤트를 사용하는 것이다.
  • 특히 비동기 이벤트를 사용하면 두 시스템 간의 결합을 크게 낮출 수 있다.

이벤트 개요

  • 여기서 말하는 이벤트란 "과거에 벌어진 어떤 것" 을 의미한다.
  • 예를 들어 사용자가 비밀번호를 변경한 것을 "암호를 변경했음 이벤트" 라고 부를 수 있다.
  • 주문을 취소했다면 ? "주문을 취소했음 이벤트"

  • 이벤트가 발생한다는 것은 상태가 변경됐다는 것을 의미한다.
  • 이벤트는 발생한 것에서 끝나지 않는다.
  • 이벤트가 발생하면 그 이벤트에 반응하여 원하는 동작을 수행하는 기능을 구현한다.

  • 도메인 모델에서도 UI 컴포넌트와 유사하게 도메인의 상태 변경을 이벤트로 표현 할수있다.

이벤트 관련 구성 요소

  • 도메인 모델에서 이벤트 주체는 엔티티, 밸류, 도메인 서비스와 같은 도메인 객체이다.
  • 이들 도메인 객체는 도메인 로직을 실행해서 상태가 바뀌면 관련 이벤트를 발생한다.
  • 이벤트 핸들러는 이벤트 생성 주체가 발생한 이벤트에 반응 한다.
  • 이벤트 핸들러는 생성 주체가 발생한 이벤트를 전달받아 이벤트에 담긴 데이터를 이용해서 원하는 기능을 실행한다.
  • 예를 들어 주문이 취소됐을 경우 "주문 취소됨 이벤트"를 받는 이벤트 핸들러는 해당 주문의 주문자에게 SMS로 주문 취소 사실을 통지할 수 있다.

  • 이벤트 생성 주체와 이벤트 핸들러를 연결해 주는것이 이벤트 디스패처이다.
  • 이벤트 생성 주체는 이벤트를 생성해서 디스패처에 이벤트를 전달한다.
  • 이벤트를 전달받은 디스패처는 해당 이벤트를 처리할 수 있는 핸들러에 이벤트를 전파한다.
  • 이벤트 디스패처의 구현 방식에 따라 이벤트 생성과 처리를 동기나 비동기로 실행하게 된다.

이벤트의 구성

  • 이벤트는 발생한 이벤트에 대한 정보를 담는다.
    • 이벤트 종류: 클래스 이름으로 이벤트 종류를 표현
    • 이벤트 발생 시간
    • 추가 데이터: 주문번호, 신규 배송지 정보등 이벤트와 관련된 정보

public class ShippingInfoChangedEvent {
  private String orderNumber;
  private long timestamp;
  private ShippingInfo newShippingInfo;

  // 생성자, getter
}

  • 클래스이름을 과거형으로 했다 과거에 벌어진것을 표현하기 떄문에 이름에는 과거형을 사용한다.

public class Order {
  public void changeShippingInfo(ShippingInfo newShippingInfo) {
    verifyNotYetShipped();
    setShippingInfo(newShippingInfo);
    Events.raise(new ShippingInfoChangedEvent(number, newShippingInfo));
  }
  ...
}

  • 위 이벤트를 발생시킨 주체는 Order 애그리것이다.
  • Order 애그리것의 배송지 변경 기능을 구현한 메서드는 다음 코드처럼 배송지 정보를 변경한 뒤에 이벤트 디스패처를 이용해서 이벤트를 발생시킬 것이다.

public class ShippingInfoChangedHandler implement EventHandler<ShppingInfoChangedEvent> {

  @Override
  public void handle(ShppingInfoChangedEvent evt) {
    shippingInfoSynchronizer.sync(
      evt.getOrderNumber(),
      evt,getNewShippingInfo();
    )
  }
}
...

  • 변경된 배송지 정보를 물류 서비스에 재전송하는 핸들러는 다음과 같다.
  • ShippingInfoChangedEvent 를 처리하는 핸들러는 디스패처로부터 이벤트를 전달받아 필요한 작업을 수행한다.

  • 이벤트는 이벤트 핸들러가 작업을 수행하는 데 필요한 최소한의 데이터를 담아야 한다.
  • 이 데이터가 부족할 경우 핸들러는 필요한 데이터를 읽기 위해 관련 API를 호출하거나 DB에서 데이터를 직접 읽어와야 한다.
  • 그렇다고 이벤트 자체와 관련 없는 데이터를 포함할 필요는 없다.
  • 바뀐 배송지 정보를 포함하는 것은 맞지만 배송지 정보 변경과 관련없는 주문 상품번호와 개수를 담을 필요는 없다.

이벤트 용도

  • 이벤트는 크게 두 가지 용도로 쓰인다.
  • 첫번쨰는 트리거의 용도로 쓰이는데 도메인의 상태가 바뀔때 다른 후처리를 해야 할 경우 후처리를 실행하기 위한 트리거로 이벤트를 사용할 수 있다.

  • 예매 결과를 SMS로 통지할 떄도 이벤트를 트리거로 사용할 수 있다.
  • 예매 도메인은 예매 완료 이벤트를 발생시키고 이 이벤트 핸들러에서 SMS를 발송시키는 방식으로 구현

  • 이벤트의 두번째 용도는 서로 다른 시스템간의 데이터 동기화이다.
  • 배송지를 변경하면 외부 배송 서비스에 바뀐 배송지 정보를 전송해야 한다.
  • 이 경우 주문 도메인은 배송지 변경 이벤트를 발생시키고 이벤트 핸들러는 외부 배송 서비스와 배송지 정보를 동기화 한다.

이벤트 장점

  • 이벤트를 사용하면 첫장에서 말한 서로 다른 도메인 로직이 섞이는 것을 방지할 수 있다.
  • 또한 기능 확장도 용이해진다. 구매 취소시 환불과 함께 이메일을 보내고 싶다면 이메일 발송을 처리하는 핸들러를 구현하고 디스패처에 등록하면 된다.
  • 기능을 확장해도 도메인 로직은 수정할 필요가 없다.

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

  • 이벤트와 관련된 코드는 다음과 같다.
    • 이벤트 클래스
    • EventHandler: 이벤트 핸들러를 위한 상위 타입으로 모든 핸들러는 이 인터페이스를 구현한다.
    • Events: 이벤트 디스패처. 이벤트 발행, 이벤트 핸들러 등록, 이벤트를 핸들러에 등록하는 등의 기능을 제공한다.

이벤트 클래스

  • 이벤트 자체를 위한 상위 타입은 존재하지 않는다. 원하는 클래스를 이벤트로 사용할 것이다.
  • 이벤트클래스의 이름을 결정할때는 과거 시제를 사용해야 한다.
  • OrderCanceledEvent와 같이 클래스 이름뒤에 접미사로 Event를 사용해서 Event로 사용하는 클래스 라는것을 명시적으로 표현할 수 있고 OrderCanceld처럼 간결함을 위해 과거 시제만 사용할 수도 있다.

public class OrderCanceledEvent extends Event {
		// 이벤트는 핸들러에서 이벤트를 처리하는 데 필요한 데이터를 포함한다.
    private String orderNumber;
    
    public OrderCanceledEvent(String number) {
        super();
        this.orderNumber = number;
    }

    public String getOrderNumber() { return orderNumber; }
}
  • 이벤트클래스는 이벤트를 처리하는데 필요한 최소한의 데이터를 포함해야 한다.

public abstract class Event {
    
    private long timestamp;

    public Event() {
        this.timestamp = System.currentTimeMillis();
    }

    public long getTimestamp() {
        return timestamp;
    }   
}
  • 모든 이벤트가 공통으로 갖는 프로퍼티가 존재한다면 관련 상위 클래스를 만들 수도 있다.
  • 위와 같은 상위 클래스를 만들고 각 이벤트 클래스가 상속받도록 할 수 있다.

EventHandler 인터페이스

  • EventHandler 인터페이스는 이벤트 핸들러를 위한 상위 인터페이스이다.

public interface EventHandler<T> {
    
    // handle 메서드를 이용해서 필요한 기능을 구현한다. 
    void handle(T event);

    // 핸들러가 이벤트를 처리할 수 있는지 여부를 검사한다.
    default boolean canHandle(Object event) {
        Class<?>[] typeArgs = TypeResolver.resolveRawArguments(
            EventHandler.class, this.getClass()
        );
        
        return typeArgs[0].isAssignableForm(event.getClass());
    }
}
  • EventHandler 인터페이스를 상속받는 클래스는 handle() 메서드를 이용해서 필요한 내용을 구현하면 된다.
  • canHandler() 메서드는 핸들러가 이벤트를 처리할 수 있는지 여부를 검사한다.
  • Event의 타입이 T의 파라미터화 타입에 할당 가능하면 true를 리턴한다.

이벤트 디스패처인 Events 구현

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

    @Transactional
    public void cancel(OrderNo orderNo) {
        // handle 메서드에 전달한 EventHandler를 이용해서 이벤트를 처리하게 된다.
        Events.handle(
            (OrderCanceledEvent evt) -> refundService.refund(evt.getOrderNumber())
        );

        Order order = findOrder(orderNo);
        order.cancel();
    
        // ThreadLocal 변수를 초기화해서 OOME가 발생하지 않도록 한다.
        Events.reset();
    }
}
  • 도메인을 사용하는 응용 서비스는 이벤트를 받아 처리할 핸들러를 Events.handle()로 등록하고 도메인 기능을 실행한다.
  • 위 코드는 OrderCancledEvent가 발생하면 Events.handle() 메서드에 전달한 EventHandler를 이용해서 이벤트를 처리하게 된다.
  • Events는 내부적으로 핸들러 목록을 유지하기 위해 ThreadLocal을 사용한다.
  • Events.handle() 메서드는 인자로 전달받은 EventHandler를 List에 보관한다.
  • 이벤트가 발생하면 이벤트를 처리할 EventHandler를 list에서 찾아 EventHandler의 handle() 메서드를 호출해서 이벤트를 처리 한다.

public class Order {
    
    public void cancel() {
        verifyNotYetShipped();
        this.state = OrderState.CANCELED;
        Events.raise(new OrderCanceledEvent(number.getNumber()));
    }
}
  • Events.raise()를 이용해서 이벤트를 발생시키면 Events.raise() 메서드는 이벤트를 처리할 핸들러를 찾아 handle() 메서드를 실행한다.

public class Events {
    
    // EventHandler 목록을 보관하는 ThreadLocal 변수를 생성한다.
    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 {
            // 이벤트 처리 중 상태를 true로 변경한다.
            publishing.set(Boolean.TRUE);
            
            // handlers에 담긴 EventHandler가 파라미터로 전달받은 이벤트를 처리할 수 있는지 확인하고 
            List<EventHandler<?>> eventHandlers = handlers.get();
            if (eventHandlers == null) return;
            for (EventHandler handler: eventHandlers) {
                // handlers에 담긴 EventHandler가 파라미터로 전달받은 이벤트를 처리할 수 있는지 확인한다.
                if (handler.canHandle(event)) {
                    // 처리 가능하면 핸들러의 handle() 메서드에 이벤트 객체를 전달한다.
                    handler.handle(event);
                }
            }
        } finally {
            // 핸들러의 이벤트 처리가 끝나면 처리 중 상태를 False로 변경한다.
            publishing.get(Boolean.FALSE);
        }
    }

    // 이벤트 핸들러를 등록하는 메서드
    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);
    }

    // handlers에 보관된 List 객체를 삭제한다.
    public static void reset() {
        if (!publishing.get()) {
            handlers.remove();
        }
    }
}

출처: https://kjgleh.github.io/ddd/2019/08/18/ddd_start_10.html

public class Order {
    
    public void cancel() {
        verifyNotYetShipped();
        this.state = OrderState.CANCELED;
        
        Events.raise(new OrderCanceledEvent(number.getNumber()));
    }
}

  • Events는 핸들러 목록을 유지하기 위해 ThreadLocal 변수를 사용한다.
  • 톰캣과 같은 웹 애플리케이션 서버는 스레드를 재사용하므로 ThreadLocal에 보관한 값을 제거하지 않으면 기대했던것과 다르게 코드가 동작할수 있다.
  • 예를 들어 사용자의 요청을 처리한 뒤 Events.reset()을 실행하지 않으면 스레드 handlers가 담고 있는 List에 계속 핸들러 객체가 쌓이게 되어 결국 메모리 부족 에러가 발생하게 된다.
  • 따라서 이벤트 핸들러를 등록하는 응용 서비스는 다음과 같이 마지막에 Events.reset() 메서드를 실행해야 한다.

흐름 정리

출처: https://kjgleh.github.io/ddd/2019/08/18/ddd_start_10.html

  1. 이벤트 처리에 필요한 이벤트 핸들러 생성
  1. 이벤트 발생 전에 이벤트 핸들러를 Events.handle() 메서드를 이용해서 등록
  1. 이벤트를 발생하는 도메인 기능을 실행
  1. 도메인은 Events.raise()를 이용해서 이벤트 발생
  1. Events.raise()는 등록된 핸들러의 canHandle()를 이용해서 이벤트를 처리할수 있는지 확인
  1. 핸들러가 처리할 수 있다면 handle()메서드를 이용해서 이벤트를 처리
  1. Events.raise() 실행을 끝내고 리턴
  1. 도메인 기능 실행을 끝내고 리턴
  1. Events.reset()를 이용해서 ThreadLocal을 초기화 한다

  • 코드 흐름을 보면 응용 서비스와 동일한 트랜잭션 범위에서 핸들러의 handle()이 실행되는것을 알수 있다.
  • 즉 도메인의 상태변경과 이벤트 핸들러는 같은 트랜잭션 범위에서 실행된다.

AOP를 이용한 Events.reset() 실행

  • 응용 서비스가 끝나면 ThreadLocal에 등록된 핸들러 목록을 초기화 하기 위해
  • Events.reset() 메서드를 실행한다.
  • 모든 응용 서비스마다 메서드 말미에 Events.reset()을 실행하는 코드를 넣는것은 중복에 해당한다.
  • 이런 류의 중복을 없앨 떄 적합한 것이 바로 AOP다.
  • 코드는 아래와 같다.
@Aspect
// 우선순위를 0으로 지정한다. 이를 통해 트랜잭션 관련 AOP보다 우선순위를 높여 이 AOP가 먼저 적용되도록 한다.
@Order(0)
@Component
public class EventsResetProcessor {
		//서비스 메서드의 중첩 실행 개수를 저장하기 위한 ThreadLocal 변수를 생성한다.
    private ThreadLocal<Integer> nestedCount = new ThreadLocal<Integer>() {
        @Override
        protected Integer initialValue() {
            return new Integer(0);
        }
    };

		// @Around Aspect를 이용해서 AOP를 구현한다.
		// 적용 대상은 com.myshop 패키지 및 그 하위 패키지에 위치한 @Service가 붙은 빈 객체다
		@Around(
			"@target(org.springframework.stereotype.Service) and within(com.myshop..*)")
    public Object doReset(ProceedingJoinPoint joinPoint) throws Throwable {
				// 중첩 실행 횟수를 1 증가한다.
        nestedCount.set(nestedCount.get() + 1);
        try {
            // 대상 메서드를 실행한다.
            return joinPoint.proceed();
        } finally {
            // 중첩 실행 횟수를 1 감소한다.
            nestedCount.set(nestedCount.get() - 1);
            // 중첩 실행횟수가 0이면 Events.reset()을 실행한다.
            if (nestedCount.get() == 0) {
                Events.reset();
            }
        }
    }
}
  • Service 애노테이션을 이용해서 응용 서비스를 지정했는데 @Service를 사용하지 않을 경우
  • @Around의 포인트 컷에 @Target대신 execution() 명시자를 사용해도 된다.

@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();
            }
        }
    }

동기 이벤트 처리 문제

  • 이벤트를 사용해서 강결합 문제는 해소 했지만 외부 서비스에 영향을 받는 문제는 해결하지 못했다.

		@Transactional // 외부 연동 과정에서 익셉션이 발생하면 트랜잭션 처리는 ?
    public void cancel(OrderNo orderNo) {
        Events.handle(
				// refundService.refund()가 오래 걸리면 ?
            (OrderCanceledEvent evt) -> refundService.refund(evt.getOrderNumber())
        );

        Order order = findOrder(orderNo);
        order.cancel();
    
        Events.reset();
    }

  • refundService.refund()가 외부와 연동으로 인해 느려졌다고 가정하면 나머지 후속 호출도 느려지게 된다.
  • 외부서비스의 성능 저하가 바로 내 시스템의 성능 저하로 연결된다는 것을 의미.
  • 성능 저하 뿐만아니라 트랜잭션도 문제가 된다.
  • refundServicerefund()에서 익셉션이 발생하면 cancel() 메서드의 트랜잭션을 롤백해야 할까 ? 롤백하면 구매 취소기능을 롤백하는것이므로 구매 취소에 실패하는 것이다.
  • 하지만 외부의 환불 서비스 실행에 실패했다고해서 반드시 트랜잭션을 롤백해야 하는지에 대한 의문이다
  • 일단 구매 취소 자체는 처리하고 환불만 재처리하거나 수동으로 처리할 수도 있다.

  • 외부 시스템과의 연동을 동기로 처리할 때 발생하는 성능과 트랜잭션 범위 문제를 해소하는 방법중 하나가 이벤트를 비동기로 처리하는 것이다.

비동기 이벤트 처리

  • 우리가 구현해야 할 것 중에 "A하면 이어서 B하라" 는 내용을 담고 있는 요구사항은 실제로
  • "A하면 최대 언제까지 B하라" 인 경우가 많다.
  • 즉 후속조치를 바로 할 필요없이 일정 시간 안에만 처리하면 되는 경우가 적지 않다.
  • 게다가 "A하면 이어서 B하라" 는 요구사항을 B를 하는데 실패하면 일정 간격으로 재시도를 하거나 수동 처리를 해도 상관이 없는 경우가 있다.
  • "A하면 일정 시간안에 B하라" 는 요구사항에서 "A하면" 은 이벤트로 볼수도 있다.
  • "A하면 이어서 B하라" 는 요구사항 중에서 "A하면 최대 언제까지 B하라"로 바꿀수 있는 요구 사항은 이벤트를 비동기 처리하는 방식으로 구현할 수 있다.
  • 다시 말해서, A 이벤트가 발생하면 별도 스레드로 B를 수행하는 핸들러를 실행하는 방식으로 요구사항을 구현할 수 있는 것이다.

  • 이벤트를 비동기로 구현하는 4가지 방법
    1. 로컬 핸들러를 비동기로 실행하기
    1. 메시지 큐를 사용하기
    1. 이벤트 저장소와 이벤트 포워더 사용하기
    1. 이벤트 저장소와 이벤트 제공 API 사용하기

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

  • 로컬핸들러를 비동기로 실행하는 방법은 이벤트 핸들러를 별도 스레드로 실행하는 것이다.
  • 앞서 구현한 Events 클래스에 비동기로 핸들러를 실행하는 기능을 구현한다
    • 비동기로 이벤트 핸들러를 실행할떄 ExecutorService를 사용하고 ExecutorService를 초기화하고 셧다운 기능을 구현

public class Events {
    private static ThreadLocal<List<EventHandler<?>>> handlers =
            new ThreadLocal<>();
    private static ThreadLocal<List<EventHandler<?>>> asyncHandlers =
            new ThreadLocal<>();
    private static ThreadLocal<Boolean> publishing =
            new ThreadLocal<Boolean>() {
                @Override
                protected Boolean initialValue() {
                    return Boolean.FALSE;
                }
            };

    private static ExecutorService executor;

    public static void init(ExecutorService executor) {
        Events.executor = executor;
    }

    public static void close() {
        if (executor != null) {
            executor.shutdown();
            try {
                executor.awaitTermination(10, TimeUnit.SECONDS);
            } catch (InterruptedException e) {
            }
        }
    }

    public static void raise(Object event) {
        if (publishing.get()) return;

        try {
            publishing.set(Boolean.TRUE);

            List<EventHandler<?>> asyncEvtHandlers = asyncHandlers.get();
            if (asyncEvtHandlers != null) {
                for (EventHandler handler : asyncEvtHandlers) {
                    if (handler.canHandle(event)) {
                        executor.submit(() -> handler.handle(event));
                    }
                }
            }
            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);
        }
    }

    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);
    }

    public static void handleAsync(EventHandler<?> handler) {
        if (publishing.get()) return;

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

    public static void reset() {
        if (!publishing.get()) {
            handlers.remove();
            asyncHandlers.remove();
        }
    }
}

  • 비동기로 실행할것은 executor.submit을 이용
  • 동기로 실행할것은 handler.handle로 바로 실행

  • Events.handleAsync()로 등록한 이벤트 핸들러는 별도 스레드로 실행되고
  • 그냥 등록한것들은 같은 스레드에서 실행된다.
  • 한 트랜잭션으로 실행해야하는 이벤트 핸들러는 비동기로 처리하면 안된다
  • 비동기로 처리하면 한 트랜잭션 범위에 묶을수 없기 떄문이다.

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

  • 비동기로 이벤트를 처리해야 할 때 사용하는 또 다른 방법은 RabbitMQ와 같은 메시징 큐를 사용하는 것이다.

출처: https://minkukjo.github.io/dev/2020/11/20/DDD-10/

  • 이벤트가 발생하면 위와 같이 이벤트를 메시지 큐에 보낸다.
  • 메시지 큐는 이벤트를 메시지 리스너에 전달하고 메시지 리스너는 알맞은 이벤트 핸들러를 이용해서 이벤트를 처리한다.
  • 이때 이벤트를 메시지 큐에 저장하는 과정과 메시지 큐에서 이벤트를 읽어와 처리하는 과정은 별도 스레드나 프로세스로 처리된다.
  • 필요하다면 이벤트를 발생하는 도메인 기능과 메시지 큐에 이벤트를 저장하는 절차를 한 트랜잭션으로 묶어야 한다.
  • 도메인 기능을 실행한 결과를 DB에 반영하고 이 과정에서 발생한 이벤트를 메시지 큐에 저장하는 것을 같은 트랜잭션 범위에서 실행하려면 글로벌 트랜잭션이 필요하다.
  • 글로벌 트랜잭션을 사용하면 안전하게 이벤트를 메시지 큐에 전달할 수 있는 장점이 있지만 반대로 글로벌 트랜잭션으로 인해 성능이 떨어지는 단점도 있다.

  • 많은 경우 메시지 큐를 사용하면 보통 이벤트를 발생하는 주체와 이벤트 핸들러가 별도 프로세스에서 동작한다.
  • 이는 자바의 경우 이벤트 발생 JVM과 이벤트 처리 JVM이 다르다는 것을 의미한다.
  • 물론 한 JVM에서 비동기 처리를 위해 메시지 큐를 사용하는 것은 시스템을 복잡하게 만들 뿐이다.

  • RabbitMQ처럼 많이 사용되는 메시징 시스템은 글로벌 트랜잭션 지원과 함께 클러스터와 고가용성을 지원하기 때문에 안정적으로 메시지를 전달할 수 있는 장점이 있다.
  • 또한 다양한 개발언어와 통신 프로토콜을 지원하고 있다.
  • 카프카 또한 많이 사용된다.

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

출처: https://minkukjo.github.io/dev/2020/11/20/DDD-10/

  • 비동기 이벤트를 처리하기위한 또다른 방법은 이벤트를 일단 DB에 저장한 다음 별도 프로그램을 이용해서 이벤트 핸들러에 전달하는 것이다.
  • 이벤트가 발생하면 핸들러는 이벤트 스토리지에 이벤트를 저장한다.
  • 포워더는 별도 스레드를 이용하기 떄문에 이벤트 발행과 처리가 비동기로 처리된다.
  • 이 방식은 도메인의 상태와 이벤트 저장소로 동일한 DB를 사용한다
  • 즉 도메인의 상태 변화와 이벤트 저장이 로컬 트랜잭션으로 처리된다.
  • 이벤트를 물리적 저장소에 보관하기 때문에 핸들러가 이벤트 처리에 실패할 경우 포워더는 다시 이벤트 저장소에서 이벤트를 읽어와 핸들러를 실행하면 된다.

출처: https://minkukjo.github.io/dev/2020/11/20/DDD-10/

  • 또다른 저장소를 이용한 방법은 위와 같이 이벤트를 외부에 제공하는 API를 사용 하는 것이다.
  • API방식과 포워더 방식의 차이점은 이벤트를 전달하는 방식에 있다
  • 포워더 방식에서는 포워더를 이용해 이벤트를 외부에 전달하고
  • API방식은 외부 핸들러가 API 서버를 통해 이벤트 목록을 가져오는 것이다.
    • PUSH와 PULL의 차이인듯
  • 포워더 방식은 이벤트를 어디까지 처리했는지 추적하는 역할이 포워더에 있다면
  • API 방식에서는 이벤트 목록을 요구하는 외부 핸들러가 자신이 어디까지 이벤트를 처리했는지 기억해야 한다.

이벤트 저장소 구현

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

출처: https://developer-jang.tistory.com/26

  • EventEntry
    • 이벤트 저장소에 보관할 데이터
    • EventEntry는 이벤트를 식별하기 위한 id, 이벤트 타입인 type, 직렬화한 데이터인 contentType, 이벤트 데이터 자체인 payload, 이벤트 시간인 timestamp를 갖는다.
  • EventStore
    • 이벤트를 저장하고 조회하는 인터페이스를 제공한다.
  • JdbcEventStore
    • JDBC를 이용한 EventStore 구현 클래스이다.
  • EventApi
    • Rest API를 이용해서 이벤트 목록을 제공하는 컨트롤러이다.

소스참조: https://github.com/madvirus/ddd-start/tree/master/src/main/java/com/myshop/eventstore/api

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

소스참조: https://github.com/madvirus/ddd-start/tree/master/src/main/java/com/myshop/common/event

Rest API 구현

소스참조: https://github.com/madvirus/ddd-start/tree/master/src/main/java/com/myshop/eventstore/ui

포워더 구현

소스참조: https://github.com/madvirus/ddd-start/tree/master/src/main/java/com/myshop/integration

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

  1. 이벤트 소스를 EventEntry에 추가할지 여부다
    • 위에서 구현한 EventEntry는 이벤트 발생 주체가 없다
    • 따라서 특정 주체가 발생한 이벤트만 조회하는 기능을 구현할 수 없다.
    • (알아서 직접구현하라고 함)
  1. 포워더에서 전송 실패를 얼마나 허용할 것이냐 에 대한 여부
    • 특정이벤트에서 계속 전송을 실패하게되면 이 이벤트 때문에 나머지 이벤트를 전송할수 없게된다.
    • 이럴때 실패한 이벤트의 재전송 횟수에 제한을 두어야 한다.
  1. 이벤트 손실에 대한 여부
    • 이벤트 저장소를 사용하는 방식은 이벤트 발생과 이벤트 저장을 한 트랜잭션으로 처리하기 떄문에 트랜잭션에 성공하면 이벤트가 저장소에 보관된다는 것을 보장할 수 있다.
    • 반면 로컬 핸들러를 이용해서 이벤트를 비동기로 처리할 경우 이벤트 처리에 실패하면 이벤트를 유실하게 된다.
  1. 이벤트 순서에 대한 여부
    • 이벤트를 발생 순서대로 외부 시스템에 전달해야 할 경우 이벤트 저장소를 사용하는것이 좋다.
    • 이벤트 저장소는 일단 저장소에 이벤트를 발생순서대로 저장하고 그 순서대로 이벤트 목록을 제공하기 떄문이다.
    • 반면 메시징 시스템은 사용 기술에 따라 이벤트 발생 순서와 메시지 전달 순서가 다를수 있다.
  1. 이벤트 재처리에 대한 여부
    • 동일한 이벤트를 다시 처리해야 할때 이벤트를 어떻게 할지 결정해야 한다.
    • 가장 쉬운 방법은 마지막으로 처리한 이벤트 순번을 기억해 두었다가 이미 처리한 순번의 이벤트가 도착하면 해당 이벤트를 처리하지않고 무시하는것이다.
    • 이외에 이벤트 처리를 멱등으로 처리하는 방법도 있다.

 

https://www.notion.so/8-2-43d0ddc04fad4909a763063ab821a709

강제 버전 증가

 

  • 애그리것에 애그리것 루트 외에 다른 엔티티가 존재하는데 기능 실행 도중 루트가 아닌 다른 엔티티의 값만 변경된다고 하자. 이 경우 JPA는 루트 엔티티의 버전 값을 증가하지 않는다.
  • 연관된 엔티티의 값이 변경된다고 해도 루트 엔티티 자체의 값은 바뀌는 것이 없으므로 루트 엔티티의 버전값을 갱신하지는 않는 것이다.
  • 근데 비록 루트 엔티티의 값이 바뀌지 않았더라도 애그리것의 구성 요소중 일부값이 바뀌면 논리적으로 그 애그리것은 바뀐 것이다.
  • 따라서 애그리것 내에 어떤 구성요소의 상태가 바뀌면 루트 애그리것의 버전 값을 증가해야 비 선점 잠금이 올바르게 동작한다.
  • JPA는 이런 문제를 처리할 수 있도록 EntityManager find() 메서드로 엔티티를 구할 때 강제로 버전 값을 증가시키는 잠금 모드를 지원하고 있다.
  • 다음은 비선점 강제 버전 증가 잠금 모드를 사용해서 엔티티를 구하는 코드의 예제이다.
		public Order findByIdOptimisticLockMode(OrderNo id) {
        Map<String, Object> hints = new HashMap<>();
        hints.put("javax.persistence.lock.timeout", 2000);
        return entityManager.find(Order.class, id, LockModeType.OPTIMISTIC_FORCE_INCREMENT, hints);
    }
  • OPTIMISTIC_FORCE_INCREMENT를 사용하면 해당 엔티티의 상태가 변경되었는지 여부에 상관없이 트랜잭션 종료 시점에 버전 값 증가 처리를 한다.

 

오프라인 선점 잠금

  • 단일 트랜잭션에서 동시에 변경을 막는 선점 잠금 방식과 달리 오프라인 선점 잠금은 여러 트랜잭션에 걸쳐 동시 변경을 막는다.
  • 첫번째 트랜잭션을 시작할 때 오프라인 잠금을 선점하고, 마지막 트랜잭션에서 잠금을 해제한다.
  • 잠금을 해제하기 전까지 다른 사용자는 잠금을 구할 수 없다.

 

  • 예를 들어 수정 기능을 생각해보자, 보통 수정 기능은 두 개의 트랜잭션으로 구성된다.
  • 첫번째 트랜잭션은 폼을 보여주고, 두번째 트랜잭션은 데이터를 수정한다.
  • 오프라인 선점 잠금을 사용하면 폼 요청과정에서 잠금을 선점하고 수정과정에서 잠금을 해제한다.
  • 이미 잠금을 선점한 상태에서 다른 사용자가 폼을 요청하면 과정 2처럼 잠금을 구할 수 없어 에러 화면을 보게 된다.

출처: https://incheol-jung.gitbook.io/docs/study/ddd-start/8

 

  • 위의 그림에서 사용자 A가 과정 3의 수정 요청을 수행하지 않고 프로그램을 종료하면 어떻게 될까 ?
  • 이 경우 잠금을 해제하지 않으므로 다른 사용자는 영원히 잠금을 구할 수 없는 상황이 발생한다.
  • 이런 사태를 방지하기 위해 오프라인 선점 방식은 잠금의 유효 시간을 가져야 한다.
  • 유효시간이 지나면 자동으로 잠금을 해제해서 다른 사용자가 잠금을 일정 시간 후에 다시 구할 수 있도록 해야 한다.

 

  • 사용자 A는 잠금 유효시간이 지난 후 1초 뒤에 3번 과정을 수행했다고 가정하자
  • 잠금이 해제되어 사용자 A는 수정에 실패하게 된다.
  • 이런 상황을 만들지 않으려면 일정 주기로 유효시간을 증가시키는 방식이 필요하다.
  • 예를 들어 수정품에서 1분 단위로 ajax 호출을 해서 잠금 유효 시간을 1분씩 증가시키는 방법이 있다.

 

오프라인 선점 잠금을 위한 LockManager 인터페이스와 관련 클래스

  • 오프라인 선점 잠금은 크게 잠금 선점 시도, 잠금 확인, 잠금 해제, 락 유효시간 연장의 네가지 기능을 제공 해야한다.
public interface LockManager {
    LockId tryLock(String type, String id) throws LockException;
    void checkLock(LockId lockId) throws LockException;
    void releaseLock(LockId lockId) throws LockException;
    void extendLockExpiration(LockId lockId, long inc) throws LockException;
}

 

public class LockId {
    private String value;

    public LockId(String value) {
        this.value = value;
    }

    public String getValue() {
        return value;
    }
}

 

@RequestMapping("/some/edit/{id}")
public String editForm(@PathVariable("id") Long id, ModelMap model) {
	// 1. 오프라인 선점 잠금 시도
	LockId lockId = lockManager.tryLock("data", id);

	// 2. 기능 실행
	Data data = someDao.select(id);
	model.addAttribute("data", data);
	
	// 3. 잠금 해제에 사용할 LockId를 모델에 추가
	model.addAttribute("lockId", lockId);

	return "editForm";
}

 

  • 잠금을 해제할 경우 전달받은 LockId를 이용한다.

 

  • 잠금시 반드시 주어진 lockId를 갖는 잠금이 유효한지 검사해야한다.
    1. 잠금의 유효 시간이 지났으면 이미 다른 사용자가 잠금을 선점한다.
    1. 잠금을 선점하지 않은 사용자가 기능을 실행했다면 기능 실행을 막아야 한다.

DB를 이용한 LockManager 구현

  • 잠금 정보를 저장할 테이블과 인덱스를 생성한다.
  • 쿼리는 MySQL용
CREATE TABLE LOCKS (
    `type` varchar(255),
    id varchar(255),
    lockid varchar(255),
    expiration_time datetime,
    primary key (`type`, id)
) character set utf8;

 

https://www.notion.so/8-2-43d0ddc04fad4909a763063ab821a709

'book > DDD start' 카테고리의 다른 글

11장 CQRS  (0) 2021.07.31
10장 이벤트  (0) 2021.07.28
8장 애그리것 트랜잭션 관리  (0) 2021.07.07
9장 도메인 모델과 바운디드 컨텍스트 2부  (0) 2021.06.30
9장 도메인 모델과 바운디드 컨텍스트 1부  (0) 2021.06.30
https://www.notion.so/8-85ff9c1d45774f8e9ce7177a61d6fad9

 

 

애그리것과 트랜잭션

  • 한 주문 애그리것에 대해 운영자는 배송 상태로 변경할 때 사용자는 배송지 주소를 변경하면 어떻게 될까 ?

 

출처: https://incheol-jung.gitbook.io/docs/study/ddd-start/8

  • 한 애그리것을 두 사용자가 동시에 변경할 때 트랜잭션이 필요하다.

 

  • 메모리 캐시를 사용하지 않을 경우 운영자 스레드와 고객 스레드는 같은 주문 애그리것을 나타내는 다른 객체를 구하게 된다. ( 트랜잭션마다 리포지터리는 새로운 액릐것 객체를 생성한다. )

 

  • 운영자스레드와 고객스레드는 개념적으로 동일한 애그리것이지만 물리적으로는 서로 다른 애그리것 객체를 사용한다.
  • 떄문에 운영자 스레드가 주문 애그리것 객체를 배송 상태로 변경하더라도 고객 스레드가 사용하는 주문애그리것 객체에는 영향을 주지 않는다.
  • 고객스레드 입장에서 주문 애그리것 객체는 아직 배송 상태 전이므로 배송 정보를 변경할 수 있다.

 

  • 이 상황에서 두 스레드는 각각 트랜잭션을 커밋할 때 수정한 내용을 DBMS에 반영한다.
  • 즉 배송상태로 바뀌고 배송지 정보도 바뀌게 된다.
  • 이 순서의 문제점은 운영자는 기존 배송지 정보를 이용해서 배송상태로 변경했는데 그사이 고객은 배송지 정보를 변경했다는 점이다. 즉 애그리것의 일관성이 깨지는 것이다.

 

  • 이 문제를 방지하려면 두가지 중 하나를 해야 한다.
    1. 운영자가 배송지 정보를 조회하고 상태 변경하는 동안 고객이 애그리것을 수정하지 못하게 막는다.
    1. 운영자가 배송지 정보를 조회한 후에 고객이 정보를 변경하면 운영자가 애그리것을 다시 조회한 뒤 수정 하도록 한다.
  • 대표적인 트랜잭션 처리 기법에는 선점 잠금과 비선점 잠금의 두가지 방식이 있다.
  • 선점 잠금을 비관적 잠금, 비선점 잠금을 낙관적 잠금 이라고 표현하기도 한다.

 

선점 잠금

  • 선점 잠금은 먼저 애그리것을 구한 스레드가 애그리것 사용이 끝날 때 까지 다른 스레드가 해당 애그리것을 수정하는 것을 막는 방식이다.

출처: https://incheol-jung.gitbook.io/docs/study/ddd-start/8

 

  • 쓰레드1이 선점 잠금 방식으로 애그리것을 구한 뒤 이어서 쓰레드2가 애그리것을 구하고 있는데 이경우 쓰레드2는 쓰레드1이 애그리것에 대한 잠금을 해제할때까지 블로킹 된다.

 

  • 쓰레드1이 애그리것을 수정하고 트랜잭션을 커밋하면 잠금을 해제 한다.
  • 이 순간 대기하고 있던 쓰레드2가 애그리것에 접근하게 된다.
  • 쓰레드2는 이미 쓰레드1이 트랜잭션을 커밋한 뒤에 애그리것을 구하게 되므로 쓰레드2는 쓰레드1이 수정한 애그리것의 내용을 보게 된다.

 

  • 쓰레드1이 수정하는 동안 다른 쓰레드가 애그리것을 구할수 없으므로 동시에 애그리것을 수정할때 발생하는 데이터 충돌 문제를 해소할수 있다.

 

  • 선점잠금은 보통 DBMS가 제공하는 행 단위의 잠금을 사용해서 구현한다.
  • 오라클을 비롯한 다수 DBMS가 for update와 같은 쿼리를 사용해서 특정 레코드에 한 사용자만 접근할 수 있는 잠금 장치를 제공한다.

 

  • JPA의 EntityManager는 LockModeType을 인자로 받는 find() 메서드를 제공하는데, LockModeType.PESSIMISTIC_WRITE를 값으로 전달하면 해당 엔티티와 매핑된 테이블을 이용해서 선점 잠금 방식을 적용할 수 있다

 

선점 잠금과 교착 상태

  • 선점 잠금 기능을 사용할 떄는 잠금 순서에 따른 교착 상태 ( Dead Lock )이 발생하지 않도록 주의 해야 한다.
  • 예를 들어 다음과 같은 순서로 두 스레드가 잠금 시도를 한다고 해보자.
    1. 스레드1: A 애그리것에 대한 선점 잠금 구함
    1. 스레드2: B 애그리것에 대한 선점 잠금 구함
    1. 스레드1: B 애그리것에 대한 선점 잠금 시도
    1. 스레드2: A 애그리것에 대한 선점 잠금 시도
  • 이 순서에 따르면 스레드1은 영원히 B 애그리것에 대한 선점 잠금을 구할 수 없다.
  • 왜냐면 스레드2가 B 애그리것에 대한 잠금을 이미 선점하고 있기 떄문이다.
  • 동일한 이유로 스레드2도 A애그리것에 대한 잠금을 구할 수 없다.
  • 두 스레드는 상대방 스레드가 먼저 선점한 잠금을 구할 수 없어 더이상 다음 단계를 진행하지 못하게 된다.
  • 스레드1과 스레드2는 데드락에 빠지게 되는 것이다.

 

  • 선점 잠금에 따른 교착 상태는 상대적으로 사용자 수가 많을 때 발생할 가능성이 높고 사용자 수가 많아지면 교착 상태에 빠지는 스레드가 더 빠르게 증가하게 된다.
  • 더 많은 스레드가 교착 상태에 빠질수록 시스템은 점점 아무것도 할 수 없는 상황에 이르게 된다.

 

  • 이런 문제가 발생하지 않도록 하려면 잠금을 구할 때 까지 최대 대기시간을 지정해야 한다.
  • JPA에서 선점 잠금을 시도할 때 최대 대기시간을 지정하려면 다음과 같이 힌트를 사용하면 된다.

 

Map<String, Object> hints = new HashMap<>();
hints.put("javax.persistence.lock.timeout", 2000);
Order order = entityManager.find( Order.class, orderNo, 
		LockModeType.PRESSIMISTIC_WRITE, hints);

 

  • 지정한 시간 이내에 잠금을 구하지 못하면 익셉션을 발생 시킨다.
  • 이 힌트를 사용할 떄 주의할 점은 DBMS에 따라 힌트가 적용되지 않는다는 것이다.
  • 이 힌트를 이용할 떄에는 사용 중인 DBMS가 관련 기능을 지원하는지 확인해야 한다.

 

  • DBMS에 따라 교체 상태에 빠진 커넥션을 처리하는 방식이 다르다.
  • 쿼리별로 대기 시간을 지정할수 있는 DBMS가 있고 커넥션 단위로만 대기 시간을 지정할 수 있는 DBMS도 있다.
  • 따라서 선점 잠금을 사용하려면 사용하고 있는 DBMS에 대해 JPA가 어떤 식으로 대기 시간을 처리하는지 반드시 확인해야 한다.

 

비선점 잠금

  • 선점잠금이 강력해 보이지만 선점 잠금으로 모든 트랜잭션 충돌문제가 해결되는것은 아니다.

출처: https://incheol-jung.gitbook.io/docs/study/ddd-start/8

감사합니다 인철이형님 [그림 8.4]

 

  • 실행 순서는 다음과 같다.
    1. 운영자는 배송을 위해 주문 정보를 조회 한다. 시스템은 정보를 제공한다.
    1. 고객이 배송지 변경을 위해 변경 폼을 요청한다. 시스템은 변경 폼을 제공한다.
    1. 고객이 새로운 배송지를 입력하고 폼을 전송해서 배송지를 변경한다.
    1. 운영자가 1번에서 조회한 주문 정보를 기준으로 배송지를 정하고 배송 상태 변경을 요청한다.
  • 여기서 문제는 운영자가 배송지 정보를 조회하고 배송 상태로 변경하는 사이에 고객이 배송지를 변경한다는 것이다.
  • 운영자는 고객이 변경하기 전의 배송지 정보를 이용해서 배송 준비를 한 뒤에 배송 상태로 변경하게 된다.
  • 즉 배송상태 변경전에 배송지를 한번 더 확인하지 않으면 운영자는 다른배송지로 물건을 발송하게 되고 고객은 배송지를 변경했음에도 불구하고 엉뚱한 곳으로 주문한 물건을 받는 상황이 발생한다.
  • 이 문제는 선점 잠금 방식으로는 해결할 수 없다.
  • 이때 필요한것이 비선점 잠금이다. ( 낙관적 잠금 )
  • 비선점 잠금 방식은 잠금을 해서 동시에 접근하는것을 막는 대신 변경한 데이터를 실제 DBMS에 반영하는 시점에 변경 가능 여부를 확인하는 방식이다.
  • 비선점 잠금을 구현하려면 애그리것에 버전으로 사용할 숫자 타입의 프로퍼티를 추가해야 한다.
  • 애그리것을 수정할 때마다 버전으로 사용할 프로퍼티의 값이 1씩 증가하는데 이때 다음과 같은 쿼리를 사용한다.
UPDATE aggtable SET version = version + 1, colx = ?, coly = ?
WHERE aggid = ? and version = 현재버전
  • 이 쿼리는 수정할 애그리것과 매핑되는 테이블의 버전값이 현재 애그리것의 버전과 동일한 경우에만 데이터를 수정한다.
  • 그리고 수정에 성공하면 버전값을 1 증가시킨다.
  • 따라서 다른 트랜잭션이 먼저 데이터를 수정해서 버전값이 바뀌면 데이터 수정에 실패하게 된다.

 

 

출처: https://incheol-jung.gitbook.io/docs/study/ddd-start/8

 

  • 스레드1과 스레드2는 같은 버전을 갖는 애그리것을 읽어와 수정하고 있다.
  • 스레드1이 먼저 커밋시도, 이 시점에 애그리것의 버전은 5지만 스레드1이 수정에 성공하고 버전은 6이 된다.
  • 스레드1이 트랜잭션을 커밋한 후에 스레드2가 커밋을 시도하는데 이미 애그리것 버전이 6이므로 스레드2는 데이터 수정에 실패하게 된다.

 

  • JPA는 버전을 이용한 비선점 잠금 기능을 지원한다.
@Entity
@Table(name = "purchage_order")
@Access(AccessType.FIELD)
public class Order {
	@EmbeddedId
	private OrderNo number;

	@Version
	private long version;
	
	...
}

 

  • JPA는 엔티티가 변경되어 UPDATE 쿼리를 실행할때 @Version에 명시한 필드를 이용해서 비선점 잠금 쿼리를 실행한다.
  • 즉 애그리것 객체의 버전이 10이면 UPDATE쿼리를 실행할때 아래와 같은 쿼리를 실행한다
UPDATE purchage_order SET ..., version = version + 1
	WHERE number = ? and version = 10

 

  • 응용서비스는 버전에 대해 알 필요가 없다.
  • 리포지토리에서 필요한 애그리것을 구하고 알맞은 기능만 실행하면 된다.
  • 기능을 실행하는 과정에서 애그리것의 데이터가 변경되면 JPA는 트랜잭션 종료 시점에 비선점 잠금을 위한 쿼리를 실행한다.
public class ChangeShippingService {
    private OrderRepository orderRepository;

    @Transactional
    public void changeShipping(ChangeShippingRequest changeReq) {
        Order order = orderRepository.findById(new OrderNo(changeReq.getNumber()));
        checkNoOrder(order);
        order.changeShippingInfo(changeReq.getShippingInfo());
    }
		...
}

 

  • 비선점 잠금을 위한 쿼리를 실행할 때 쿼리 실행 결과로 수정된 행의 개수가 0이면 이미 누군가 앞서 데이터를 수정한 것이다.
  • 이는 트랜잭션이 충돌한 것이므로 트랜잭션 종료 시점에 익셉션이 발생한다.
  • 위 코드의 경우 스프링의 @Transactional을 이용해서 트랜잭션 범위를 정했으므로 changeShipping() 메서드가 리턴될때 트랜잭션이 종료되고 이시점에 트랜잭션 충돌이 발생하면 OptimisticLockingFailureException을 발생시킨다.

 

  • 표현 영역의 코드는 이 익셉션의 발생 여부에 따라 트랜잭션 충돌이 일어났는지 확인할 수 있다.
@Controller
public class OrderController {
	...
	@RequestMapping(value = "/changeShipping", method = RequestMethod.POST)
	public String changeShipping(ChangeShippingRequest changeReq) {
		try {
			changeShippingService.changeShipping(changeReq);
			return "changeShippingSuccess";
		} catch(optimisticLockingFailureException ex) {
				// 누군가 먼저 같은 주문 애그리거트를 수정했으므로, 
				// 트랜잭션 충돌이 일어났다는 메시지를 보여준다. 
				return "changeShippingExConflic";
		}
}

 

  • 비선점 잠금을 확장해서 적용할 수 있다.
  • 시스템은 사용자에게 수정 폼을 제공할 때 애그리것 버전을 함꼐 전송하고 사용자가 폼을 전송할 때와 폼을 생성할 때 사용한 애그리것 버전을 함께 전송하도록 할 수 있다.
  • 시스템을 애그리것을 수정할 때 사용자가 전송한 버전과 애그리것 버전이 동일한 경우에만 수정 기능을 수행하도로 함으로써 트랜잭션 충돌문제를 해소할 수 있다.

 

출처: https://incheol-jung.gitbook.io/docs/study/ddd-start/8

 

  • 위의 과정2에서 운영자는 배송 상태 변경을 요청할 때 앞서 과정 1을 통해 받은 애그리것의 버전값을 함께 전송한다.
  • 시스템은 애그리것을 읽는데 해당 시점의 버전 값도 함께 읽어온다. 만약 과정 1에서 받은 버전 A와 과정 2.1을 통해 읽은 애그리것의 버전 B가 다르면 과정 1과 과정 2 사이에 다른 사용자가 해당 애그리것을 수정한 것이다.
  • 이 경우 시스템은 운영자가 이전 데이터를 기준으로 작업을 요청한 것으로 간주하여 과정 2.1.2와 같이 수정할 수 없다는 에러를 응답으로 전송한다.
  • 만약 A와 B의 버전이 같다면 과정 1과 과정 2 사이에 아무도 애그리것을 수정하지 않은것이므로 이 경우 시스템은 과정 2.1.3과 같이 애그리것을 수정하고 과정 2.1.4를 이용해서 변경 내용을 DBMS에 반영한다.
  • 과정 2.1.1과 과정 2.1.4사이에 아무도 애그리것을 수정하지 않았다면 커밋에 성공하므로 성공 결과를 응답으로 전송한다.
  • 반면에 과정 2.1.1과 과정 2.1.4사이에 누군가 애그리것을 수정해서 커밋했다면 버전값이 증가한 상태가 되므로 트랜잭션 커밋에 실패하고 결과로 에러 응답을 전송한다.

 

  • 위와 같이 비선점 잠금 방식을 여러 트랜잭션으로 확장하려면 애그리것 정보를 뷰로 보여줄때 버전 정보도 함께 사용자 화면에 전달해야 한다.
  • HTML 폼을 생성하는 경우 버전 값을 갖는 hidden 타입 (input) 태그를 생성해서 폼 전송 시 버전 값이 서버에 함께 전달되도록 한다.

 

 

  • 사용자 요청을 처리하는 응용서비스를 위한 요청 데이터는 사용자가 전송한 버전값을 포함한다.
  • 예를 들어, 배송 상태 변경을 처리하는 응용 서비스가 전달받는 데이터는 다음과 같이 주문 번호와 함께 해당 주문을 조회한 시점의 버전 값을 포함해야 한다.
public class StartShippingRequest {
    private String orderNumber;
    private long version;

    protected StartShippingRequest() {}

    public StartShippingRequest(String orderNumber, long version) {
        this.orderNumber = orderNumber;
        this.version = version;
    }

    public String getOrderNumber() {
        return orderNumber;
    }

    public long getVersion() {
        return version;
    }
}

 

  • 응용서비스는 전달받은 버전 값을 이용해서 애그리것의 버전과 일치하는지 확인하고 일치하는 경우에만 요청한 기능을 수행한다.
@Service
public class StartShippingService {
    private OrderRepository orderRepository;

    @Transactional
    public void startShipping(StartShippingRequest req) {
        Order order = orderRepository.findById(new OrderNo(req.getOrderNumber()));
        checkNoOrder(order);
        if (!order.matchVersion(req.getVersion())) {
//            throw new VersionConflictException();
            throw new OptimisticLockingFailureException("version conflict");
        }
        order.startShipping();
    }

    @Autowired
    public void setOrderRepository(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }
}

 

  • 버전이 맞지않으면 표현 계층으로 exception 을 전달한다.

 

@Controller
public class OrderAdminController {
	private StartShippingService startShippingService;

	@RequestMapping(value = "/startShipping", method = RequestMethod.POST)
	public String startShipping(StartShippingRequest startReq) {
		try {
			startShippingService.startShipping(startReq);
			return "shippingStarted";
		} catch(OptimisticLockingFailureException | VersionConflictException ex) {
			// 트랜잭션 충돌
			return "startShippingTxConflict";
		}
	}
	...
  • 표현 계층에서 익셉션을 받아 처리한다.
  • VersionConflictException은 응용 서비스코드에서 발생시키고 OptimisticLockingFailureException 는 스프링에서 발생시킨다.
  • 버전 충돌상황에 대한 구분이 명시적으로 필요 없다면 응용 서비스에서 프레임워크용 익셉션을 발생시켜도 된다.

 

 

https://www.notion.so/8-85ff9c1d45774f8e9ce7177a61d6fad9

https://www.notion.so/8-85ff9c1d45774f8e9ce7177a61d6fad9

https://www.notion.so/9-2-f46f116cce4745a09a32a815fc297323

Bounded Context간의 관계

출처: https://minkukjo.github.io/dev/2020/11/11/DDD-09/

  • Bounded Context는 다양한 방식으로 관계를 맺는다.
  • 두 Bounded Context간 관계중 흔한 관계는 한쪽에서 API를 제공하고 다른 한쪽에서 그 API를 호출하는 관계다. 대표적인게 REST API 다.
  • 이것을 공급자/고객 관계라고 한다.
  • 공급자는 상류 컴포넌트, 고객은 하류 컴포넌트라고 칭한다.
  • 상류 컴포넌트가 제공하는 데이터를 하류 컴포넌트가 의존한다.
  • 하류는 상류에 의존하므로 상류가 제공하는 인터페이스가 바뀌면 하류의 코드도 바뀐다.

  • 상류는 보통 하류가 사용할 수 있는 통신 프로토콜을 정의하고 이를 공개한다.
  • 예를 들어 상류는 하류가 사용할 수 있는 REST API를 제공할 수 있다.
  • 상류의 고객인 하류가 다수 존재하면 상류는 여러 하류의 요구사항을 수용할 수 있는 API를 만들고 이를 서비스 형태로 공개해서 서비스의 일관성을 유지할 수 있다.
  • 이런 서비스를 가리켜 공개 호스트 서비스 (OPEN HOST SERVICE) 라고 한다.

출처: https://minkukjo.github.io/dev/2020/11/11/DDD-09/

  • 이런 서비스의 대표적인 예가 검색이다.
  • 블로그, 카페, 게시판과 같은 서비스를 제공하는 포탈은 각 서비스 별로 검색 기능을 구현하기 보다는 검색을 위한 전용 시스템을 구축하고 검색 시스템과 각 서비스를 통합한다.
  • 이때 검색은 상류 컴포넌트 가 되고 블로그 카페 게시판은 하류 컴포넌트가 된다.
  • 상류 컴포넌트는 각 하류 컴포넌트의 요구사항을 수용하는 단일 API를 만들어 이를 공개하고 각 하류팀은 공개된 API를 사용해서 검색 기능을 구현하게 된다.

  • 상류 컴포넌트의 서비스는 상류 Bounded Context를 따르므로 하류는 하류 Bounded Context가 오염되지 않게 막아주도록하는 완충지대를 만들어야 한다.

출처: https://nesoy.github.io/articles/2018-07/DDD-Bounded-Context

  • 이 그림에서 RecSystemClient는 외부 시스템과 연동을 처리하는데 외부 시스템의 도메인 모델이 내 도메인 모델을 침범하지 않도록 하는 역할을 한다.
  • 즉 이것이 안티코럽션 계층이 된다.

  • 두 Bounded Context가 같은 모델을 공유하는 경우도 있다.
  • 예를 들어 운영자를 위한 주문 관리 도구를 개발하는 팀과 고객을 위한 주문 서비스를 개발하는 팀이 다르다고 가정하자.
  • 이 두팀은 주문을 표현하는 모델을 공유함으로써 주문과 관련된 중복 개발을 막을 수 있다.
  • 이렇게 두팀이 공유하는 모델을 공유 커널 이라고 부른다

  • 공유 커널의 장점은 중복을 줄여준다는 것
  • 하지만 두팀이 한 모델을 공유하기 때문에 한 팀에서 임의로 모델을 변경하면 안된다.
  • 그래서 두팀이 밀접한 관계를 유지하며 계속 소통 해야한다.
  • 두팀이 소통이 안되면 공유 커널로 인한 장점보다 단점이 더 많아진다.

  • 독립방식 관계
  • 이 관계는 아예 두 Bounded Context를 통합하지 않는 방식이다.
  • 그러므로 서로 독립적으로 발전시켜 나간다.

출처: https://minkukjo.github.io/dev/2020/11/11/DDD-09/

  • 예를 들어 온라인 쇼핑몰 솔루션과 외부의 ERP 서비스를 사용하고 있다고 하자.
  • 온라인 쇼핑몰 솔루션은 외부 ERP 서비스와의 연동을 지원하지 않으므로 온라인 쇼핑몰에서 판매가 발생하면 쇼핑몰 운영자는 쇼핑몰 시스템에서 판매 정보를 보고 ERP 시스템에 입력해야 한다.

  • 수동으로 통합하는 방식은 규모가 커질수록 한계가 있다.
  • 그러므로 결국 통합해야 한다.

출처: https://minkukjo.github.io/dev/2020/11/11/DDD-09/

  • 이때 외부에서 구매한 솔루션과 ERP를 완전히 대체할 수 없다면 두 Bounded Context를 통합해주는 별도의 시스템 (번역기) 을 만들어야 할 수도 있다.

컨텍스트 맵

  • 개별 Bounded Context에 매몰되면 전체를 보지 못할때가 있다.
  • 나무만 보다 숲을 보지 못하는 상황을 방지하려면 전체 비지니스를 조망할수 있는 지도가 필요한데 그것이 컨텍스트 맵이다.

  • 그림만봐도 한눈에 각 Bounded Context의 경계가 명확하게 드러나도 서로 어떤 관계를 맺고 있는지 알 수 있다.
  • Bounded Context 영역에 주요 애그리것을 함께 표시하면 모델에 대한 관계가 더 명확히 드러난다.

출처: https://minkukjo.github.io/dev/2020/11/11/DDD-09/

  • 컨텍스트맵은 시스템의 전체 구조를 보여준다. 이는 하위 도메인과 일치하지 않는 Bounded Context를 찾아 도메인에 맞게 Bounded Context를 조절하고 사업의 핵심 도메인을 위해 조직 역량을 어떤 Bounded Context에 집중할지 파악하는데 도움을 준다.

  • 컨텍스트 맵은 따로 그리는 규칙은 없다.
  • 간단한 도형과 선을 이용해서 각 컨텍스트의 관계를 이해할수 있는 수준에서 그리면 된다.

(Published Language등이 책에 없다.)

 

https://www.notion.so/9-1-fcb6b44a0067439a933d23aa8280ca4e

도메인 모델과 경계

  • 처음 도메인 모델을 만들때 빠지기 쉬운 함정이 도메인을 완벽하게 표현하는 단일 모델을 만드는 시도를 하는 것이다.
  • 1장에서 말한거처럼 한 도메인은 다시 여러 하위 도메인으로 구분되기 떄문에 한개의 모델로 여러 하위 도메인을 모두 표현하려고 시도하게 되면 모든 하위 도메인에 맞지 않는 모델을 만들게 된다.

 

  • 상품이라는 모델을 생각해보면 카탈로그에서의 상품, 재고 관리에서의 상품, 주문에서의 상품, 배송에서의 상품은 이름만 같지 실제로 의미하는것은 다르다.
  • 카탈로그에서의 상품은 상품 이미지, 상품명, 상품 가격, 옵션목록과 같은 상품정보 위주라면 재고 관리에서의 상품은 실존하는 개별 객체를 추적하기 위한 목적으로 상품을 사용한다.
  • 즉 카탈로그는 물리적으로 한 개인의 상품이 재고 관리에서는 여러 개 존재할 수 있다.

 

  • 논리적으로 같은 존재처럼 보이지만 하위 도메인에 따라 다른 용어를 사용하는 경우도 있다.
  • 카탈로그 도메인에서 상품이 검색 도메인에서는 문서로 불리기도 한다.
  • 비슷하게 시스템을 사용하는 사람을 회원 도메인에서 회원이라고 부르지만 주문 도메인에서는 주문자 라고 부르고 배송 도메인에서는 보내는 사람이라 부르기도 한다.
  • 이렇게 하위 도메인마다 같은 용어라도 의미가 다르고 같은 대상이라도 지칭하는 용어가 다를수 있기 떄문에 한개의 모델로 모든 하위 도메인을 표현하려는 시도는 올바른 방법이 아니며 표현할 수도 없다.

 

  • 하위 도메인마다 사용하는 용어가 다르기 떄문에 올바른 도메인 모델을 개발하기 위해서라면 하위 도메인마다 모델을 만들어야 한다.
  • 각 모델은 명시적으로 구분되는 경계를 가져서 섞이지 않도록 해야 한다.
  • 여러 하위도메인의 모델이 섞이기 시작하면 모델의 의미가 약해질뿐 아니라 여러 도메인의 모델이 서로 얽혀 있기 떄문에 각 하위 도메인 별로 다르게 발전하는 요구사항을 모델에 반영하기 어려워 진다.

 

  • 모델은 특정한 컨텍스트(문맥) 하에 완전한 의미를 갖는다.
  • 같은 제품이라도 카탈로그 컨텍스트와 재고 컨텍스트에서 의미가 서로 다르다.
  • 이렇게 구분되는 경계를 갖는 컨텍스트를 DDD에서는 바운디드 컨텍스트 라고 부른다.

 

Bounded Context

  • Bounded Context는 모델의 경계를 결정하며
  • 한 개의 Bounded Context는 논리적으로 한개의 모델을 갖는다.

 

  • Bounded Context는 용어를 기준으로 구분 한다.
  • 카탈로그 컨텍스트와 재고 컨텍스트는 서로 다른 용어를 사용하므로 이 용어를 기준으로 컨텍스트를 분리할수 있다.
  • Bounded Context는 실제로 사용자에게 기능을 제공하는 물리적 시스템으로 도메인 모델은 이 Context안에서 도메인을 구현한다.

 

  • 이상적으로 하위 도메인과 Bounded Context가 1대1 관계를 가지면 좋겠지만 현실은 그렇지 않을 때가 많다.
  • Bounded Context가 기업의 팀 조직 구조에 따라 결정되기도 한다.
  • 예를 들어, 주문 하위 도메인이라도 주문을 처리하는 팀과 복잡한 결제 금액 계산 로직을 구현하는 팀이 따로 있다고 해보자. 이 경우 주문 하위도메인에 주문 Bounded Context와 결제 금액 계산 Bounded Context가 존재하게 된다.
  • 아직 용어를 명확하게 하지 못해 두 하위 도메인을 한 Bounded Context에서 구현하기도 한다.
  • 카탈로그와 재고 관리가 아직 명확하게 구분되지 않은 경우 두 하위 도메인을 한 Bounded Context에서 구현하기도 한다.

 

  • 규모가 작은 기업은 전체 시스템을 한 개 팀에서 구현할 떄도 있다.
  • 예를 들어 소규모 쇼핑몰을 운영할 경우 한개의 웹 애플리케이션으로 온라인 쇼핑을 서비스한다.
  • 이 경우 하나의 시스템에서 회원, 카탈로그, 재고, 구매, 결제와 관련된 기능을 제공 한다. 즉 여러 하위도메인을 한 개의 Bounded Context에서 구현한다

 

  • 여러 하위 도메인을 하나의 Bounded Context에서 개발할때 주의할 점은 하위 도메인의 모델이 뒤섞이지 않도록 하는것이다.
  • 한개의 이클립스 프로젝트에 각 하위 도메인의 모델이 위치하면 아무래도 전체 하위 도메인을 위한 단일 모델을 만들고 싶은 유혹에 빠지기 쉽다.
  • 이런 유혹에 걸려들면 결과적으로 도메인 모델이 개별 하위 도메인을 제대로 반영하지 못해서 하위 도메인별 기능 확장이 어렵게 되고 이는 서비스의 경쟁력을 떨어뜨리는 원인이 된다.
  • 따라서 비록 한개의 Bounded Context에서 여러 하위 도메인을 포함하더라도 하위 도메인마다 구분되는 패키지를 갖도록 구현해야 하위 도메인을 위한 모델이 서로 뒤섞이지 않아서 하위 도메인마다 Bounded Context를 갖는 효과를 낼 수 있다.

 

  • Bounded Context는 도메인 모델을 구분하는 경계 가 되기 때문에 Bounded Context는 구현하는 하위 도메인에 알맞은 모델을 포함한다.
  • 같은 사용자라 하더라도 주문 Bounded Context와 회원 Bounded Context가 갖는 모델이 달라진다.
  • 또한 같은 상품이라도 카탈로그 Bounded Context의 Product와 재고 Bounded Context의 Product는 각 컨텍스트에 맞는 모델을 갖는다.
  • 따라서 회원의 Member는 애그리것 루트이지만 주문의 Orderer는 밸류가 되고 카탈로그의 Product는 상품이 속할 Catergory와 연관을 갖지만 재고의 Product는 카탈로그의 Category와 연관을 맺지 않는다.

 

Bounded Context의 구현

출처: https://minkukjo.github.io/dev/2020/11/11/DDD-09/

 

  • Bounded Context는 도메인 모델 뿐만 아니라 도메인의 기능을 사용자에게 제공하는 데 필요한 표현 영역, 응용 서비스, 인프라 영역등을 모두 포함한다.
  • 도메인 모델의 데이터 구조가 바뀌면 DB 테이블 스키마도 함께 변경해야 하므로 해당 테이블도 Bounded Context에 포함된다.

 

 

  • 표현영역은 인간 사용자를 위하 HTML 페이지를 생성할 수도 있고 다른 Bounded Context를 위해 Rest API를 제공할 수도 있다.

 

출처: https://minkukjo.github.io/dev/2020/11/11/DDD-09/

 

  • 모든 Bounded Context가 반드시 DDD로 개발할 필요는 없다.
  • 상품의 리뷰는 복잡한 도메인 로직을 갖지 않기 때문에 CRUD 방식으로 구현해도 된다.
  • 즉, DAO와 데이터 중심의 밸류 객체를 이용해서 리뷰기능을 구현해도 기능을 유지보수 하는데 큰 문제가 없다.

 

  • 한 Bounded Context에서 두 방식을 혼합해서 사용할 수도 있다.
  • 대표적인 예가 CQRS 패턴이다.
    출처: https://leejaedoo.github.io/bounded_context/
    • CQRS는 상태를 변경하는 명령과, 조회하는 쿼리 기닝을 위한 모델을 구분하는 패턴이다.
    • 이 패턴을 단일 Bounded Context에 적용하면 상태를 변경하는 기능은 도메인 모델 기반으로 구현하고 조회 기능은 서비스-DAO를 이용해서 구현할 수 있다.
  •  
  • 각 Bounded Context는 서로 다른 구현기술을 사용할 수도 있다.
  • 웹 MVC는 스프링 MVC를 사용하고 리포지터리 구현 기술로는 JPA/하이버네이트를 사용하는 Bounded Context가 존재하고
  • Netty를 사용해서 RestAPi를 제공하고 Mybatis를 리포지터리 구현 기술로 사용하는 Bounded Context가 존재할수도 있다.
  • 어떤 Bounded Context는 RDBMS대신 HBase나 몽고 DB와 같은 NoSQL을 사용할 수도 있을 것이다.

 

  • 예를들어 상품상세 정보를 보여주는 페이지를 생각해보면
  • 웹 브라우저는 카탈로그 Bounded Context를 통해 상세 정보를 읽어온 뒤 리뷰 Bounded Context의 Rest API를 직접 호출해서 로딩한 JSON 데이터를 알맞게 가공해서 리뷰 목록을 보여줄 수도 있다.
  • 아니면 UI 를 처리하는 서버를 따루 두고 UI 서버에서 Bounded Context와 통신해서 사용자의 요청을 처리하는 방법도 있다.

 

Bounded Context 간 통합

  • 두팀이 관련된 Bounded Context를 개발하면 자연스럽게 Bounded Context간의 통합이 발생한다.
    • 사용자가 제품 상세 페이지를 볼때 보고있는 상품과 유사한 상품 목록을 하단에 보여준다
    • 위와 같은 조건의 기능이 필요할때 Bounded Context간의 통합이 필요하다.
    • 카탈로그 Bounded Context와 추천 Bounded Context의 통합

 

  • 카탈로그 컨텍스트와 추천 컨텍스트의 도메인 모델은 서로 다르다.
  • 카탈로그는 제품 중심으로 도메인 모델을 구현
  • 추천은 추천 연산을 위한 모델을 구현한다.
  • 예를 들어 추천 시스템은 상품의 상세정보를 포함하지 않으며 상품 번호 대신 아이템 ID라는 용어를 사용해서 식별자를 표현하고 추천 순위와 같은 데이터를 담게 된다.
  • 카탈로그 시스템은 추천 시스템으로부터 추천 데이터를 받아오지만 카탈로그 시스템에서는 추천의 도메인 모델을 사용하기 보다는 카탈로그 도메인을 사용해서 추천 상품을 표현해야 한다.
  • 즉 다음과 같이 카탈로그 모델을 기반으로 하는 도메인 서비스를 이용해서 상품 추천 기능을 표현해야 한다.

 

// 상품 추천 기능을 표현하는 카탈로그 도메인 서비스
public interface ProductRecommendationService {
    public List<Product> getRecommendationsOf(ProductId id);
}

 

  • 도메인 서비스를 구현한 클래스는 인프라스트럭처 영역에 위치한다.
  • 이 클래스는 외부 시스템과의 연동을 처리하고 외부 시스템의 모델과 현재 도메인 모델간의 변환을 책임진다.
  • ( 이거 완전 안티커럽션 레이언데 안티커럽션 레이어란 말을 안한다. )

 

출처: https://leejaedoo.github.io/bounded_context/

 

  • RecSystemClient는 외부 추천 시스템이 제공하는 Rest API를 이용해서 특정 상품을 위한 추천 상품 목록을 로딩한다.
  • 이 Rest API가 제공하는 데이터는 추천 시스템의 모델을 기반으로 하고 있기 떄문에 API 응답은 다음과 같이 상품 도메인 모델과 일치하지 않는 데이터를 제공할 것이다.

 

  • RecSystemClient는 Rest API로부터 데이터를 읽어와 카탈로그 도메인에 맞는 상품 모델로 변환한다.
public class RecSystemClient implements ProductRecommendationService {
    private ProductRepository productRepository;
    
    @Override
    public List<Product> getRecommendationOf(ProductId id) {
        List<RecommendationItem> items = getRecItems(id.getValue());
        return toProducts(items);
    }

    private List<RecommendationItem> getRecItems(String itemId) {
        // externalRecClient는 외부 추천 시스템을 위한 클라이언트라고 가정
        return externalRecClient.getRecs(itemId);
    }

    private List<Product> toProducts(List<RecommendationItem> items) {
        return items.stream()
                    .map(item -> toProductId(item.getItemId()))
                    .map(prodId -> productRepository.findById(prodId))
                    .collect(toList());
    }

    private ProductId toProductId(String itemId) {
        return new ProductId(itemId);
    }
    //...
}

 

  • getRecItems는 exeternalRecClient는 외부 추천 시스템에 연결할 때 사용하는 클라이언트로서 추천 시스템을 관리하는 팀에서 배포하는 모듈이라고 가정하자. ( 어댑터 ? )
  • 이 모듈이 제공하는 RecommendationItem은 추천 시스템의 모델을 따를 것이다.
  • RecSystemClient는 추천 시스템의 모델을 받아와 toProducts() 메서드를 이용해서 카탈로그 도메인의 Product 모델로 변환하는 작업을 처리한다. ( 번역기 )

 

  • 두 모델간의 변환 과정이 복잡하면 번역기만 따로 두기도 한다.
  • Rest API를 통해 직접 두 Bounded Context를 직접 통합 하는 방법이다.
  • 대표적인 간접 통합 방법은 메세지큐를 이용하는것이다.

 

 

  • 카탈로그 Bounded Context는 추천 시스템이 필요로 하는 사용자 활동 이력을 메시지 큐에 추가한다. ( 카탈로그 Bounded Context는 상류 같음 )
  • 메시지 큐는 보통 비동기로 메시지를 처리하기 때문에 카탈로그 Bounded Context 는 메시지를 큐에 추가한 뒤에 추천 Bounded Context가 메시지를 처리할 때까지 기다리지 않고 바로 이어서 자신의 처리를 계속한다.

 

  • 추천 Bounded Context는 큐에서 이력 메시지를 읽어와 추천을 계산하는데 사용할 것이다.
  • 이는 두 Bounded Context가 사용할 메시지의 데이터 구조를 맞춰야 함을 의미.
  • 각각의 Bounded Context를 담당하는 팀은 서로 만나서 주고받을 데이터 형식에 대해 협의해야 한다. ( 협조적인 상류팀 )
  • 메시지 시스템을 카탈로그 측에서 관리하고 있다면 위의 그림과 같이 카탈로그 도메인을 따르는 데이터를 담을것이다.

 

  • 어떤 도메인 관점에서 모델을 사용하느냐에 따라 두 Bounded Context의 구현 코드가 달라지게 된다.
  • 카탈로그 도메인 관점에서 큐에 저장할 메시지를 생성하면 카탈로그 시스템의 연동 코드는 카탈로그 기준의 데이터를 그대로 메싲 큐에 저장한다. ( 이러면 카탈로그가 상류, 추천이 하류 ? )
  • 카탈로그 도메인 모델을 기준으로 메시지를 전송하므로 추천 시스템에서는 자신의 모델에 맞게 메시지를 변환해서 처리해야 한다. ( 카탈로그가 상류, 추천이 하류 )

 

  • 카탈로그 도메인을 메시징 큐에 카탈로그와 관련된 메시지를 저장하고 다른 Bounded Context는 이 큐로부터 필요한 메시지를 수신하는 방식
  • 즉, 한쪽에서 메시지를 출판하고 다른 쪽에서 메시지를 구독하는 출판/구독 (publish subscribe)모델을 따른다.

 

마이크로 서비스와 Bounded Context

  • 마이크로서비스는 애플리케이션을 작은 서비스로 나누어 개발하는 아키텍처 스타일이다.
  • 개별서비스를 독립된 프로세스로 실행하고 각 서비스가 REST API나 메시징을 이용해서 통신하는 구조를 갖는다.
  • 이런 특징은 Bounded Context와 잘 어울린다.
  • 각 Bounded Context는 모델의 경계를 형성하는데 Bounded Context를 마이크로 서비스로 구현하면 자연스럽게 컨텍스트별로 모델이 분리된다.
  • 별도 프로세스로 개발한 Bounded Context는 독립적으로 배포하고 모니터링하고 확장하게 되는데 이 역시 마이크로서비스의 특징이다.

https://www.notion.so/9-1-fcb6b44a0067439a933d23aa8280ca4e

https://www.notion.so/9-1-fcb6b44a0067439a933d23aa8280ca4ehttps://www.notion.so/9-1-fcb6b44a0067439a933d23aa8280ca4ehttps://www.notion.so/9-1-fcb6b44a0067439a933d23aa8280ca4ehttps://www.notion.so/9-1-fcb6b44a0067439a933d23aa8280ca4ehttps://www.notion.so/9-1-fcb6b44a0067439a933d23aa8280ca4e

https://www.notion.so/9-1-fcb6b44a0067439a933d23aa8280ca4e

https://www.notion.so/9-1-fcb6b44a0067439a933d23aa8280ca4e

https://www.notion.so/9-1-fcb6b44a0067439a933d23aa8280ca4e

https://www.notion.so/9-1-fcb6b44a0067439a933d23aa8280ca4ehttps://www.notion.so/9-1-fcb6b44a0067439a933d23aa8280ca4e

https://www.notion.so/9-1-fcb6b44a0067439a933d23aa8280ca4e

https://www.notion.so/9-1-fcb6b44a0067439a933d23aa8280ca4e

https://www.notion.so/9-1-fcb6b44a0067439a933d23aa8280ca4e

https://www.notion.so/9-1-fcb6b44a0067439a933d23aa8280ca4e

https://www.notion.so/9-1-fcb6b44a0067439a933d23aa8280ca4e

https://www.notion.so/9-1-fcb6b44a0067439a933d23aa8280ca4e

여러 애그리것이 필요한 기능

  • 도메인 영역 코드를 작성하다 보면 하나의 애그리것으로 기능을 구현할 수 없을 때가 있다.
  • 대표적인 예가 결제 금액 계산 로직
    • 상품 애그리거트 : 구매하는 상품의 가격이 필요하다. 또한 상품에 따라 배송비가 추가되기도 한다.
    • 주문 애그리거트 : 상품별로 구매 개수가 필요하다.
    • 할인 쿠폰 애그리거트 : 쿠폰별로 지정한 할인 금액이나 비율에 따라 주문 총금액을 할인한다.
    • 회원 애그리거트 : 회원 등급에 따라 추가 할인이 가능하다
    • 위 상황에서 실제 결제 금액을 계산하는 주체는 어떤 애그리것일까 ?
      • 총 주문 금액을 계산하는 것은 주문 애그리것이 할수 있지만 총 주문 금액에서 할인 금액을 계산해야 하는데 이 할인금액을 구하는 것은 누구의 책임일까 ?
      • 할인 쿠폰이 규칙을 갖고 있으니 할인 쿠폰의 책임인가 ?
      • 그런데 할인 쿠폰을 두개 이상 적용할 수 있다면 단일 할인 쿠폰 애그리것으로는 총 결제 금액을 계산할 수 없다.
    • 만약 주문 애그리것이 필요한 애그리것이나 필요 데이터를 모두 가지도록 한뒤 할인금액계산을 주문 애그리것에 할당한다면 어떻게 될까 ?
      • 만약 할인정책이 변경되거나 추가된다면 주문애그리것은 할인 애그리것과 관련이 1도 없는데도 불구하고 수정을 해야 한다.
    • 이렇게 한 애그리것에 넣기에 애매한 도메인 기능을 특정 애그리것에서 억지로 구현하면 안된다.
    • 왜냐 이경우 애그리것은 자신의 책임 범위를 넘어서는 구현을 하기 떄문에 코드가 길어지고 외부에 대한 의존이 높아지게 된다.
    • 이는 코드를 복잡하게 만들어 수정을 어렵게 만드는 요인이 된다.
    • 게다가 애그리것의 범위를 넘어서는 도메인 개념이 애그리것에 숨어들어서 명시적으로 드러나지 않게 된다.
    • 이런 경우엔 도메인 서비스 를 사용하자.

도메인 서비스

  • 위처럼 한 애그리것에 넣기 애매한 도메인 개념을 구현하려면 애그리것에 억지로 넣기 보단 도메인 서비스를 이용해서 도메인 개념을 명시적으로 드러내면 된다.
  • 응용 영역의 서비스가 응용 로직을 다룬다면 도메인 서비스는 도메인 로직을 다룬다.
  • 도메인 서비스는 상태 없이 로직만 구현한다
  • 필요한 상태는 애그리것이나 다른 방법으로 전달받는다.
  • 도메인 서비스는 도메인의 의미가 드러나는 용어를 타입과 메서드 이름으로 짓는다.
public class DiscountCalculationService {
	public Money calculateDiscountAmounts(
			List<OrderLIne> orderLines,
			List<Coupon> coupons,
			MemberGrade grade) {
		Money couponDiscount = coupons.stream()
																		.map(coupon -> calculateDiscount(coupon))
																		.reduce(Money(0), (v1, v2) -> v1.add(v2));

		Money membershipDiscount = calculateDiscount(orderer.getMember().getGrade());

		return couponDiscount.add(membershipDiscount);
	}

	...
}

 

  • 할인 계산 서비스를 사용하는 주체는 애그리것이 될 수 있고 응용 서비스가 될수도 있다.
  • 다음과 같이 기능을 전달하면 사용 주체는 애그리것이 된다.
public class Order {
	public void calculateAmounts(
			DiscountCalculationService disCalSvc, MemberGrade grade) {
		Money totalAmounts = getTotalAmounts();
		Money discountAmounts = disCalSvc.calculateDiscountAmounts(this.orderLInes, this.coupons, greade);
		this.paymentAmounts = totalAmounts.minus(discountAmounts);
	}
	...

 

  • 애그리것 객체에 도메인 서비스를 전달하는 것은 응용 서비스 책임이다.
public class OrderService {
	private DiscountCalculationService discountCalculationService;

	@Transactional
	public OrderNo placeOrder(OrderRequest orderRequest) {
		OrderNo orderno = orderRepository.nextId();
		Order order = createOrder(orderNo, orderRequest);
		orderRepository.save(order);
		// 응용 서비스 실행 후 표현 영역에서 필요한 값 리턴

		return orderNo;
	}

	private Order createOrder(OrderNo orderNo, OrderRequest orderReq) {
		Member member =findMember(orderReq.getOrdererId());
		Order order = new Order(orderNo, orderReq.gerOrderLines(),
							orderReq.getCoupons(), createOrderer(member),
							orderReq.getShippingInfo());
		order.calculateAmounts(this.discountCalculationService, member.getGrade());
		return order;
	}
	...
}

 

도메인 서비스 객체를 애그리것에 주입하지 않기.

  • 애그리것의 메소드를 실행할때 도메인 서비스 객체를 파라미터로 전달하는 것은 애그리것이 도메인 서비스에 의존한다는 것을 의미
  • 스프링이 제공하는 의존주입을 사용하여 도메인 서비스를 애그리것에 주입해야 기술적으로 나은것 같은 착각이 들 수 있다.
  • 하지만 개인적 (작가의견) 으론 좋은 방법이 아니라고 한다.
  • 도메인 객체는 필드로 구성된 데이터와 메서드를 이용한 기능을 이용해서 개념적으로 하나인 모델을 표현한다.
  • 모델의 데이터를 담는 필드는 모델에서 중요한 구성요소이다. 그런데 도메인 서비스 필드는 데이터 자체와는 관련이 없다.
  • order 객체를 DB에 보관할때 다른 필드와 달리 저장 대상도 아니다.
  • 또 Order이 제공하는 모든 기능에서 도메인 서비스 ( 할인정책 ) 을 필요로 하는 것도 아니다.
  • 일부 기능만 필요로 하는 도메인서비스 객체를 애그리것에 의존 주입할 필요는 없다.
  • 이는 프레임워크의 기능을 사용하고 싶은 개발자의 욕심을 채우는것에 불과하다.

 

 

  • 도메인 서비스를 인자로 전달하지 않고 반대로 도메인 서비스의 기능을 실행할때 애그리것을 전달하기도 한다.
public class TransgerService {
	public void transfer(Account fromAcc, Account toAcc, Money amounts) {
		fromAcc.withdraw(amounts);
		toAcc.credit(amounts);
	}
}

 

  • 특정기능이 도메인 서비스인지 응용서비스인지 감이 안오는 경우엔 해당 로직이 애그리것의 상태를 변경하거나 애그리것의 상태값을 변경하는지 검사해보면 된다.
  • 도메인 로직이면서 (위와 같이) 한 애그리것에 넣기 적합하지 않은 것은 도메인 서비스로 구현하게 된다.

 

도메인 서비스의 패키지 위치

  • 도메인 서비스는 도메인 로직을 실행하므로 도메인 서비스의 위치는 다른 도메인 구성요소와 동일한 패키지에 위치한다.
  • 예를 들어 주문금액계산 도메인 서비스는 주문 애그리것과 동일 패키지에 위치한다.
  • 도메인 서비스의 개수가 많거나 엔티티나 밸류와 같은 다른 구성요소와 명시적으로 구분하고 싶다면 domain 패키지 밑에 domain.model, domain.service, domain.repository와 같이 하위 패키지를 구분해서 위치시켜도 된다.

 

 

도메인 서비스의 인터페이스와 클래스

  • 도메인 서비스의 로직이 고정되어 있지 않은 경우 도메인 서비스 자체를 인터페이스로 구현하고 이를 구현한 클래스를 둘 수도 있다.
  • 특히 도메인 로직을 외부 시스템이나 별도 엔진을 이용해서 구현해야 할 경우에 인터페이스와 클래스를 분리하게 된다.

 

출처:https://minkukjo.github.io/dev/2020/11/19/DDD-07/

 

  • 도메인 서비스의 구현이 특정 구현 기술에 의존적이거나 외부 시스템 API를 실행한다면 도메인서비스를 인터페이스로 추상화 해야한다. 이를 통해 도메인 영역이 특정 구현에 종속되는 것을 방지할 수 있고 도메인 영역에 대한 테스트가 수월해진다.

표현 영역과 응용 영역

사용자에게 기능을 잘 제공하려면 도메인과 사용자를 연결시킬 표현영역과 응용영역이 필요하다.

  • 표현영역은 응용서비스가 요구하는 형식으로 사용자 요청을 변환한다.
  • 응용서비스가 요구하는 객체를 생성한뒤 응용서비스의 메서드를 호출한다.
public ModelAndView join(HttpServletRequest request) {
	String email = request.getParameter("email");
	String password = request.getParameter("password");

	//사용자 요청을 응용서비스에 맞게 변환
  JoinRequest joinReq = new JoinRequest(email, password);
  joinService.join(joinReq);
}

  • 응용서비스를 실행한뒤에 표현영역은 실행결과를 사용자에 알맞은 형식으로 응답한다.
  • 응용서비스는 표현영역이 뭘하던 의존하지 않는다. 사용자가 무엇을 사용하던지 알 필요가 없다.

응용 서비스의 역할

  • 응용서비스는 사용자의 요청을 처리하기 위해 리포지터리로 부터 도메인 객체를 구하고 도메인 객체를 사용한다.
  • 응용서비스의 주요 역할은 도메인 객체를 사용해서 사용자의 요청을 처리하는것이므로 표현영역에서 봤을때 응용 서비스는 도메인 영역과 표현영역을 연결해주는 창구인 파사드 역할을 한다.
public Result doSomeFunc(SomeReq req) {
	//1. 리포지터리에서 애그리것을 구한다
	SomeAgg agg = someAggRepository.findByid(req.getId());
	checkNull(agg);

	//2. 애그리것의 도메인 기능을 실행한다.
	agg.doFunc(req.getValue());

	// 3. 결과를 리턴한다.
  return createSuccessResult(agg);
}

  • 응용서비스가 복잡하다면 응용서비스에서 도메인 로직의 일부를 구현하고 있는지 확인해보자.
  • 트랜잭션은 응용서비스에서 처리한다.

도메인 로직 넣지 않기

  • 도메인로직을 도메인 영역과 응용 서비스에 분산해서 구현하면 코드품질에 문제가 발생한다.
  1. 코드의 응집도가 떨어진다.
  1. 여러 응용서비스에서 동일한 도메인 로직을 구현할 가능성이 높아진다.
    1. 보조클래스를 만들어서 사용할수 있지만 애초에 도메인 영역에 구현하면 간단하다.

응용서비스의 구현

  • 응용서비스는 표현 영역과 도메인 영역을 연결하는 매개채 역할을 하는데 디자인 패턴에서 파사드 패턴과 같은 역할을 한다.
  • 응용 서비스는 복잡한 로직이 없기 때문에 구현이 쉽다.

응용 서비스의 크기

  • 보통 응용서비스의 크기는 다음 두가지 방법중 한가지 방식으로 구현한다.
  1. 한 응용 서비스 클래스에 회원 도메인의 모든 기능 구현하기.
    • 한 도메인과 관련된 기능을 구현한 코드가 한 클래스에 위치하므로 각 기능에서 동일로직에 대한 코드 중복을 제거할수 있다.
    • 하지만 단점으로는 서비스 클래스의 크기가 커진다.
    • 코드가 커진다는 것은 연관성이 적은 코드가 한 클래스에 함께 위치할 가능성이 높아짐을 의미하는데 이는 결과적으로 관련없는 코드가 뒤섞여서 코드를 이해하는데 방해가 될 수 있다.
  1. 구분되는 기능별로 응용 서비스 클래스를 따로 구현하기.
    • 구분되는 기능별로 서비스 클래스를 구현하는 방식은 서비스 클래스에서 한개 내지 2~3개의 기능을 구현한다.
    • 이방식을 사용하면 클래스 갯수는 많아지지만 한 클래스에 관련 기능을 모두 구현하는것과 비교해서 코드 품질을 일정수준으로 유지하는데 도움이 된다.
    • 각 기능마다 동일한 로직을 구현할 경우 여러 클래스에 중복해서 동일한 코드를 구현할 가능성이 있다.
    • 이런 경우 별도 클래스에 로직을 구현해서 코드가 중복되는것을 방지할수 있다.
    public final class MemberServiceHelper {
    	//구현
    }

  • 범균이 형님은 한 클래스가 여러 역할을 갖는 것보다 각 클래스마다 구분되는 역할을 갖는것을 선호하신다. ( 2번 선호 )

응용 서비스의 인터페이스와 클래스(흥미로운 주제)

  • 응용 서비스를 구현할때 논쟁이 될만한 것이 인터페이스가 필요한지 여부이다
    • 인터페이스가 필요한 경우는 의존성을 분리할 때 또는 구현 클래스가 여러개일때 등등 여러가지 이유가 있지만 구현 클래스가 다수일때 인터페이스가 유용하게 쓰인다.
    • 그런데 응용서비스는 보통 런타임에 이를 교체하는 경우가 거의 없을뿐만 아니라 한 응용 서비스의 구현 클래스가 두 개인 경우도 매우 드물다.
    • 이런 이유로 인터페이스와 클래스를 따로 구현하면 소스 파일만 많아지고 구현 클래스에 대한 간접참조가 증가해서 전체 구조만 복잡해지는 문제가 발생한다.
    • 따라서 인터페이스가 명확하게 필요하기 전까지는 응용 서비스에 대한 인터페이스를 작성하는것이 좋은 설계는 아니다.
    • 만약 TDD를 하는 경우에는 미리 응용서비스를 구현할수 없으므로 응용서비스의 인터페이스를 작성하게 될것이다.
    • 하지만 mockito와 같은 테스트 도구를 사용하면 테스트용 가짜 객체를 만들기 때문에 인터페이스가 없어도 표현영역을 테스트 할수 있다.
    • 결과적으로 응용서비스에 대한 인터페이스 필요성을 약화시킨다.

메서드 파라미터와 값 리턴

파라미터

  • 값 전달을 파라미터로 받을수도 별도의 데이터 클래스 dto를 만들어 전달받을 수도 있다.
  • 스프링 MVC는 웹 요청 파라미터를 자바 객체로 변환해주는 기능 ( Converter ) 를 제공해주므로 파라미터가 두개 이상 존재하면 데이터 전달을 위한 별도의 클래스를 사용하는것이 편리하다.

값 리턴

  • 값리턴시 편하게 애그리것을 return할수도 있지만 애그리것을 리턴할 경우 의존성이 깨진다.
  • 도메인의 로직이 응용서비스에서 소비되는것이 아닌 표현영역까지 가서 소비되면 응집도를 낮추는 원인이 된다.
  • 응용서비스는 표현영역에서 필요한 데이터만 return 하도록 하자.

표현영역에 의존하지 않기.

  • 응용서비스의 파라미터 타입을 결정할때 주의할 점은 표현 영역과 관련된 타입을 사용하면 안된다.
  • 예를 들어 HttpServletRequest 나 HttpSession을 응용서비스에 파라미터로 전달하면 안된다.
  • 응용서비스에서 표현 영역에 대한 의존이 발생하면 응용 서비스만 단독으로 테스트하기가 어려워진다.
  • 만약 httpsession을 응용 서비스까지 가져와 변경해버린다면 표현 영역에서만 사용해야되는 httpsession이 응용서비스까지 내려온것이므로 표현 영역에 대한 응집도가 꺠진다.

트랜잭션 처리

  • 트랜잭션을 관리하는것은 응용 서비스의 중요한 역할이다
  • 스프링에서 제공하는 @Transaction 어노테이션을 사용하자. 매우 편하다
  • 이 어노테이션을 적용하면 RuntimeException이 발생하면 트랜잭션을 롤백하고 그렇지 않으면 커밋한다.

도메인 이벤트 처리

  • 응용서비스는 또한 도메인 영역에서 발생시킨 이벤트를 처리하는것이 주요 역할중 하나다.
  • 도메인 이벤트란 도메인에서 발생한 상태변경을 의미하며 "암호 변경됨", "주문 취소함"과 같은것이 이벤트가 될 수 있다.
public void changePassword(String oldPw, String newPw) {
        if (!password.match(oldPw)) {
            throw new IdPasswordNotMatchingException();
        }
        this.password = new Password(newPw);
        Events.raise(new PasswordChangedEvent(id.getId(), newPw));
    }
  • 도메인에서 이벤트를 발생시키면 그 이벤트를 받아서 처리할 코드가 필요한데 그 역할을 하는것이 바로 응용 서비스다.
  • 암호가 변경되면 암호가 변경됬다는 알림을 보내는 이벤트 핸들러를 등록할수 있을것이다.
  • 이벤트를 사용하면 코드가 다소 복잡해지는 대신 도메인 간의 의존성이나 외부시스템에 대한 의존을 낮춰주는 장점을 얻을 수 있다.
  • 또한 시스템을 확장하는데에 이벤트가 핵심 역할을 수행하게 된다.

표현 영역

  • 표현영역의 책임은 크게 다음과 같다.
    1. 사용자가 시스템을 사용할 수 있는 (화면) 흐름을 제공하고 제어한다.
    1. 사용자의 요청을 알맞은 응용 서비스에 전달하고 결과를 사용자에게 제공한다.
    1. 사용자의 세션을 관리한다. ( 권한 검사와도 연결이 된다. )

값 검증

  • 값 검증은 표현과 응용 두곳에서 모두 할수 있다.
  • 원칙적으로는 응용서비스에서 처리한다.

  • 표현계층에서 값 검증을 할 경우
    • 스프링 MVC는 에러 메시지를 보여주기 위한 용도로 Errors나 BindingResult를 사용한다.
    • 그렇기에 폼에 에러메시지를 보여주기 위해 다소 번잡한 코드를 작성해야 한다.

  • 응용계층에서 값 검증을 할 경우
    • 각 값이 올바른지 확인할 목적으로 익셉션을 사용할때의 문제점은 사용자에게 좋지않은 경험을 제공한다는 것이다.
    • 사용자는 잘못입력했을경우 어느부분에서 잘못됬는지 알고싶을것이다.
    • 하지만 응용계층에서 값을 검사하는시점에 첫번째값이 잘못되어 익셉션을 발생시키면 나머지 항목에 대해서는 값을 검사하지 않게된다.
    • 그래서 첫번째값의 검증만 알게되고 나머지 항목은 값이 올바른지 여부를 알수 없게되어 사용자가 여러번 입력하게 만든다.

  • 결국 표현계층에서 검증해야 하는데 Spring은 Validator인터페이스를 별도로 제공하므로 위 코드를 사용하면 간결하게 줄일수 있다.
  • 이렇게 표현영역에서 필수값과 값의 형식을 검사하면 실질적으로 응용서비스는 아이디 중복여부와 같은 논리적 오류만 검사하면 된다.
  • 표현영역
    • 필수값, 값의 형식, 범위등을 검증
  • 응용 서비스
    • 데이터의 존재 유무와 같은 논리적 오류를 검증한다.

  • 범균이형님은 두곳에서 다 값검사를 하는것이 아닌 위처럼 구분지어서 하시는 편이라고 하신다.
  • 하지만 응용서비스를 사용하는 주체가 다양하면 응용 서비스에서 반드시 파라미터로 전달받은 값이 올바른지 검사를 해야 된다고 하신다.

권한 검사

  • 보안 프레임워크의 복잡도를 떠나 보통 다음의 세곳에서 권한 검사를 수행할 수 있다.
    • 표현 영역
    • 응용 서비스
    • 도메인 영역

  • 이 URL을 처리하는 컨트롤러에 웹 요청을 전달하기 전에 인증 여부를 검사해서 인증된 사용자의 웹 요청만 컨트롤러에 전달.
  • 인증된 사용자가 아닐경우 로그인 화면으로 리다이렉트
  • 위와 같은 접근제어를 하기 좋은 위치가 서블릿 필터다.
  • 만약 URL만으로도 불가능한 경우에는 스프링 시큐리티는 AOP를 이용한 어노테이션을 이용해 검사를 할 수 있다.
@PreAuthorize("hasRole('ADMIN')")
    @Transactional
    public void block(String memberId) {
        Member member = memberRepository.findById(new MemberId(memberId));
        if (member == null) throw new NoMemberException();

        member.block();
    }

  • 개별 도메인 단위로 검사해야하는 경우는 구현이 아래와 같이 복잡해진다. ( 게시글 삭제는 본인이 또는 관리자 역할을 가진 사용자만 할수 있는 경우의 검증 )

	public void delete(String userId, Long articleId) {
		Article article = articleRepository.findById(articleId);
		checkArticleExistence(article);
		permissionService.checkDeletePermission(userId, article);
		article.markDeleted();
	}

  • 만약 프레임워크 이해도가 낮아 프레임 워크 확장을 원하는 수준으로 할 수 없다면 프레임워크를 사용하는 대신 도메인에 맞는 권한 검사 기능을 직접 구현하는 것이 코드 유지보수에 유리할 수 있다.

조회 전용 기능과 응용 서비스

public class OrderListService {
	public List<OrderView> getOrderList(String ordererId) {
		return orderViewDao.selectByOrderer(ordererId);
	}
}
  • 위와 같이 아주 간단한 조회기능인 경우 표현영역에서 바로 조회 전용 기능을 사용해도 된다.

@RequestMapping("/myorders")
	public String list(ModelMap model) {
		String ordererId = SecurityContext.getAuthentication().getId();
		List<OrderView> orders = orderViewDao.selectByOrderer(ordererId);
		model.addAttribute("orders", orders);
		return "order/list";
	}

  • 범균이형님은 굳이 응용 서비스를 만들 필요성을 못느낀다면 안만들어도 된다고 생각하신다.
  • 조회를 위한 응용 서비스가 단지 조회 전용 기능을 실행하는 코드밖에 없다면 응용 서비스를 생략한다.

+ Recent posts