Chapter1. 도메인 모델 시작하기
도메인이란?
개발자 입장에서 바라보면 온라인 서점은 구현해야 할 소프트웨어의 대상이 된다.
온라인 서점 소프트웨어는 온라인으로 책을 판매하는 데 필요한 상품 조회, 구매, 결제, 배송 추적 등의 기능을 제공해야 한다.
이때 온라인 서점은 소프트웨어로 해결하고자 하는 문제 영역, 즉 도메인에 해당한다.
한 도메인은 다시 하위 도메인으로 나눌 수 있다.
예를 들어 온라인 서점 도메인은 몇 개의 하위 도메인으로 나눌 수 있다.
특정 도메인을 위한 소프트웨어라고 해서 도메인이 제공해야 할 모든 기능을 직접 구현하는 것은 아니다.
많은 온라인 쇼핑몰이 자체적으로 배송 시스템을 구축하기 보다는 외부 배송 업체의 시스템을 사용하고 배송 추적 정보를 제공하는 데 필요한 기능만 일부 연동한다.
도메인 전문가와 개발자 간 지식 공유
요구사항은 개발의 첫 단추와 같다.
첫 단추를 잘못 끼우면 모든 단추가 잘못 끼워지듯이 요구사항을 올바르게 이해하지 못하면 요구하지 않은 엉뚱한 기능을 만들게 된다.
요구사항을 올바르게 이해하는 것이 중요하다.
요구사항을 올바르게 이해하려면 개발자와 전문가가 직접 대화해야 하며 도메인 전문가 만큼은 아니겠지만 이해관계자와 개발자도 도메인 지식을 갖춰야 한다.
도메인 모델
도메인 모델을 사용하면 여러 관계자들이 동일한 모습으로 도메인을 이해하고 도메인 지식을 공유하는 데 도움이 된다.
도메인을 이해하려면 도메인이 제공하는 기능과 도메인의 주요 데이터 구성을 파악해야 하는데, 이런 면에서 기능과 데이터를 함께 보여주는 객체 모델은 도메인을 모델링하기에 적합하다.
도메인 모델은 기본적으로 도메인 자체를 이해하기 위한 개념 모델이다.
개념 모델을 이용해서 바로 코드를 작성할 수 있는 것은 아니기에 구현 기술에 맞는 구현 모델이 따로 필요하다.
개념 모델과 구현 모델은 서로 다른 것이지만 구현 모델이 개념 모델을 최대한 따르도록 할 수는 있다.
개념 모델은 순수하게 문제를 분석한 결과물이다.
개념 모델은 데이터베이스, 트랜잭션 처리, 성능, 구현 기술과 같은 것을 고려하고 있지 않기 때문에 실제 코드를 작성할 때 개념 모델을 있는 그대로 사용할 수 없다.
그래서 개념 모델을 구현 가능한 형태의 모델로 전환하는 과정을 거치게 된다.
따라서 처음부터 완벽한 개념 모델을 만들기보다는 전반적인 개요를 알 수 있는 수준으로 개념 모델을 작성해야 한다.
프로젝트 초기에는 개요 수준의 개념 모델로 도메인에 대한 전체 윤곽을 이해하는데 집중하고, 구현하는 과정에서 개념 모델을 구현 모델로 점진적으로 발전시켜 나가야 한다.
도메인 모델 패턴
일반적인 애플리케이션의 아키텍처는 표현, 응용, 도메인, 인프라스트럭처 네 개의 영역으로 구성된다.
영역 | 설명 |
사용자 인터페이스
또는 표현 | 사용자의 요청을 처리하고 사용자에게 정보를 보여준다.
여기서 사용자는 소프트웨어를 사용하는 사람뿐만 아니라 외부 시스템일 수도 있다. |
응용 | 사용자가 요청한 기능을 실행한다.
업무 로직을 직접 구현하지 않으며 도메인 계층을 조합해서 기능을 실행한다. |
도메인 | 시스템이 제공할 도메인 규칙을 구현한다. |
인프라스트럭처 | 데이터베이스나 메시징 시스템과 같은 외부 시스템과의 연동을 처리한다. |
도메인 계층은 도메인의 핵심 규칙을 구현한다.
주문 도메인의 경우 ‘출고 전에 배송지를 변경할 수 있다’ 라는 규칙과 ‘주문 취소는 배송 전에만 할 수 있다’라는 규칙을 구현한 코드가 도메인 계층에 위치하게 된다.
이런 도메인 규칙을 객체 지향 기법으로 구현하는 패턴이 도메인 모델 패턴이다.
도메인 모델 도출
도메인을 모델링할 때 기본이 되는 작업은 모델을 구성하는 핵심 구성요소, 규칙, 기능을 찾는 것이다.
이 과정은 요구사항에서 출발한다.
•
최소 한 종류 이상의 상품을 주문해야 한다.
•
한 상품은 한 개 이상 주문할 수 있다.
•
총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.
public class Order {
private List<OrderLine> orderLines;
private Money totalAmounts;
public Order(List<OrderLine> orderLines) {
setOrderLines(orderLines);
}
private void setOrderLines(List<OrderLines> orderLines) {
verifyAtLeastOrMoreOrderLines(orderLines);
this.orderLines = orderLines;
calculateTotalAmounts();
}
private void verifyAtLeastOrMoreOrderLines(List<OrderLine> orderLines) {
if (orderLines == null || orderLines.isEmpty()) {
throw new IllegalArgumentException("no OrderLine");
}
}
private void calculateTotalAmounts() {
int sum = orderLines.stream()
.mapToInt(x -> x.getAmounts())
.sum();
this.totalAmounts = new Money(sum);
}
}
Java
복사
Order는 한 개 이상의 OrderLine을 가질 수 있으므로 Order를 생성할 때 OrderLine 목록을 List로 전달한다.
생성자에서 호출하는 setOrderLines() 메서드는 요구사항에 정의한 제약 조건을 검사한다.
요구사항에 따르면 최소 한 종류 이상의 상품을 주문해야 하므로 verifyAtLeastOrMoreOrderLines() 메서드를 이용해서 OrderLine이 한 개 이상 존재하는지 검사한다.
또한 calculateTotalAmounts() 메서드를 이용해서 총 주문 금액을 계산한다.
엔티티와 밸류
도출한 모델은 크게 엔티티와 밸류로 구분할 수 있다.
엔티티와 밸류를 제대로 구분해야 도메인을 올바르게 설계하고 구현할 수 있기 때문에 이 둘의 차이를 명확하게 이해하는 것은 도메인을 구현하는데 있어 중요하다.
엔티티
엔티티의 가장 큰 특징은 식별자를 가진다는 것이다.
식별자는 엔티티 객체마다 고유해서 각 엔티티는 서로 다른 식별자를 갖는다.
밸류 타입
밸류 타입은 개념적으로 완전한 하나를 표현할 때 사용한다.
밸류 객체의 데이터를 변경할 때는 기존 데이터를 변경하기보다는 변경한 데이터를 갖는 새로운 밸류 객체를 생성하는 방식을 선호한다.
도메인 모델에 set 메서드 넣지 않기
도메인 모델에 get/set 메서드를 무조건 추가하는 것은 좋지 않은 버릇이다.
set 메서드는 도메인의 핵심 개념이나 의도를 코드에서 사라지게 하며, 도메인 객체를 생성할 때 온전하지 않은 상태가 될 수 있다.
도메인 객체가 불완전한 상태로 사용되는 것을 막으려면 생성 시점에 필요한 것을 전달해 주어야 한다.
즉 생성자를 통해 필요한 데이터를 모두 받아야 한다.
도메인 용어와 유비쿼터스 언어
코드를 작성할 때 도메인에서 사용하는 용어는 매우 중요하다.
도메인에서 사용하는 용어를 코드에 반영하지 않으면 그 코드는 개발자에게 코드의 의미를 해석해야 하는 부담을 준다.
에릭 에반스는 도메인 주도 설계에서 언어의 중요함을 강조하기 위해 유비쿼터스 언어라는 용어를 사용했다.
전문가, 관계자, 개발자가 도메인과 관련된 공통의 언어를 만들고 이를 대화, 문서, 도메인 모델, 코드, 테스트 등 모든 곳에서 같은 용어를 사용한다.
이렇게 하면 소통 과정에서 발생하는 용어의 모호함을 줄일 수 있고 개발자는 도메인과 코드 사이에서 불필요한 해석 과정을 줄일 수 있다.
Chapter2. 아키텍처 개요
네 개의 영역
표현, 응용, 도메인, 인프라스트럭처는 아키텍처를 설계할 때 출현하는 전형적인 네 가지 영역이다.
웹 애플리케이션의 표현 영역은 HTTP 요청을 응용 영역이 필요로 하는 형식으로 변환해서 응용 영역에 전달하고 응용 영역의 응답을 HTTP 응답으로 변환하여 전송한다.
응용 영역은 시스템이 사용자에게 제공해야 할 기능을 구현하며 기능을 구현하기 위해 도메인 영역의 도메인 모델을 사용한다.
도메인 영역은 도메인 모델을 구현하며 도메인 모델은 도메인의 핵심 로직을 구현한다.
인프라스트럭처 영역은 구현 기술에 대한 것을 다루며 논리적인 개념을 표현하기보다는 실제 구현을 다룬다.
계층 구조 아키텍처
네 영역을 구성할 때 가장 많이 사용하는 아키텍처가 아래와 같은 계층 구조이다.
계층 구조는 그 특성상 상위 계층에서 하위 계층으로의 의존만 존재하고 하위 계층은 상위 계층에 의존하지 않는다.
계층 구조를 엄격하게 적용한다면 상위 계층은 바로 아래의 계층에만 의존을 가져야 하지만 구현의 편리함을 위해 계층 구조를 유연하게 적용하기도 한다.
하지만 짚고 넘어가야 할 것은 표현, 응용, 도메인 계층이 상세한 구현 기술을 다루는 인프라스트럭처 계층에 종속된다는 점이다.
DIP
고수준 모듈이 제대로 동작하려면 저수준 모듈을 사용해야 한다.
그런데 고수준 모듈이 저수준 모듈을 사용하면 앞서 계층 구조 아키텍처에서 언급했던 두 가지 문제, 즉 구현 변경과 테스트가 어렵다는 문제가 발생한다.
DIP는 이 문제를 해결하기 위해 저수준 모듈이 고수준 모듈에 의존하도록 바꾼다.
DIP 주의사항
DIP를 잘못 생각하면 단순히 인터페이스와 구현 클래스를 분리하는 정도로 받아들일 수 있다.
DIP의 핵심은 고수준 모듈이 저수준 모듈에 의존하지 않도록 하기 위함인데 DIP를 적용한 결과 구조만 보고 저수준 모듈에서 인터페이스를 추출하는 경우가 있다.
DIP를 적용할 때 하위 기능을 추상화한 인터페이스는 고수준 모듈 관저에서 도출한다.
DIP와 아키텍처
인프라스트럭처 영역은 구현 기술을 다루는 저수준 모듈이고 응용 영역과 도메인 영역은 고수준 모듈이다.
인프라스트럭처 계층이 가장 하단에 위치하는 계층형 구조와 달리 아키텍처에 DIP를 적용하면 인프라스트럭처 영역이 응용 영역과 도메인 영역에 의존(상속)하는 구조가 된다.
인프라스트럭처에 위치한 클래스가 도메인이나 응용 영역에 정의한 인터페이스를 상속받아 구현하는 구조가 되므로 도메인과 응용 영역에 대한 영향을 주지 않거나 최소화하면서 구현 기술을 변경하는 것이 가능하다.
도메인 영역의 주요 구성요소
요소 | 설명 |
엔티티 | 고유의 식별자를 갖는 객체로 자신의 라이프 사이클을 갖는다.
주문, 회원, 상품과 같이 도메인의 고유한 개념을 표현한다.
도메인 모델의 데이터를 포함하며 해다 데이터와 관련된 기능을 함께 제공한다. |
밸류 | 고유의 식별자를 갖지 않는 객체로 주로 개념적으로 하나인 값을 표현할 때 사용된다.
배송지 주소를 표현하기 위한 주소나 구매 금액을 위한 금액과 같은 타입이 밸류 타입이다.
엔티티의 속성으로 사용할 뿐만 아니라 다른 밸류 타입의 속성으로도 사용할 수 있다. |
애그리거트 | 애그리거트는 연관된 엔티티와 밸류 객체를 개념적으로 하나로 묶은 것이다.
예를 들어 주문과 관련된 Order 엔티티, OrderLine 밸류, Orderer 밸류 객체를 ‘주문’ 애그리거트로 묶을 수 있다. |
레포지터리 | 도메인 모델의 영속성을 처리한다.
예를 들어 DBMS 테이블에서 엔티티 객체를 로딩하거나 저장하는 기능을 제공한다. |
도메인 서비스 | 특정 엔티티에 속하지 않은 도메인 로직을 제공한다.
’할인 금액 계산’은 상품, 쿠폰, 회원 등급, 구매 금액 등 다양한 조건을 이용해서 구현하게 되는데, 이렇게 도메인 로직이 여러 엔티티와 밸류를 필요로 하면 도메인 서비스에서 로직을 구현한다. |
엔티티와 밸류
도메인 모델의 엔티티는 단순히 데이터를 담고 있는 데이터 구조라기보다는 데이터와 함께 기능을 제공하는 객체이다.
도메인 관점에서 기능을 구현하고 기능 구현을 캡슐화해서 데이터가 임의로 변경되는 것을 막는다.
또 다른 차이점은 도메인 모델의 엔티티는 두 개 이상의 데이터가 개념적으로 하나인 경우 밸류 타입을 이용해서 표현할 수 있다는 것이다.
애그리거트
도메인이 커질수록 개발할 도메인 모델도 커지면서 많은 엔티티와 밸류가 출현하고 모델은 점점 더 복잡해진다.
도메인 모델이 복잡해지면 개발자가 전체 구조가 아닌 한 개 엔티티와 밸류에만 집중하는 상황이 발생한다.
도메인 모델을 개별 객체뿐만 아니라 상위 수준에서 모델을 볼 수 있어야 전체 모델의 관계와 개별 모델을 이해하는 데 도움이 된다.
애그리거트를 사용하면 개별 객체가 아닌 관련 객체를 묶어서 객체 군집 단위로 모델을 바라볼 수 있게 된다.
애그리거트는 군집에 속한 객체를 관리하는 루트 엔티티를 갖는다.
루트 엔티티는 애그리거트에 속해 있는 엔티티와 밸류 객체를 이용해서 애그리거트가 구현해야 할 기능을 제공한다.
이것은 애그리거트의 내부 구현을 숨겨서 애그리거트 단위로 구현을 캡슐화할 수 있도록 돕는다.
레포지터리
도메인 객체를 지속적으로 사용하려면 RDBMS, NoSQL, 로컬 파일과 같은 물리적인 저장소에 도메인 객체를 보관해야 한다.
이를 위한 도메인 모델이 레포지터리이다.
엔티티나 밸류가 요구사항에서 도출되는 도메인 모델이라면 레포지터리는 구현을 위한 도메인 모델이다.
레포지터리는 애그리거트 단위로 도메인 객체를 저장하고 조회하는 기능을 정의한다.
요청 처리 흐름
사용자 입장에서 봤을 때 웹 어플리케이션이나 데스크톱 어플리케이션과 같은 소프트웨어는 기능을 제공한다.
사용자가 어플리케이션에 기능 실행을 요청하면 그 요청을 처음 받는 영역은 표현 영역이다.
표현 영역은 사용자가 전송한 데이터 형식이 올바른지 검사하고 문제가 없다면 데이터를 이용해서 응용 서비스에 기능 실행을 위임한다.
이때 표현 영역은 사용자가 전송한 데이터를 응용 서비스가 요구하는 형식으로 변환해서 전달한다.
응용 서비스는 도메인 모델을 이용해서 기능을 구현한다.
기능 구현에 필요한 도메인 객체를 레포지터리에서 가져와 실행하거나 신규 도메인 객체를 생성해서 레포지터리에 저장한다.
두 개 이상의 도메인 객체를 사용해서 구현하기도 한다.
이때 응용 서비스는 도메인의 상태를 변경하므로 변경 상태가 물리 저장소에 올바르게 반영되도록 트랜잭션을 관리해야 한다.
인프라스트럭처 개요
인프라스트럭처는 표현 영역, 응용 영역, 도메인 영역을 지원한다.
도메인 객체의 영속성 처리, 트랜잭션, SMTP 클라이언트, REST 클라이언트 등 다른 영역에서 필요로 하는 프레임워크, 구현 기술, 보조 기능을 지원한다.
도메인 영역과 응용 영역에서 인프라스트럭처의 기능을 직접 사용하는 것보다 이 두 영역에 정의한 인터페이스를 인프라스트럭처 영역에서 구현하는 것이 시스템을 더 유연하고 테스트하기 쉽게 만들어준다.
하지만 무조건 인프라스트럭처에 대한 의존을 없앨 필요는 없다.
응용 영역과 도메인 영역이 인프라스트럭처에 대한 의존을 완전히 갖지 않도록 시도하는 것은 자칫 구현을 더 복잡하고 어렵게 만들 수 있다.
모듈 구성
아키텍처의 각 영역은 별도 패키지에 위치한다.
패키지 구성 규칙에 정답이 존재하는 것은 아니지만 영역별로 모듈이 위치할 패키지를 구성할 수 있다.
도메인이 크다면 하위 도메인으로 나누고 각 하위 도메인마다 별도 패키지를 구성한다.
도메인 모듈은 도메인에 속한 애그리거트를 기준으로 다시 패키지를 구성한다.
모듈 구조를 얼마나 세분화해야 하는지에 대해 정해진 규칙은 없다.
한 패키지에 너무 많은 타입이 몰려서 코드를 찾을 때 불편할 정도만 아니면 된다.
개인적으로는 한 패키지에 10~15개 미만으로 타입 개수를 유지하고 넘어가면 패키지를 분리하려는 시도를 해본다.
Chapter3. 애그리거트
애그리거트
도메인 객체 모델이 복잡해지면 개별 구성요소 위주로 모델을 이해하게 되고 전반적인 구조나 큰 수준에서 도메인 간의 관계를 파악하기 어려워진다.
주요 도메인 요소 간의 관계를 파악하기 어렵다는 것은 코드를 변경하고 확장하는 것이 어려워진다는 것을 의미한다.
복잡한 도메인을 이해하고 관리하기 쉬운 단위로 만들려면 상위 수준에서 모델을 조망할 수 있는 방법이 필요한데, 그 방법이 바로 애그리거트다.
애그리거트는 관련된 모델을 하나로 모았기 때문에 한 애그리거트에 속한 객체는 유사하거나 동일한 라이프 사이클을 갖는다.
한 애그리거트에 속한 객체는 다른 애그리거트에 속하지 않기 때문에 경계를 갖는다.
애그리거트는 독립된 객체 군이며 각 애그리거트는 자기 자신을 관리할 뿐 다른 애그리거트를 관리하지 않는다.
경계를 설정할 때 기본이 되는 것은 도메인 규칙과 요구사항이다.
도메인 규칙에 따라 함께 생성되는 구성요소는 한 애그리거트에 속할 가능성이 높다.
하지만 ‘A가 B를 갖는다’로 해석할 수 있는 요구사항이 있다고 하더라도 이것이 반드시 A와 B가 한 애그리거트에 속한다는 것을 의미하는 것은 아니다.
애그리거트 루트
애그리거트는 여러 객체로 구성되기 때문에 한 객체만 상태가 정상이면 안 된다.
애그리거트에 속한 모든 객체가 일관된 상태를 유지하려면 애그리거트 전체를 관리할 주체가 필요한데, 이 책임을 지는 것이 바로 애그리거트의 루트 엔티티이다.
애그리거트에 속한 객체는 애그리거트의 대표 엔티티인 루트 엔티티에 직접 또는 간접적으로 속하게 된다.
도메인 규칙과 일관성
애그리거트 루트의 핵심 역할은 애그리거트의 일관성이 깨지지 않도록 하는 것이다.
애그리거트 루트가 제공하는 메서드는 도메인 규칙에 따라 애그리거트에 속한 객체의 일관성이 깨지지 않도록 구현해야 한다.
애그리거트 외부에서 애그리거트에 속한 객체를 직접 변경하면 안 된다.
이는 업무 규칙을 무시하고 직접 DB 테이블의 데이터를 수정하는 것과 같은 결과를 만든다.
불필요한 중복을 피하고 애그리거트 루트를 통해서만 도메인 로직을 구현하게 만들려면 도메인 모델에 대해 다음의 두 가지를 습관적으로 적용해야 한다.
1.
단순히 필드를 변경하는 set 메서드를 공개(public) 범위로 만들지 않는다.
공개 set 메서드는 도메인의 의미나 의도를 표현하지 못하고 도메인 로직을 도메인 객체가 아닌 응용 영역이나 표현 영역으로 분산시킨다.
2.
밸류 타입은 불변으로 구현한다.
밸류 객체가 불변이면 밸류 객체의 값을 변경하는 방법은 새로운 밸류 객체를 할당하는 것뿐이다.
즉, 애그리거트 루트가 제공하는 메서드에 새로운 밸류 객체를 전달해서 값을 변경하는 방법 밖에 없다.
애그리거트 루트의 기능 구현
애그리거트 루트는 애그리거트 내부의 다른 객체를 조합해서 기능을 완성한다.
애그리거트 루트가 구성 요소의 상태만 참조하는 것은 아니고, 기능 실행을 위임하기도 한다.
보통 한 애그리거트에 속하는 모델은 한 패키지에 속하기 때문에 패키지나 protected 범위를 사용하면 애그리거트 외부에서 상태 변경 기능을 실행하는 것을 방지할 수 있다.
트랜잭션 범위
트랜잭션 범위를 작을수록 좋다.
한 트랜잭션에서는 한 개의 애그리거트만 수정해야 한다.
한 트랜잭션에서 두 개 이상의 애그리거트를 수정하면 트랜잭션 충돌이 발생할 가능성이 더 높아지기 때문에 한 번에 수정하는 애그리거트 개수가 많아질수록 전체 처리량이 떨어지게 된다.
만약 부득이하게 한 트랜잭션으로 두 개 이상의 애그리거트를 수정해야 한다면 애그리거트에서 다른 애그리거트를 직접 수정하지 말고 응용 서비스에서 두 애그리거트를 수정하도록 구현한다.
레포지터리와 애그리거트
애그리거트는 개념상 완전한 한 개의 도메인 모델을 표현하므로 객체의 영속성을 처리하는 레포지터리는 애그리거트 단위로 존재한다.
애그리거트를 저장할 때 애그리거트 루트와 매핑되는 테이블뿐만 아니라 애그리거트에 속한 모든 구성요소에 매핑된 테이블에 데이터를 저장해야 한다.
동일하게 애그리거트를 조회하는 레포지터리 메서드는 완전한 애그리거트를 제공해야 한다.
FetchType을 LAZY로 설정하면 해당 객체를 사용할 때 쿼리가 실행되며 조회된다.
Repository의 Entity와 Domain의 Entity는 서로 다르기 때문에 Repository에서 조회를 한 후 응용 계층에서는 Domain Entity로 변환해서 다룬다.
이때 완전한 애그리거트를 만들려면 LAZY가 아니라 EAGER 로 Fetch 할 수 밖에 없다.
EAGER는 성능에 굉장히 안좋기 때문에 LAZY가 일반적이며 권장된다.
그럼 JPA와 같은 ORM에서 1:N, N:M 관계 같은 경우 이 책에서 권장하는 ‘완전한 애그리거트’ 라는걸 실현하려면 어떻게 해야할까?
ID를 이용한 애그리거트 참조
한 객체가 다른 객체를 참조하는 것처럼 애그리거트도 다른 애그리거트를 참조한다.
애그리거트 관리 주체는 애그리거트 루트이므로 애그리거트에서 다른 애그리거트를 참조한다는 것은 다른 애그리거트의 루트를 참조한다는 것과 같다.
ORM 기술 덕에 애그리거트 루트에 대한 참조를 쉽게 구현할 수 있고 필드(또는 get 메서드)를 이용한 애그리거트 참조를 사용하면 다른 애그리거트의 데이터를 쉽게 조회할 수 있다.
하지만 필드를 이용한 애그리거트 참조는 다음 문제를 야기할 수 있다.
•
편한 탐색 오용
한 애그리거트 내부에서 다른 애그리거트 객체에 접근할 수 있으면 다른 애그리거트의 상태를 쉽게 변경할 수 있게 된다.
한 애그리거트에서 다른 애그리거트의 상태를 변경하는 것은 애그리거트 간의 의존 결합도를 높여서 결과적으로 애그리거트의 변경을 어렵게 만든다.
•
성능에 대한 고민
JPA를 사용하면 참조한 객체를 지연(Lazy) 로딩과 즉시(Eager) 로딩의 두 가지 방식으로 로딩할 수 있다.
단순히 연관된 객체의 데이터를 함께 화면에 보여줘야 하면 즉시 로딩이 조회 성능에 유리하지만 애그리거트의 상태를 변경하는 기능을 실행하는 경우에는 불필요한 객체를 함께 로딩할 필요가 없으므로 지연 로딩이 유리할 수 있다.
•
확장 어려움
사용자가 늘고 트래픽이 증가하면 자연스럽게 부하를 분산하기 위해 하위 도메인별로 시스템을 분리하기 시작한다.
이 과정에서 하위 도메인마다 서로 다른 종류의 DBMS 또는 데이터 저장소를 사용할 때도 있다.
이것은 더 이상 다른 애그리거트 루트를 참조하기 위해 JPA와 같은 단일 기술을 사용할 수 없음을 의미한다.
이런 세 가지 문제를 완화할 때 사용할 수 있는 것이 ID를 이용해서 다른 애그리거트를 참조하는 것이다.
ID 참조를 사용하면 모든 객체가 참조로 연결되지 않고 한 애그리거트에 속한 객체들만 참조로 연결된다.
참조하는 애그리거트가 필요하면 응용 서비스에서 ID를 이용해서 로딩하면 되므로 애그리거트 수준에서 지연 로딩을 하는 것과 동일한 결과를 만든다.
ID를 이용한 참조와 조회 성능
다른 애그리거트를 ID로 참조하면 참조하는 여러 애그리거트를 읽을 때 조회 속도가 문제 될 수 있다.
N + 1 조회 문제는 더 많은 쿼리를 실행하기 때문에 전체 조회 속도가 느려지는 원인이 된다.
ID 참조 방식을 사용하면서 N + 1 조회와 같은 문제가 발생하지 않도록 하려면 조회 전용 쿼리를 사용하면 된다.
애그리거트마다 서로 다른 저장소를 사용하면 한 번의 쿼리로 관련 애그리거트를 조회할 수 없다.
이때는 조회 성능을 높이기 위해 캐시를 적용하거나 조회 전용 저장소를 따로 구성한다.
이 방법은 코드가 복잡해지는 단점이 있지만 시스템의 처리량을 높일 수 있다는 장점이 있다.
애그리거트 간 집합 연관
개념적으로 존재하는 애그리거트 간의 연관을 실제 구현에 반영하는 것이 요구사항을 충족하는 것과는 상관없을 때가 있다.
카테고리에 속한 상품을 구할 필요가 있다면 상품 입장에서 자신이 속한 카테고리를 연관 지어 구하면 된다.
이를 구현 모델에 반영하면 Product에 Category로의 연관을 추가하고 그 연관을 이용해서 특정 Category에 속한 Product 목록을 구하면 된다.
JPA를 이용하면 다음과 같은 매핑 설정을 사용하여 ID 참조를 이용한 M-N 단방향 연관을 구현할 수 있다.
@Entity
@Table(name = "product")
public class Product {
@EmbeddedId
private ProductId id;
@ElementCollection
@CollectionTable(name = "product_category", joinColumns = @JoinColumn(name = "product_id"))
private Set<CategoryId> categoryIds;
...
}
Java
복사
이 매핑을 사용하면 다음과 같이 JPQL의 member of 연산자를 이용해서 특정 Category에 속한 Product 목록을 구하는 기능을 구현할 수 있다.
@Repository
public class JpaProductRepository implements ProductRepository {
@PersistenceContext
private EntityManager entityManager;
@Override
public List<Product> findByCategoryId(CategoryId catId, int page, int size) {
TypedQuery<Product> query = entityManager.createQuery(
"select p from Product p" +
"where :catId member of p.categoryIds" +
"order by p.id.id desc",
Product.class
);
query.setParameter("catId", catId);
query.setFirstResult((page - 1) * size);
query.setMaxResults(size);
return query.getResultList();
}
}
Java
복사
애그리거트를 팩토리로 사용하기
고객이 특정 상점을 여러 차례 신고해서 해당 상점이 더 이상 물건을 등록하지 못하도록 차단한 상태라고 해보자.
Store가 Product를 생성할 수 있는지를 판단하고 Product를 생성하는 것은 논리적으로 하나의 도메인 기능인데 이 도메인 기능을 응용 서비스에서 구현할 수 있을 것이다.
public class ProductService {
...
public ProductId registerNewProduct(NewProductRequest request) {
Store store = storeRepository.findById(request.getStoreId());
checkNull(store);
if (store.isBlocked()) {
throw new StoreBlockedException();
}
ProductId id = productRepository.nextId();
Product product = new Product(id, store.getId(), ...);
productRepository.save(product);
return id;
}
...
}
Java
복사
Store 애그리거트의 createProduct()는 Product 애그리거트를 생성하는 팩토리 역할을 하면서도 중요한 도메인 로직을 구현하고 있다.
팩토리 기능을 구현했으므로 이제 응용 서비스는 팩토리 기능을 이용해서 Product를 생성하면 된다.
public class Store {
public Product createProduct(ProductId newProductId, ...) {
if (isBlocked() throw new StoreBlockedException();
return new Product(newProductId, getId(), ...);
}
}
Java
복사
이제 Product 생성 가능 여부를 확인하는 도메인 로직을 변경해도 도메인 영역의 Store만 변경하면 되고 응용 서비스는 영향을 받지 않고 도메인의 응집도도 높아졌다.
이것이 바로 애그리거트를 팩토리로 사용할 때 얻을 수 있는 장점이다.
다른 팩토리에 위임하더라도 차단 상태의 상점은 상품을 만들 수 없다는 도메인 로직은 한곳에 계속 위치한다.