의존성 분리를 통해 설계 개선하기 2편 (우아한객체지향)

도메인 컨셉

가게와 메뉴
주문

문제점

사용자가 가게에서 메뉴 A를 골라서 장바구니에 저장해놓은 상태에서 가게 주인이 메뉴 A의 세부 내용을 바꾸면 사용자 장바구니에 있는 메뉴 A와 가게에서 판매 중인 메뉴 A의 불일치가 생긴다.
그래서 이제 주문할 때마다 사용자가 주문하려는 메뉴 A가 실제 가게에서 판매 중인 메뉴 A와 일치하는 지 검증하려고 한다.

주문 검증

  1. 메뉴 이름 == 주문 항목의 이름
  2. 옵션 그룹의 이름 == 주문 옵션 그룹 이름
  3. 옵션 이름 == 주문 옵션 이름
  4. 옵션 가격 == 주문 옵션의 가격
  5. 가게가 영업중인지 확인
  6. 주문금액 >= 최소 주문 금액

주문 검증 협력 흐름

이런 식으로 협력 구조를 잡으면 어떻게든 런타임에서 같은 방향으로 의존성을 가져야 한다.
그렇다면 의존성의 방향을 어떤 종류의 의존성으로 구현할 지 선택하면 된다. 협력은 연관관계와 의존관계로 구현할 수 있다.

연관관계

연관관계는 영구적인 탐색 구조이다. 매우 빈번하게 협력해야 하는 경우에 사용한다.
연관 관계는 탐색 가능성을 의미한다. 즉 객체 A를 알면 객체 B를 탐색할 수 있음을 의미한다.

일반적인 연관관계 구현 방식 : 객체 참조

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Order {
private List<OrderLineItem> orderLineItems;

public voidrplace() {
public Long getShopId() {...}
public Money getTotalPrice() {...}
validate()
ordered();
}

private void validate() {
// ...
for (OrderLineItem orderLineItem : orderLineItems) {
orderLineItem.validate(); // 협력!
}
}
}

참고로 연관관계는 개념이고 객체 참조는 그 개념의 구현방법이다. 연관관계가 즉 객체 참조는 아니다!

지금 예시의 도메인 모델에서는 다음과 같이 연관관계를 객체 참조로 가지고 있다

설계 진화시키기

설계를 개선하기 위해서는 의존성을 그려보자. 의존성이 순환하는 경우는 코드를 잘못 나눴거나 같이 있어야 할 코드를 잘못 위치시킨 것을 판단할 수 있다.

대표적인 문제

  1. 객체 참조로 결합도 상승
  2. 패키지 의존성 사이클

패키지 의존성 사이클
위 그림을 보면 알 수 있듯이 shop 패키지와 order 패키지가 서로 의존성이 순환되고 있다. 이렇게 되면 의존성 방향이 잘못 됐거나 패키지 분리를 잘못한 것이다.

자 이제 이런 문제를 해결하는 몇가지 방법을 배워보자

중간 객체 이용해서 의존성 사이클 끊기

중간 객체를 사용한 예시 클래스 다이어그램
중간 객체를 둬서 두 패키지 간 의존성이 한 방향으로 흐르게 만들었다.
이렇게 구현하면 OrderOptionGroup을 통해 OptionGroup을 생성하고 OptionGroupSpecification이 생성된 OptionGroup과 비교하여 데이터 불일치를 검증하는 방식으로 구현한다.

객체 참조의 문제점.

  1. 성능 문제 : 어디까지 조회 할 것인가?
  2. 수정 시 도메인 규칙을 함께 적용할 경계는? : 즉 트랜잭션의 범위가 어디까지인가?

수정 시 도메인 규칙을 함께 적용할 경계 문제

간단한 예시를 들어보자.
배달 완료됐음을 알리는 서비스 로직이 있다.

  1. 주문 객체의 상태를 배달 완료로 변경
  2. 가게 객체의 수수료를 부과
  3. 배달 객체의 상태를 배달 완료로 변경

언뜻 보면 이 세 객체를 한 트랜잭션으로 묶어서 수정하는 게 큰 문제가 없어보인다.
그러나 문제는 주문, 가게, 배달 이 세가지 객체의 트랜잭션 주기가 다르다는 사실이다!

가게는 주인이 가게 정보를 바꾸려고 할 때도 트랜잭션이 걸릴 수 있다.
주문은 고객이 주문을 취소할 때도 트랜잭션이 걸릴 수 있다.
배달은 배달 성공 실패에 트랜잭션이 걸릴 수 있다.

즉 만약 개발자가 서비스의 모든 가게 객체의 특정 정보를 일괄 수정하는 트랜잭션을 실행한다고 가정하자.
우리가 개발한 배달 완료 로직은 개발자가 수행한 모든 가게 정보 수정 트랜잭션이 완료될 때까지 기다려야 될 것이다!!!
이는 트랜잭션 주기가 다른 여러 객체들이 한 트랜잭션에 묶여있어서 생긴 문제이다!

그렇다면 언제 객체 참조를 해야하나?
도메인 규칙마다 다르다. 생명 주기가 같은 객체. 즉 같이 생성되고 같이 제거되는 객체는 객체 참조할 법하다.
혹은 도메인 제약 사항을 공유하는 객체들을 함께 묶어라! (도메인 제약 사항에 다른 객체가 필요한 경우!)
이렇게 객체 참조로 연결된 단위는 트랜잭션/조회/비즈니스 제약의 단위이다!

객체 참조 문제 해결법 : 레포지토리를 활용한 간접 참조

1
2
3
4
5
6
7
8
9
10
public class Shop {
private Long id;
}

public class Order {
private Long shopId;
// ...
}

Shop shop = shopRepository.findById(order.getShopId());

이렇게 구현하면 ShopOrder 사이의 강한 결합이 제거된다!

검증 로직은 Validator 객체를 만들어서 도입하기

문제는 이렇게 구현하면 기존에 객체들이 가지고 있던 검증 로직을 도메인 객체에서 수행할 수 없게 된다!
Validator라는 객체를 만들어서 주입해서 사용하는 건 어떨까?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Component
public class OrderValidator {
private ShopRepository shopRepository;
private MenuRepository menuRepository;

public OrderValidator(ShopRepository shopRepository,
MenuRepository menuRepository) {
this.shopRepository = shopRepository;
this.menuRepository = menuRepository;
}

public void validate(Order order) {
validate(order, getShop(order), getMenus(order));
}

void validate(Order order, Shop shop, Map<Long, Menu> menus) {
if (!shop.isOpen()) {
throw new IllegalArgumentException("가게가 영업중이 아닙니다.");
}

if (order.getOrderLineItems().isEmpty()) {
throw new IllegalStateException("주문 항목이 비어 있습니다.");
}

if (!shop.isValidOrderAmount(order.calculateTotalPrice())) {
throw new IllegalStateException(String.format("최소 주문 금액 %s 이상을 주문해주세요.", shop.getMinOrderAmount()));
}

for (OrderLineItem item : order.getOrderLineItems()) {
validateOrderLineItem(item, menus.get(item.getMenuId()));
}
}

이런 방식이 낯설 수 있다. 객체가 주도적으로 행동하는 것이 아닌 외부에 따로 객체를 만들어서 검증 책임을 하는 것처럼 보인다. 이런 방식은 객체지향적이지 않은 대신 제약조건을 한 눈에 볼 수 있고, 객체들의 응집도(검증 로직과 비즈니스 로직이 하나의 객체가 아닌 다른 객체로 분리)가 높아진다.

다른 서비스 객체를 만들어서 도입하기

그리고 객체 참조를 제거하면 다른 문제가 또 생긴다. 배달 완료 로직에서 객체 참조가 끊겨서 객체 내부에서 협력을 통해 로직을 실행 시킬 수 없다
-> 그렇다면 다른 Service를 객체를 추가해서 로직을 절차지향적으로 구성할 수 있다!

1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
public class OrderDeliveredService {
@Transactional
public void deliverOrder(Long orderId) {
Order order = orderRepository.findById(orderId);
Shop shop = shopRepository.findById(order.getShopId());
Delivery delivery = deliveryRepository.findById(orderId);

order.delivered();
shop.billComissionFee(order.calculateTotalPrice());
delivery.complete();
}
}

이 서비스를 주입받아서 사용하면 된다. 이때 서비스를 새로 만들면서 의존성 사이클이 도는 경우는 DIP를 활용한 인터페이스를 추가해서 적용해보자. 이 방법은 객체간의 결합도를 낮추는 대신 로직간의 결합도를 높이는 방법이다.

도메인 이벤트 퍼블리싱

위 방법들 외에도 도메인 이벤트 퍼블리싱 방법을 사용할 수 있다. 도메인 이벤트는 객체 간 결합은 최대한 느슨하게 하는 방법이다.
도메인 객체가 이벤트 객체를 발행하면 다른 패키지의 이벤트 리스너들이 이를 감지해 해당 패키지의 도메인 객체에게 알리는 방식이다.

주문 완료의 예시에서는

  1. Order 객체가 배달 완료됐다는 메시지를 받는다.
  2. OrderOrderDeliveredEvent라는 이벤트를 발행한다
  3. shop 패키지는 발행된 이벤트를 감지해 가게에 수수료를 부과하는 로직을 실행한다.
  4. delivery 패키지도 발행된 이벤트를 감지해 Delivery 객체의 상태를 완료로 변경한다.

도메인 이벤트 발행 코드로 이해하기

이런 식으로 Order 객체는 이제 이벤트를 발행하기만 한다. 다른 패키지 도메인 객체에는 관심이 없다.
예제는 Spring Data의 AbstractAggregatedRoot를 상속받아서 registerEvent 메서드를 활용해서 이벤트를 등록한다. registerEvent 메서드는 이벤트를 모아놨다가 DB에 커밋될 때 이벤트를 발행한다. 이런 클래스의 도움을 받기 보다는 직접 구현하는 게 낫다고 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Order extends AbstractAggregatedRoot<Order> {
public void delivered() {
this.orderStatus = OrderStatus.DELIVERED;
registerEvent(new OrderDeliveredEvent(this));
}
}

public class OrderDeliveredEvent {
private Order order;

public Long getOrderId() {...}
public Long getShopId() {...}
public Money getTotalPrice() {...}
}

이벤트 발행을 받는 쪽(shop 패키지)은 Spring의 이벤트 리스너를 활용해서 이벤트 핸들러를어만들어 사용하면 된다.

1
2
3
4
5
6
7
8
9
10
11
@Component
public class BillShopWithOrderDeliveredEventHandler {
// DI 생략...
@Async //비동기로 처리. 동기로도 가능
@EventListener
@Transactional // 다른 트랜잭션으로 분리해서도 가능
public void handle(OrderDeliveredEvent event) {
Shop shop = shopRepository.findById(event.getShopId());
shop.billCommissionFee(event.getTotalPrice());
}
}

문제는 이렇게 이벤트와 관련된 객체를 만들고 나서 다시 의존성 사이클이 발생할 수 있다는 사실이다.
이벤트를 받아 처리하는 리스너에서 파라미터로 order 패키지의 이벤트를 받도록 했기 때문에 shoporder가 서로 의존하고 있다.

이 문제는 order에서 발행한 이벤트를 처리하는 이벤트 핸들러가 shop 패키지에 있기 때문이다!
그렇다면 문제가 되는 이벤트 핸들러를 패키지 분리하면 어떨까?

billing 패키지를 추가해봤다.

그리고 이벤트 핸들러가 의존하던 수수료 관련 코드를 Shop과 분리하자!

기존의 Shop 객체는 다음과 같이 수수료 모으는 코드가 존재했었다.

1
2
3
4
5
6
7
8
public class Shop {
private Ratio commissionRate;
private Money commissin = Money.Zero;

public void billCommissionFee(Money price) {
commission = commission.plus(commissionRate.of(price));
}
}

이를 Billing 이란 객체를 도입해서 역할을 분리하자!!!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Billing {
private Long shopId;
private Money commission = Money.ZERO;

public void billCommissionFee(Money commission) {
commission = commission.plus(commission);
}
}

public class Shop {
private Ratio commissionRate;

public Money calculateCommissionFee(Money price) {
return commissionRate.of(price);
}
}

그리고 이벤트 핸들러도 shopbilling을 구분해서 협력하도록 하자.

1
2
3
4
5
6
7
8
9
10
11
12
@Component
public class BillShopWithOrderDeliveredEventHandler {
// DI 생략...
@Async //비동기로 처리. 동기로도 가능
@EventListener
@Transactional // 다른 트랜잭션으로 분리해서도 가능
public void handle(OrderDeliveredEvent event) {
Shop shop = shopRepository.findById(event.getShopId());
Billing billing = billingRepository.findById(event.getShopId());
billing.billCommissionFee(shop.calculateCommissionFee(event.getTotalPrice()));
}
}

이러면 패키지끼리 순환하지 않게 된다!

패키지 의존성 사이클 제거하는 세가지 방법 정리!

  1. 중간 객체 추가!
  2. 의존성 역전 시키기! -> 새로운 서비스 객체를 사용할 때 생긴 의존성 제거에 유용했다.
  3. 새로운 패키지 추가! -> 도메인 이벤트 퍼블리싱을 사용할 때 생긴 의존성 제거에 유용했다.

의존성과 시스템 분리

도메인 패키지 간의 의존성 사이클을 제거하면 각 패키지를 분리해서 배포할 수 있게된다!
그리고 이벤트는 메시징을 통해 외부 시스템으로 보내줄 수 있다!

Share