DDD와 MSA 기초(1)

DDD와 MSA에 대해 여러 시리즈로 정리합니다. 이번 편에서는요,

- 도메인
- 도메인 모델
- DIP
- 도메인 영역의 주요 구성요소
  - ENTITY
  - VALUE
  - AGGREGATE
  - REPOSITORY
  - DOMAIN SERVICE    
- 도메인 모델과 BOUNDED CONTEXT

에 대해 다룹니다.

참고: 도서 DDD START! 도메인 주도 설계 구현과 핵심 개념 익히기


도메인

개발자 입장에서 구현해야 할 소프트웨어 대상, 소프트웨어로 해결하고자 하는 문제 영역은 도메인(domain)에 해당합니다.



도메인 모델

도메인 모델은 아키텍처상의 도메인 계층을 객체 지향 기법으로 구현하는 패턴으로, 도메인 모델을 사용하면 여러 관계자들이 동일한 모습으로 도메인을 이해하고 도메인 지식을 공유할 수 있는데, 이런 도메인 모델은 객체 모델이나 상태 다이어그램 등을 사용해서 모델링할 수 있습니다. 도메인 자체를 이해하기 위한 개념 모델로, 구현 기술에 맞는 구현 모델이 따로 필요합니다.
계층 구조는 그 특성상 상위 계층에서 하위 계층으로의 의존만 존재하고 하위 계층은 상위 계층에 의존하지 않습니다. 또한 엄격하게 적용하면 상위 계층은 바로 아래의 계층에만 의존을 가져야 하지만 구현의 편리함을 위해 유연하게 적용하기도 합니다. (ex. 응용 계층이 도메인 계층에도 의존하지만 외부 시스템의 연동을 위해 더 아래 계층인 인프라 계층에 의존하기도 함)
그런데 이렇게 응용 계층이 인프라 계층에 의존하게 되면 ‘테스트 어려움’과 ‘기능 확장의 어려움’ 이라는 두 가지 문제가 발생할 수 있는데 이는 DIP를 적용해서 해결할 수 있습니다.



DIP

ex.



DIP와 아키텍처

인프라 영역은 구현 기술을 다루는 저수준 모듈이고 응용 영역과 도메인 영역은 고수준 모듈입니다. 아키텍처 수준에서 DIP를 적용하면 인프라 영역이 응용 영역과 도메인 영역에 의존(상속)하는 구조가 됩니다. 즉, 인프라 영역에 위치한 클래스가 도메인이나 응용 영역에 정의한 인터페이스를 상속받아 구현하는 구조가 되므로 도메인과 응용 영역에 대한 영향을 최소화하면서 구현 기술을 변경할 수 있습니다.

image



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

엔티티(Entity)


밸류 타입(Value)

public class ShippingInfo {
    
    //받는 사람
    private String receiverName;
    private String receiverPhoneNumber;
    
    //주소
    private String shippingAddress1;
    private String shippingAddress2;
    private String shippingZipCode;
    
}
public class Receiver {
  private String name;
  private String phoneNumber;

  //생성자, getter, setter...
}
public class Address {
  private String address1;
  private String address2;
  private String zipcode;
  //생성자, getter, setter...
}
//Value 타입 이용
public class ShippingInfo {
  private Receiver receiver;
  private Address address;
  //생성자, getter, setter...
}

엔티티 식별자와 밸류 타입

엔티티 식별자의 실제 데이터는 문자열로 구성된 경우가 많은데 Money가 단순 숫자가 아닌 도메인의 ‘돈’을 의미하는 것처럼 식별자가 도메인에서 특별한 의미를 지니도록 하기 위해 식별자를 위한 밸류타입을 사용할 수도 있습니다.

public class Order {
    //OrderNo 타입 자체로 id가 주문번호임을 알 수 있음
    private OrderNo id;
    // ...
}


애그리거트(Aggregate)

IMG_0CC9BB5F4289-1

애그리거트는 복잡한 모델을 일관성 있게 관리하는 기준이 됩니다. 복잡도가 낮아지는 만큼 도메인 기능을 확장하고 변경하는데 필요 노력도 줄어듭니다.


불필요한 중복을 피하고 애그리거트 루트를 통해서만 도메인 로직을 구현하게 만들려면,

  1. 단순히 필드를 변경하는 setter 메서드를 public으로 만들지 않습니다.
  2. 밸류 타입은 불변으로 구현합니다.


트랜잭션

한 트랜잭션에서는 한 개의 애그리거트만 수정하도록 합니다. 즉 애그리거트에서 다른 애그리거트를 변경하지 않도록 해서 애그리거트 간 결합도를 낮추도록 합니다. 부득이하게 한 트랜잭션에서 두 개의 애그리거트를 수정해야 할 때는 한 애그리거트에서 직접 수정하지 말고 응용 서비스에서 두 애그리거트를 수정하도록 구현해야 합니다.

참고: 도메인 이벤트를 사용하면 한 트랜젝션에서 한 개의 애그리거트를 수정하면서도 동기나 비동기로 다른 애그리거트의 상태를 변경하는 코드를 작성할 수 있습니다.


리포지터리(Repository)

public interface SomeRepository {
    void save(Some some);
    Some findById(SomeId id);
}


도메인 서비스(Domain Service)

특정 엔티티에 속하지 않은 도메인 로직을 제공합니다. 도메인 서비스는 애그리거트나 밸류와 다르게 상태없이 로직만 구현합니다. 도메인 서비스를 구현하는데 필요한 상태는 다른 방법으로 전달받습니다. 도메인 서비스를 사용하는 주체는 애그리거트가 될 수도 있고 응용 서비스가 될 수도 있습니다. 또한 애그리거트 객체에 도메인 서비스를 전달하는 것은 응용 서비스의 책임이 됩니다.
도메인 서비스는 도메인 로직을 실행하므로 도메인 서비스의 위치는 다른 도메인 구성 요소와 동일한 패키지에 위치합니다.

image


예시 - 결제 금액 계산 로직

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);
    }
    
    private Money calculateDiscount(Coupon coupon) {
      ...
    }
    private Money calculateDiscount(MemberGrade grade) {
      ...
    }
}
public class Order {
    public void calculateAmounts(DiscountCalculationService discountCalculationService, MemberGrade grade) {
        Money totalAmounts = getTotalAmount();
        Money discountAmounts = discountCalculationService.calculateDiscountAmounts(this.orderLines, this.coupons, grade);
        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 orderRequest) {
        Member member = findMember(orderRequest.getOrdererId());
        Order order = new Order(orderNo, orderRequest.getOrderLines(), orderRequest.getCoupons, createMember(member), orderRequest.getShippingInfo());
        order.calculateAmounts(this.discountCalculationService, member.getGrade());
        return order;
    }
}
public class TransferService {
    public void transfer(Account fromAccount, Account toAccount, Money amounts) {
        fromAccount.withdraw(amounts);
        toAccount.credit(amounts);
    }
}

도메인 서비스 vs 응용 서비스

도메인 서비스는 응용 로직을 수행하지는 않습니다. 트랜잭션 처리와 같은 로직은 응용 로직이므로 도메인 서비스가 아닌 응용 서비스에서 처리해야 합니다.


Q. 특정 기능이 응용 서비스인지 도메인 서비스인지 확인하려면?



도메인 모델과 BOUNDED CONTEXT

하위 도메인마다 사용하는 용어가 다르기 때문에 올바른 도메인 모델을 개발하려면 하위 도메인마다 모델을 만들어야 하고 각 모델은 명시적으로 구분되는 경계를 가져서 섞이지 않아야 합니다. 모델은 특정한 컨텍스트(문맥) 하에서 완전한 의미를 갖습니다. 이렇게 구분되는 경계를 갖는 컨텍스트를 DDD에서는 BOUNDED CONTEXT라고 부릅니다. BOUNDED CONTEXT는 용어를 기준으로 분리하며 실제로 사용자에게 기능을 제공하는 물리적 시스템이기 때문에 도메인 모델은 BOUNDED CONTEXT 안에서 도메인을 구현합니다.

이상적으로는 하위 도메인과 BOUNDED CONTEXT가 일대일 관계를 가지면 좋겠지만 현실적인 상황에 따라 여러 하위 도메인을 한 BOUNDED CONTEXT에서 구현하기도 합니다. 이때 하위 도메인의 모델이 뒤섞이지 않도록 하는 것이 중요합니다. 하위 도메인마다 구분되는 패키지를 갖도록 구현해야 하위 도메인을 위한 모델이 뒤섞이지 않습니다.

BOUNDED CONTEXT는 도메인 모델만 포함하는 것이 아니라 도메인 기능을 사용자에게 제공하는데 필요한 표현, 응용, 인프라 영역 등을 모두 포함합니다. 도메인 모델의 데이터 구조가 바뀌면 DB 테이블 스키마도 함께 변경해야 하므로 해당 테이블도 포함됩니다. 또한 모든 BOUNDED CONTEXT가 반드시 DDD로 개발될 필요는 없으며 CRUD 방식으로 구현해도 됩니다.
두 방식을 혼합해서 사용할 수도 있는데 이를 CQRS(Command Query Responsibility Segregation) 패턴이라고 합니다. 상태를 변경하는 명령 기능과 내용을 조회하는 쿼리 기능을 위한 모델을 구분하는 패턴으로 이를 BOUNDED CONTEXT에 적용하면,


BOUNDED CONTEXT 간 통합

image

ex. 카탈로그 하위 도메인에 개인화 추천 기능을 도입하게 되었다면? => 사용자가 제품 상세 페이지를 볼 때, 보고 있는 상품과 유사한 상품 목록을 하단에 보여주기

image

image


마이크로 서비스 아키텍처(MSA)는 애플리케이션을 작은 서비스로 나누어 개발하는 아키텍처 스타일로 개별 서비스를 독립된 프로세스로 실행하고 각 서비스가 REST API나 메시징을 이용해서 통신하는 구조를 갖습니다. 각 BOUNDED CONTEXT를 MSA로 구현하면 자연스럽게 컨텍스트 별로 모델이 분리되면서 별도의 프로세스로 마이크로 서비스마다 프로젝트가 생성되며 이를 독립적으로 배포하고 모니터링하고 확장할 수 있게 됩니다.