애그리거트

철근콘크리트·2021년 1월 2일
0

DDD

목록 보기
3/4

주문은 상품, 회원, 결제와 관련이 있다는 것을 쉽게 파악할 수 있다.





위 그림처럼 개별 객체 수준에서 모델을 바라보면 상위 수준에서 관계를 파악하기 어렵다.
( 주요 도메인 개념 간의 관계를 파악하기 어렵다는 것은 곧 코드를 변경하고 확정하는 것이 어려워진다는 것을 의미한다.)



동일한 모델이지만 애그리거트를 사용함으로써 모델 간의 관계를 개별 모델 수준뿐만 아니라 상위 수준에서도 이해할 수 있게 된다.

  • 모델을 보다 잘 이해할 수 있고 애그리커트 단위로 일관성을 관리하기 때문에 애그리거트는 복잡한 도메인을 단순한 구조로 만들어 준다.
  • 복잡도가 낮아지는 만큼 도메인 기능을 확장하고 변경하는데 필요한 노력도 줄어든다.

불필요한 중복을 피하고 애그리거트 루트를 통해서만 도메인 로직을 구현하게 만들려면 도메인 모델에 대해 다음의 두가지를 습관적으로 적용애야 한다.
1) 단순히 필드를 변경하는 set 메서드를 공개(public) 범위로 만들지 않는다.
2) 밸류 타입은 불변으로 구현한다.

도메인 모델의 엔티티나 밸류에 공개 set 메서드만 넣지 않아도 일관성이 깨질 가능성이 줄어든다.

애그리거트 외부에서 내부 상태를 함부로 바꾸지 못하므로 애그리거트의 일관성이 깨질 가능성이 줄어든다. ( 즉, 다음과 같이 애그리거트 루트가 제공하는 메서드에 새로운 밸류 객체를 전달해서 값을 변경하는 방법밖에 없다.)

public class Order{
	private ShippingInfo shippingInfo;
    	public void changeShippingInfo(ShippingInfo newShippingInfo){
        	verifyNotYetShipped();
                setShippingInfo(newShippingInfo);
        
        }
	// set 메서드의 접근 허용 범위는 private 이다.
    	private void setShippingInfo(ShippingInfo newShippingInfo){
        	// 벨류가 불변이면, 새로운 객체를 할당해서 값을 변경해야 한다.
            	// 불변이므로 this.shippingInfo.setAddress(newShippingInfo.getAddress())와 같은
                // 코드를 사용할 수 없다.
                this.shippingInfo = newShippingInfo;
        
        }

}

✔ 밸루 타입의 내부 상태를 변경하려면 애그리거트 루트를 통해서만 가능하다.




애그리거트 루트의 기능 구현

  • 애그리거트 루트는 애그리거트 내부의 다른 객체를 조합해서 기능을 완성한다.

방법1) Order는 총 주문 금액을 구하기 위해 OrderLine 목록을 사용한다.

public class Order{
	private Money totalAmounts;
    	private List<OrderLine> orderLines;
    
    	private void calculateTotalAmounts(){
        	int sum = orderLines.stream()
            		  .mapToInt(ol -> ol.getPrice() * ol.quantity())
                       	  .sum();
            	this.totalAmounts = new Money(sum);
        
        }

}
public class Member{
	private Password password;
    
    	public void changedPassword(String currentPassword, String newPassword){
        	if(!password.match(currentPassword)){
            		throw new PasswordNotMatchException();
        	}
            this.password = new Password(newPassword);
        }

}




방법2) OrderLine 클래스를 별도 클래스로 분리했다고 가정.

public class OrderLines{
	private List<OrderLine> lines; 
    
    	public Money getTotalAmounts() {  };
        public void changeOrderLines(List<OrderLine> newLines){
        	this.lines = newLines; 
        }

}
public class Order{
	private OrderLines orderLines; 
    
    	public void changeOrderLines(List<OrderLine> newLines){
        	orderLines.changeOrderLines(newLines);
            	this.totalAmounts = orderLines.getTotalAmounts(); 
        
        }

}

Order의 changeOrderLines() 메서드는 다음과 같이 내부의 orderLines 필드에 상태 변경을 위임하는 방식으로 기능을 구현.
만약 Order가 getOrderLines()와 같이 OrderLines를 구현할 수 있는 메서드를 제공하면 애그리거트 외부에서 OrderLines의 기능을 실행할 수 있게 된다.

팀 표준이나 구현 기술의 제약으로 OrderLines를 불변으로 구현할 수 없다면 Order-Lines의 변경 기능을 패키지나 protected 범위로 한정해서 외부에서 실행할 수 없도록 제한하는 방법이 있다. **보통 한 애그리거트에 속하는 모델은 한 패키지에 속하기 때문에 패키지나 protected 범위를 사용하면 애그리거트 외부에서 상태 변경 기능을 실행하는 것을 방지할 수



트랜잭션 범위

: 트랜잭션의 범위는 작을수록 좋다. DB 테이블을 기준으로 한 트랜잭션이 한 개 테이블을 수정하는 것과 세 개의 테이블을 수정하는 것은 성능에 차이가 발생한다.
(잠금 대상이 많아진다는 것은 그만큼 동시에 처리할 수 있는 트랜잭션 개수가 줄어든다는 것을 뜻하고 이는 전체적인 성능(처리량)을 떨어뜨린다. )

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


1) 다른 애그리커트의 상태 변경의 잘못된 예

public class Order{
	private Orderer orderer;
    
        public void shipTo(ShippingInfo newShippingInfo, boolean useNewShippingAddrAsMemberAddr){
        	verifyNotYetShipped();
           	setShippingInfo(newShippingInfo);
            	if(useNewShippingAddrAsMemberAddr){
         		//다른 애그리거트의 상태를 변경하면 안됨!
                	orderer.getCustomer().changeAddress(newShippingInfo.getAddress());
                }
        
        }
}

애그리거트는 서로 최대한 독립적이어야 하는데 한 애그리거트가 다른 애그리거트의 기능에 의존하기 시작하면 애그리거트 간 결합도가 높아지게 된다.
(결합도가 높아지면 높아질수록 향후 수정 비용이 증가하므로 애그리거트에서 다른 애그리거트의 상태를 변경하지 말아야 한다.)


public class ChangeOrderService{
	// 두 개 이상의 애그리커트를 변경해야 하면,
    	// 응용 서비스에서 각 애그리거트의 상태를 변경한다.
        @Transactional
        public void changedShippingInfo(OrderId id, ShippingInfo newShippingInfo, boolean useNewShippingAddrAsMemberAddr){
 		Order order = orderRepository.findbyId(id);
        	if(order == null) throw new OrderNotFoundException();
            	order.shipTo(newShippingInfo);
                if(useNewshippingAddrAsMemberAddr){
                	order.getOrderer()
                    	.getCustomer().changedAddress(newShippingInfo.getAddress());
                }
        

        }
}

다음의 경우는 한 트랜잭션에서 두 개 이상의 애그리거트를 변경하는 것을 고려할 수 있다.

  • 팀 표준 : 팀이나 조직의 표준에 따라 사용자 유스케이스와 관련된 응용 서비스의 기능을 한 트랜잭션으로 실행해야 하는 경우가 있다.
  • 기술 제약 : 한 트랜잭션에서 두 개 이상의 애그리거트를 수정하는 대신 도메인 이벤트와 비동기를 사용하는 방식을 사용하는데, 기술적으로 이벤트 방식을 도입할 수 없는 경우 한 트랜잭션에서 다수의 애그리거트를 수정해서 일관성을 처리해야 한다.
  • UI 구현의 편리 : 운영자의 편리함을 위해 주문 목록 화면에서 여러 주문의 상태를 한 번에 변경하고 싶을 것이다. 이 경우 한 트랜잭션에서 여러 주문 애그리거트의 상태를 변경할 수 있을 것이다.



리포지터리와 애그리거트

Order와 OrderLine을 물리적으로 각각 별도의 DB 테이블에 저장한다고 해서 Order와 OrderLine을 위한 리포지터리를 각각 만들지 않는다.
(Order가 애그리거트 루트이고 OrderLine인 애그리커트에 속하는 구성요소이므로 Order를 위한 리포지터리만 존재한다. )


✔ 새로운 애그리거트를 만들면 2가지 메소드가 필요하다.

  • save : 애그리거트 저장.
  • findById : ID로 애그리거트를 구함.

애그리거트를 영속화할 저장소로 무엇을 사용하든지 간에 애그리거트의 상태가 변경되면 모든 변경을 원자적으로 저장소에 반영해야 한다.
(애그리거트에서 두 개의 객체를 변경했는데 저장소에는 한 객체에 대한 변경만 반영되면 데이터 일관성이 깨지므로 문제가 된다.)


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

JPA를 사용하면 @ManyToOne, @OneToOne과 같은 애노테이션을 이용해서 연관된 객체를 로딩하는 기능을 제공하고 있으므로 필드를 이용해서 다른 애그리거트를 쉽게 참조할 수 있다.
(ORM 기술 덕에 애그리거트 루트에 대한 참조를 쉽게 구현할 수 있고, 필드를 이용한 애그리거트 참조를 사용하면 다른 애그리커트의 데이터를 객체 탐색을 통해 조회할 수 있다.)

✔ 애그리거트 참조의 문제점.

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




🔥 편한 탐색 오용
"애그리거트를 직접 참조할 때 발생할 수 있는 가장 큰 문제점은 편리함을 오용할 수 있다는 것이다. 한 애그리거트 내부에서 다른 애그리거트 객체에 접근할 수 있으면 다른 애그리거트의 상태를 쉽게 변경할 수 있게 된다. ( 트랜재견 범위에서 언급한 것처럼 한 애그리커트가 관리하는 범위는 자기 자신으로 한정해야 한다. 그런데, 애그리거트 내부에서 다른 애그리거트 객체에 접근할 수 있으면 다음 코드처럼 구현의 편리함 때문에 다른 애그리거트를 수정하고자 하는 유혹에 빠지기 쉽다. )

-> 한 애그리거트에서 다른 애그리거트의 상태를 변경하는 것은 애그리거트 간의 의존 결합도를 높여서 결과적으로 애그리거트의 변경을 어렵게 만든다.



🔥 성능에 대한 고민

JPA를 사용할 경우 참조한 객체를 지연(lazy) 로딩과 즉시(eager) 로딩의 두 가지 방식으로 로딩할 수 있다. 두 로딩 방식 중 무엇을 사용할지 여부는 애그리거트의 어떤 기능을 사용하느냐에 따라 달라진다.

단순히 연관된 객체의 데이터를 함께 화면에 보여주어야 하면 즉시 로딩이 조회 성능에 유리하지만, 애그리거트의 상태를 변경하는 기능을 실행하는 경우에는 불필요한 객체를 함께 로딩할 필요가 없으므로 지연 로딩이 유리하다.


🔥 확장 어려움
사용자가 늘고 트래픽이 증가하면 자연스럽게 부하를 분산하기 위해 하위 도메인별로 시스템을 분리하기 시작한다.
이 과정에서 후위 도메인마다 서로 다른 DBMS를 사용할 가능성이 높아진다. 심지어 하위 도메인마다 다른 종류의 데이터 저장소를 사용하기도 한다.

이 세가지 문제를 완화할 때 사용할 수 있는 것이 ID를 이용해서 다른 애그리거트를 참조하는 것이다.




ID를 이용한 참조와 조회 성능.

다른 애그리거트를 ID로 참조하는 여러 애그리거트를 읽어야 할 조회 속도가 문제될 수 있다.

애그리거트마다 서로 다른 저장소를 사용하는 경우에는 한 번의 쿼리로 관련 애그리거트를 조회할 수 없다. 이런 경우 조회 성능을 높이기 위해 캐시를 적용하거나 조회 전용 저장소를 따로 구성한다.
이 방법은 코드가 복잡해지는 단점이 있지만 시스템의 처리량을 높일 수 있다는 장점이 있다.
한 대의 DB 장비로 대응할 수 없는 수준의 트래픽이 발생하는 경우 캐시나 조회 전용 저장소는 필수로 선택해야 하는 기법이다.


애그리거트 간 집합 연관

이 절에서는 애그리거트 간 1:N과 M:N 연관에 대해 살펴보자. 이 두 연관은 컬렉션을 이용한 연관이다. 카테고리와 상품 간의 연관이 대표적이다.

애그리거트 간 1:N 관계는 Set과 같은 컬렉션을 이용해서 표현할 수 있다.

1) 1:N 연관 카테고리

public class Category{
	private Set<Product> products;
    
    	public List<Product> getProducts(int page, int size){
        	List<Product> sortedProducts = sortById(products);
            	return sortedProducts.subList((page - 1 )  * size, page * size);
        
        }
    

}

이 코드를 실제 dbms와 연동해서 구현하면 Category에 속한 모든 Product를 조회하게 된다. Product 개수가 수백에서 수만개 정도로 많다면 이 코드를 실행할 때마다 실행 속도가 급격히 느려져 성능에 심각한 문제를 일으킬 것이다. 이런 성능상의 문제 때문에 애그리서트 간의 1:N 연관을 실제 구현에 반영하는 경우는 드물다.


2) ProductRepository를 이용해서 목록 구현.

public class ProductService{
	public Page<Product> getProductOfCategory(Long categoryId, int page, int size){
    		Category category = categoryRepository.findById(categoryId);
            	checkCategory(category);
                List<Product> products = productRepository.findByCategoryId(categoryId.getId(), page, size); 
                int totalCount = productRepository.countsByCategoryId(category.getId(); 
                return new Page(page, size, totalCount, products); 
    
    	}

}

0개의 댓글