우아한 테크 세미나 : 우아한객체지향 2019

우아한 테크 세미나 : 우아한객체지향 2019

2019년 “우아한Tech - 우아한객체지향 by 조영호님” 유투브 세미나를 듣고 내용 정리하기


제목 : ‘우아한 객체지향 의존성을 이용해 설계 진화시키키’

설계에서 가장 중요한 것은 ‘의존성’이다. ‘의존성’을 어떻게 설정,관리하느냐에 따라 설계가 많이 달라진다.


의존성(Dependency)

설계는 코드를 어떻게 배치할 것인가? 어떤 코드를 어디에 넣을 것인가? 어떤 코드를 어디 클래스에, 어디 패키지에 넣을건지 등에 대한 고민을 하는 것이다.

변경에 초점을 맞춰야한다. 같이 변경되는 코드를 같이 넣어줘야하고, 같이 변경되지 않는 것은 따로 넣는다. (= 결국에는 의존성과 관련이 있다.)

// a 가 b 에 의존한다고 한다.
a ---> b

// b가 변경될 때 a도 변경이 될 수 있다. (변경 될 가능성이 있다.)
// 즉 '의존성'이 있다.
// 즉 '의존성'은 '변경'과 관련되어 있는 것이다.

의존성 = 변경에 의해서 영향을 받을 수 있는 가능성

클래스 사이에, 패키지 사이에 의존성이 있다.


클래스 의존성의 종류

연관관계(Association)

A->B 로 영구적으로 갈 수 있음

// A -> B
class A {
    private B b;
}

의존관계(Dependency)

A->B 일시적으로 연관

parameter, return type, method 안에서 해당 type 을 사용한다.

class A {
    public B method(B b) {
        return new B();
    }
}

상속관계(Inheritance)

상속관계는 B 가 변경되면 A 도 변경됨

class A extends B {

}

실체화관계(Realization) (인터페이스 구현 관계)

실체화관계에서는 B의 시그니처가 변경될 때에만 A가 변경된다.

class A implements B {

}



패키지 의존성이란?

pcakage b 안의 클래스B가 변경될 때 package a 안의 A클래스가 변경된다. (package a -> package b)

using, import 등의 문법이 있다? = 패키지 의존성이 있다.



좋은 의존성을 관리하기 위한 몇가지 팁이 있다.

양방향 의존성을 피하라

양방향 의존성 -> A가 바뀔 때, B가 바뀌고, B가 바뀌면 A가 바뀐다 -> 하나의 클래스를 억지로 찢어놓은것과 같다.

양방향일 때에는 항상 sync 도 맞춰줘야하고 등등 너무 복잡하다. 하나를 수정할 때 둘 다 수정해줘야하기때문에 (단방향으로 바꿔주어야 한다.)


다중성이 적은 방향을 선택해라

일대다 보다는 다대일을 선택해라.

class A {
    private Collection<B> bList;
}
class B {

}
class A {

}

class B {
    class A a;
}

위의 방식 보단 아래의 방식을 택한다.


의존성이 필요없다면 제거하라

class A {
    private B b;
}
class B {

}
class A{

}
class B{

}

위의 방식보단 아래의 방식을 택한다.


패키지 사이의 의존성 사이클(양방향 의존성)을 제거하라

A -> B -> C -> A 등의 관계(사이클)이 있다면 제거하라. (양방향 포함)



설계의 원칙은 무조건 ‘변경’ 이다.

만약 A 가 변경되면, 누가 변경되는지 봐야한다.



예제를 살펴보자. #

먼저 배달의민족앱 주문 flow 를 살펴보자. (아래 flow 에서 특정한 부분에 대해 설명하고자 한다.)

가게 선택 -> 메뉴 선택 -> 장바구니 담기 -> 주문

/* 가게 엔티티 설계 */
가게 1 --- * 메뉴
메뉴 1 --- * 옵션그룹 1 --- * 옵션
/* 주문 엔티티 설계 */ 
주문 1 --- * 주문항목 1 --- * 주문옵션그룹 1 --- * 주문옵션

간단하게 정리하면, ‘가게’를 중심으로 ‘메뉴’와 ‘주문’이 런타임시에 연관되어 동작한다.

문제1 : 메뉴 불일치

  1. 고객이 장바구니에 ‘1인 메뉴 세트’를 담았다. 이 장바구니 데이터는 고객 디바이스 로컬에 저장된다.
    • 메뉴 클래스
    • 옵션, 옵션그룹 클래스
  2. 이 시점에 가게에서 ‘1인 메뉴 세트’의 명칭과 가격을 변경했다고 가정하자. -> ‘0.5인 메뉴 세트’, 15000원 -> 7500원
    • 주문 클래스
    • 주문항목, 주문옵션, 주문옵션그룹 클래스

즉, 주문 시에 고객이 주문한 메뉴와 가게 메뉴를 검증한다. (Validation)

  • 메뉴명 - 주문항목 이름 검증(비교)
  • 옵션그룹명 - 주문옵션그룹명 이름 검증(비교)
  • 옵션명 - 주문옵션명 이름 검증(비교)
  • 옵션가격 - 주문옵션가격 이름 검증(비교)
  • 가게의 최소주문금액 체크
  • 가게의 영업여부 체크
  -------- 메뉴 - 옵션그룹 - 옵션
  |
가게 
  |
  ----- 주문 - 주문항목 - 주문옵션그룹 - 주문옵션

협력 설계하기

이제 위 Validation 협력,로직 등에 대해 살펴본다.

// 이후 이미지 대체
// 이후 이미지 대체


클래스 사이의 ‘관계/협력/의존성’에는 방향성이 필요하다.

  • 관계의 방향 = 협력의 방향 = 의존성의 방향
  • A 가 B 에 의존해/요청해. (어떤 객체 A 가 어떤 객체 B 에게 메세지를 보내야해.)
  • DB와는 조금 다르다. DB는 foreign key 가 잡히면 양방향으로 움직일 수 있다. 하지만 객체는 다르다. 객체는 정확한 방향성을 선택할 수 있다.

앞서 소개했던 연관관계, 의존관계 중에 생각해보자. (이건 상속이나 실체화관계가 아닌 것은 확실하니까)

  • 협력을 위해 영구적으로 탐색이 필요한 구조라면 -> 연관관계

    • order, shop 이 한번만/일시적으로만 관계를 맺으면 -> 연관관계
    • 객체 참조 (composition)
  • 협력을 위해 일시적으로 탐색이 필요한 구조라면 -> 의존관계

    • parameter, return type, local variable
    • 메서드 파라미터, 메서드 내부에서 new() 등등

관계를 맺을 때에는 ‘이유’가 있어야한다.

  • A -> B 로 향할 때에는 ‘이유’가 필요함

예를 들어, 아래와 같다.

Order -> OrderLineItem 으로 연관관계를 맺어야 하는 이유?

  1. Order 가 뭔지 알면, OrderLienItemr 을 찾을 수 있어야 한다.
  2. 이 두 클래스(객체)는 영구적으로 관계가 맺어져 있어야 한다.
class Order {
    private List<OrderLienItem> orderLineItems; // 연관관계를 통해 협력한다. 여기서는 객체참조를 통해 연관관계를 구현한 것이다.

    public void place() {
        validate();
        ordered();
    }

    public void validate() {
        ...

        for(OrderLineItem orderLineItem : orderLineItems) { // 연관관계를 통해 협력한다.
            orderLineItem.validate();
        }
    }
}

* ‘연관관계’ 는 ‘개념’, ‘객체참조’ 는 ‘구현방법’ 이다. 이 둘을 같다고 오해하지말자.
* 연관관계를 구현할 수 있는 방법은 객체참조 말고도 다른 방법이 있다.


메서드가 필요한 이유는, 메세지를 전달하기 위해서이다.

메서드를 만들었기 때문에 메세지를 받는게 아니다. 메세지를 받기 위해 메서드를 만드는 것이다.

// Order 라는 객체가 place 라는 메세지를 받는다.

public class Order {
    public void place() {
        // 주문 메세지를 위해 메서드를 만들었다.
        validate();
        ordered();
    }
    ...
}

주문은 ‘가게’ 와 ‘주문항목’ 에 메세지를 보내야한다. (위의 flow 대로)

@Entity
public class Order{
    @Id
    private Long id;

    @ManyToOne
    @JoinColumn(name="SHOP_ID")
    private Shop shop; // 가게와 협력해야한다. 메세지를 보내야한다.

    @OneToMany(cascade = CascadeType.ALL)
    @JoinColumn(name="ORDER_ID")
    private List<OrderLineItem> orderLineItems = new ArrayList<>(); // 주문항목과 협력해야한다. 메세지를 보내야한다.

    public void place() {
        validate();
        ordered();
    }

    public void validate() {
        if(orderLineItems.isEmpty()) {
            throw new IllegalStateException("주문 항목이 비어 있습니다.");
        }

        if(!shop.isOpen()) { // 가게에 메세지를 보낸다.
            throw new IllegalArgumentException("가게가 영업중이 아닙니다.");
        }

        if(!shop.isValidOrderAmount(calculateTotalPrice())) { // 주문항목에 메세지를 보낸다.
            throw new IllegalStateException(String.format("최소 주문 금액 %s 이상을 주문해주세요", shop.getMinOrderAmount()));
        }

        for(OrderLineItem orderLineItem : orderLineItems) {
            orderLineItem.validate(); // 주문항목에 메세지를 보낸다.
        }
    }

    public void ordered() {
        this.orderStatus = OrderStatus.ORDERED;
    }
}

위 코드의 베이스는 아래와 같다.

  1. 주문은 가게,주문항목과 (영구적으로) 협력해야 한다. 강한 연관관계를 갖는다. (라는 것을 의도한 것이다.)
@Entity
public class OrderLineItem {
    ...
    public void validate() {
        menu.validateOrder(name, this.orderOptionGroups); // 메뉴에 메세지를 보낸다.
    }
}
public class Menu {
    public void validateOrder(String menuName, List<OrderOptionGroup> groups) {
        if(!this.name.equal(menuName)) { // 메뉴와 주문옵션그룹의 이름을 비교한다.
            throw new IllegalArgumentException("기본상품이 변경되었습니다.");
        }
        if(!isSatisfiedBy(groups)) {
            throw new IllegalArgumentException("메뉴가 변경되었습니다.");
        }
    }

    private boolean isSatisfiedBy(List<OrderOptionGroup> groups) {
        return cartOptionGroups.stream().anyMatch(this::isSatisfiedBy);
    }

    private boolean isSatisfiedBy(OrderOptionGroup group) {
        return optionGroupSpecs.stream().anyMatch(spec -> spec.isSatisfiedBy(group));

        // 메뉴에서 옵션그룹으로 메세지를 보낸다.
    }
}
public class OptionGroupSpecification {
    ...
    public boolean isSatisfiedBy(OrderOptionGroup group) {
        return !isSatisfied(group.getName(), satisfied(group.getOptions()));
    }

    private boolean isSatisfied(String groupName, List<OrderOption> satisfied) {
        if(!name.equals(groupName) || satisfied.isEmpty() || (exclusive && satisfied.size() > 1)) {
            return false;
        }

        return true;
    }

    private List<Options> satisfied(List<OrderOption> options) {
        return optionSpecs.stream().flatMap(spec -> option.stream().filter(spec::isSatisfiedBy)).collect(toList());
    }
}

* 나머지 코드는 github 에서 살펴보자.

즉, 위와 같이 한 클래스가 다른 클래스에 메세지를 보낸다.

(위의 코드는 레이어 아키텍처에서 Domain 사이에서의 관계를 나타낸 것이다. 비즈니스 로직을 구현한 부분!)

Service, Infrastructure(Repository) 등의 대한 부분도 작성해야할 것이다. 간단히 보면 아래와 같다.

// Service 예시

@Service
public class OrderService {
    @Transactional
    public void placeOrder(Cart cart) {
        Order order = orderMapper.mapFrom(cart);
        order.place(); // 이 안에서 order 검증하고, 상태값 변경함...!! (문제가 있다면 예외가 발생한다.  )
        orderRepository.save(order);
    }
}
@Repository
public class OrderRepositoryImpl implements OrderRepository {
}



설계 개선하기 #

대부분 class 하나를 보여주고 어떻게 개선하는지 물어본다. 이때에는 이름이나 메서드의 사이즈나, 책임/원칙 등에 대해서만 피드백할 수 있다.

큰 그림에서 보곤해야한다. 코드를 짜고, class 간의 dependency 를 그려봐야한다. (손으로 직접 그려보자.)

객체를 어디에 두고 메서드를 어디에 작성하고 하는 것들이 처음엔 정말 어렵다. 그러면 일단 코드를 작성해보고 의존성을 그려보자. 그리고 다시 봐보자.

오늘은 (위에서 작성했던 코드들에 대한) 아래의 2가지 문제에 대해 살펴본다.

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

패키지 의존성 사이클의 문제

// package order <-> shop
class Order {
    private void validate() {
        if(!shop.isOpen()) { // package order -> package shop 의존
            ...
        }
        ...
    }
}

class OptionGroupSpecification {
    public boolean isSatisfiedBy(OrderOptionGroup group) { // package shop -> package order 의존 
        ...
    }
}

class OptionSpecification {
    public boolean isSatisfiedBy(OrderOption option) { // package shop -> package order 의존
        ...
    }
}

개선 방법1. 중간 객체를 이용한 의존성 사이클 끊기(ManyToMany 를 쪼개듯 중간에 하나의 도메인을 만든다)

// OptionGroupSpecification -> OptionGroup <- OrderOptionGroup
// OptionSpecification -> Option <- OrderOption

class OptionGroupSpecification {
    public boolean isSatisfiedBy(OptionGroup group) { // OptionGroupSpecification -> OptionGroup
        ...
    }
}

class OrderOptionGroup {
    public OptionGroup convertToOptionGroup() { // OrderOptionGroup -> OrderOption
        return new OptionGroup(name, ...);
    }
}

class OrderOption {
    public Option convertToOption() {
        return new Option(name, ...);
    }
}

이렇게 바꿨을 때의 장점은 무엇일까?

OptioGroup, Option 의 재사용성이 증가될 수 있다. 저 클래스는 다른 곳에서도 사용할 수 있게 되었다. (Cart 에서 사용한다거나 등등)

다만, 위의 방법은 확실히 어색하게 보는 사람이 많이 있다. 이 방법은 하나의 방법으로 보자. 따라서 기존대로 유지하되 이것도 하나의 방법으로 둘 것같다고 말씀해주셨다.

* 여기서 참고!!

  • DIP 는 클래스들이 구체적인것이 아닌 추상화된 것에 의존하라는 것인데, 이걸로 패키지 싸이클을 끊을 수 있다.
  • 근데 사람들이 오해하는게 여기서 말하는 추상화는 인터페이스나 super 클래스가 아니다. 그냥 자주 안변하는 클래스라고 보면된다.

객체 참조의 문제

객체들이 다 연결되어 있기 때문에, 하나의 객체에서 연결되어 있는 모든 객체를 다 탐색할 수 있다.

이것은 ORM 을 사용할 때 헬게이트로 다가온다..! (LazyLoading 등등. .. JPA 사용할 때의 연관관계 문제 등등)

  1. 객체가 다 연결되어 있다보니, 하나를 볼 때 어디까지 봐야하는지 알기 힘들다.
  2. 하나의 객체가 변경될 때, 같이 변경되어야 하는 객체들은 어디까지?(어디까지 변경시킬 건지?, 범위) -> Transaction 의 경계가 모호해진다. (Locking)

객체 참조는 너무 쉽게 다른 객체를 참조할 수 있다보니 다른 객체에 대한 수정이나 작업을 계속해서, 쉽게 변경하게 되곤 한다.

public class OrderService {
    @Transactional
    public void payOrder(Long orderId) {
        Order order = orderRepository.findById(orderId) ...
        order.payed();

        Delivery delivery = Delivery.started(order);
        deliveryRepository.save(delivery);
    }

    ...

    @Transactional
    public void deliverOrder(Long orderId) {
        Order order = orderRepository.findById(orderId);
        order.delivered();

        Delivery delivery = deliveryRepository.findById(orderId);
        delivery.complete();
    }
}

public class Order {
    public enum OrderStatus {
        ORDERED, PAYED, DELIVERED
    }

    @Enumerated(EnumType.STRING)
    @Column(name="STATUS")
    private OrderStatus orderStatus;

    public void payed() {
        this.orderStatus = PAYED;
    }
    public void delivered() {
        this.orderStatus = DELIVERED;
        // 여기에 가게에 수수료를 부과한다라는 부분도 추가해보자.
        this.shop.billCommissionFee(calculateTotalPrice());
    }
}

public class Delivery {
    enum DeliveryStatus { DELIVERING, DELIVERED }

    ...

    @OneToOne
    @JoinColumn(name="ORDER_ID")
    private Order order;

    @Enumerated(EnumType.STRING)
    @Column(name="STATUS")
    private DeliveryStatus deliveryStatus;

    public static Delivery started(Order order) {
        return new Delivery(order, DELIVERING);
    }
    public void complete() {
        this.deliveryStatus = DELIVERED;
        this.order.completed();
    }
}

public class Shop {
    private Ratio commissionRate;
    private Money commision = Money.ZERO;

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

문제점 : 이들은 변경의 빈도/순간이 다르다.

위의 OrderService.deliverOrder() 는 shop, order, delivery 에 대해서 transaction(lock) 을 건다. 그런데.. 각각의 아래 요청을 처리할 수 있다.

Shop <- admin 에서 가게 상태 변경

Order <- 주문 상태 변경

Delivery <- 배달 상태 변경

서비스가 커지면 커질수록 lock 늪에 빠질 수 있다.

객체 참조로 인해 -> 트랜잭션 경합이 발생할 가능성이 높아진다. -> 성능 저하로 이뤄진다. (잘못된다면 장애까지 발생할 수 있다.)

객체참조 = 한 트랜잭션 안에서 동작하는 객체가 많아진다.

객체 참조는 결합도가 굉장히 굉장히 높은 의존성이다. 꼭 필요한 경우를 제외하곤 다 끊어야한다. (‘어떤 객체들을 묶고 어떤 객체들을 분리할 것인가?’ 부분에서 다시 봐보자.)

그러면 어떻게 해결할 수 있을까??


‘Repository 를 통한 탐색(약한 결합도)’ 방법을 사용한다.

public class Order {
    @Column(name="SHOP_ID")
    private Long shopId;
}

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

@Entity
@Table(name="SHOPS")
public class Shop {
    @Id
    private Long id;
}

특히 이 부분은 내가 아예 잘못된 방향으로 생각했다..! 나는 객체 탐색을 쉽게 하기 위해 오히려 객체 참조를 걸곤 했다.

즉.. Repository 로 연관관계를 넘기는 것이다.

-> 비즈니스 로직은 단방향으로 깔끔하게 작성할 수 있곤하는데, 조회로직이 들어가면서부터 양방향 등등의 복잡한 관계가 설정되곤한다.


어떤 객체들을 묶고 어떤 객체들을 분리할 것인가?

(* 모든 객체참조가 불필요하지는 않다.) 즉 알고쓰자는 것이다.

  • 함께 생성되고 함께 삭제되는, 함께 변경되는 애들은 묶자.
    (객체참조는 트랜잭션이 같이 묶이는 것이라고 했다. 그러니까 도메인 관점에서 같이 변경될 것들은 객체참조로 묶어도된다는 것이다.) (트랜잭션단위, 조회단위(lazy,eager) 등등)

  • 도메인 제약사항을 공유하는 객체들은 묶자.

  • 가능하면 분리하자.

  • (도메인)의미적으로 강하게 묶여야하는 애들은 묶자. 근데 이걸 repository 방식으로 풀수 있다고 생각한다면 풀자.

  • 장바구니, 장바구니항목은 무조건 (객체참조로) 결합되어야 한다?
    -> 아닐 수 있다. 보통 장바구니, 장바구니항목이 변경되는 시점이나 도메인 제약사항에 따라 다르다.
    -> 단순하게 이름으로만, 쉽게 생각해서는 안된다!

즉, 규칙은 없다. 도메인에 따라 다를 것이다.

// 이후에 이미지 참조 (객체 묶기)

위의 그림에서 같은 경계안에 있다면 연관관계로 묶었다고 한다. (묶는 것이 맞다고한다.)

  1. lazy/eager loading
  2. 같이 CRUD 되어야하고 등등

그림에서 같은 경계에 있따면 id(repository)로 묶는다.

public class Order {
    private List<OrderLineItem> orderLineItems;

    @Column(name="SHOP_ID")
    private Long shopId;
}

객체참조를 사용하는것은 굉장히 편하고, 이론적으로 설명하기에 너무 쉽다.

다만 실무에서는 객체간에 어디서 끊고, 어디까지 관리할건지 등이 중요하기에 이런것들이 중요하다.


위에처럼 경계를 나누면

  1. 어디까지 db에서 한번에 가져올건지, 트랜잭션을 어디까지 잡을건지 확 알 수 있다.
  2. 그룹별로 (영속성) 저장소도 변경할 수 있다.
public class Order {
    private Shop shop; // 이제 더이상 안쓰이니까 컴파일 에러 발생할 것이다.
    private Long shopId;
}

이렇게 끊고나면 일단 컴파일 에러가 날텐데 아래 부터는 어떻게 해결했는지 설명한다. (객체참조로 된 기존 소스와 다르니까)


이렇게 해결했다 1 : “객체를 직접 참조하는 로직을 다른 객체(OrderValidator)로 옮기자.”

// 컴파일 에러가 났던 부분(order-shop)을 이쪽으로 다 뺸다.

@Component
public class OrderValidator {
    public void validate(Order order) {
        validate(order, getShop(order), getMenus(order));
    }

    private void validate(Order order, Shop shop, Map<Long, Menu> Menus) {
        if(!shop.isOpen()) ...
        ...
    }
}

@Service public class OrderService {
    private OrderValidator orderValidator;

    @Transactional
    public void placeOrder(Cart cart) {
        Order order = orderMapper.mapFrom(cart);

        order.place(orderValidator); // 이것도 보면, 나같으면 service 에서 orderValidator.validate(order) 이런식으로 했을 것 같은데, order 쪽에 넘겼다.

        orderRepository.save(order);
    }
}

public class Order {
    public void place(OrderValidator orderValidator) {
        orderValidator.validate(this);
        ordered();
    }
}

위의 로직을 좋다고 생각한 이유.

  1. 사실 여러 객체를 타고타고 들어가는 것은 이해하기 힘들 수 있다. 한곳에 모은 것이 좋다고 생각한다. (한눈에 볼 수 있다.)
  2. 응집도가 높아진다.
    • 응집도 = 관련된 책임의 집합, 다르게 말하면 응집도는 ‘변경’과 관련이 있다. (모든 설계는 변경과 관련이 있다.)
    • 같이 변경되는 게 한곳에 있다? == 응집도가 높다.
    • 다르게 변경되는 것이 한곳에 있다? == 응집도가 낮다. (기존 order 는 주문처리, validation 둘다 있음)

(* 때로는 절차지향이 객체지향보다 좋을 수 있다.)

객체 안에 validation 로직을 넣어야한다는 강박관념을 벗어나자.

간단한 validation 이라면 ok, 여러 객체가 사용되야한다면 위에처럼 따로 뺴는 것도 방법(절차지향) (tradeoff, 잘 선택할 것)


이렇게 해결했다 2 : 1번 방법처럼 “절차지향”, 도메인 이벤트(domain event) 발행

1. 절차지향

// 문제 코드
public class Order {
    public void delivered() {
        this.orderStatus = OrderStatus.DELIVERED;
        this.shop.billCommissionFee(calculateTotalPrice()); // 이코드가 작성된 이유
        // 1. orderStatus 먼저 변경되고 shop 의 수수료 값이 변경 되어야한다.(도메인 규칙)
        // 2. order -> shop 갖고 있으니 편하게 작성하자.
    }
}
// OrderDeliverdService 만든다.
public class OrderDeliveredService {
    public void deliverOrder(Long orderId) {
        // 1번 처럼 기존 로직을 여기로 다 넣는다. // 현재 내가 작성하고 있는 코드와 유사함. 
        Order order = orderRepository.findById(orderId);
        Shop shop = shopRepository.findById(order.getShopId());
        Delivery delivery = deliveryRepository.findByOrderId(orderId);

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

위의 코드는 주문 완료후 변경되는 로직으 2개의 클래스에 있음. (OrderService, Order)
아래 코드는 OrderDeliveredService 한 곳에 있음

(* 이렇게 수정하면 패키지 사이클돔)


이럴 때에는 인터페이스 써서 의존성 역전하면됨.

// 이후에 이미지 참조 (의존성 싸이클!!)
// 이후에 이미지 참조 (의존셩 역전)

* 위와같이 서비스에서 해결하면, 로직을 한눈에 볼 수 있다라는 장점 : 객체간의 결합도 낮되 로직간의 결합도 명확히 확인


2. 도메인 이벤트

* 로직간의 순서를 명확히 알되 느슨하게한다?

Order -> OrderDeliveredEvent -> Delivery,Shop

// 이후에 이미지 참조 (Domain Event 를 이용한 의존성 제거)


// 직접 구현해도 되고,
// Spring 에서 제공하는 AbstractAggregateRoot 써도됨
public class Order extends AbstractAggregateRoot<Order> {
    public void delivered() {
        this.orderStatus = OrderStatus.DELIVERED;
        registerEvent(new OrderDeliveredEvent(this)); // DB commit 시에 발행해줌 (바로 발행하는 게아님)
    }
}

public class OrderDeliveredEvent {
    private Order order;

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

// Shop 이벤트 핸들러
@Component
public class BillShopWithOrderDeliveredEvnetHandler {
    // 동기,비동기 가능
    // 다른 트랜잭션, 같은 트랜잭션 다 가능
    @Asnyc
    @EventListener
    @Transactional
    public void handle(OrderDeliveredEvent event) {
        Shop shop = shopRepository.findById(event.getShopId());
        shop.billCommissionFee(event.getTotalPrice());
    }
}

// Delivery 이벤트 핸들러
@Component
public class CompleteDeliveryWithOrderDeliveredEventHandler {
    @Asnyc
    @EventListner
    @Transactional
    public void handle(OrderDeliveredEvent event) {
        Delivery delivery = deliveryRepository.findById(event.getOrderId());
        delivery.complete();
    }
}

* 이것도 그려보면 사이클 돈다.

여기서는 패키지를 분리해서 해결해보자.

BillShopWithOrderDeliveredEvnetHandler 를 특정 패키지/클래스로 뺀다.

// Shop -> Shop, Billing
public class Shop {
    private Ratio commissionRate;
    private Money commission = Money.ZERO;

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

// 아래와 같이 분리
public class Shop {
    private Ratio commissionRate;

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

public class Billing {
    private Long shopId;
    private Money commission = Money.ZERO;

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

@Component
public class BillShopWithOrderDeliveredEvnetHandler {
    @Asnyc
    ...
    public void handle(OrderDeliveredEvent event) {
        Shop shop = ...;
        Billing billing = ...;

        billing.billCommissionFee(...);
    }
}

// 이후에 이미지 참고(Billing 을 새로 만든 패키지에 포함)

패키지가 싸이클이 돌아서 해결해보면, 도메인(클래스)을 분리해야되는 경우가 많은 것을 확인할 수 있다.

  • Shop 안에 정산로직이 있었던 것이 이상했던 것을 자연스럽게 알게 된다.
  • (정산)Billing 이라는 클래스를 따로 뺀다는 의사결정을 자연스럽게 하게 된다.

* 세미나에서는 패키지 사이클이 돌면 기계처럼 분리했는데, 실제로 해보면 도메인이 분리되어야 하는 것을 보게 되는 경우가 많다.


패키지 사이클 해결법

  1. 추상화 + 의존성 역전
  2. 중간객체(클래스) 두는것
  3. 패키지/클래스 새로 생성한다.

tradeoff



의존성과 시스템 분리 #

의존성을 관리하다보면, 시스템을 쉽게 분리할 수 있게 된다.

내가 지금 작성하고 있는 방식은 layered architecture 에 도메인들을 넣어 사용하고 있는 것이다. 의존성 관리 없이 이렇게 사용하는 것은 그나마 나을 수 있다고한다 (같은 레이어 안에서만 의존성 관리해주면 되니까.) (고민할 게 없다. 걍 쓰곤한다.)

근데 의존성 관리하면 (도메인단위로 패키지 분리할 수 있다.) 도메인이 먼저, 그다음 layered architecture 가 나오게 만들 수 있다. (의존성을 컨트롤 할 수 있다.)

// 이후에 이미지 참조(Domain Evnet 사용 전의 의존성) // 이후에 이미지 참조(도메인 단위 모듈)

이렇게 하면, 추후에 시스템 분리도 가능하다. (물론 작업이 필요하겠지만 패키지 관리를 안하는 것보단 훨씬 수월할 것이다.)

* 도메인 이벤트, 시스템 이벤트

mono 로 가더라도, 꼭 패키지(의존성)관리는 해주자..!

시스템 관리 = 의존성 관리

의존성을 따라 시스템을 진화시켜라