[DDD] 에그리거트까지

sa46lll·2023년 2월 22일
0

DDD

목록 보기
1/1

해당 글은 최범균 님의 '도메인 주도 개발 시작하기'을 학습하며 정리한 글입니다.

1. 도메인 모델 시작하기

1-1. 도메인이란?

온라인 서점 시스템을 구현한다고 할 때, 소프트웨어로 해결하고자 하는 문제의 영역인 온라인 서점이 도메인이 된다.
도메인은 여러 하위 도메인으로 구성된다. 예를 들어, 주문, 결제, 혜택, 회원 등 말이다.

하위 도메인은 고정되어 있지 않으며, 어떻게 구성할지 여부는 상황에 따라 달라진다.

1-2. 도메인 전문가와 개발자 간 지식 공유

개발자는 코딩에 앞서 요구사항을 올바르게 이해하는 것이 중요하다. 요구사항을 제대로 이해하지 않으면 쓸모없거나 유용함이 떨어지는 시스템을 만들기 때문이다.

요구사항을 올바르게 이해하려면 어떻게 해야할까?

바로 개발자와 전문가가 직접 대화하는 것이다. 개발자와 전문가 사이에 내용을 전파하는 전달자가 많을수록 정보가 왜곡되고, 개발자는 최초에 요구된 것과는 다른 무언가를 만들게 된다.

도메인 전문가만큼은 아니겠지만, 이해관계자와 개발자도 도메인 지식을 갖춰야 한다. 같은 지식을 공유하고 직접 소통할수록 도메인 전문가가 원하는 제품을 만들 가능성이 높아진다.

💡 “Garbage in, Garbage out”
잘못된 값이 들어가면 잘못된 결과가 나온다.
= 잘못된 요구사항이 들어가면 잘못된 제품이 나온다.

1-3. 도메인 모델

도메인 모델은 다양한 정의가 존재한다. 기본적으로 특정 도메인을 개념적으로 표현한 것이다.
도메인 모델은 기본적으로 도메인 자체를 이해하기 위한 개념 모델이다.

1-6. 엔티티와 벨류

모델은 크게 Entity와 Value로 구분할 수 있다.

Entity와 Value의 차이를 명확하게 이해하는 것은 도메인을 구현하는 데 있어 중요하다.

1-6-1. 엔티티

엔티티의 가장 큰 특징은 식별자를 가지는 것이다. 식별자는 엔티티 객체마다 고유하며, 변경되지 않는다. 이러한 이유로 두 엔티티 객체의 식별자가 같으면 두 엔티티는 같다고 판단한다.

엔티티를 구현한 클래스는 식별자를 이용해서 equals() 메서드와 hashCode() 메서드를 구현할 수 있다.

public class Order {
	private String orderNumber;
	
	@Override
	public boolean equals(object obj) {
		if (this == obj) return true;
		if (obj == null) return false;
		if (obj.getClass() != Order.class) return false;
		Order other = (Order)obj;
		if (this.orderNumber == null) {
			return false;
		}
		return this.orderNumber.equals(other.orderNumber);
	}
}

1-6-2. 엔티티의 식별자 생성

엔티티의 식별자를 생성하는 시점은 도메인의 특징과 사용하는 기술에 따라 달라지는데 흔히 다음 중 한 가지 방식으로 생성한다.

  • 특정 규칙에 따라 생성
  • UUID나 Nano ID와 같은 고유 식별자 생성기 사용
  • 값을 직접 입력
  • 일련번호 사용 (시퀀스나 DB 자동 증가 컬럼 사용)
  1. 특정 규칙에 따라 생성

예를 들어, 주문번호, 운송장번호, 카드번호 등 도메인과 회사마다 다른 경우에 주로 쓰인다.
흔히 현재 시간과 다른값을 조합한다. 2023010931728OOOO 이렇게 말이다. 이 경우 같은 시간에 동시에 식별자를 생성해도 같은 식별자가 만들어지면 안된다는 주의할 점이 있다.

  1. UUID나 Nano ID와 같은 고유 식별자 생성기 사용

다수의 개발 언어가 UUID 생성기를 제공하고 있으므로 마땅한 규칙이 없다면 UUID를 식별자로 사용하자. 자바는 java.util.UUID 클래스를 사용해서 UUID를 생성한다.

UUID uuid = UUID.randomUUID();

  1. 값을 직접 입력

회원의 아이디나 이메일 같은 식별자는 값을 직접 입력한다. 사용자가 직접 입력하는 값이기 때문에 식별자를 중복해서 입력하지 않도록 사전에 방지하자.

  1. 일련번호 사용 (시퀀스나 DB 자동 증가 컬럼 사용)

일련번호를 식별자로 사용하기도 한다. 예를 들어, 포털 사이트에서 게시글 URL의 일부를 발췌한 것이다.

`sports/soccer/netizen/talk/#read?articleId=123456&bbsId=F01`

일련번호 방식은 주로 데이터베이스가 제공하는 자동 증가 기능을 사용한다. 자동 증가 컬럼을 제외한 다른 방식은 다음과 같이 실벽자를 먼저 만들고 엔티티 객체를 생성할 때 식별자를 전달한다.

// 식별자를 생성하고 엔티티 생성
String orderNumber = orderRepository.generateOrderNumber();

Order order = new Order(orderNumber, ...);
orderRepository.save(order);

자동 증가 칼럼은 DB 테이블에 데이터를 삽입해야 값을 알 수 있기 때문에 테이블에 데이터를 추가하기 전에는 식별자를 알 수 없다. 이것은 엔티티 객체를 생성할 떄 식별자를 전달할 수 없음을 의미한다.

1-6-3. 밸류 타입

Value 타입은 개념적으로 완전한 하나를 표현할 때 사용한다.
예를 들어, ShippingInfo 클래스의 receiverName 필드와 receiverPhoneNumber 필드는 서로 다른 두 데이터를 담고 있지만, 개념적으로 받는 사람과 관련된 데이터라는 것을 유추하여 실제로 하나의 개념을 표현하고 있다.

밸류 타입의 장점

  • 다른 데이터를 가진 필드를 하나의 개념으로 표현
  • 코드의 의미를 보다 명확하게 표현
  • 밸류 타입을 위한 기능 추가

ShippingInfo의 주소 관련 데이터도 다음의 Address 밸류 타입을 사용해서 보다 명확하게 표현할 수 있다.

// Address 클래스
public class Address {
	private String address1;
	private String address2;
	private String zipcode;

	public Address(String address1, String address2, String zipcode) {
		this.address1 = address1;
		this.address2 = address2;
		this.zipcode = zipcode;
	}
}

// ShippingInfo 클래스
public class ShippingInfo {
	private Receiver receiver;
	private Address address;
}

벨류 타입이 꼭 두 개 이상의 데이터를 가져야하는 것은 아니다. 의미를 명확하게 표현하기 위해 밸류 타입을 사용하는 경우가 있다.

// Money 클래스
public class Money {
	private int value;
	public Money(int value) {
		this.value = value;
	}
	public int getValue() {
		return this.value;
	}
	public Money add(Money money) { // 새로운 기능 추가
		return new Money(this.value + money.value);
	}
}
// OrderLine 클래스
public class OrderLine {
	private Product product;
	private Money price;
	private int quantity;
	private Money amounts;
}

밸류 객체의 데이터를 변경할 때

기존의 데이터를 변경하기보다 새로운 밸류 객체를 생성하는 방식을 선호한다.

다음 밸류 객체의 코드를 보자.

public class Money {
	private int value;

	public Money add(Money money) {
		return new Money(this.value + money.value);
	}
}

Money는 불변 객체로써, 데이터 변경 기능을 제공하지 않는 타입이다.

밸류 타입을 불변으로 구현하는 가장 중요한 이유는 안전한 코드를 작성할 수 있다는 점이다.

그런데 setValue와 같은 메서드를 제공해서 값을 변경할 수 있다면?

Money price = new Money(100);
OrderLine orderLine = new OrderLine(product, price, quantity);
// price=100, quantity=2, amounts=2000
price.setValue(200);
// price=200, quantity=2, amounts=2000

주석과 같이 quantity, amounts 값이 잘못 반영 되어 데이터 정합성이 떨어지게 된다.

1-7. 도메인 언어와 유비쿼터스 언어

도메인에서 사용하는 용어는 매우 중요하다.

코드를 도메인 용어로 해석하거나 도메인 용어를 코드로 해석하는 과정이 줄어든다. 이는 코드의 가독성을 높여서 코드를 분석하고 이해하는 시간을 줄인다.

에릭 에반스는 도메인 주도 설계에서 언어의 중요함을 강다음 CalculateDiscountService는 두가지

유비쿼터스 언어

전문가, 관계자, 개발자가 도메인과 관련된 공통의 언어를 만들고 이를 모든 곳에서 같은 용어를 사용함으로써, 소통 과정에서 발생하는 용어의 모호함을 줄이고, 개발자는 도메인과 코드 사이에서 불필요한 해석 과정을 줄일 수 있다.

도메인 용어에 알맞은 단어를 찾는 시간을 아까워하지 말자.

2. 아키텍처 개요

2-1. 네 개의 영역

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

표현, 응용, 도메인, 인프라스트럭처는 아키텍처를 설계할 때 나오는 전형적인 네 가지 영역이다.
각 영역을 살펴보자.

  1. 표현 영역(또는 UI 영역): 사용자 요청을 받아 응용 영역에 전달하고 응용 영역의 처리 결과를 다시 사용자에게 보여주는 역할
    • ex) 스프링 MVC 프레임워크
  2. 응용 영역: 시스템이 사용자에게 제공해야 할 기능을 구현한다. 기능 구현을 위해 도메인 영역의 도메인 모델을 사용한다.
    • ex) 주문 등록, 주문 취소와 같은 기능 구현
  3. 도메인 영역: 도메인 모델을 구현한다. 도메인 모델은 도메인의 핵심 로직을 구현한다.
    • ex) Order, OrderLine, ShippingInfo와 같은 도메인 모델이 위치한다. 배송지 변경, 결제 완료, 주문 총액 계산 등 핵심 로직을 구현
  4. 인프라스트럭처: 구현 기술을 다룬다. 논리적인 개념을 표현하기보다는 실제 구현을 다룬다.
    • ex) RDBMS 연동, 메시징 큐 처리, 몽고DB나 레디스의 데이터 연동 처리, HTTP 클라이언트를 이용해 REST API를 호출하여 처리

표현, 응용, 도메인은 구현 기술을 사용한 코드를 직접 만들지 않는다. 대신 인프라스트럭처 영역에서 제공하는 기능을 사용하여 필요한 기능을 개발한다.

2-2. 계층 구조 아키텍처

계층 구조는 상위 계층에서 하위 계층으로만 의존하고 하위 계층은 상위 계층에 의존하지 않는다.
예를 들면, 표현 계층은 응용 계층에 의존하고, 응용 계층이 도메인 계층에 의존하지만, 그 반대는 아니다.

계층 구조를 엄격하게 적용한다면 상위 계층은 바로 아래의 계층에만 의존을 가지지만 구현의 편리함을 위해 게층 구조를 유연하게 적용하기도 한다.

다음 CalculateDiscountService는 동작은 하지만 두 가지 문제를 안고 있다.

public vlass CalculateDiscountService {
	private DroolsRuleEngine ruleEngine;

	public ValvulateDiscountService() {
		ruleEngine = new DroolsRuleEngine();
	}

	public Money calculateDiscount(RoderLine orderLines, String customId) {
			Customer customer = findCustomer(customerId);

			// Drools에 특화된 코드
			MutableMoney money = new MutableMoney(0); // 연산 결과를 받기 위해 추가한 타입
			List<?> facts = Arrays.asList(customer, money); // 룰에 필요한 데이터(지식)
			facts.addAll(orderLines);
			ruleEngine.evaluate("discountCalculation", facts); // Drools의 세션 이름
			return money.toImmutableMoney();
		}
	...
}
  1. CalculateDiscountService만 테스트하기 어렵다.
    CalculateDiscountService를 테스트하려면 RuleEngine이 완벽하게 동작해야 한다. RuleEngine 클래스와 관련 설정 파일을 모두 만든 이후에 비로소 CalculateDiscountService가 올바르게 동작하는지 확인할 수 있다.
  2. 구현 방식을 변경하기 어렵다.
    ”discountCalculation” 문자열은 Drools의 세션 이름을 의미한다. 따라서 Drools의 세션 이름을 변경하면 CalculateDiscountService의 코드도 함께 변경해야 한다. MutableMoney는 룰 적용 결괏값을 보관하기 위해 추가한 타입인데 다른 방식을 사용했다면 필요 없는 타입니다.

이처럼 CalculateDiscountService가 인프라스트럭처의 기술에 직접적인 의존을 하지 않는 것처럼 보여도 실제로는 Drools라는 인프라스트럭처 영역의 기술에 완전하게 의존하고 있다. 이런 상황에서 다른 구현 기술을 사용하려면 코드의 많은 부분을 고쳐야 한다.

따라서 인프라스트럭처에 의존하면 다음 두 가지 문제가 발생한다.

  1. 테스트 어려움
  2. 기능 확장의 어려움

이를 어떻게 해소할 수 있을까? 해답은 DIP에 있다.

2-3. DIP

CalculateDiscountService는 고수준 모듈이다. 고수준 모듈은 의미 있는 단일 기능, 즉 하위 기능을 제공하는 모듈이다.
저수준 모듈은 하위 기능을 실제로 구현한 것이다. 예를 들면, JPA를 이용해서 고객 정보를 읽어오는 모듈과 Drools로 룰을 실행하는 모듈이 저수준 모듈이 된다.

고수준 모듈이 제대로 동작하려면 저수준 모듈을 사용해야 하는데, 이때 앞서 구조 아키텍처에서 언급했던 두가지 문제가 발생한다. 테스트와 구현 변경이 어렵다는 문제 말이다.

DIP는 저수준 모듈이 고수준 모듈에 의존하도록 바꾼다. 고수준 모듈을 구현하려면 저수준 모듈을 하용해야 하는데, 반대로 저수준 모듈이 고수준 모듈에 의존하게 하여면 어떻게 해야 할까?

바로 추상화한 인터페이스를 사용한다.

// interface
public interface RuleDiscounter {
	Money applyRules(Customer customer, List<OrderLine> orderLines);
}

// CalculateDiscountService
public vlass CalculateDiscountService {
	private RuleDiscounter ruleDiscounter;

	public CalculateDiscountService(RuleDiscounter ruleDiscounter) {
		this.ruleDiscounter = ruleDiscounter;
	}

	public Money calculateDiscount(List<OrderLing> orderLines, String customerId) {
		Customer customer = findCustomer(customerId);
		return ruleDiscounter.applyRules(customer, orderLines);
	}
	...
}
// RuleDiscounter 구현체
public class DroolsRuleDiscounter implements RuleDiscounter {
	private FieContainer kContainer;
	public DroolsRuleEngine() {
		KieService ks = KieService.Factory.get();
		kContainer = ks.getKieClasspathContainer();
	}

	@Override
	public Money applyRules(Customer customer, List<OrderLine> orderLines) {
		KieSession kSession = kContainer.newKieSession("discountSession");
		try {
			...
			kSession.fireAllRules();
		} finally {
			kSession.fireAllRules();
		}
		return money.toImmutableMoney();
	}
}

위 코드에선 CalculateDiscountService가 Drools에 의존하는 코드가 없다.
단지 RuleDiscounter가 룰을 적용한다(applyRule)는 사실만 알 뿐이다.

DIP를 적용하면 저수준 모듈이 고수준 모듈에 의존하게 된다.
고수준 모듈이 저수준 모듈을 사용하려면 고수준 모듈이 저수준 모듈에 의존해야 하는데, 반대로 저수준 모듈이 고수준 모듈에 의존한다고 해서 이를 DIP(Dependency Inversion Principle), 의존 역전 원칙이라고 부른다.

여기서 CustomerRepository의 고수준 인터페이스를 추가해서 기능을 구현해보자.

// CalculateDiscountService
public class CalculateDiscountService {
	private CustomerRepository customerRepository;
	private RuleDiscounter ruleDiscounter;

	public CalculateDiscountService(
		CustomerRepository customerRepository, RuleDiscounter ruleDiscounter) {
		this.customerRepository = customerRepository;
		this.ruleDiscounter = ruleDiscounter;
	}

	public Money calculateDiscount(List<OrderLing> orderLines, String customerId) {
		Customer customer = findCustomer(customerId);
		return ruleDiscounter.applyRules(customer, orderLines);
	}
	private Customer findCustomer(Strint customerId) {
		Customer customer = customerRepository.findById(customerId);
		if (customer == null) throw new NoCustomerException();
		return customer;
	}
	...
}

기존의 CalculateDiscountService는 저수준 모듈이 만들어지기 전까지 테스트를 할 수 없었겠지만, CustomerRepository와 RuleDiscounter는 인터페이스이므로 대역 객체를 사용해서 테스트를 진행할 수 있다.

다음은 대역 객체를 사용해서 Customer가 존재하지 않는 경우 예외 발생에 대한 테스트 코드이다.

public class CalculateDiscountServiceTest {
	@Test
	public void customer가_없으면_예외가_발생한다() {
		// 테스트 목적의 대역 객체
		CustomerRepository stubRepo = mock(CustomerRepository.class);
		when(stubRepo.findById("onCustId")).thenReturn(null);
		
		RuleDiscounter studRule = (cust, lines) -> null;

		// 대영 객체를 주입 받아 테스트 진행
		CalculateDiscountService calculateDiscountService =
			new CalculateDiscountService(stubRepo, stubRule);
		assertThrows(NoCustomerException.class,
			() -> calDisSvc.calculateDiscount(someLines, "noCustId"));
	}
}

이 테스트 코드는 CustomerRepository, RuleDiscounter의 실제 구현 클래스가 없어도 CalculateDiscountService를 테스트할 수 있음을 보여준다.

stubRepostubRule은 각각 CustomerRepository, RuleDiscounter의 대역 객체이다.
stubRepo는 Mockito의 Mock 프레임워크를 이용하고, stubRule은 메서드가 한개여서 람다식을 이용하여 대역 객체를 생성했다. 두 대역 객체는 테스트를 수행하는 데 필요한 기능만 수행한다.

2-3-1. DIP 주의사항

DIP를 잘못 생각하면 단순히 인터페이스와 구현 클래스를 분리하는 정도로 받아들일 수 있다.

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

고수준 모듈 관점에서 저수준 모듈이 하는 일의 구체적인 방법을 몰라도 될 때 DIP를 사용하자.

2-4. 도메인 영역의 주요 구성요소

네 영역 중 도메인 영역은 도메인의 핵심 모델을 구현한다고 했다.

도메인 영역을 구성하는 요소는 다음과 같다.

  • Entity: 고유 식별자를 갖는 객체로 자신의 라이프 사이클을 갖는다.
    • ex) Order, Member, Product 등
  • Value: 고유의 식별자를 갖지 않는 객체로 주로 개념적인 하나의 값을 표현할 때 사용된다.
    • ex) 주소(Address), 금액(Money) 등
  • Aggregate: 연관된 엔티티와 밸류 객체를 개념적으로 하나로 묶은 것이다.
    • ex) ‘주문’ 애그리거트 (Order 엔티티, OrderLine 밸류, Orderer 밸류 객체)
  • Repository: 도메인 모델의 영속성을 처리한다.
    • ex) DBMS 테이블에서 엔티티 객체를 로딩하거나 저장하는 기능
  • Domain Service: 특정 엔티티에 속하지 않은 도메인 로직을 제공한다. 도메인 로직이 여러 엔티티와 밸류를 필요로 하면 도메인 서비스에서 로직을 구현한다.
    • ex) ‘할인 금액 계산’은 상품, 쿠폰, 회원 등급, 구매 금액 등 다양한 조건을 이용해서 구현하는 기능

2-4-1. 엔티티와 벨류

도메인 영역의 주요 구성요소는 엔티티와 벨류 타입이다.

엔티티과 도메인 모델의 엔티티의 차이

둘은 엄연히 다르다.

  1. 도메인 모델의 엔티티는 데이터와 함께 기능을 제공하는 객체이다. 도메인 관점에서 기능을 구현하고 기능 구현을 캡슐화해서 데이터가 임의로 변경되는 것을 막는다.
  2. 도메인 모델의 엔티티는 두개 이상의 데이터가 개념적으로 하나인 경우 밸류 타입을 이용해서 표편할 수 있다.

밸류 타입

RDBMS와 같은 관계형 데이터베이스는 밸류 타입을 표현하기 힘들다. Order 객체의 데이터를 저장하기 위한 테이블은 개별 데이터를 저장하거나 별도 테이블로 분리해서 저장해야 한다.

밸류는 불변으로 구현할 것을 권장하며,

3. 에그리거트

3-1. 애그리거트

3-3. 리포지터리와 애그리거트

애그리거트는 완전한 한 개의 도메인 모델을 표현한다. 따라서 객체의 영속성을 처리하는 리포지토리는 애그리커드 단위로 존재한다.

별도의 DB 테이블에 저장한다고 해서 리포지터리를 각각 만들지 않는다. 애거리거트 루트를 위한 리포지터리만 존재한다.

예를 들어, Order와 OrderLine는 각각 주문이라는 하나의 애그리거트 안에 루트와 구성요소로 속해있으므로, Order 애그리거트를 저장할 때 애그리거트와 매핑되는 테이블뿐만 아니라 애그리거트에 속한 모든 구성요소에 매핑된 테이블에 데이터를 저장해야 한다.

Order order = orderRepository.save(order);

동일하게 애그리거트를 구하는 리포지터리 메서드는 완전한 애그리거트를 제공해야 한다.

애그리거트에서 두 개의 객체를 변경했는데, 저장소에는 한 객체에 대한 변경만 반영되면 데이터 일관성이 깨지므로 문제가 된다. RDBMS를 이용해서 리포지터리를 구현하면 트랜잭션을 이용해서 애그리거트의 변경이 저장소에 반영되는 것을 보장할 수 있다.

이에 대한 예시는 4장에서 살펴보자.

3-4. ID를 이용한 애그리거트 참조

한 객체가 다른 객체를 참조하는 것처럼 애그리거트도 다른 애그리거트를 참조한다. 즉, 다른 애그리거트의 루트를 참조한다는 것과 같다.

애그리거트 간의 참조는 필드를 통해 쉽게 구현할 수 있다. 필드를 이용해 다른 애그리거트를 직접 참조하는 것은 개발자에게 구현의 편리함을 제공한다. 예를 들어 다음과 같이 회원 id를 구할 수 있다.

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

JPA는 연관관계 어노테이션을 사용하여 연관된 객체를 로딩하는 기능을 제공하고 있으므로 필드를 이용해 다른 애그리거트를 쉽게 참조할 수 있다.

하지만, 이렇게 필드를 이용한 애그리거트 참조는 다음 문제를 야기한다.

3-4-1. 필드를 이용한 애그리거트 참조의 문제점

  1. 편한 탐색 오용
  2. 성능에 대한 고민
  3. 확장의 어려움

이 중 편한 탐색 오용이 가장 큰 문제인데, 한 애그리거트 내부에서 다른 애그리거트 객체에 접근할 수 있으면 다른 애그리거트의 상태를 쉽게 변경할 수 있다. 하지만, 한 애그리거트가 관리하는 범위는 자기 자신으로 한정해야 한다.

두 번째로 성능과 관련하여, 참조한 객체를 가져올 때 지연(lazy) 로딩과 즉시(eager) 로딩 중 고민할 때, 여러 경우의 수를 고려하여 연관 매핑과 JPQL/Criteria 궈리의 로딩 전략을 결정해야 한다.

세 번째는 확장 문제이다. 초기에는 단일 DBMS로 서비스를 제공하는 것이 가능하지만, 사용자가 몰리면서 트래픽이 증가하고 부하를 분산하기 위해 하위 도메인별로 시스템을 분리한다.
이 과정에서 하위 도메인마다 서로 다른 DBMS를 사용할 때도 있으며, 다른 종류의 데이터 저장소를 사용하기도 한다. 이것은 더 이상 다른 애그리거트 루트를 참조하기 위해 JPA와 같은 단일 기술을 사용할 수 없음을 의미한다.

이런 세 가지 문제를 완화하기 위해 제시된 방법이 ID를 이용하여 다른 애그리거트를 참조하는 것이다.

3-4-2. ID를 이용한 애그리거트 참조

ID 참조를 사용하면 모든 객체가 참조로 연결되지 않고, 한 애그리거트에 속한 객체들만 참조로 연결된다. 애그리거트 간의 물리적인 연결을 제거하고 의존을 제거하므로 응집도를 높여준다.

또한 구현 복잡도도 낮아진다. 다른 애그리거트를 직접 참조하지 않으므로 애그리거트 간 참조를 지연 로딩으로 할지 즉시 로딩으로 할지 고민하지 않아도 된다. 필요한 애그리거트만 응용 서비스에서 로딩하면 되는 것이다.

이것은 애그리거트 수준에서 지연 로딩을 하는 것과 동일한 결과를 낳는다.

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

  • 복잡도가 낮아짐
  • 한 애그리거트에서 다른 애그리거트를 수정하는 문제를 근원적으로 방지
  • 애그리거트별로 다른 구현 기술 사용 가능 (확장에 용이)

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

ID를 이용한 애그리거트 참조 시에 여러 애그리거트를 읽으면 조회 속도가 문제가 될 수 있다.

예를 들어, 주문 목록을 보여주기 위해 상품 애그리거트와 회원 애그리거트를 함께 읽어야한다고 하자. 한 DBMS가 있다면 조인을 이용해서 한 번에 보든 데이터를 가져올 수 있음에도 불구하고 주문마다 상품 정보를 읽어오는 쿼리를 실행하게 된다.

Member member = memberRepository.findById(orderOd)
List<Order> orders = orderRepository.findByOrderer(orderId);
List<OrderView> dtos = orders.stream()
	.map(order -> {
		ProductId = prodId = order.getOrderLines().get(0).getProductId();
		// 각 주문마다 첫 번째 주문 상품 정보 로딩
		Product product = productRepository.findById(prodId);
		return new OrderView(order, member, product);
	}).collect(toList());

주문이 10개면, 1번당 주문별 각 상품을 읽어 오기 위해 10번의 쿼리가 실행된다. 이것은 지연 로딩의 대표적인 문제이며, 더 많은 쿼리를 실행하기 때문에 전체 조회 속도가 느려지는 원인이 된다.

따라서 ID 참조 방식을 사용하면서 N+1 조회 같은 문제가 발생하지 않도록 다음과 같은 조회 전용 쿼리를 사용하자.

@Repository
public class JpaOrderViewDao implements OrderViewDao {
    @PersistenceContext
    private EntityManager em;

		// 특정 사용자의 주문 내역을 보여주기 위한 코드
    @Override
    public List<OrderView> selectByOrder(String ordererId) {
        String selectQuery =
            "select new com.myshop.order.application.dto.OrderView(o, m, p) " +
            "from Order o join o.orderLines ol, Member m, Product p " +
            "where o.orderer.memberId.id = :ordererId " +
            "and o.orderer.memberId = m.id " +
            "and ol.productId = p.id " +
            "order by o.number.number desc";

        TypedQuery<OrderView> query =
            em.createQuery(selectQuery, OrderView.class);
        query.setParameter("ordererId", ordererId);
        return query.getResultList();
    }
}

이 JPQL은 Order, Member, Product 애그리거트를 조인으로 조회하여 한 번의 쿼리로 로딩한다. 쿼리가 복잡하거나 SQL에 특화된 기능을 사용해야 한다면, 조회를 위한 부분만 MyBatis와 같은 기술을 이용해서 구현할 수 있다.

💡 처음 JPA를 사용하면 각 객체 간 모든 연관을 지연 로딩, 즉시 로딩으로 처리하고 싶겠지만, 이것은 그리 실용적인 방법이 아니다. 앞의 코드와 같이 ID를 이용해 애그리거트를 참조해도 한 번의 쿼리로 필요한 데이터를 로딩하는 것이 가능하다.

애그리거트마다 서로 다른 저장소를 사용하면 한 번의 쿼리로 관련 애그리거트를 모두 조회할 수 없다. 이때는 개시를 적용하거나 조회 전용 저장소를 따로 구성하여 조회 성능을 높이자.

코드가 복잡해지는 단점이 있지만, 시스템의 처리량을 높일 수 있다는 장점이 있다.

3-5. 애그리거트 간 집합 연관

애그리거트 간 1-N, M-N 연관을 보자.

카테고리와 상품 간의 연관이 대표적이다.

  1. 1-N 연관: 카테고리는 한 개 이상의 상품이 속하고, 상품은 하나의 카테고리만 가지는 경우
  2. M-N 연관: 카테고리는 한 개 이상의 상품이 속하고, 상품도 여러개의 카테고리를 가지는 경우

3-5-1. 1-N 연관

1-N 연관으로 구현된 Product를 조회해보자.
실제 DBMS와 연동해서 구현하면 Category에 속한 모든 Product를 조회하게 된다. Product의 개수가 많아질 수록 실행 속도가 급격히 느려져 성능에 심각한 문제를 일으킨다.

따라서 개념적으로 애그리거트 간 1-N 연관이 있더라도, 성능 문제 때문에 애그리거트 간 1-N 연관을 실제로 구현하지 않는다.

카테고리에 속한 상품을 구할 필요가 있다면, N-1 연관을 지어 특정 Category에 속한 Product 목록을 구하면 된다.

카테고리에 속한 상품 목록을 조회하는 비즈니스 로직은 다음과 같다.

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

3-5-1. N-M 연관

N-M 연관은 개념적으로 양쪽 애그리거트에 컬렉션으로 연관을 만든다. 보통 특정 카테고리에 속한 상품 목록을 보여줄 때, 목록 화면에서 각 상품이 속한 모든 카테고리를 상품 정보에 표시하지는 않는다. 제품이 속한 모든 카테고리가 필요한 화면은 상품 상세 화면이다.

이런 요구 사항을 고려하면 카테고리에서 상품으로서의 집합 연관은 필요하지 않고, 상품에서 카테고리로만 갈 수 있으면 된다.

즉 개념적으로는 M-N 양방향 연관이 존재하지만, 길제 구현에서는 상품에서 카테고리로의 단방향 M-N 연관만 적용하면 된다.

JPA를 이용하면 다음과 같이 ID 참조를 이용해 M-N 단방향 연관을 구현할 수 있다.

@Entity
@Table(name = "product")
public class Product {
    @EmbeddedId
    private ProductId id;

    @ElementCollection(fetch = FetchType.LAZY)
    @CollectionTable(name = "product_category",
            joinColumns = @JoinColumn(name = "product_id"))
    private Set<CategoryId> categoryIds;
    ...
}

💡 목록이나 상세 화면과 같은 조회 기능은 조회 전용 모델을 이용해서 구현하자.
이 내용은 11장에서 추가로 설명되어있다.

3-6. 애그리거트를 팩토리로 사용하기

고객이 특정 상점을 여러번 신고해서 해당 상점이 더 이상 물건을 등록하지 못하도록 차단한다고 해보자. 상품 등록 기능은 상점 계정이 차단 상태가 아닌 경우에만 상품을 생성할 수 있도록 구현한다.

public class RegisterProductService {
  public ProductId registerNewProduct(NewProductRequest req) {
    Store account = accountRepository.findStoreById(req.getStoreId());
    checkNull(account);
    
    if (!account.isBlocked()){
			throw new StoreBlockedException();	
		}
    
    ProductId id = productRepository.nextId();
    Product product = new Product(id, account.getId(), ...);
    productRepository.save(product);
    return id;
  }
  ...
}

이 코드는 Product를 생성 가능한지 판단하고, 생성하는 코드가 응용 서비스에서 분리되어 있다. 나빠 보이지는 않지만, 중요한 도메인 로직 처리가 응용 서비스에 노출되었다.

이 경우에 도메인 서비스나 팩토리 클래스를 사용할 수 있지만, Store 애그리거트에 구현할 수도 있다.

public class Store {
  ...
  public Product createProduct(ProductId newProductId, ...) {
    if (isBlocked()) {
			throw new StoreBlockedException();
		}
    return new Product(newProductId, getId(), ...);
	}
}

Store 애그리거트의 createProduct()는 Product 애그리거트를 생성하는 팩토리 역할을 한다. 팩토리 역할을 하면서도 중요한 도메인 로직을 구현한다.

이제 다음과 같이 응용 서비스에서 팩토리 기능을 이용해 Product를 새성하자.

public class RegisterProductService{
  public ProductId registerNewProduct(NewProductRequest req){
    Store account = accountRepository.findStoreById(req.getStoreId()));
    checkNull(account);
    
    ProductId id = productRepository.nextId();
    Product product = account.createProduct(id, ...);
    productRepository.save(product);
    return id;
  }
}

응용 서비스는 더 이상 Store의 상태를 확인하지 않는다. Store가 Product를 생성할 수 있는지 확인하는 도메인 로직은 Store에서 구현하고 있으므로 Product 생성 가능 여부를 확인하는 도메인 로직은 도메인 영역의 Store에서만 변경하고 응용 서비스는 영향을 받지 않는다.
이로써 도메인의 응집도도 높아졌고, 이것이 애그리거트를 팩토리로 사용할 때 얻을 수 있는 장점이다.

애그리거트가 갖고 있는 데이터로 다른 애그리거트를 생성해야 한다면, 애그리거트에 팩토리 메서드를 구현하는 것을 고려하자.

Product의 경우, Store의 데이터를 이용해 Product를 생성해야하기 때문에 Store에 Product를 생성하는 팩토리 메서드를 추가하면 Product를 생성할 때 필요한 데이터의 일부를 직접 제공하면서 동시에 중요한 도메인 로직을 함께 구현할 수 있다.

Store 애그리거트가 Product 애그리거트를 생성할 때 많은 정보를 알아야 한다면, Store 애그리거트에서 Product를 직접 생성하지 않고 다른 팩토리에 위임하자.

public class Store {
  ...
  public Product createProduct(ProductId newProductId, ProductInfo pi) {
    if (isBlocked()) {
			throw new StoreBlockedException();
		}
    return ProductFactory.create(newProductId, getId(), pi);
  }
}

다른 팩토리에 위임하더라도 차단 상태의 상점은 상품을 만들 수 없다는 도메인 로직은 한 곳에 계속 위치한다.

profile
비열한 커비

0개의 댓글