Skip to content

Latest commit

 

History

History
681 lines (463 loc) · 60.4 KB

DDD-START.md

File metadata and controls

681 lines (463 loc) · 60.4 KB

목차

1장 도메인 모델 시작

도메인 모델 패턴

계층 설명
사용자인터페이스 UI 사용자의 요청을 처리하고 사용자에게 정보를 보여준다. 여기서 사용자는 소프트웨어를 사용하는 사람뿐만 아니라 외부 시스템도 사용자가 돌 수 있다.
응용 사용자가 요청한 기능을 실행한다. 업무 로직을 직접 구현핮 않으며 도메인 계층을 조합해서 기능을 실행한다.
도메인 시스템이 제공할 도메인의 규칙을 구현한다.
인프라스트럭처 데이터베이스나 메시징 시스템과 같은 외부 시스템과 연동을 처리한다.

핵심 규칙을 구현한 코드는 도메인 모델에만 위치하기 때문에 규칙이 바꾸거나 규칙을 확장해야 할 때 다른 코드에 영향을 덜 주고 변경 내역을 모델에 반영할 수 있게 된다.

2장 아키텍처 개요

네 개의 영역

public class CancelOrderService {
    public void cancelOrder(String orderId){
        Order order = findOrderById(orderId);
        if(order == null) throw new OrderNotFoundExcepton(orderId);
        order.cancel();
    }
}

응용 서비스는 로직을 직접 수행하깁보다는 도메인 모델에 로직수행을 위임한다. 위코드도 주문 취소 로직을 집적 구혀하지 않고 Order 객체에 취소 처리를 위임하고있다.

도메인 영역은 도메인 모델을 구현한다. 도메인 모델은 도메인의 핵심 로직을 구현한다. 주문 도메인의 경우 배송지 변경, 결제 완료, 주문 총액 계산과 같은 핵심 로직을 도메인 모델에서 구현한다.

인프라스트럭처 여역은 구현 기술에 대한 것을 다룬다. RDBMS 연동, 메시징 큐에 메시지 전송, 외부 API 호출 등을 처리한다. 인프라스트럭처 영역은 논리적인 개념 표현하기보다는 실제 구현을 다룬다.

계층 구조 아키텍처

[표현]
  ↓
[응용]
  ↓
[도메인]
  ↓
[인프라스트럭처]

계층 구조는 특정상 상위 계층에서 하위 계층으로 의존만 존재하고 하위 계층은 상위 계층에 의존하지 앟는다. 예를 들어 표현 계층은 응용 계층에 의존하고 응용 계층은 도메인 계층에 의존하지만, 반대로 인프라스트럭처 계층이 도메인 계층에 의존하거나 도메인 응용 계층에 의존하지는 않는다.

하지만 구현의 편리함을위해 계층 구조를 유연하게 적용한다. 예를들어, 응용 계층은 바로 아래 계층인 도메인 계층의 의존하지만 외부 시스템과의 연동을 위해 더 아래 계층인 인프라스트럭처 게층에 의존하기도 한다.

DIP

고수준 모듈이 제대로 동작하려면 저수준 모듈을 사용해야 한다. 그런데, 고수준 모듈이 저수준 모듈을 사용하면 구현 변경과 테스트하기가 어려운 문제가 발생한다

DIP는 이 문제를 해결 하기위해 저수준 모듈이 고수준 모듈에 의존하도록 바꾼다. 고수준 모듈을 구현하려면 저수주 모듈을 사용해야 하는데, 반대로 저수준 모듈이 고수준 모듈에 의존하도록 추상화 인터페이스를 추가 해야한다.

DIP 주의사항

DIP의 핵심은 고소준 모듈이 저수준 모듈에 의존하지 않도록 하기 위함인데 DIP를 적용한 결과 구조만 보고 저수준 모듈에서 인터페이스를 수출하는 경우가 있다.

DIP를 적용할 때 하위 기능을 추상화한 인터페이스는 고수준 모듈 관점에서 도출한다. 예를 들어 DiscountService 입장에서 봤을 때 할인 금액을 구하기 위해 룰 엔진을 사용하는지, 직접 연사하는지 여부는 중요하지 않다. 단지 규칙에 따라 할인금액을 계산한다는 것이 중요할 뿐이다. 즉 할인 금액 계산을 추상화한 인터페이스는 저수준 모듈이 아닌 고수준 모듈에 위치한다.

DIP와 아키텍처

응용 계층과 인프라스트럭처 계층을 인터페이스를 두고 DIP를 적용하면 변경에 유용하다.

도메인 영역의 주요 구성요소

요소 설명
엔티티 고유의 식별자를 갖는 객체로 자신의 라이프사이클을 갖는다. 주문, 회원, 상품과 같이 도메인의 고유한 개념을 표현한다. 도메인의 모델의 데이터를 포함하며 해당 데이터와 관련된 기능을 함께 제공한다.
벨류 고유의 식별자를 갖지 않은 객체로 주로 개념적으로 하나의 도메인의 객체의 속성을 표현할 때 사용 한다. 배송지 주소를 표현하기 위해 주소, 구매 금액을 금액과 같은 타입이 벨류 타입이다. 엔티티의 속성으로 사용될 뿐만 아니라 다른 벨류 타입의 속성으로도 사용될 수 있다.
애그리거트 애그리거트는 관련된 엔티리와 벨류 객체를 개념적으로 하나 물ㄲ은 것이다. 예를들어 즈믄과 연관된 Order 엔티티, OrderLine 벨류, Order 벨류 객체를 주문 애그리거트로 묶을수 있다.
리포지터리 도메인 모델의 영속성을 처리한다. 예를들어 RDBMS 테이블에서 엔티티 객체를 로딩하거나 저장하는 기능을 제공한다.
도메인 서비스 특정 엔티티에 속하지지 않은 도메인 로직을 제공한다.

엔티티와 벨류

실제 도메인 모델의 엔티티와 DB 관계형 모델의 엔티티는 같지 않다.

이 두 모델의 가장큰 차이점은 도메인 모델의 엔티티는 데이터와 함께 도메인 기능을 함께 제공한다는 점이다. 예를 들어 주문을 표현하는 엔티티는 주문과 관련된 데이터뿐만 아니라 배송지 주소 변경을 위한 기능을 함께 제공한다.

도메인 모델의 엔틴티는 단순히 데이터를 담고 있는 데이터 구조라기보다는 데이터와 함께 기능을 제공하는 객체이다. 도메인 관점에서 기능을 구현하고 기능을 구현을 캡슐화해서 데이터가 임의로 변경되는 것을 막는다.

또 다른 차이점은 도메인 모델의 엔티티는 두 개 이상의 데이터가 개념적으로 하나 인 경우 벨류 타입을 이용해서 표현 할 수 있다라는 것이다.

애그리거트

도메인이 커질수록 개발할 도메인 모델도 커지면서 많은 엔티티와 벨류가 출현한다. 엔티티와 밸류 개수가 많아지면 많아질수록 모델은 점점 더 복잡해진다. 도메인 모델에서 전체 구조를 이해하는데 도움이 되는 것이 바로 애그리거트이다.

애그리거트는 관련 객체를 하나로 묶는 군집이다. 애그리거트의 대표적인 예가 주문이다. 주문이라는 도메인 개념은 주문, 배송지 정보, 주문자, 주문 목록, 총 결제 금액 하위 모델로 구성되는 데 이 때 하위 개념을 표현한 모델을 하나로 묶어서 주문이라는 상위 개념으로 표현할 수 있다.

애그리거트를 구현할 때는 고려할 것이 많다. 애그리거트를 어떻게 구성했느냐에 따라 구현이 복잡해지기도 하고 트랜잭션 범위가 달라지기도 한다. 또한 선택한 구현 기술에 따라 애그리거트 구현에 제약이 생기기도 한다.

리포지터리

도메인 객체를 지속적으로 사용하려면 RDMBS, NoSQL 과 같은 물리적인 저장소에 도메인 객체를 보관해야 한다. 이를 위해 도메인 모델이 리포지터리이다.

3장 애그리거트

백 개 이상의 테이블을 한장의 ERD에 모두 표시하면 개별 테이블 간의 관계를 파악하느라 큰 틀에서 데이터 구조를 이해하는 데 어려움을 겪게 되는 것처럼, 도메인 객체 모델이 복잡해지면 개별 구성요소 모델을 이해하게 되고 전박적인 구조나 큰 수준에서 도메인 간의 관계를 파악하기 어려워 진다.

복잡한 도메인을 이해하고 관리하기 쉬운 단우로 만들려면 상위 수준에서 모델을 조망할 수 있는 방법이 필요한데, 그 방법이 바로 애그리거트이다. 2장에에서 설명한 것처럼 애그리거트는 관련된 객체를 하나의 군으로 묶어준다. 수많은 객체를 애그리거트로 묶어서 바로보면 좀 더 상위 수준에서 도메인 모델 간의 관계를 파알 할 수 있다.

애그리거트는 경계를 갖는다 한 애그리거트에 속한 객체는 다른 애그리거트에 속하지 않는다. 애그리거트는 독립적 객체 군이며, 각 애그리거트는 자기 자신을 관리할 뿐 다른 애그리거트를 관리하지 않는다. 예를 들어 주문 애그리거트는 배송지를 변경하거나 주문 상품 개수를 변경하는 등 자기 자신을 관리하지만, 주문 애그리거트에서 회원의 비밀번호를 변경하거나 상품의 가격을 변경하지는 않는다.

경계를 설정할 때 기본이 되는 것은 도메인 규칙과 요구사항이다. 도메인 규칙에 따라 함께 생성되는 구성요소는 한 애그리거트에 속할 가능성이 높다.

A가 B를 갖는다는 해석으로 요구사항이 있다고 하더라도 이것으 반드시 A와 B가 한 애그리거트에 속한다는 것을 의미하는 것은 아니다.

좋은 예가 상품과 리뷰다. 상품 상세 페이지에 들어가면 상품 상세 정보와 함께 리뷰내용을 보여줘야하는 요구사항이 있다면 Product, Review 엔티티가 한 애그리거트에 속한다고 생각할 수 있다. 하지만 Product, Review는 함께 생성되지 않고 함께 변경되지도 않는다. 게다가 Product와 Review는 함께 생성되지도 함께 변경되지도 않는다. 게다가 Product를 변경하는 주체가 상품 담당자라면 Review를 생성학 변경하는 주체는 고객이다.

애그리거트 루트

애그리거트는 여러 객체로 구성되기 때문에 한 객체만 상태가 정상이어서는 안된다. 도메인 규칙을 지키려면 애그리거트에 속한 모든 객체가 정상 상태를 가저야한다.

애그리거트 속한 모든 객체가 일관된 상태를 유지하려면 애그리거트 전체를 관리할 주체가 필요한데 이 책임을 지키는 것이 바로 애그리거트의 루트 엔티티이다.

도메인 규칙과 일관성

애그리거트 루트가 단순히 애그리거트에 속한 객체를 포함하는 것으로 끝나는 것은 아니다. 애그리거트 루트의 핵심 역할은 애그리거트의 일관성이 깨지지 않도록 하는 것이다. 이를 위해 애그리거트는 루트 애그리거트가 제공해야 할 도메인 기능을 구현한다.

트랜잭션 범위

트랜잭션 범위는 작을수록 좋다. DB 테이블을 기준으로 한 트랜잭션이 한 개 테이블을 수정하는 것과 세 개의 테이블을 수정하는 것은 성능에서 차이가 발생한다.

동일하게 한 트랜잭션에서는 한 개의 애그리거트만 수정해야 한다. 한 트랜잭션에서 두 개 이상의 애그리거트를 수정함 트랜잭션 충돌이 발생할 가능성이 더 높아지기 때문에 한번에 수정하는 애그리거트 개수가 많아질수록 전체 처리량이 떨어지게 된다.

한 트랜잭션에 한 애그리거트만 수정한다는 것은 애그리거트에서 다른 애그리거트를 변경하지 않는 다는 것을 뜻한다. 한 애그리거트에서 다른 애그리거트를 수정하면 결과적으로 두 개의 애그리거트를 한 트랜잭샨에서 수정하게 되므로 한 애그리거트 내부에서 다른 애그리거트의 상태를 변경하는 기능을 실행하면 안 된다.

ID를 이용한 애그리거트 참조

한 객체가 다른 객체를 참조하는 것처럼 애그리거트도 다른 애그리거트를 참조한다. 애그리거트의 관리 주체가 애그리거트 루트이므로 애그리거트에서 다른 애그리거트를 참조한다는 것은 애그리거트의 루트를 참조한다는 것과같다.

필드를 이용해서 다른 애그리거트를 직접 참조하는 것은 개발자에게 구현의 편리함을 제공한다.

order.getOrder().getMember().getId();

JPA를 사용하면 @ManyToOne, @OneToOne과 같은 애노테이션을 시용해서 연관된 객체를 로딩하는 기능을 제공하고 있음으로 필드를 이용해서 다른 애그리거트를 쉽게 참조할 수 있다.

하지만 필드를 이용한 애그리거트 참조는 다음의 문제를 야기할 수 있다.

  • 편한 탐색 오용
  • 성능에 대한 고민
  • 확장 어려움

편한 탐색 오용

한 애그리거트 내부에서 다른 애그리거트 객체에 접그할 수 있으면 다른 애그리거트의 상태를 쉽게 변경할 수 있게 된다. 트랜잭션 범위에서 언급한 것처럼 애그리거트가 관리하는 범위는 자기 자신으로 한정해야 한다.

트랜잭션 범위에서 말한 것처럼, 한 애그리거트에서 다른 애그리거트의 상태를 변경하는 것은 애그리거트 간의 의존 결합도를 높여서 결과적으로 애그리거트의 변경을 어렵게 만든다.

성능에 대한 고민

애그리거트를 직접 참조하면 성능과 관련된 여러 가지 고민을 해야한다는 것이다. JPA를 사용할 경우 참조한 객체를 지연, 즉시 로딩하는 두 가지 방식으로 로딩할 수 있다. 이러한 다양한 경우의 수를 고려해서 연관 매핑 전략을 결정해야한다.

확장 어려움

초기에는 단일 서버 DBMS로 서비스를 제공하는 것이 가능하다. 문제는 사용자가 몰리기 시작하면서 발생한다. 사용자가 늘고 트래픽이 증가하면 자연스럽게 부하를 분산하기 위해 도메인 별로 시스템을 분리하기 시작한다. 이런 과정에서 하위 도메인 마다 서로 다른 DBMS 를 사용할 가능성이 높아진다. 이 과정에서 하위 도메인 마다 서로 다른 DBMS를 사용할 가능성이 높아진다. 심지어 하위도메인 마다 다른 종류의 데이터저장소를 사용하기도한다. 이는 더이상 다른 애그리거트 루트를 참조하기 위해 JPA와 같은 단일 기술을 사용할 수 없음을 의미한다.

4장 리포지터리와 모델구현(JPA 중심)

매핑 구현

엔티티와 벨류 기본 매핑 구현

애그리거트와 JPA 매픙을 위한 기본 규칙은 다음과 같다

  • 애그리거트 루트 엔티티이므로 @Entity로 매핑 설정한다.
  • 한 테이블에 엔티티와 벨류 데이터가 같이 있다면
    • 벨류는 @Embeddable로 매핑 설정한다
    • 벨류 타입 프로머티는 @Embedded로 매핑한다.

필드 접근 방식 사용

엔티티에 프로퍼티를 위한 공개 get/set 메서드를 추가하려면 도메인의 의도가 사라지고 객체가 아닌 데이터 기반으로 엔티티를 구현할 가능성이 높아진다. 특히 set 메서드는 내부 데이터를 외부에서 변경할 수 있는 수단이 되기 때문에 캡슐화를 깨는 원인이 될 수 있다.

엔티티가 객체로서 제 역할을 하려면 외부에 set 메서드 대신 의도가 잘 드러나는 기능을 제공해야 한다. 상태 변경을 위한 setState() 메서드 보다 주문 취소를 위한 cancel()메서드가 도메인을 더 잘 표현한다. setShippingInfo() 메서드보다 배송지를 변경한다는 의미를 갖는 changeShippingInfo()가 도메인을 더 잘 표현한다.

벨류 타입을 불변으로 구성하고 싶은 경우 set 메서드 자체가 필요 없는데 JPA의 구현 방식 때문에 set 메서드를 추가하는 것도 좋지 않다.

기본 생성자

엔티티와 벨류의 생성자 객체를 생성할 때 필요한 것은 전달받는다. 분변 타입이면 생성 시점에 필요한 값을 모두 전달받음으로 값을 변경하는 set 메서드를 제공하지 않는다. 그래서 기본 생성자를 제공해주지 않아도 된다. 하지만 JPA @Entity @Embeddable로 클래스를 매핑하려면 기본 생성자를 제공 해야한다. 하이버네이트와 같은 JPA 프러바이더는 DB에서 데이터를 읽어와 매핑된 객체를 생성할 때 기본 기본 생성자를 사용해서 객체를 생성한다.

하이버네이트는 클래스를 상속한 프록시 객체를 이용해서 지연 로딩을 구한다. 이 경우 프록시 클래스 상위 클래스의 기본 생성자를 호출할 수 있어야 하므로 지연 로딩 대상이되는 @Entity, @Embeddable의 게본 생성자는 private가 아닌 proteted로 지정해야한다.

별도 테이블에 저장하는 벨류 매핑

애그리거트에서 루트 엔티티를 뺀 나머지 구성요스는 대부분 벨류이다. 루트 엔티티 외에 또 다른 엔티티가 있다면 진짜 엔티티인지 의심해봐야한다. 단지 별도 테이블에 데이터를 저장한다고 해서 엔티티인 것은 아니다.

벨류가 아니라 엔티티가 확실하다면 다른 애그리거트는 아닌지 확인 해야한다. 특히 자신만의 독자적인 라이프사이클을 갖는다면 다른 애그리거트일 가능성이 높다. 예를 들어 상품 상세 화면을 보면 상품 자체에 대한 정보와 고객의 리뷰를 함께 보여주는데, 이를 보고 상품 애그리거트에 곡객 리뷰가 포함된다고 생각할 수 있다. 하지만 Product와 Review는 함께 생성되지 않고, 함께 변경되지 않는다. 게다가 변경 주체도 다르다.

애그리거트에 속한 객체가 벨류인지 엔티티인지 구분하는 방법은 고유 식별자를 갖는지 여부를 확인하는 것이다.

6장 응용 서비스와 표현 영역

응용 서비스를 실행한 뒤에 표현 영역은 실행 결과를 사용자에게 알맞은 형식으로 응답한다. 사용자와의 상호작용은 표현 영역이 처리하기 때문에 응용 서비스는 표현 영역에 의존하지 않는다. 응용 영역은 사용자가 웹 브라우저를 사용하든지, REST API를 호출하든지, TCP 소켓을 사용하는지 여부를 알 필요가 없다. 단지 응용 영역은 기능 실행에 필요한 입력값을 전달 받고 실행 결과만 리턴하면 될 뿐이다.

응용 서비스의 역할

응용 서비스는 사용자(클라이언트)가 요청한 기능을 실행한다. 응용 서비스의 주요 역할은 도메인 객체를 사용해서 사용자의 요청을 처리하는 것임으로 표현 영역 입장에서 보았을 때 응용 서비스는 도메인 영역과 표현영역을 연결해주는 창구인 파사드 역할을 한다.

도메인 객체 간의 실행 흐름을 제어하는 것과 더불어 응용 서비스의 주된 역할 중 하나는 트랜잭션 처리이다. 응용 서비스는 도메인의 상태 변경을 트랜잭션으로 처리해야 한다.

도메인 로직 넣지 않기

도메인 로직은 도메인 영역에 위치하고 응용 서비스는 도메인 로직을 구현하지 않는다고 했다. 도메인 로직을 도메인 영역과 응용 서비스에 분산해서 구현하면 코드 품질에 문제가 발생한다.

첫 번째 문제는 코드의 응집성이 떨어진다는 것이다. 도메인 데이터와 그 데이터를 조작하는 도메인 로직이 한 영역에 취지하지 않고 서로 다른 영역에 위치 한다는 것은 도메인 로직을 파악하기 위해 여러 영역을 분석해야 한다는 것을 뜻한다.

두 번째 문제는 여러 응용 서비스에 동일한 도메인 로직을 구현할 가능성이 높아 진다는 것이다. 응집도가 떨어지고 코드 중복이 발생하여 결과적으로 코드 변경을 어렵게 만든다. 코드 중복을 막기 위해 응용 서비스 영역에 별도의 보조 클래스를 만들 수 있지만 애초에 도메인 영역에 암호 확인 기능을 구현했으면 응용 서비스는 그 기능을 사용하기만 하면된다.

도메인 로직이 응용 서브스에 출현하면서 발생하는 두 가지 문제는 응집도가 떨어지고 코드 중복이 발생하여 결과적으로 코드 변경을 어렵게 만든다. 소프트웨어의 중요한 경쟁 요소 중 하나는 변경의 용이성이다. 그러한 것을 어렵게 만드는 것은 소프트웨어의 가치를 떨어진다는 것을 뜻한다. 도메인 로직을 도메인 영역에 모아서 코드 중복이 발생하지 않도록 하고 응집도를 높여야 한다.

응용 서비스 구현

응용 서비스는 표현 영역과 도메인 영역을 연결하는 매개체 역할을 하는데 이는 디자인 패턴에서 파사드와 같은 역할을 한다.

응용 서비스의 크기

회원 도메인을 생각해보자. 응용 서비스는 회원 가입하기, 회원 탈퇴하기, 회원 암호 변경하기, 비밀번호 초기화하기 같은 기능을 구현하기 위해 도메인 모델을 사용하게 된다. 이 경우, 응용 서비스는 보통 다음 두가지 방법 중 한가지 방식으로 구현한다.

  • 한 응용 서비스 클래스에 회원 도메인의 모든 기능 구현하기
  • 구분되는 기능별로 응용 서비스 클래스를 따로 구현하기
// 한 응용 서비스 클래스에 회원 도메인의 모든 기능 구현하기 케이스
public class MemberService {
    private void join(){...}
    private void leave(){...}
    ....
}

각 기능에 동일한 로직을 위한 코드 중복을 제거하는 것이 쉽다는 장점이라면 한 서비스 클래스의 크기가 커진다는 것은 이 방식의 단점이 된다. 코드 크기가 커진다는 것은 연관성이 적은 코드가 한 클래스에 함께 위치할 가능성이 높아짐을 의미하는데, 이는 결과적으로 관련 없는 코드가 뒤썩여서 코드를 이해하는 데 방해가 될 수 있다. 이 처럼 코드를 점점 얽히게 만들어 코드 품질을 낮추는 결과를 초래한다.

구분되는 기능별로 서비스 클래스를 구현하는 방식은 한 응용 서비스 클래스에서 한 개 내지 2~3개의 기능을 구현한다. 이 방식을 사용하면 클래스 개수는 많아지지지만 한 클래스에 관련된 기능을 모두구현하는 것과 비교해서 코드 품질을 일정 수준으로 유지하는데 도움이 된다. 또한, 각각 클래스 별로 필요한 의존 객체만 포함함으로 다른 기능을 구현한 코드에 영향을 받지 않는다.

각 기능마다 동일한 로직을 구현할 경우 여러 클래스에 중복해서 동일한 코드를 구현할 가능성이 있다. 이런 경우 XXXServiceHelper 와 같은 클래스에 로직을 구현해서 코드가 중복되는 것을 방지할 수 있다.

개인적 의견

XXXService 이러한 서비스 클래스는 안티패턴이라고 생각한다. 서비스의 크기는 작아야 그 객체가 갖는 의존성, 책임 등이 작아진다. 이는 테스트 코드작성시에도 많은 도움이 된다. 무엇보다 XXXService가 안티패턴인 이유는 다형성을 만족시키기 힘들다. 객체의 책임이 모여 역할이 되고 이 역할은 대체를 의미한다.

비밀번호 변경 기능은 크게 2가지 정도가 있다.

  1. 비밀번호 기반으로 비밀번호를 변경하는 기능
  2. 비밀번호를 잊어 버렸을 경우 제 3의 인증 방법을 통해서 비밀번호를 변경하는 기능

만약 이 기능을 MemberService에 기능이 구현 했을 때는 메서드로 위의 두 가지 기능을 구현할 것이다.

이는 MemberService가 점점 책임이 많아 지는 것이며 책임이 많아 지는 경우 그 역할의 대체성을 상실하게 된다. 대체성을 상실하게 되면 DIP, OCP를 적용할 수 없으며 이는 유지보수하기 어려운 코드가 된다.

public interface ChangePasswordService {
    public void change(MemberId id, PasswordDto.ChangeRequest dto);
}

public class ByAuthChangePasswordService implements ChangePasswordService {
    private MemberFindService memberFindService;

    @Override
    public void change(MemberId id, PasswordDto.ChangeRequest dto) {
        if (dto.getAuthCode().equals("인증 코드가 적합한지 로직 추가...")) {
            final Member member = memberFindService.findById(id);
            final String newPassword = dto.getNewPassword().getValue();
            member.changePassword(newPassword);
            // 필요로직...
        }
    }
}

public class ByPasswordChangePasswordService implements ChangePasswordService {
    private MemberFindService memberFindService;

    @Override
    public void change(MemberId id, PasswordDto.ChangeRequest dto) {
        if (dto.getPassword().equals("비밀번호가 일치하는지 판단 로직...")) {
            final Member member = memberFindService.findById(id);
            final String newPassword = dto.getNewPassword().getValue();
            member.changePassword(newPassword);
        }
    }
}

이렇게 되면 비밀번호 변경이라는 책임을 갖게되고 이런 책임으로 비밀번호 변경 역할을 갖게 되는 것이다. 여기서 중요한것은 이 역할이 대체된다는 것이다.

비밀번호 변경이라는 역할 ChangePasswordService이 있고 이 역할은 대체 가능하다. 비밀번호 기반으로 비밀번호를 변경하는 ByPasswordChangePasswordService 역할로 대체 가능, 다른 인증수단으로 비밀번호를 변경하는 ByAuthChangePasswordService 역할로 대체 가능하다.

MemberService라는 곳에 모든 로직이 담겨있다면 MemberService의 책임이 너무 많고 이 책임을 대체할 수 있는 역할을 만들지 못한다.

응용 서비스의 인테페이스와 클래스

인터페이스가 필요한 몇가지 상황이 있는데 그중 하나는 구현 클래스가 여러 개인 경우이다. 구현 클래스가 다수 존재하거나 런타임에 구현 객체를 교체해야할 경우 인터페이스를 유용하게 사용할 수 있다.

그런데 응용 서비스는 보통 런타임에 이를 교체하는 경우가 거의 없을 뿐만 아니라 한 서비스의 구현 클래스가 두 개인 경우도 매우 드물다.

이런 이유로 인터페이스와 클래스를 따로 구현한면 소스 파일만 많아지고 구현 클래스에 대한 간접 참조가 증가해서 전체 구조만 복잡해지는 문제가 발생한다. 따라서 인터페이스가 명확하게 필요하기 전까지는 응용 서비스에 대한 인터페이스를 작성하는 것이 좋은 설계라고 불 수없다.

메서드 파라미터와 값 리턴

응용 서비스가 제공하는 메서드는 도메인을 이용해서 사용자가 요규한 기능을 실행 하는데 필요한 값을 파라미터를 통해 전달받아야 한다. 응용 서비스에 데이터로 전달할 요청 파라미터가 두 개 이상 존재하면 데이터 전달을 위한 별도 클래스를 사용하는 것이 편리하다.

응용 서비스에서 애그리거트 자체를 리턴하면 코딩은 편할 수 있지만 도메인의 로직 실행을 응용 서비스와 표현 영역 두 곳에서 할 수 있게 된다. 이는 기능 실행 로직을 응용 서비스와 표현 영역에 분산시켜 코드의 응집도를 낮추는 원인이 된다.

표현 영역에 의존하지 않기

응용 서비스의 파라미터 타입을 결정할 때 주의할 점은 표현 영역과 관련된 타입을 사용하면 안된다는 점이다. 예를 들어 HttpServletRequest나 HttpSession을 응용 서비스에 파라미터로 전달하면 안된다. 응용 서비스에서 표현 영역에 대한 의존성이 발생하면 응용 서비스만 단독으로 테스트하기 어려워진다.

도메인 이벤트 처리

응용 서비스의 역할 중 하나는 도메인 영역에서 발생시킨 이벤트를 처리하는 것이다. 여기서 이벤트 도메인에서 발생한 상태 변경을 의미하면 암호 변경됨, 주문 취소함 같은 이벤트가 될 수 있다.

도메인 영역은 상태가 변경되면 이를 외부에 알리기 위해 이벤트를 발생시킬수 있다. 예를 들어 암호 초기화 기능은 다음과 같이 암호 변경에 후에 암호 변경됨 이벤트를 발생시킬 수 있다. 도메인에서 이벤트를 발생시키면 그 이벤트를 받아서 처리할 코드가 필요한데 그 역할을하는 것이 응용 서비스이다. 응용 서비스는 이벤트를 바당서 이벤트에 알맞은 후처리를할 수 있다.

표현 영역

표현 여역의 책음은 크게 다음과 같다

  • 사요자가 시스템을 사용할 수 있는 흐름을 제공하고 제어한다.
  • 사용자의 요청을 알맞는 응용 서비스에 전달하고 결과를 사용자에게 제공한다.
  • 사용자의 세션을 관리한다.

값 검증

값 검증은 표현 영역과 응용 서비승 두 곳에서 모두 수행할 수 있다. 원친적으로는 모든 값에 대한 검증은 응용 서비스에서 처리한다.

응용 서비스를 사용하는 표현 여역 코드가 한 곳이면 구현의 편리함을 위해 다음과 같이 역할을 나누어서 검증을 수행 할 수도 있다.

  • 표현 영역: 필수 값, 값의 형식, 범위 등을 검증한다.
  • 응용 서비스: 데이터의 존재 유무와 같은 논리적인 오류를 검증한다.

7장 도메인 서비스

여러 애그리거트가 필요한 기능

도메인 영역의 코드를 작성하다 보면 한 애그리거트로 기능을 구현할 수 없을 때가 있다. 애표적인 예가 경제 금액 계산로직이다. 실제 결제 금액을 계산할 때는 다음 과 같은 내용이 필요하다.

  • 상품 애그리거트: 구매 상품의 가격이 필요하다. 또한 상품에 따라 배송비가 추가되기도 한다.
  • 주문 애그리거트: 상품별로 구매 개수가 필요하다.
  • 할인 쿠폰 애그리거트: 크폰 별로 지정한 할인 금액이나 비율에 따라 할인 된다.
  • 회원 애그리거트: 회원 등급에 따라 추가 할인이 가능하다.

한 애그리거트에 넣기에 애매한 도메인 기능을 특정 애그리거트에서 억지로 구현하면 안된다. 이러한 문제를 해결하기 가장 쉬운 방법이 도메인 서비스 이다.

도메인 서비스

할인 금액 규칙 계산 처럼 한 애그리거트에 넣기 애매한 도메인 개념을 구현하면 애그리거트에 억지로 넣기 보다는 도메인 서비스를 이용해서 도메인 개념을 명시적으로 드러내면된다. 응용 영역의 서비스가 응용 로직을 다룬다면 도메인 서비스는 도메인 로직을 다룬다.

8장 애그리거트 트랜잭션 관리

한 주문 애그리거트에 대해 운영자는 배송 준비 상태로 변경할 때 사용자는 배송지 주소를 변경하면 어떻게 될까? 아래 그림은 운영자와 고객이 동시에 한 주문 애그리거트를 수정하는 과정을 보여준다. (배송 상태로 변경되면 더 이상 배송지 변경은 할 수 없다.)

운영자 스레드와 고객 스레드는 같은 주문 애그리거트를 나타내는 다른 객체를 구하게 된다 (트랜잭션 마다 리포지토리라는 새로운 애그리거트를 생성한다.)

운영자 스레드와 고객 스레드는 개념적으로 동일한 애그리거트이지만 물리적으로 서로 다른 애그리거트 객체를 사용한다.

때문에 운영자 스레드가 주문 애그리거트 객체 배송 상태로 변경하더라도 고객 스레드가 사용하는 주문 애그리거트 객체에는 영향을 주지 않는다. 고객 스레드 입장에서 주문 애그리거트 객체는 아직 배송 상태 전이므로 배송지 정보를 변경할 수 있다.

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

이런 문제가 발생하지 않도록 하려면 다음 두 가지 중 하나를 해야한다.

  • 운영자 배송지 정보를 조회하고 상태를 변경하는 동안 고객이 애그리거트를 수정하지 못하게 막는다. (수정하지 못하는 것보다 조회까지 막아야 된다고 생각함)
  • 운영자가 배송지 정보를 조회한 이후 고객이 배송지 정보를 변경하면 운영자가 애그리거트를 다시 조회한 뒤 수정하도록 한다.

이 두 가지는 애그리거트 자체의 트랜잭션과 관련이 있다. DBMS가 지원하는 트랜잭션과 함께 애그리거트를 위한 추가적인 트랜잭션 처리 기법이 필요하다. 애그리거트에 대해 사용할 수 있는 대표적인 트랜잭션 처리 방식에는 선점 잠금과 비선점 잠금의 두 가지 방식이 있다.

Isolation REPEATABLE_READ 으로 해결 못하는 이유

REPEATABLE_READ은 트랜잭션이 지속되는 동안 다른 트랜잭션이 해당 필드를 변경할 수 없는 격리 레벨이다. 이것으로 위의 문제를 해결할 수 있지는 않다.

  1. 운영자 스레드가 주문 애그리거트를 구함 (배송 이전 상태)
  2. 고객 스레드가 주문 애그리거트를 구함 (배송 이전 상태) REPEATABLE_READ 속성이므로 select 트랜잭션이 진행중에 있더라도 select는 진행됨
  3. 운영자 스레드가 주문의 상태를 배송 상태로 변경
  4. 고객 스레드가 배송지를 변경
  5. 운영 스레드 트랜잭션 커밋
  6. 고객 스레드는 REPEATABLE_READ 격리 레벨임으로 트랜잭션 임으로 운영 스레드 트랜잭션 커밋 이후 커밋 됨

운영자는 배송 상태로 변경하고, 고객은 배송지를 변경이 모두 데이터베이스에 반영된다는 것이다. 2번 고객 스레드가 주문 애그리거트를 구하는것을 Lock 해야 이 문제를 해결 할 수 있다.

선점 잠금

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

스레드1 선점 잠금방식으로 애그리거트를 구한 뒤 이에서 스레드2가 같은 애그리거트를 구하고 있는데, 이 경우 스레드2는 스레드1이 애그리거트에대한 잠금을 해제할 때 까지 블로킹된다.

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

한 스레드가 애그리거트를 구하고 수정하는 동안 다른 스레드가 수정할 수 없음므로 동시에 애그리거트를 수정할 때 발생하는 데이터 충돌 문제를 해소할 수 있다.

선점 잠금 적용

  1. 운영자 스레드가 먼저 선점 잠금 방식으로 주문 애그리거트를 구함
  2. 고객 스레드는 운영자 스레드가 잠금을 해제할 때까지 고객 스레드는 대기 상태
  3. 운영자 스레드가 배송 상태로 변경한 뒤에 트랜잭션을 커밋하면 잠금을 해제한다.
  4. 잠금이 해제된 시점에 고객 스레드가 구하는 주문 애그리거트는 운영자 스레드가 수정한 배송 상태의 주문 애그리거트이다. 배송 상태이므로 주문 애그리거트는 배송지 변경 시 에러를 발생하거 트랜잭션이 실패한다.
  5. 고객은 이미 배송이 시작되어 배송지를 변경할 수 없습니다.와 같은 안내 문구를 받게 됨

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

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

Order order = entityManager.find(Order.class, orderNo, LockModeType.PESSIMISTIC_WRITE)

JPA 프로바이더와 DBMS에 따라 잠금 모드의 구현이 다른데, 하이버네티으의 경우 PESSIMISTIC_WRITE를 잠금 모드로 사용하면 for update 쿼리를 사용해서 선점 잠금을 구현한다.

선점 잠금과 교착상태

선점 잠금 기능을 사용할 때는 잠금 순서에 따른 교착 상태가 발생하지 않도록 주의해야 한다. 예를 들어, 다음과 같은 순서로 두 스레드가 선점 잠금을 시도를 한다고 해보자

  1. 스레드 1: A 애그리거트에 대한 선점 잠금 구함
  2. 스레드 2: B 애그리거트에 대한 선점 잠금 구함
  3. 스레드 1: B 애그리거트에 대한 선점 잠금 시도
  4. 스레드 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.PESSIMISTIC_WRITE, hints);

JPA의 javax.persistence.lock.timeout 힌트는 잠금을 구하는 대기 시간을 밀리초 단위로 지정한다. 지정한 시간이내에 잠금을 구하지 못하면 익셉션을 발생 시킨다. 이 힌트를 사용할 때 주의할 점은 DBMS에 따라 힌트가 적용되지 않는 다는 점이다. 이 힌트를 이용할 때에는 사용중인 DBMS가 관련 기능을 지원하는지 확인해야 한다.

비선점 잠금

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

  1. 운영자는 배송을 위해 주문 정보를 조회한다.
  2. 고객이 배송지 변경을 위해 변경 폼을 요청한다. 시스템은 변경 폼을 제공한다.
  3. 고객이 새로운 배송지를 입력하고 폼을 전송해서 배송지를 변경한다.
  4. 운영자가 1번에서 조회한 주문 정보를 기준으로 배송지를 정하고 배송 상태 변경을 요청한다.

여기서 문제는 운영자가 배송지 정보를 조회하고 배송 상태로 변경하는 사이에 고객이 배송지를 변경한다는 것이다. 운영자는 고객이 변경하기 전에 배송지 정보를 이용해서 배송 준비를 한 뒤에 배송 상태로 변경하게 된다.

즉 배송 상태 변경 전에 배송지를 한 번 더 확인 하지 않으면 운영자는 다른 배송지로 물건을 발송하게 되고, 고객은 배송지를 변경했음에도 불구하고 엉뚱한 곳으로 주문한 물건을 받는 상황이 발생한다.

이 문제는 선점 잠금 방식으로 해결 할 수 없는데, 이 때 필요한 것이 비선점 잠금이다. 비선점 잠금 방식은 잠금 을 해서 동시에 접근하는 것을 막는 대신 변경한 데이터를 실제 DBMS에서 반영하는 시점에 변경 가능 여부를 확인하는 방식이다.

비선점 잠금을 구현하려면 애그리거트에 버전으로 사용할 숫자 타입의 프로퍼티를 추가해야한다. 애그리거트를 수정할 때마다 버전으로 사용할 프로피터 값이 1씩 증가하는데, 이때 다음과 같은 쿼리를 사용한다.

UPDATE aggrable SET version = version +1, colx = ?, coly =?
WHERE aggid =? and version = 현재 버전

이 쿼리는 수정할 애그리거트와 매핑되는 테이블의 버전 값이 현재 애그리거트의 버전과 동일한 경우에만 데이터를 수정한다. 그리고 수정에 성공하면 버전 값을 1증가 시킨다. 따라서, 다른 트랜잭션이 먼저 데이터를 수정해서 버전 값이 바뀌면 데이터 수정에 실패하게 된다.

비선점 잠금을 이용한 트랜잭션 충돌 방지

  1. 스레드 1 애그리거트 조회
  2. 스레드 2 애그리거트 조회
  3. 스레드 1 애그리거트 수정 시도 (스레드 2 보다 먼저 시도한다), 수정에 성공하고 버전은 6이 된다.
  4. 스레드 2 애그리거트 수정 시도, 이미 애그리거트 버전이 6이므로 스레드2는 데이터 수정에 실패하게 된다.

JPA는 버전을 이용한 비선점 잠금을 기능을 지원한다. 다음과 같이 버전으로 사용할 필드에 @Version 애노테이션을 붙이거 매핑되는 테이블 버전을 지정한 칼럼을 추가하기만 하면된다.

@Entity
@Table(name = "purchase_order")
public class Order {
    ...
    @Version
    private long version;
}

JPA는 엔티티가 변경되어 UPDATE 쿼리를 실행할 때 @Version에 명시한 필드를 이용해서 비선점 잠금 쿼리를 실행한다. 즉 애그리거트 객체의 버전 10이면 UP-DATE 쿼리를 실행할 때 다음과 같은 쿼리를 사용해서 버전 일치하는 경우에만 데이터를 수정한다.

update purchase_order SET ..., version = version + 1
where number ? and version = 0;

응용 서비스 버전에 대해 알 필요가 없다. 리포티터리에 필요한 애그리거트를 구현하고 알맞은 기능을 실행하면 된다. 기능을 실행하는 과정에서 애그리거트의 데이터가 변경되면 JPA트랜잭션 종료 시점에 비선점 잠금을 위한 쿼리를 실행 한다.

@Controller
public class OrderController {
    private ChangeShppingService changeShippingService;

    @PutMapping("/shpping")
    public String changeShipping(ChangeShippingsRequest changeReq){
        try {
            changeShppingService.changeShpping(changeReq);
            return "changeShppingSuccess";
        } catch (OptimistickLockingFailureException ex){
            // 누군가 먼저 같은 주문 애그리거트를 수정 했음으로
            // 트랜잭션 충돌이 일어났다는 메시지를 보여준다.
            return "changeShppingTxConflict";

        }
    }
}

비선점 트랜잭션 충돌 문제 해결 Flow

9장 도메인 모델과 BOUNDED COUNTEXT

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

하위 도메인 마다 같은 용어라도 의미가 다르고 같은 대상이라도 지칭하는 용어가 다를 수 있기 때문에 한 개의 모델로 모든 하위 도메인을 표현하는 시도는 올바른 방법이 아니며 표현할 수도 없다.

BOUNDED CONTEXT

BOUNDED CONTEXT는 모델의 경계를 결정하며 한 개의 BOUNDED CONTEXT는 논리저적으로로 한 개의 모델을 갖는다.

BOUNDED CONTEXT의 구현

BOUNDED CONTEXT가 도메인 모델만 포함하는 것은 아니다. BOUNDED CONTEXT는 도메인 모델 뿐만이 아니라 도메인 기능을 사용자에게 제공하는데 필요한 표현 영역, 응용 서비스, 인프라 영역 등 모두 포함한다.

10장 이벤트

시스템 간 강력합의 문제

쇼핑몰에서 구매를 취소하면 환불을 처리해야 한다. 이때 환불 기능을 실행하는 주체는 주문 도메인 엔티티가 될 수 있다. 도메인 객체에서 환불 긴응을 실행하려면 도메인 기능에서 도메인 서비스를 실행하게 된다.

보통 결제 시스템은 외부에 존재하므로 외부의 환불 시스템 서버시를 호출하는데, 이때 두 가지 문제가 발생한다.

첫 번째 문제는 이부 서비스가 정상이 아닐 경우 트랜잭션을 어떻게 처리할지가 애매하다는 것이다. 외부의 환불 서비스를 실행하는 과정에서 익셉션이 발생하면 환불에 실패했음으로 주문 취소 트랜잭션을 롤백하는 것이 맞는 것을 보인다. 하지만 반드시 트랜잭션을 롤백해야 하는 것은 아니다. 주문은 취소 상태로 변경하고 환불만 누중에 다시 시도하는 방식으로 처리할 수도 있다.

두 번째 문제는 성능에 대한 것이다. 환불을 처리하는 외부 시스템의 응답 시간이 길어지면 그 만큼 대기 시간이 발생한다. 환불 처리 기능이 30초가 걸리면 주문 취소 기능은 30초만큼 대기 시간이 증가한다.

Order는 주문을 표현하는 도메인 객체인데 결제 도메인의 환불 관련 로직이 뒤썩이게 된다. 이는 환불 기능이 바뀌면 Order도 영향을 받게 된다는 것을 의미한다.

이러한 강한 결갑을 업앨 수 있는 방법이 있는데 그것은 바로 이벤트를 사용하는 것이다. 특히 비동기 이벤트를 사용하면 두 시스템 간의 결합을 크게 낮출 수 있다.

이벤트 개요

이 절에서 사용하는 이벤트라는 용어는 '과거에 벌어진 어떤 것'을 뜻한다. 예를 들어 사용자가 비밀번호를 변경한 것은 '암호를 변경했음 이벤트' 라고 부를 수 있다.

이벤트 관련 구성 요소

도메인 모델에서 이벤트 주체는 엔티티, 벨류, 도메인 서비스와 같은 도메인 객체이다. 이를들 도메인 객체는 도메인 로직을 실행해서 상태가 바뀌면 관련 이벤트를 발생한다.

이벤트의 구성

이벤트는 발생한 이벤트에 대한 정보를 담는다. 이 정보는 다음을 포함한다.

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

배송지를 변경할 때 발생하는 이벤트를 생각 해보자

public class ShippingInforChangedEvnet {
    private String orderNumber;
    private long timestpa;
    private ShippingInfo newShippingInfo;

    // 생성자, getter
}

클래스 이름을보면 과거 시제를 사용했다. 이벤트는 현재 기준으로 과거에 벌어진 것을 표햔하기 때문에 이벤트 이름은 과거 시제를 사용한다.

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

public class Order {
    
    public void changeShippingInfo(ShippingInfo newShippongInfo){
        setShipponhInfo(newShippongInfo);
        Events.raise(new ShippongInfoChangedEvent(number, newShippongInfo));
    }
    ...
}

ShippongInfoChangedEvent를 처리하는 핸들러는 디스패처로부터 이벤트를 전달받아 필요 작업을 수행한다.

이벤트 용도

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

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

이벤트 장점

이벤트를 사용하면 서로 다른 도메인 로직이 섞이는 것을 방지 할 수 있다. 구매 취소 로직에 이벤트를 적용함으로써 환불 로직이 없어진다. 이벤트를 사용해서 주문 도미인에서 결제 도메인으로 의존을 제거했다.

이벤트 핸들러를 사용하면 기능 확장도 용이하다. 구매 취소 시 환불과 함께 이메일로 취소 내용을 보내고 싶다면 이메일 발송 처리하는 핸들러 구현하고 디스패처에 등록하면 된다.

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

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

이벤트 클래스

이벤트 자체를 위한 사위 타입은 존재하지 않는다. 원하는 클래스를 이벤트로 사용할 것이다. 이벤트는 과거에 벌어진 상태 변화나 사건을 의미하므로 이벤트 클래스의 이름을 결정할 때 과거 시체를 사용해야 한다느 점만 유의하면 된다.

이벤트 구성에서 설명한 것처럼 이벤트 클래스는 이벤트를 처리하는 데 필요한 최소한의 데이터를 포함해야 한다. 예를 들어, 주문 취소 이벤트는 적어도 주문번호를 포햄해야 관련 핸들러의 후속처리를할 수 있다.

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

비동기로 이벤트를 처리해야 할 때 사용하는 또 다른 방법은 RabbitMQ와 같은 메시징 큐를 사용하는 것이다. 이벤트가 발생하면 이벤트 디스패처는 이벤트를 메시지 큐에 보낸다. 메시지 큐는 이벤트를 메시지 리스너에 전달하고 메시지는 리스너는 알맞은 이벤트 핸들러를 이용해서 이벤트를 처리한다. 이때 이벤트를 메시지 큐에 저장하는 과정과 메시지 큐에서 이벤트를 읽어와 처리하는 과정은 별도 스레드나 프로세스로 처리된다.

11장 CQRS

단일 모델의 단점

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

3장에서 언급한 ID를 이용해서 애그리거트를 참조하는 방식을 사용하면 즉시로딩 방식과 같은 JPA의 쿼리 관련 최적화 기능을 사용할 수없다. 이는 한번의 select 쿼리로 조회 화면에 필요한 데이터를 읽어 올 수 없어 조회 속도에 문제가 생길 수 있다.

애그리거트 간의 연관을 ID가 아니라 직접 참조하는 방식으로 연결해도 고민거리가 생긴다. 조회 화면의 특성에 따라 연관된 즉시 로딩이나 지연로딩으로 처리해야 하기 때문이다. 때에 따라서는 JPA의 네이티브 쿼리를 사용해야 할 수도 있다.

이러한 고민이 발생하는 이유는 시스템의 상태를 변경할 때와 조회할 때 단일 도메인 모델을 사용하기 때문이다. 객체 지향 도메인 모델을 구현할 때는 주로 사용하는 ORM 기법은 도메인 상태 변경을 구현하는데 적합하지만, 주문 상 세 조회 화면처럼 여러 애그리거트에서 데이터를 가좌 추력하는 기능을 구현하기에 고려할 것들이 많아서 구현을 복잡하게 만드는 원이이 된다.

CQRS

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

CQRS는 복잡한 도메인에 접합하다. 도메인이 복잡할수록 명령 기능과 조회 기능이 다루는데 데이터 범위에 차이가 발생하는데, 이 두기능을 단일 모델로 처리하게 되면 조회 기능의 로딩 속도를 위해 모델 구현이 필요 이상으로 복잡해지는 문제가 발생한다.